diff --git a/.gitignore b/.gitignore index f8a038f85..0369474bf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ tmp/ .DS_Store .cursor +# Snapshots +snapshots/ \ No newline at end of file diff --git a/deno.lock b/deno.lock index f1716a6d2..0abdd94f9 100644 --- a/deno.lock +++ b/deno.lock @@ -1,11 +1,33 @@ { "version": "5", "specifiers": { + "jsr:@cliffy/ansi@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/command@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/flags@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/internal@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/keycode@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/prompt@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/table@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@std/assert@~1.0.6": "1.0.16", + "jsr:@std/collections@^1.1.3": "1.1.3", "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/encoding@~1.0.5": "1.0.10", "jsr:@std/encoding@~1.0.8": "1.0.10", + "jsr:@std/fmt@^1.0.5": "1.0.8", + "jsr:@std/fmt@~1.0.2": "1.0.8", + "jsr:@std/fs@^1.0.11": "1.0.20", "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/io@~0.224.9": "0.224.9", + "jsr:@std/io@~0.225.2": "0.225.2", + "jsr:@std/log@0.224": "0.224.14", + "jsr:@std/path@*": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/path@~1.0.6": "1.0.9", + "jsr:@std/text@~1.0.7": "1.0.16", + "jsr:@std/toml@*": "1.0.11", + "jsr:@sylc/dkill@~0.12.3": "0.12.3", "npm:@aurowallet/mina-provider@^1.0.12": "1.0.12", + "npm:@bloxbean/yaci-devkit@*": "0.10.6", "npm:@bloxbean/yaci-devkit@0.10.6": "0.10.6", "npm:@cardano-foundation/cardano-verify-datasignature@1.0.11": "1.0.11", "npm:@coderspirit/nominal@^4.1.1": "4.1.1_typescript@5.6.3", @@ -95,6 +117,7 @@ "npm:@sinclair/typebox@*": "0.34.41", "npm:@sinclair/typebox@0.34.41": "0.34.41", "npm:@subsquid/ss58-codec@^1.2.3": "1.2.3", + "npm:@txpipe/dolos@*": "0.19.1", "npm:@txpipe/dolos@0.19.1": "0.19.1", "npm:@types/node@*": "24.2.0", "npm:@types/react@18.3.1": "18.3.1", @@ -197,6 +220,7 @@ "npm:vite-plugin-static-copy@^3.1.1": "3.1.4_vite@7.1.3__picomatch@4.0.3_@types+node@24.2.0", "npm:vite-plugin-top-level-await@^1.6.0": "1.6.0_vite@7.1.3__picomatch@4.0.3_@types+node@24.2.0", "npm:vite-plugin-wasm@^3.5.0": "3.5.0_vite@7.1.3__picomatch@4.0.3_@types+node@24.2.0", + "npm:vite@*": "7.1.3_picomatch@4.0.3_@types+node@24.2.0", "npm:vite@7.1.3": "7.1.3_picomatch@4.0.3_@types+node@24.2.0", "npm:vitest@^3.2.4": "3.2.4_vite@7.1.3__picomatch@4.0.3_@types+node@24.2.0", "npm:wagmi@^2.16.9": "2.19.5_@tanstack+react-query@5.90.12__react@18.3.1_react@18.3.1_typescript@5.6.3_viem@2.37.3__typescript@5.6.3__ws@8.18.3___bufferutil@4.1.0___utf-8-validate@5.0.10_@wagmi+core@2.22.1__typescript@5.6.3__viem@2.37.3___typescript@5.6.3___ws@8.18.3____bufferutil@4.1.0____utf-8-validate@5.0.10__@types+react@18.3.1__react@18.3.1__use-sync-external-store@1.4.0___react@18.3.1_@types+react@18.3.1_use-sync-external-store@1.4.0__react@18.3.1_ws@8.18.1", @@ -208,17 +232,111 @@ "npm:ws@^8.18.3": "8.18.3_bufferutil@4.1.0_utf-8-validate@5.0.10" }, "jsr": { + "@cliffy/ansi@1.0.0-rc.7": { + "integrity": "f71c921cce224c13d322e5cedba4f38e8f7354c7d855c9cb22729362a53f25aa", + "dependencies": [ + "jsr:@cliffy/internal", + "jsr:@std/encoding@~1.0.5", + "jsr:@std/io@~0.224.9" + ] + }, + "@cliffy/command@1.0.0-rc.7": { + "integrity": "1288808d7a3cd18b86c24c2f920e47a6d954b7e23cadc35c8cbd78f8be41f0cd", + "dependencies": [ + "jsr:@cliffy/flags", + "jsr:@cliffy/internal", + "jsr:@cliffy/table", + "jsr:@std/fmt@~1.0.2", + "jsr:@std/text" + ] + }, + "@cliffy/flags@1.0.0-rc.7": { + "integrity": "318d9be98f6a6417b108e03dec427dea96cdd41a15beb21d2554ae6da450a781", + "dependencies": [ + "jsr:@std/text" + ] + }, + "@cliffy/internal@1.0.0-rc.7": { + "integrity": "10412636ab3e67517d448be9eaab1b70c88eba9be22617b5d146257a11cc9b17" + }, + "@cliffy/keycode@1.0.0-rc.7": { + "integrity": "5b3f6c33994e81a76b79f108b1989642ac22705840da33781f7972d7dff05503" + }, + "@cliffy/prompt@1.0.0-rc.7": { + "integrity": "a9cbd13acd8073558447cae8ca4cf593c09d23bcbe429cc63346920c21187b83", + "dependencies": [ + "jsr:@cliffy/ansi", + "jsr:@cliffy/internal", + "jsr:@cliffy/keycode", + "jsr:@std/assert", + "jsr:@std/fmt@~1.0.2", + "jsr:@std/io@~0.224.9", + "jsr:@std/path@~1.0.6", + "jsr:@std/text" + ] + }, + "@cliffy/table@1.0.0-rc.7": { + "integrity": "9fdd9776eda28a0b397981c400eeb1aa36da2371b43eefe12e6ff555290e3180", + "dependencies": [ + "jsr:@std/fmt@~1.0.2" + ] + }, + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532" + }, + "@std/collections@1.1.3": { + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" + }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.20": { + "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187" + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, + "@std/io@0.224.9": { + "integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3" + }, + "@std/io@0.225.2": { + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7" + }, + "@std/log@0.224.14": { + "integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e", + "dependencies": [ + "jsr:@std/fmt@^1.0.5", + "jsr:@std/fs", + "jsr:@std/io@~0.225.2" + ] + }, + "@std/path@1.0.9": { + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + }, "@std/path@1.1.3": { "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ "jsr:@std/internal" ] + }, + "@std/text@1.0.16": { + "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, + "@sylc/dkill@0.12.3": { + "integrity": "7321d192fed6b09ad5bb1b7f6a4a3b89fcde50cecf5529914548415535a68704", + "dependencies": [ + "jsr:@cliffy/command", + "jsr:@cliffy/prompt" + ] } }, "npm": { diff --git a/e2e/client/node/e2e-tests/e2e.snapshot.test.ts b/e2e/client/node/e2e-tests/e2e.snapshot.test.ts new file mode 100644 index 000000000..c618dd6ba --- /dev/null +++ b/e2e/client/node/e2e-tests/e2e.snapshot.test.ts @@ -0,0 +1,116 @@ +import { assert, blockWatcher } from "@e2e/engine"; +import type { Client } from "pg"; + +const isSnapshotEnabled = Deno.env.get("PAIMA_SNAPSHOT_INTERVAL") !== undefined; + +async function pollCondition(condition: () => Promise, timeout = 5000, interval = 200): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + if (await condition()) return true; + } catch (e) { + // Ignore errors during polling + } + await new Promise(r => setTimeout(r, interval)); + } + return false; +} + +export async function snapshotTest(db: Client) { + if (!isSnapshotEnabled) { + console.log("Skipping snapshotTest (PAIMA_SNAPSHOT_INTERVAL not set)"); + return; + } + const interval = parseInt(Deno.env.get("PAIMA_SNAPSHOT_INTERVAL")!); + + console.log(`Running snapshotTest with interval ${interval}...`); + + // Wait for at least one snapshot interval + // We'll wait for block height to cross a multiple of interval. + + const currentBlock = blockWatcher.getLatestBlock(); + const targetBlock = Math.ceil((currentBlock + 1) / interval) * interval; // next multiple + + console.log(`Running snapshotTest: waiting for rollup block ${targetBlock} to trigger snapshot... (Current rollup block: ${currentBlock})`); + await blockWatcher.waitForBlock("__main__", targetBlock + 1); // wait past the trigger block + + // Check if snapshot file exists + // The path relative to CWD (which is typically e2e/client/node when running the test task?) + // start-pglite.ts writes to ./snapshots relative to where IT runs. + // The Pglite process runs in e2e/client/node (workspace). + const snapshotPath = `./snapshots/snapshot-${targetBlock}.tar.gz`; + const snapshotAbsPath = new URL(snapshotPath, `file://${Deno.cwd()}/`).pathname; + + await assert(`Snapshot created at ${snapshotPath}`, async () => { + return await pollCondition(async () => { + try { + const stats = await Deno.stat(snapshotPath); + if (stats.isFile && stats.size > 0) { + console.log(`Snapshot found: ${snapshotPath}, size: ${stats.size}`); + return true; + } + } catch (e) { + // Ignore not found during polling + } + return false; + }); + }); +} + +export async function snapshotRetentionTest(db: Client) { + // Only run if snapshot interval is configured + if (!isSnapshotEnabled) { + console.log("Skipping snapshotRetentionTest (PAIMA_SNAPSHOT_INTERVAL not set)"); + return; + } + const interval = parseInt(Deno.env.get("PAIMA_SNAPSHOT_INTERVAL")!); + const maxSnapshots = 1; // Hardcoded to 1 for testing retention + + console.log(`Running snapshotRetentionTest with maxSnapshots=${maxSnapshots}, interval=${interval}...`); + + // Get snapshot directory path + const snapshotDir = Deno.env.get("PAIMA_SNAPSHOT_PATH") || "./snapshots"; + + // Check if enough blocks have elapsed to already have 2+ snapshots + // We need at least 2 snapshots to test retention (verify older ones are deleted) + const currentBlock = blockWatcher.getLatestBlock(); + const snapshotsCreatedSoFar = Math.floor(currentBlock / interval); + + if (snapshotsCreatedSoFar >= 2) { + // Enough blocks have elapsed - check if we already have snapshots + console.log(`Current block ${currentBlock} suggests ${snapshotsCreatedSoFar} snapshot(s) should already exist. Checking...`); + } else { + // Need to wait for more snapshots to be created + const firstTargetBlock = Math.ceil((currentBlock + 1) / interval) * interval; + const secondTargetBlock = firstTargetBlock + interval; + + console.log(`Waiting for first snapshot at block ${firstTargetBlock}...`); + await blockWatcher.waitForBlock("__main__", firstTargetBlock + 1); + + console.log(`Waiting for second snapshot at block ${secondTargetBlock}...`); + await blockWatcher.waitForBlock("__main__", secondTargetBlock + 1); + } + + // Count snapshot files in the directory + await assert(`Only ${maxSnapshots} snapshot(s) exist with retention policy`, async () => { + return await pollCondition(async () => { + try { + const snapshotFiles: string[] = []; + for await (const entry of Deno.readDir(snapshotDir)) { + if (entry.isFile && entry.name.startsWith("snapshot-") && entry.name.endsWith(".tar.gz")) { + snapshotFiles.push(entry.name); + } + } + + if (snapshotFiles.length === maxSnapshots) { + console.log(`✓ Retention policy working correctly: ${snapshotFiles.length} snapshot(s) found (expected ${maxSnapshots})`); + return true; + } + return false; + } catch (e) { + console.error(`Error reading snapshot directory ${snapshotDir}:`, e); + return false; + } + }); + }); +} diff --git a/e2e/client/node/scripts/e2e.test.ts b/e2e/client/node/scripts/e2e.test.ts index 63d706f0d..a71ca59e4 100644 --- a/e2e/client/node/scripts/e2e.test.ts +++ b/e2e/client/node/scripts/e2e.test.ts @@ -14,6 +14,7 @@ import { testMigrations } from "../e2e-tests/e2e.migrations.ts"; import { RPCTest } from "../e2e-tests/e2e.rpc.test.ts"; import { tokenTests } from "../e2e-tests/e2e.tokens.ts"; import { bitcoinTest, bitcoinBatcherTest } from "../e2e-tests/e2e.bitcoin.test.ts"; +import { snapshotTest, snapshotRetentionTest } from "../e2e-tests/e2e.snapshot.test.ts"; const isEnvTrue = (key: string) => ["true", "1", "yes", "y"].includes((Deno.env.get(key) || "").toLowerCase()); @@ -63,6 +64,8 @@ async function test() { await bitcoinTest(db, sharedState); await bitcoinBatcherTest(db, sharedState); } + await snapshotTest(db); + await snapshotRetentionTest(db); await testMigrations(db); // Done testing. diff --git a/e2e/client/node/src/main.ts b/e2e/client/node/src/main.ts index 13336fb0f..1fbb77719 100644 --- a/e2e/client/node/src/main.ts +++ b/e2e/client/node/src/main.ts @@ -37,6 +37,23 @@ main(function* () { apiRouter, grammar, userDefinedPrimitives, + snapshotConfig: Deno.env.get("PAIMA_SNAPSHOT_INTERVAL") + ? { + interval: parseInt(Deno.env.get("PAIMA_SNAPSHOT_INTERVAL")!), + path: Deno.env.get("PAIMA_SNAPSHOT_PATH"), + retention: Deno.env.get("PAIMA_SNAPSHOT_MAX_SNAPSHOTS") + ? { + maxSnapshots: parseInt(Deno.env.get("PAIMA_SNAPSHOT_MAX_SNAPSHOTS")!), + } + : Deno.env.get("PAIMA_SNAPSHOT_MAX_BLOCK_RANGE") + ? { + maxBlockRange: parseInt(Deno.env.get("PAIMA_SNAPSHOT_MAX_BLOCK_RANGE")!), + } + : { + maxSnapshots: 1, // Hardcoded to 1 for E2E tests + }, + } + : undefined, }); }); diff --git a/packages/node-sdk/db/scripts/start-pglite.ts b/packages/node-sdk/db/scripts/start-pglite.ts index 7b30fd8a8..f73d20798 100644 --- a/packages/node-sdk/db/scripts/start-pglite.ts +++ b/packages/node-sdk/db/scripts/start-pglite.ts @@ -5,6 +5,8 @@ import net from "node:net"; import { fromNodeSocket } from "pg-gateway/node"; import { ENV } from "@effectstream/utils/node-env"; +import { handleSnapshotTrigger } from "../src/snapshot-handler.ts"; + // TODO PORT be a ENV variable // Get port from arguments. const portArgName = "--port"; @@ -70,6 +72,15 @@ const db = new PGlite( return; } + const dataStr = new TextDecoder().decode(data); + + if ( + data[0] === 0x51 // 'Q' (Query) is a query message + && dataStr.includes("PAIMA_SNAPSHOT_TRIGGER") + ) { + await handleSnapshotTrigger(db, dataStr); + } + // Forward raw message to PGlite and send response to client return await db.execProtocolRaw(data); }, @@ -80,3 +91,4 @@ const db = new PGlite( console.info(`database: server listening on port ${port}`); }); } + diff --git a/packages/node-sdk/db/src/snapshot-handler.ts b/packages/node-sdk/db/src/snapshot-handler.ts new file mode 100644 index 000000000..4c86614bc --- /dev/null +++ b/packages/node-sdk/db/src/snapshot-handler.ts @@ -0,0 +1,143 @@ +import { PGlite } from "@electric-sql/pglite"; + +interface SnapshotConfig { + path?: string; + retention?: { + maxSnapshots?: number; + maxBlockRange?: number; + }; +} + +export async function handleSnapshotTrigger(db: PGlite, dataStr: string) { + // Extract block height and config (simple parsing, assuming it follows the string) + // The query sent by pg protocol might contain other bytes, so we look for the pattern. + // We expect the query to be constructed as: SELECT 'PAIMA_SNAPSHOT_TRIGGER', 123, '{"path":"./snapshots","retention":{"maxSnapshots":5}}' + // In pg wire protocol, this will be inside a 'Q' (Query) message. + try { + // Regex to find the block height and optional config JSON after the trigger string + // Pattern: PAIMA_SNAPSHOT_TRIGGER', , '' + // Match block height, then optionally match JSON config between single quotes + const match = dataStr.match(/PAIMA_SNAPSHOT_TRIGGER'.*?(\d+)(?:,\s*'([^']*(?:''[^']*)*)')?/); + if (match && match[1]) { + const blockHeight = parseInt(match[1], 10); + + // Parse config JSON if present + let config: SnapshotConfig = {}; + if (match[2]) { + try { + // Unescape SQL-escaped single quotes ('') back to single quotes (') + // This handles SQL string literal escaping in the protocol message + const unescapedJson = match[2].replace(/''/g, "'"); + config = JSON.parse(unescapedJson); + } catch (e) { + console.warn("[Pglite] Failed to parse snapshot config JSON, using defaults:", e); + // Continue with empty config if parsing fails + } + } + + const snapshotDir = config.path || "./snapshots"; + const snapshotPath = `${snapshotDir}/snapshot-${blockHeight}.tar.gz`; + console.log(`[Pglite] Creating snapshot at ${snapshotPath}...`); + + try { + await Deno.mkdir(snapshotDir, { recursive: true }); + const dump = await db.dumpDataDir(); + if (dump instanceof Blob) { + const buffer = new Uint8Array(await dump.arrayBuffer()); + await Deno.writeFile(snapshotPath, buffer); + } else if (dump instanceof Uint8Array) { + await Deno.writeFile(snapshotPath, dump); + } else { + // Fallback if type is unexpected, though PGlite usually returns Blob or File + console.warn("[Pglite] Unexpected dump type:", typeof dump); + } + console.log(`[Pglite] Snapshot created.`); + + // Apply retention policy + await applyRetentionPolicy(snapshotDir, blockHeight, config.retention); + } catch (e) { + console.error("[Pglite] Error writing snapshot:", e); + } + } else { + console.warn( + "[Pglite] Snapshot trigger received but could not parse block height.", + ); + } + } catch (err) { + console.error("[Pglite] Failed to create snapshot:", err); + } +} + +async function applyRetentionPolicy( + snapshotDir: string, + currentBlockHeight: number, + retention?: { maxSnapshots?: number; maxBlockRange?: number } +) { + if (!retention) { + return; + } + try { + if (retention.maxSnapshots !== undefined && retention.maxSnapshots <= 0) { + console.warn(`[Pglite] Invalid maxSnapshots value: ${retention.maxSnapshots}. Must be > 0.`); + return; + } + if (retention.maxBlockRange !== undefined && retention.maxBlockRange <= 0) { + console.warn(`[Pglite] Invalid maxBlockRange value: ${retention.maxBlockRange}. Must be > 0.`); + return; + } + + const snapshotFiles: Array<{ name: string; blockHeight: number; path: string }> = []; + + try { + for await (const entry of Deno.readDir(snapshotDir)) { + if (entry.isFile && entry.name.startsWith("snapshot-") && entry.name.endsWith(".tar.gz")) { + const match = entry.name.match(/snapshot-(\d+)\.tar\.gz/); + if (match && match[1]) { + const blockHeight = parseInt(match[1], 10); + snapshotFiles.push({ + name: entry.name, + blockHeight, + path: `${snapshotDir}/${entry.name}`, + }); + } + } + } + } catch (e) { + // Directory might not exist or be readable, skip retention + console.warn("[Pglite] Could not read snapshot directory for retention:", e); + return; + } + + snapshotFiles.sort((a, b) => a.blockHeight - b.blockHeight); + + const filesToDelete: string[] = []; + + if (retention.maxSnapshots && snapshotFiles.length > retention.maxSnapshots) { + const filesToKeep = snapshotFiles.slice(-retention.maxSnapshots); + const filesToRemove = snapshotFiles.slice(0, snapshotFiles.length - retention.maxSnapshots); + filesToDelete.push(...filesToRemove.map(f => f.path)); + console.log(`[Pglite] Retention: Keeping ${retention.maxSnapshots} snapshots, deleting ${filesToRemove.length} old snapshots`); + } + + if (retention.maxBlockRange) { + const cutoffBlock = currentBlockHeight - retention.maxBlockRange; + const filesOutsideRange = snapshotFiles.filter(f => f.blockHeight < cutoffBlock); + filesToDelete.push(...filesOutsideRange.map(f => f.path)); + if (filesOutsideRange.length > 0) { + console.log(`[Pglite] Retention: Deleting ${filesOutsideRange.length} snapshots outside block range (older than block ${cutoffBlock})`); + } + } + + const uniqueFilesToDelete = [...new Set(filesToDelete)]; + for (const filePath of uniqueFilesToDelete) { + try { + await Deno.remove(filePath); + console.log(`[Pglite] Deleted old snapshot: ${filePath}`); + } catch (e) { + console.warn(`[Pglite] Failed to delete snapshot ${filePath}:`, e); + } + } + } catch (err) { + console.error("[Pglite] Error applying retention policy:", err); + } +} diff --git a/packages/node-sdk/runtime/src/main.ts b/packages/node-sdk/runtime/src/main.ts index 76ad487ea..79370771c 100644 --- a/packages/node-sdk/runtime/src/main.ts +++ b/packages/node-sdk/runtime/src/main.ts @@ -97,6 +97,20 @@ export function* start(config: StartConfig): Operation { dbClient, blockHash, ); + + // Trigger Snapshot if configured and interval matches + if ( + config.snapshotConfig?.interval && + value.blockNumber % config.snapshotConfig.interval === 0 + ) { + // The trigger is a special SQL comment/string intercepted by Pglite wrapper + const snapshotConfigJson = JSON.stringify({ + path: config.snapshotConfig.path, + retention: config.snapshotConfig.retention, + }); + const escapedConfig = snapshotConfigJson.replace(/'/g, "''"); + yield* until(dbClient.query(`SELECT 'PAIMA_SNAPSHOT_TRIGGER', ${value.blockNumber}, '${escapedConfig}'`)); + } } finally { releaseDBMutex(`processing-blocks:${value.blockNumber}`); if (dbClient) { diff --git a/packages/node-sdk/runtime/src/types.ts b/packages/node-sdk/runtime/src/types.ts index 4c663e7a2..f73b86ad4 100644 --- a/packages/node-sdk/runtime/src/types.ts +++ b/packages/node-sdk/runtime/src/types.ts @@ -55,6 +55,14 @@ export type StartConfig = { apiRouter?: StartConfigApiRouter; grammar?: GrammarDefinition; userDefinedPrimitives?: Record>; + snapshotConfig?: { + interval?: number; + path?: string; + retention?: { + maxSnapshots?: number; // Keep only last N snapshots + maxBlockRange?: number; // Keep snapshots within last N blocks + }; + }; dev?: { resetPublicData?: boolean; };