@hearthlog/ledger 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ import { SQL_EXPORT_BOUNDARY, SQL_EXPORT_COMPLETE } from "@hearthlog/contracts/ledger/admin";
2
+
3
+ // The markers are the wire format shared with the browser's export worker.
4
+ export { SQL_EXPORT_BOUNDARY, SQL_EXPORT_COMPLETE };
5
+
6
+ interface SchemaRow extends Record<string, SqlStorageValue> {
7
+ name: string;
8
+ type: string;
9
+ sql: string;
10
+ }
11
+
12
+ /** A complete export of Ledger-owned SQL tables; never reads platform internals. */
13
+ export function sqliteExport(
14
+ storage: DurableObjectStorage,
15
+ filename: string,
16
+ ): Response {
17
+ const sql = storage.sql;
18
+ // SQLite's connection-wide counter includes rolled-back writes. Rejecting
19
+ // those conservatively is preferable to returning a mixed-time backup.
20
+ const changes = () =>
21
+ sql.exec<{ count: number }>("SELECT total_changes() AS count").one()
22
+ .count;
23
+ const initialChanges = changes();
24
+ const readSchema = () =>
25
+ sql
26
+ .exec<SchemaRow>(
27
+ `SELECT name, type, sql FROM sqlite_schema
28
+ WHERE sql IS NOT NULL AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'
29
+ ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name`,
30
+ )
31
+ .toArray();
32
+ const schema = readSchema();
33
+ const tables = schema
34
+ .filter((item) => item.type === "table")
35
+ .map((table) => {
36
+ // Ledger's tables all have rowids. Fail rather than silently omit data
37
+ // if a future schema introduces a different table storage model.
38
+ sql.exec(
39
+ `SELECT rowid FROM ${identifier(table.name)} LIMIT 0`,
40
+ ).toArray();
41
+ const columns = sql
42
+ .exec<{ name: string; hidden: number }>(
43
+ `PRAGMA table_xinfo(${identifier(table.name)})`,
44
+ )
45
+ .toArray()
46
+ .filter((column) => column.hidden === 0)
47
+ .map((column) => column.name);
48
+ const values = columns
49
+ .map((name) => {
50
+ const column = identifier(name);
51
+ // Hex text preserves NULs, quotes and Unicode; SQLite quote(TEXT)
52
+ // alone truncates at the first NUL. SQL formats numeric values so
53
+ // 64-bit integers never pass through JavaScript numbers.
54
+ return `CASE typeof(${column}) WHEN 'text' THEN 'CAST(X''' || hex(${column}) || ''' AS TEXT)' ELSE quote(${column}) END`;
55
+ })
56
+ .join(" || ',' || ");
57
+ const insert = `INSERT INTO ${identifier(table.name)} (${columns.map(identifier).join(",")}) VALUES (`;
58
+ return {
59
+ ...table,
60
+ query: `SELECT CAST(rowid AS TEXT) AS cursor, ${literal(insert)} || ${values} || ');' AS statement FROM ${identifier(table.name)}`,
61
+ };
62
+ });
63
+ function* statements(): Generator<string> {
64
+ yield `-- Ledger SQL export ${new Date().toISOString()}\n-- All Ledger-owned tables, schema and indexes. Platform KV/PITR state is excluded.\nPRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;`;
65
+ for (const table of tables) {
66
+ yield table.sql + ";";
67
+ let cursor: string | null = null;
68
+ while (true) {
69
+ // Consume each SQL cursor synchronously. No cursor survives a
70
+ // stream pull, and every pull checks that no write interleaved.
71
+ const rows: { cursor: string; statement: string }[] = sql
72
+ .exec<{ cursor: string; statement: string }>(
73
+ table.query +
74
+ (cursor === null
75
+ ? ""
76
+ : " WHERE rowid > CAST(? AS INTEGER)") +
77
+ " ORDER BY rowid LIMIT 8",
78
+ ...(cursor === null ? [] : [cursor]),
79
+ )
80
+ .toArray();
81
+ for (const row of rows) yield row.statement;
82
+ if (rows.length < 8) break;
83
+ cursor = rows.at(-1)!.cursor;
84
+ }
85
+ }
86
+ for (const item of schema.filter((item) => item.type !== "table"))
87
+ yield item.sql + ";";
88
+ if (JSON.stringify(readSchema()) !== JSON.stringify(schema))
89
+ throw new Error(
90
+ "Database schema changed during export. Retry the download.",
91
+ );
92
+ yield "COMMIT;\n" + SQL_EXPORT_COMPLETE;
93
+ }
94
+ const iterator = statements();
95
+ const encoder = new TextEncoder();
96
+ return new Response(
97
+ new ReadableStream<Uint8Array>({
98
+ pull(controller) {
99
+ if (changes() !== initialChanges) {
100
+ iterator.return(undefined);
101
+ controller.error(
102
+ new Error(
103
+ "Database changed during export. Retry when application writes have stopped.",
104
+ ),
105
+ );
106
+ return;
107
+ }
108
+ const result = iterator.next();
109
+ if (result.done) controller.close();
110
+ else
111
+ controller.enqueue(
112
+ encoder.encode(result.value + SQL_EXPORT_BOUNDARY),
113
+ );
114
+ },
115
+ cancel() {
116
+ iterator.return(undefined);
117
+ },
118
+ }),
119
+ {
120
+ headers: {
121
+ "Content-Type": "application/sql; charset=utf-8",
122
+ "Content-Disposition": `attachment; filename="${filename}.sql"`,
123
+ "Cache-Control": "no-store",
124
+ "Content-Security-Policy": "default-src 'none'; sandbox",
125
+ "X-Content-Type-Options": "nosniff",
126
+ },
127
+ },
128
+ );
129
+ }
130
+
131
+ function identifier(value: string): string {
132
+ return '"' + value.replaceAll('"', '""') + '"';
133
+ }
134
+ function literal(value: string): string {
135
+ return "'" + value.replaceAll("'", "''") + "'";
136
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Identity-preserving transfer of one application between Ledgers.
3
+ *
4
+ * A copy writes through the public API and gets new IDs. A move must not:
5
+ * every device holds the app and log IDs, event IDs and versions, and file keys
6
+ * embed the app ID. This is the administrator-only path that carries all of it
7
+ * (IDs, per-log versions, timestamps, authors, action batches, fork lineage,
8
+ * hidden logs, and the idempotency records that recognize a retried request) into
9
+ * an empty application, and a checksum per log to prove nothing changed.
10
+ *
11
+ * The stream is NDJSON, one record per line. `parseTransferRecords` is strict:
12
+ * an import is refused rather than repaired.
13
+ */
14
+
15
+ import { LedgerError, validationError } from "./errors";
16
+ import type { StoredEvent } from "./schemas";
17
+
18
+ export const TRANSFER_FORMAT = "ledger-application-transfer";
19
+ export const TRANSFER_VERSION = 1;
20
+ /** Events per `events` record and per checksum batch; both sides use the same pages. */
21
+ export const TRANSFER_PAGE = 256;
22
+
23
+ export interface TransferLog {
24
+ t: "log";
25
+ id: string;
26
+ name: string;
27
+ createdAt: string;
28
+ lastUpdatedAt: string | null;
29
+ sourceLogId: string | null;
30
+ sourceVersion: number | null;
31
+ hiddenAt: string | null;
32
+ headVersion: number;
33
+ }
34
+
35
+ export interface TransferRequest {
36
+ requestId: string;
37
+ requestHash: string;
38
+ createdAt: string;
39
+ /** Null on rows from before replays read the version range; `result` then holds the stored response. */
40
+ firstVersion: number | null;
41
+ lastVersion: number | null;
42
+ result: string | null;
43
+ }
44
+
45
+ export interface TransferFork {
46
+ requestId: string;
47
+ sourceLogId: string;
48
+ targetVersion: number;
49
+ expectedSourceHeadVersion: number;
50
+ name: string;
51
+ forkLogId: string;
52
+ }
53
+
54
+ export type TransferRecord =
55
+ | TransferLog
56
+ | { t: "events"; logId: string; events: StoredEvent[] }
57
+ | { t: "requests"; logId: string; requests: TransferRequest[] }
58
+ | { t: "forks"; requests: TransferFork[] };
59
+
60
+ export interface LogChecksum {
61
+ id: string;
62
+ headVersion: number;
63
+ eventCount: number;
64
+ sha256: string;
65
+ }
66
+
67
+ export interface TransferTotals {
68
+ logs: number;
69
+ events: number;
70
+ requests: number;
71
+ }
72
+
73
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
74
+
75
+ function fail(message: string): never {
76
+ throw validationError(`transfer: ${message}`);
77
+ }
78
+
79
+ function record(value: unknown, label: string): Record<string, unknown> {
80
+ if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label} must be an object`);
81
+ return value as Record<string, unknown>;
82
+ }
83
+
84
+ function uuid(value: unknown, label: string): string {
85
+ if (typeof value !== "string" || !UUID.test(value)) fail(`${label} must be a UUID`);
86
+ return (value as string).toLowerCase();
87
+ }
88
+
89
+ function text(value: unknown, label: string, max = 4096): string {
90
+ if (typeof value !== "string" || value === "" || value.length > max) fail(`${label} must be a non-empty string`);
91
+ return value as string;
92
+ }
93
+
94
+ function nullableText(value: unknown, label: string): string | null {
95
+ return value === null || value === undefined ? null : text(value, label, 2_000_000);
96
+ }
97
+
98
+ function integer(value: unknown, label: string, min = 0): number {
99
+ if (!Number.isSafeInteger(value) || (value as number) < min) fail(`${label} must be an integer of at least ${min}`);
100
+ return value as number;
101
+ }
102
+
103
+ function nullableInteger(value: unknown, label: string): number | null {
104
+ return value === null || value === undefined ? null : integer(value, label);
105
+ }
106
+
107
+ function iso(value: unknown, label: string): string {
108
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) fail(`${label} must be an ISO-8601 timestamp`);
109
+ return value as string;
110
+ }
111
+
112
+ function event(value: unknown, label: string): StoredEvent {
113
+ const raw = record(value, label);
114
+ const payload = record(raw.payload, `${label}.payload`);
115
+ const action = raw.action === undefined ? undefined : record(raw.action, `${label}.action`);
116
+ return {
117
+ version: integer(raw.version, `${label}.version`, 1),
118
+ id: uuid(raw.id, `${label}.id`),
119
+ type: text(raw.type, `${label}.type`, 128),
120
+ entityId: text(raw.entityId, `${label}.entityId`, 512),
121
+ entityVersion: integer(raw.entityVersion, `${label}.entityVersion`, 1),
122
+ timestamp: integer(raw.timestamp, `${label}.timestamp`),
123
+ payload: payload as StoredEvent["payload"],
124
+ ...(action ? { action: { id: text(action.id, `${label}.action.id`, 256), index: integer(action.index, `${label}.action.index`), size: integer(action.size, `${label}.action.size`, 1) } } : {}),
125
+ ...(raw.actor === undefined || raw.actor === null ? {} : { actor: text(raw.actor, `${label}.actor`, 512) }),
126
+ } as StoredEvent;
127
+ }
128
+
129
+ export function parseTransferRecords(value: unknown): TransferRecord[] {
130
+ if (!Array.isArray(value) || value.length === 0 || value.length > 2000) fail("records must be a list of 1 to 2000 entries");
131
+ return value.map((entry, index): TransferRecord => {
132
+ const label = `records[${index}]`;
133
+ const raw = record(entry, label);
134
+ switch (raw.t) {
135
+ case "log":
136
+ return {
137
+ t: "log",
138
+ id: uuid(raw.id, `${label}.id`),
139
+ name: text(raw.name, `${label}.name`, 512),
140
+ createdAt: iso(raw.createdAt, `${label}.createdAt`),
141
+ lastUpdatedAt: raw.lastUpdatedAt === null || raw.lastUpdatedAt === undefined ? null : iso(raw.lastUpdatedAt, `${label}.lastUpdatedAt`),
142
+ sourceLogId: raw.sourceLogId === null || raw.sourceLogId === undefined ? null : uuid(raw.sourceLogId, `${label}.sourceLogId`),
143
+ sourceVersion: nullableInteger(raw.sourceVersion, `${label}.sourceVersion`),
144
+ hiddenAt: raw.hiddenAt === null || raw.hiddenAt === undefined ? null : iso(raw.hiddenAt, `${label}.hiddenAt`),
145
+ headVersion: integer(raw.headVersion, `${label}.headVersion`),
146
+ };
147
+ case "events": {
148
+ if (!Array.isArray(raw.events) || raw.events.length === 0 || raw.events.length > TRANSFER_PAGE) fail(`${label}.events must hold 1 to ${TRANSFER_PAGE} events`);
149
+ return { t: "events", logId: uuid(raw.logId, `${label}.logId`), events: raw.events.map((item, at) => event(item, `${label}.events[${at}]`)) };
150
+ }
151
+ case "requests": {
152
+ if (!Array.isArray(raw.requests) || raw.requests.length === 0 || raw.requests.length > 1000) fail(`${label}.requests must hold 1 to 1000 entries`);
153
+ return {
154
+ t: "requests",
155
+ logId: uuid(raw.logId, `${label}.logId`),
156
+ requests: raw.requests.map((item, at) => {
157
+ const request = record(item, `${label}.requests[${at}]`);
158
+ const first = nullableInteger(request.firstVersion, `${label}.requests[${at}].firstVersion`);
159
+ const last = nullableInteger(request.lastVersion, `${label}.requests[${at}].lastVersion`);
160
+ if ((first === null) !== (last === null)) fail(`${label}.requests[${at}] has half a version range`);
161
+ const result = nullableText(request.result, `${label}.requests[${at}].result`);
162
+ if (first === null && result === null) fail(`${label}.requests[${at}] has neither a version range nor a stored response`);
163
+ return {
164
+ requestId: text(request.requestId, `${label}.requests[${at}].requestId`, 256),
165
+ requestHash: text(request.requestHash, `${label}.requests[${at}].requestHash`, 256),
166
+ createdAt: iso(request.createdAt, `${label}.requests[${at}].createdAt`),
167
+ firstVersion: first,
168
+ lastVersion: last,
169
+ result,
170
+ };
171
+ }),
172
+ };
173
+ }
174
+ case "forks": {
175
+ if (!Array.isArray(raw.requests) || raw.requests.length === 0 || raw.requests.length > 1000) fail(`${label}.requests must hold 1 to 1000 entries`);
176
+ return {
177
+ t: "forks",
178
+ requests: raw.requests.map((item, at) => {
179
+ const fork = record(item, `${label}.requests[${at}]`);
180
+ return {
181
+ requestId: text(fork.requestId, `${label}.requests[${at}].requestId`, 256),
182
+ sourceLogId: uuid(fork.sourceLogId, `${label}.requests[${at}].sourceLogId`),
183
+ targetVersion: integer(fork.targetVersion, `${label}.requests[${at}].targetVersion`),
184
+ expectedSourceHeadVersion: integer(fork.expectedSourceHeadVersion, `${label}.requests[${at}].expectedSourceHeadVersion`),
185
+ name: text(fork.name, `${label}.requests[${at}].name`, 512),
186
+ forkLogId: uuid(fork.forkLogId, `${label}.requests[${at}].forkLogId`),
187
+ };
188
+ }),
189
+ };
190
+ }
191
+ default:
192
+ return fail(`${label}.t is not a known record type`);
193
+ }
194
+ });
195
+ }
196
+
197
+ /** One stored event row as the line the checksum covers; the same on both Ledgers. */
198
+ export function checksumLine(row: {
199
+ version: number; event_id: string; event_type: string; entity_id: string; entity_version: number;
200
+ event_timestamp: string; payload: string; action_id: string | null; action_index: number | null;
201
+ action_size: number | null; actor: string | null;
202
+ }): string {
203
+ return [row.version, row.event_id, row.event_type, row.entity_id, row.entity_version, row.event_timestamp, row.payload,
204
+ row.action_id ?? "", row.action_index ?? "", row.action_size ?? "", row.actor ?? ""].join("\u001f");
205
+ }
206
+
207
+ export async function sha256Hex(value: string): Promise<string> {
208
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
209
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
210
+ }
211
+
212
+ /** Chains one page of lines onto the running digest. */
213
+ export async function chainChecksum(previous: string, lines: readonly string[]): Promise<string> {
214
+ return sha256Hex(`${previous}\n${lines.join("\n")}`);
215
+ }
216
+
217
+ export function transferError(code: string, message: string, status = 409): LedgerError {
218
+ return new LedgerError(status, { error: "Conflict", code, message });
219
+ }