@sverka/storage 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +37 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +231 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +38 -10
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SnapshotStore } from "@sverka/runtime";
|
|
2
|
+
//#region src/file-store.d.ts
|
|
3
|
+
interface FileSnapshotStoreConfig {
|
|
4
|
+
readonly root?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Create a file-based `SnapshotStore`. Snapshots are written as pretty-printed
|
|
8
|
+
* JSON to `<root>/.sverka/runs/<runId>/snapshot.json`. Zero dependencies,
|
|
9
|
+
* human-debuggable. `load` returns `undefined` for missing files (ENOENT).
|
|
10
|
+
* `delete` is idempotent. Writes are atomic (temp file + rename).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createFileSnapshotStore(config?: FileSnapshotStoreConfig): SnapshotStore;
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/sqlite-store.d.ts
|
|
15
|
+
interface SqliteSnapshotStoreConfig {
|
|
16
|
+
readonly path?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create a SQLite-backed `SnapshotStore` using `node:sqlite` (built into
|
|
20
|
+
* Node 24+ and Bun, no install, no native build). One row per run keyed by
|
|
21
|
+
* `runId`; snapshot stored as JSON text. Returns `SnapshotStore & { close(): void }`
|
|
22
|
+
* — callers may call `close()` to release the `DatabaseSync` handle.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSqliteSnapshotStore(config?: SqliteSnapshotStoreConfig): SnapshotStore & {
|
|
25
|
+
close(): void;
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/errors.d.ts
|
|
29
|
+
type StorageErrorCode = "STORE_IO_FAILED" | "CORRUPT_SNAPSHOT" | "INVALID_RUN_ID";
|
|
30
|
+
export declare class StorageError extends Error {
|
|
31
|
+
readonly code: StorageErrorCode;
|
|
32
|
+
readonly cause: unknown;
|
|
33
|
+
constructor(code: StorageErrorCode, message: string, cause?: unknown);
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export type { FileSnapshotStoreConfig, SqliteSnapshotStoreConfig, StorageErrorCode };
|
|
37
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/file-store.ts","../src/sqlite-store.ts","../src/errors.ts"],"mappings":";;UAYiB;WACN;;;;;;;;wBAuBK,wBAAwB,SAAS,0BAA0B;;;UCzB1D;WACN;;;;;;;;wBAqBK,0BACd,SAAS,4BACR;EAAkB;;;;KChCT;qBAEC,qBAAqB;WACvB,MAAM;WACG;EAEN,YAAA,MAAM,kBAAkB,iBAAiB"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
//#region src/errors.ts
|
|
8
|
+
var StorageError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
cause;
|
|
11
|
+
constructor(code, message, cause) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "StorageError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
if (cause !== void 0) this.cause = cause;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/internal/serialize.ts
|
|
20
|
+
/** Serialize a RunSnapshot to pretty-printed JSON (2-space indent). */
|
|
21
|
+
function serialize(snapshot) {
|
|
22
|
+
return JSON.stringify(snapshot, null, 2);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Deserialize a JSON string into a RunSnapshot, validating required fields.
|
|
26
|
+
* Throws StorageError(CORRUPT_SNAPSHOT) on parse failure or shape mismatch.
|
|
27
|
+
*/
|
|
28
|
+
function deserialize(text, runId) {
|
|
29
|
+
const parsed = parseJson(text);
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null) throw new StorageError("CORRUPT_SNAPSHOT", "snapshot is not an object");
|
|
31
|
+
const obj = parsed;
|
|
32
|
+
validateScalarFields(obj, runId);
|
|
33
|
+
validateCompletedSteps(obj);
|
|
34
|
+
validateResumeSchema(obj);
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
function parseJson(text) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(text);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
throw new StorageError("CORRUPT_SNAPSHOT", "snapshot is not valid JSON", e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function validateScalarFields(obj, runId) {
|
|
45
|
+
validateStringField(obj, "runId");
|
|
46
|
+
if (obj["runId"] !== runId) throw new StorageError("CORRUPT_SNAPSHOT", `snapshot runId "${obj["runId"]}" does not match requested runId "${runId}"`);
|
|
47
|
+
validateStringField(obj, "planId");
|
|
48
|
+
validatePlanField(obj);
|
|
49
|
+
if (!Array.isArray(obj["completedSteps"])) throw new StorageError("CORRUPT_SNAPSHOT", "missing or invalid field: completedSteps");
|
|
50
|
+
validateStringField(obj, "suspendedStepId");
|
|
51
|
+
if (obj["status"] !== "suspended") throw new StorageError("CORRUPT_SNAPSHOT", `expected status "suspended", got "${String(obj["status"])}"`);
|
|
52
|
+
if (typeof obj["suspendedAt"] !== "number") throw new StorageError("CORRUPT_SNAPSHOT", "missing or invalid field: suspendedAt");
|
|
53
|
+
}
|
|
54
|
+
function validateStringField(obj, field) {
|
|
55
|
+
if (typeof obj[field] !== "string") throw new StorageError("CORRUPT_SNAPSHOT", `missing or invalid field: ${field}`);
|
|
56
|
+
}
|
|
57
|
+
function validatePlanField(obj) {
|
|
58
|
+
if (typeof obj["plan"] !== "object" || obj["plan"] === null || Array.isArray(obj["plan"])) throw new StorageError("CORRUPT_SNAPSHOT", "missing or invalid field: plan");
|
|
59
|
+
}
|
|
60
|
+
function validateCompletedSteps(obj) {
|
|
61
|
+
const steps = obj["completedSteps"];
|
|
62
|
+
for (let i = 0; i < steps.length; i++) {
|
|
63
|
+
const entry = steps[i];
|
|
64
|
+
if (typeof entry !== "object" || entry === null) throw new StorageError("CORRUPT_SNAPSHOT", `completedSteps[${i}] is not an object`);
|
|
65
|
+
const step = entry;
|
|
66
|
+
if (typeof step["stepId"] !== "string") throw new StorageError("CORRUPT_SNAPSHOT", `completedSteps[${i}].stepId is missing or not a string`);
|
|
67
|
+
if (typeof step["outputs"] !== "object" || step["outputs"] === null || Array.isArray(step["outputs"])) throw new StorageError("CORRUPT_SNAPSHOT", `completedSteps[${i}].outputs is missing or not an object`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function validateResumeSchema(obj) {
|
|
71
|
+
if (obj["resumeSchema"] === void 0) return;
|
|
72
|
+
if (typeof obj["resumeSchema"] !== "object" || obj["resumeSchema"] === null) throw new StorageError("CORRUPT_SNAPSHOT", "resumeSchema is not an object");
|
|
73
|
+
const rs = obj["resumeSchema"];
|
|
74
|
+
if (rs["required"] !== void 0) {
|
|
75
|
+
if (!Array.isArray(rs["required"]) || !rs["required"].every((v) => typeof v === "string")) throw new StorageError("CORRUPT_SNAPSHOT", "resumeSchema.required is not an array of strings");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/internal/io-helpers.ts
|
|
80
|
+
/**
|
|
81
|
+
* Check if an error is an ENOENT (file not found) error.
|
|
82
|
+
*/
|
|
83
|
+
function isENOENT(e) {
|
|
84
|
+
return e.code === "ENOENT";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Wrap an async I/O operation, throwing StorageError(STORE_IO_FAILED) on failure.
|
|
88
|
+
*/
|
|
89
|
+
async function wrapIO(label, fn) {
|
|
90
|
+
try {
|
|
91
|
+
return await fn();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
throw new StorageError("STORE_IO_FAILED", `failed to ${label}`, e);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/file-store.ts
|
|
98
|
+
/** Validate that a runId is safe to use as a path component (no traversal). */
|
|
99
|
+
function validateRunId(runId) {
|
|
100
|
+
if (runId.length === 0 || runId.includes("/") || runId.includes("\\") || runId.includes("..") || runId === "." || runId.includes("\0")) throw new StorageError("INVALID_RUN_ID", `runId contains invalid path characters`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Create a file-based `SnapshotStore`. Snapshots are written as pretty-printed
|
|
104
|
+
* JSON to `<root>/.sverka/runs/<runId>/snapshot.json`. Zero dependencies,
|
|
105
|
+
* human-debuggable. `load` returns `undefined` for missing files (ENOENT).
|
|
106
|
+
* `delete` is idempotent. Writes are atomic (temp file + rename).
|
|
107
|
+
*/
|
|
108
|
+
function createFileSnapshotStore(config) {
|
|
109
|
+
const root = config?.root ?? process.cwd();
|
|
110
|
+
return {
|
|
111
|
+
async save(snapshot) {
|
|
112
|
+
validateRunId(snapshot.runId);
|
|
113
|
+
const dir = join(root, ".sverka", "runs", snapshot.runId);
|
|
114
|
+
const finalPath = join(dir, "snapshot.json");
|
|
115
|
+
const tmpPath = join(dir, `.snapshot.${randomBytes(6).toString("hex")}.tmp`);
|
|
116
|
+
await wrapIO(`save snapshot ${snapshot.runId}`, async () => {
|
|
117
|
+
await mkdir(dir, { recursive: true });
|
|
118
|
+
await writeFile(tmpPath, serialize(snapshot), "utf8");
|
|
119
|
+
await rename(tmpPath, finalPath);
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
async load(runId) {
|
|
123
|
+
validateRunId(runId);
|
|
124
|
+
const filePath = join(root, ".sverka", "runs", runId, "snapshot.json");
|
|
125
|
+
let text;
|
|
126
|
+
try {
|
|
127
|
+
text = await readFile(filePath, "utf8");
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (isENOENT(e)) return void 0;
|
|
130
|
+
throw new StorageError("STORE_IO_FAILED", `failed to load snapshot ${runId}`, e);
|
|
131
|
+
}
|
|
132
|
+
return deserialize(text, runId);
|
|
133
|
+
},
|
|
134
|
+
async delete(runId) {
|
|
135
|
+
validateRunId(runId);
|
|
136
|
+
const filePath = join(root, ".sverka", "runs", runId, "snapshot.json");
|
|
137
|
+
try {
|
|
138
|
+
await unlink(filePath);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
if (isENOENT(e)) return;
|
|
141
|
+
throw new StorageError("STORE_IO_FAILED", `failed to delete snapshot ${runId}`, e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/sqlite-store.ts
|
|
148
|
+
const DEFAULT_PATH = ".sverka/runs.db";
|
|
149
|
+
const CREATE_TABLE_SQL = `
|
|
150
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
151
|
+
run_id TEXT PRIMARY KEY,
|
|
152
|
+
plan_id TEXT NOT NULL,
|
|
153
|
+
status TEXT NOT NULL,
|
|
154
|
+
suspended_at INTEGER NOT NULL,
|
|
155
|
+
snapshot_json TEXT NOT NULL
|
|
156
|
+
)
|
|
157
|
+
`;
|
|
158
|
+
/**
|
|
159
|
+
* Create a SQLite-backed `SnapshotStore` using `node:sqlite` (built into
|
|
160
|
+
* Node 24+ and Bun, no install, no native build). One row per run keyed by
|
|
161
|
+
* `runId`; snapshot stored as JSON text. Returns `SnapshotStore & { close(): void }`
|
|
162
|
+
* — callers may call `close()` to release the `DatabaseSync` handle.
|
|
163
|
+
*/
|
|
164
|
+
function createSqliteSnapshotStore(config) {
|
|
165
|
+
const path = config?.path ?? DEFAULT_PATH;
|
|
166
|
+
if (path !== ":memory:") try {
|
|
167
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
168
|
+
} catch {}
|
|
169
|
+
let db;
|
|
170
|
+
try {
|
|
171
|
+
db = new DatabaseSync(path);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
throw new StorageError("STORE_IO_FAILED", `failed to open sqlite database at ${path}`, e);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
db.exec(CREATE_TABLE_SQL);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
try {
|
|
179
|
+
db.close();
|
|
180
|
+
} catch {}
|
|
181
|
+
throw new StorageError("STORE_IO_FAILED", `failed to initialize sqlite schema at ${path}`, e);
|
|
182
|
+
}
|
|
183
|
+
let saveStmt;
|
|
184
|
+
let loadStmt;
|
|
185
|
+
let deleteStmt;
|
|
186
|
+
try {
|
|
187
|
+
saveStmt = db.prepare("INSERT OR REPLACE INTO snapshots (run_id, plan_id, status, suspended_at, snapshot_json) VALUES (?, ?, ?, ?, ?)");
|
|
188
|
+
loadStmt = db.prepare("SELECT snapshot_json FROM snapshots WHERE run_id = ?");
|
|
189
|
+
deleteStmt = db.prepare("DELETE FROM snapshots WHERE run_id = ?");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
try {
|
|
192
|
+
db.close();
|
|
193
|
+
} catch {}
|
|
194
|
+
throw new StorageError("STORE_IO_FAILED", `failed to prepare sqlite statements at ${path}`, e);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
async save(snapshot) {
|
|
198
|
+
await wrapIO(`save snapshot ${snapshot.runId}`, async () => {
|
|
199
|
+
saveStmt.run(snapshot.runId, snapshot.planId, snapshot.status, snapshot.suspendedAt, serialize(snapshot));
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
async load(runId) {
|
|
203
|
+
let row;
|
|
204
|
+
try {
|
|
205
|
+
row = loadStmt.get(runId);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
throw new StorageError("STORE_IO_FAILED", `failed to load snapshot ${runId}`, e);
|
|
208
|
+
}
|
|
209
|
+
if (row === void 0) return void 0;
|
|
210
|
+
const text = row["snapshot_json"];
|
|
211
|
+
if (typeof text !== "string") throw new StorageError("CORRUPT_SNAPSHOT", `snapshot_json is not a string for ${runId}`);
|
|
212
|
+
return deserialize(text, runId);
|
|
213
|
+
},
|
|
214
|
+
async delete(runId) {
|
|
215
|
+
try {
|
|
216
|
+
deleteStmt.run(runId);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
throw new StorageError("STORE_IO_FAILED", `failed to delete snapshot ${runId}`, e);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
close() {
|
|
222
|
+
try {
|
|
223
|
+
db.close();
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
export { StorageError, createFileSnapshotStore, createSqliteSnapshotStore };
|
|
230
|
+
|
|
231
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/errors.ts","../src/internal/serialize.ts","../src/internal/io-helpers.ts","../src/file-store.ts","../src/sqlite-store.ts"],"sourcesContent":["// StorageError — error class for @sverka/storage.\n// Spec 31 — Error handling.\n\nexport type StorageErrorCode = \"STORE_IO_FAILED\" | \"CORRUPT_SNAPSHOT\" | \"INVALID_RUN_ID\";\n\nexport class StorageError extends Error {\n readonly code: StorageErrorCode;\n override readonly cause: unknown;\n\n constructor(code: StorageErrorCode, message: string, cause?: unknown) {\n super(message);\n this.name = \"StorageError\";\n this.code = code;\n if (cause !== undefined) {\n this.cause = cause;\n }\n }\n}\n","// serialize / deserialize — JSON round-trip + validation for RunSnapshot.\n// Spec 31 — Serialization.\n\nimport type { RunSnapshot } from \"@sverka/runtime\";\nimport { StorageError } from \"../errors.js\";\n\n/** Serialize a RunSnapshot to pretty-printed JSON (2-space indent). */\nexport function serialize(snapshot: RunSnapshot): string {\n return JSON.stringify(snapshot, null, 2);\n}\n\n/**\n * Deserialize a JSON string into a RunSnapshot, validating required fields.\n * Throws StorageError(CORRUPT_SNAPSHOT) on parse failure or shape mismatch.\n */\nexport function deserialize(text: string, runId: string): RunSnapshot {\n const parsed = parseJson(text);\n if (typeof parsed !== \"object\" || parsed === null) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"snapshot is not an object\");\n }\n const obj = parsed as Record<string, unknown>;\n validateScalarFields(obj, runId);\n validateCompletedSteps(obj);\n validateResumeSchema(obj);\n return parsed as RunSnapshot;\n}\n\nfunction parseJson(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch (e) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"snapshot is not valid JSON\", e);\n }\n}\n\nfunction validateScalarFields(obj: Record<string, unknown>, runId: string): void {\n validateStringField(obj, \"runId\");\n if (obj[\"runId\"] !== runId) {\n throw new StorageError(\n \"CORRUPT_SNAPSHOT\",\n `snapshot runId \"${obj[\"runId\"]}\" does not match requested runId \"${runId}\"`,\n );\n }\n validateStringField(obj, \"planId\");\n validatePlanField(obj);\n if (!Array.isArray(obj[\"completedSteps\"])) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"missing or invalid field: completedSteps\");\n }\n validateStringField(obj, \"suspendedStepId\");\n if (obj[\"status\"] !== \"suspended\") {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", `expected status \"suspended\", got \"${String(obj[\"status\"])}\"`);\n }\n if (typeof obj[\"suspendedAt\"] !== \"number\") {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"missing or invalid field: suspendedAt\");\n }\n}\n\nfunction validateStringField(obj: Record<string, unknown>, field: string): void {\n if (typeof obj[field] !== \"string\") {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", `missing or invalid field: ${field}`);\n }\n}\n\nfunction validatePlanField(obj: Record<string, unknown>): void {\n if (typeof obj[\"plan\"] !== \"object\" || obj[\"plan\"] === null || Array.isArray(obj[\"plan\"])) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"missing or invalid field: plan\");\n }\n}\n\nfunction validateCompletedSteps(obj: Record<string, unknown>): void {\n const steps = obj[\"completedSteps\"] as unknown[];\n for (let i = 0; i < steps.length; i++) {\n const entry = steps[i];\n if (typeof entry !== \"object\" || entry === null) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", `completedSteps[${i}] is not an object`);\n }\n const step = entry as Record<string, unknown>;\n if (typeof step[\"stepId\"] !== \"string\") {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", `completedSteps[${i}].stepId is missing or not a string`);\n }\n if (typeof step[\"outputs\"] !== \"object\" || step[\"outputs\"] === null || Array.isArray(step[\"outputs\"])) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", `completedSteps[${i}].outputs is missing or not an object`);\n }\n }\n}\n\nfunction validateResumeSchema(obj: Record<string, unknown>): void {\n if (obj[\"resumeSchema\"] === undefined) return;\n if (typeof obj[\"resumeSchema\"] !== \"object\" || obj[\"resumeSchema\"] === null) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"resumeSchema is not an object\");\n }\n const rs = obj[\"resumeSchema\"] as Record<string, unknown>;\n if (rs[\"required\"] !== undefined) {\n if (!Array.isArray(rs[\"required\"]) || !rs[\"required\"].every((v) => typeof v === \"string\")) {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", \"resumeSchema.required is not an array of strings\");\n }\n }\n}\n","// Shared I/O helpers for snapshot store implementations.\n// Spec 31 — Error handling.\n\nimport { StorageError } from \"../errors.js\";\n\n/**\n * Check if an error is an ENOENT (file not found) error.\n */\nexport function isENOENT(e: unknown): boolean {\n return (e as NodeJS.ErrnoException).code === \"ENOENT\";\n}\n\n/**\n * Wrap an async I/O operation, throwing StorageError(STORE_IO_FAILED) on failure.\n */\nexport async function wrapIO<T>(label: string, fn: () => Promise<T>): Promise<T> {\n try {\n return await fn();\n } catch (e) {\n throw new StorageError(\"STORE_IO_FAILED\", `failed to ${label}`, e);\n }\n}\n","// FileSnapshotStore — JSON file per run at <root>/.sverka/runs/<runId>/snapshot.json.\n// Spec 31 — File layout.\n\nimport { mkdir, readFile, rename, unlink, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\nimport process from \"node:process\";\nimport type { RunSnapshot, SnapshotStore } from \"@sverka/runtime\";\nimport { StorageError } from \"./errors.js\";\nimport { serialize, deserialize } from \"./internal/serialize.js\";\nimport { wrapIO, isENOENT } from \"./internal/io-helpers.js\";\n\nexport interface FileSnapshotStoreConfig {\n readonly root?: string;\n}\n\n/** Validate that a runId is safe to use as a path component (no traversal). */\nfunction validateRunId(runId: string): void {\n if (\n runId.length === 0 ||\n runId.includes(\"/\") ||\n runId.includes(\"\\\\\") ||\n runId.includes(\"..\") ||\n runId === \".\" ||\n runId.includes(\"\\0\")\n ) {\n throw new StorageError(\"INVALID_RUN_ID\", `runId contains invalid path characters`);\n }\n}\n\n/**\n * Create a file-based `SnapshotStore`. Snapshots are written as pretty-printed\n * JSON to `<root>/.sverka/runs/<runId>/snapshot.json`. Zero dependencies,\n * human-debuggable. `load` returns `undefined` for missing files (ENOENT).\n * `delete` is idempotent. Writes are atomic (temp file + rename).\n */\nexport function createFileSnapshotStore(config?: FileSnapshotStoreConfig): SnapshotStore {\n const root = config?.root ?? process.cwd();\n\n return {\n async save(snapshot: RunSnapshot): Promise<void> {\n validateRunId(snapshot.runId);\n const dir = join(root, \".sverka\", \"runs\", snapshot.runId);\n const finalPath = join(dir, \"snapshot.json\");\n const tmpPath = join(dir, `.snapshot.${randomBytes(6).toString(\"hex\")}.tmp`);\n await wrapIO(`save snapshot ${snapshot.runId}`, async () => {\n await mkdir(dir, { recursive: true });\n await writeFile(tmpPath, serialize(snapshot), \"utf8\");\n await rename(tmpPath, finalPath);\n });\n },\n\n async load(runId: string): Promise<RunSnapshot | undefined> {\n validateRunId(runId);\n const filePath = join(root, \".sverka\", \"runs\", runId, \"snapshot.json\");\n let text: string;\n try {\n text = await readFile(filePath, \"utf8\");\n } catch (e) {\n if (isENOENT(e)) return undefined;\n throw new StorageError(\"STORE_IO_FAILED\", `failed to load snapshot ${runId}`, e);\n }\n return deserialize(text, runId);\n },\n\n async delete(runId: string): Promise<void> {\n validateRunId(runId);\n const filePath = join(root, \".sverka\", \"runs\", runId, \"snapshot.json\");\n try {\n await unlink(filePath);\n } catch (e) {\n if (isENOENT(e)) return;\n throw new StorageError(\"STORE_IO_FAILED\", `failed to delete snapshot ${runId}`, e);\n }\n },\n };\n}\n","// SqliteSnapshotStore — SQLite database via node:sqlite (built into Node 24+ and Bun).\n// Spec 31 — SQLite schema.\n\nimport { mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport type { RunSnapshot, SnapshotStore } from \"@sverka/runtime\";\nimport { StorageError } from \"./errors.js\";\nimport { serialize, deserialize } from \"./internal/serialize.js\";\nimport { wrapIO } from \"./internal/io-helpers.js\";\n\nexport interface SqliteSnapshotStoreConfig {\n readonly path?: string;\n}\n\nconst DEFAULT_PATH = \".sverka/runs.db\";\n\nconst CREATE_TABLE_SQL = `\n CREATE TABLE IF NOT EXISTS snapshots (\n run_id TEXT PRIMARY KEY,\n plan_id TEXT NOT NULL,\n status TEXT NOT NULL,\n suspended_at INTEGER NOT NULL,\n snapshot_json TEXT NOT NULL\n )\n`;\n\n/**\n * Create a SQLite-backed `SnapshotStore` using `node:sqlite` (built into\n * Node 24+ and Bun, no install, no native build). One row per run keyed by\n * `runId`; snapshot stored as JSON text. Returns `SnapshotStore & { close(): void }`\n * — callers may call `close()` to release the `DatabaseSync` handle.\n */\nexport function createSqliteSnapshotStore(\n config?: SqliteSnapshotStoreConfig,\n): SnapshotStore & { close(): void } {\n const path = config?.path ?? DEFAULT_PATH;\n\n if (path !== \":memory:\") {\n try {\n mkdirSync(dirname(path), { recursive: true });\n } catch {\n // Directory may already exist or parent is \".\" — ignore.\n }\n }\n\n let db: DatabaseSync;\n try {\n db = new DatabaseSync(path);\n } catch (e) {\n throw new StorageError(\"STORE_IO_FAILED\", `failed to open sqlite database at ${path}`, e);\n }\n try {\n db.exec(CREATE_TABLE_SQL);\n } catch (e) {\n try { db.close(); } catch { /* ignore */ }\n throw new StorageError(\"STORE_IO_FAILED\", `failed to initialize sqlite schema at ${path}`, e);\n }\n\n let saveStmt: ReturnType<DatabaseSync[\"prepare\"]>;\n let loadStmt: ReturnType<DatabaseSync[\"prepare\"]>;\n let deleteStmt: ReturnType<DatabaseSync[\"prepare\"]>;\n try {\n saveStmt = db.prepare(\n \"INSERT OR REPLACE INTO snapshots (run_id, plan_id, status, suspended_at, snapshot_json) VALUES (?, ?, ?, ?, ?)\",\n );\n loadStmt = db.prepare(\"SELECT snapshot_json FROM snapshots WHERE run_id = ?\");\n deleteStmt = db.prepare(\"DELETE FROM snapshots WHERE run_id = ?\");\n } catch (e) {\n try { db.close(); } catch { /* ignore */ }\n throw new StorageError(\"STORE_IO_FAILED\", `failed to prepare sqlite statements at ${path}`, e);\n }\n\n return {\n async save(snapshot: RunSnapshot): Promise<void> {\n await wrapIO(`save snapshot ${snapshot.runId}`, async () => {\n saveStmt.run(\n snapshot.runId,\n snapshot.planId,\n snapshot.status,\n snapshot.suspendedAt,\n serialize(snapshot),\n );\n });\n },\n\n async load(runId: string): Promise<RunSnapshot | undefined> {\n let row: { snapshot_json?: string } | undefined;\n try {\n row = loadStmt.get(runId) as { snapshot_json?: string } | undefined;\n } catch (e) {\n throw new StorageError(\"STORE_IO_FAILED\", `failed to load snapshot ${runId}`, e);\n }\n if (row === undefined) return undefined;\n const text = row[\"snapshot_json\"];\n if (typeof text !== \"string\") {\n throw new StorageError(\"CORRUPT_SNAPSHOT\", `snapshot_json is not a string for ${runId}`);\n }\n return deserialize(text, runId);\n },\n\n async delete(runId: string): Promise<void> {\n try {\n deleteStmt.run(runId);\n } catch (e) {\n throw new StorageError(\"STORE_IO_FAILED\", `failed to delete snapshot ${runId}`, e);\n }\n },\n\n close(): void {\n try {\n db.close();\n } catch {\n // Already closed — ignore.\n }\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
{
|
|
2
|
-
"bugs": "https://github.com/sverka-dev/sverka/issues",
|
|
3
|
-
"description": "Placeholder for @sverka/storage published by @nx-devkit/prepare-for-release.",
|
|
4
|
-
"homepage": "https://sverka.dev",
|
|
5
|
-
"license": "MIT",
|
|
6
2
|
"name": "@sverka/storage",
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
},
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Sverka storage — file and SQLite snapshot stores for run persistence",
|
|
5
|
+
"license": "MIT",
|
|
11
6
|
"repository": {
|
|
12
7
|
"type": "git",
|
|
13
8
|
"url": "https://github.com/sverka-dev/sverka",
|
|
14
9
|
"directory": "packages/storage"
|
|
15
10
|
},
|
|
11
|
+
"homepage": "https://sverka.dev",
|
|
12
|
+
"bugs": "https://github.com/sverka-dev/sverka/issues",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
16
|
"type": "module",
|
|
17
|
-
"
|
|
18
|
-
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.5.0"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.mjs",
|
|
21
|
+
"module": "./dist/index.mjs",
|
|
22
|
+
"types": "./dist/index.d.mts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.mts",
|
|
26
|
+
"import": "./dist/index.mjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsdown",
|
|
34
|
+
"test": "vitest run --passWithNoTests",
|
|
35
|
+
"lint": "eslint src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@sverka/runtime": "^0.1.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"tsdown": "^0.23.0",
|
|
43
|
+
"typescript": "^5.8.0",
|
|
44
|
+
"vitest": "^5.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|