@optimystic/db-p2p-storage-ns 1.15.0 → 1.16.0

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.
@@ -1,243 +1,370 @@
1
1
  import type { ActionId, BlockId } from '@optimystic/db-core';
2
- import { KvRawStorage, identityForHandle, type RawStoreDriver, type StoreIdentity } from '@optimystic/db-p2p';
3
- import type { SqliteDb, SqliteStatement } from './db.js';
2
+ import {
3
+ KvRawStorage,
4
+ StoreFailedError,
5
+ SyncDeadlines,
6
+ WriteGroupDriver,
7
+ identityForHandle,
8
+ type BatchBackend,
9
+ type BatchEntry,
10
+ type CommittedBatch,
11
+ type SetTimer,
12
+ type SinkCommitOptions,
13
+ type StoreDurability,
14
+ type StoreIdentity,
15
+ type StoreKey,
16
+ type SyncCover,
17
+ type WriteGroupOptions,
18
+ } from '@optimystic/db-p2p';
19
+ import type { SqliteDb, SqliteParam, SqliteStatement, SqliteTransaction } from './db.js';
4
20
  import { createLogger } from './logger.js';
21
+ import { bumpDurabilityMark } from './sqlite-sync.js';
5
22
 
6
23
  const log = createLogger('storage:sqlite');
7
24
 
25
+ type Store = StoreKey['store'];
26
+
27
+ /** How a logical store is laid out: its table is named after it, with these key columns and this value column. */
28
+ interface TableLayout {
29
+ readonly keyColumns: readonly string[];
30
+ readonly valueColumn: string;
31
+ }
32
+
33
+ const LAYOUTS: Record<Store, TableLayout> = {
34
+ metadata: { keyColumns: ['block_id'], valueColumn: 'value' },
35
+ revisions: { keyColumns: ['block_id', 'rev'], valueColumn: 'action_id' },
36
+ proofs: { keyColumns: ['block_id', 'rev'], valueColumn: 'value' },
37
+ pending: { keyColumns: ['block_id', 'action_id'], valueColumn: 'value' },
38
+ transactions: { keyColumns: ['block_id', 'action_id'], valueColumn: 'value' },
39
+ materialized: { keyColumns: ['block_id', 'action_id'], valueColumn: 'value' },
40
+ };
41
+
42
+ const STORES = Object.keys(LAYOUTS) as Store[];
43
+
44
+ const whereKey = (store: Store): string => LAYOUTS[store].keyColumns.map(column => `${column} = ?`).join(' AND ');
45
+
46
+ const sqlByStore = (build: (store: Store) => string): Record<Store, string> =>
47
+ Object.fromEntries(STORES.map(store => [store, build(store)])) as Record<Store, string>;
48
+
49
+ const GET_SQL = sqlByStore(store => `SELECT ${LAYOUTS[store].valueColumn} FROM ${store} WHERE ${whereKey(store)}`);
50
+ const PUT_SQL = sqlByStore(store => {
51
+ const columns = [...LAYOUTS[store].keyColumns, LAYOUTS[store].valueColumn];
52
+ return `INSERT OR REPLACE INTO ${store} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
53
+ });
54
+ const DELETE_SQL = sqlByStore(store => `DELETE FROM ${store} WHERE ${whereKey(store)}`);
55
+
56
+ /** The key's bind parameters, in the order of its store's key columns. */
57
+ function keyParams(key: StoreKey): SqliteParam[] {
58
+ switch (key.store) {
59
+ case 'metadata':
60
+ return [key.blockId];
61
+ case 'revisions':
62
+ case 'proofs':
63
+ return [key.blockId, key.rev];
64
+ case 'pending':
65
+ case 'transactions':
66
+ case 'materialized':
67
+ return [key.blockId, key.actionId];
68
+ }
69
+ }
70
+
71
+ /** What a {@link SqliteBatchBackend} is built with: the test-only `setTimer` that arms its deadline timer. */
72
+ export interface SqliteBatchBackendOptions {
73
+ readonly setTimer?: SetTimer;
74
+ }
75
+
8
76
  /**
9
- * SQLite {@link RawStoreDriver}: the six logical block-storage stores mapped to
10
- * the six relational tables (`metadata`, `revisions`, `pending`, `transactions`,
11
- * `proofs`, `materialized`) with their original columns and keys.
77
+ * SQLite {@link BatchBackend}: the six logical block-storage stores mapped to the six relational
78
+ * tables (`metadata`, `revisions`, `pending`, `transactions`, `proofs`, `materialized`) with their
79
+ * original columns and keys, every batch applied as ONE SQLite transaction. The reads are the
80
+ * former per-write driver's statements unchanged, so the on-disk format did not change with the
81
+ * move to batches.
82
+ *
83
+ * `KvRawStorage` owns all JSON serialization, so this backend only ever reads/writes `Uint8Array`
84
+ * values: the value columns are BLOB and SQLite binds a `Uint8Array` as a BLOB and returns it as a
85
+ * `Uint8Array`, so no codec lives here (a TEXT column would risk UTF-8 coercion corrupting non-ASCII
86
+ * JSON bytes).
87
+ *
88
+ * ### Why a committed batch survives a power cut, and in order
89
+ *
90
+ * `applySchema` (`db.ts`) opens every database in write-ahead-log (WAL) mode under
91
+ * `synchronous = NORMAL`. A commit appends the batch's frames to the one WAL file, each frame
92
+ * checksummed over the frames before it, and recovery stops at the first bad frame; before every
93
+ * checkpoint SQLite syncs the WAL, so the database file never holds later content ahead of earlier
94
+ * content. What a power cut leaves is therefore a prefix of the committed transactions, in commit
95
+ * order — rule 3 of the flush contract on `RawStoreDriver.flush` — and a batch is one transaction,
96
+ * so it survives whole or not at all. A commit under `synchronous = FULL`
97
+ * (`SqliteTransactionOptions.synced`) syncs the WAL at that commit, and the WAL being one
98
+ * append-only file, that sync covers every earlier commit on the database, the key-value store's
99
+ * and the identity helper's included, since all three share one connection. So the sync of a batch
100
+ * is one `FULL` commit — this batch's own when it must be synced at once, a later batch's, or the
101
+ * `durability_mark` bump the deadline timer commits — which is what `durability: 'power-loss'`
102
+ * states.
12
103
  *
13
- * `KvRawStorage` now owns all JSON serialization, so this driver only ever
14
- * reads/writes `Uint8Array` values: the value columns are BLOB and SQLite binds
15
- * a `Uint8Array` as a BLOB and returns it as a `Uint8Array`, so no codec lives
16
- * here (a TEXT column would risk UTF-8 coercion corrupting non-ASCII JSON bytes).
17
- * Everything SQLite-specific stays: each CRUD op goes through a prepared
18
- * statement bound once in the constructor; range/list queries drain their rows
19
- * (`.all(...)`) before yielding so no cursor straddles a consumer's `await`; and
20
- * `promote` runs as a single `db.transaction(fn)` — `BEGIN IMMEDIATE; INSERT…;
21
- * DELETE…; COMMIT;` — whose three statements are re-prepared against the OPEN
22
- * transaction so they run on the held mutex slot without re-locking (which would
23
- * deadlock — see `st-nativescript-sqlite-transaction-mutex`).
104
+ * ### How a commit decides
105
+ *
106
+ * The deadlines, the timer and the backpressure test are the rules of `SyncDeadlines` from
107
+ * `@optimystic/db-p2p`, shared with the file backend. `commit` takes the batch's deadline as it is
108
+ * called and decides whether the transaction is synced BEFORE taking the connection mutex, since
109
+ * the pragma is set as the transaction starts; the bookkeeping — `defer` of a deferred batch,
110
+ * `take` of the cover a synced one settles — runs INSIDE the transaction body, under the mutex, so
111
+ * it is ordered against every other commit and sync: a cover taken there is exactly the batches
112
+ * whose transactions committed earlier, every one of which this `FULL` commit syncs. A deadline
113
+ * that passes between the decision and the mutex only delays that commit's backpressure to the
114
+ * timer, which is due anyway.
115
+ *
116
+ * A read sees a batch as soon as its transaction commits, before it is synced, which `BatchBackend`
117
+ * allows; the overlay above serves the batch until it is durable. The handle is shared with
118
+ * `SqliteKVStore` and `loadOrCreateNSPeerKey`, so `close()` never closes it. Every write of a batch
119
+ * goes through `tx.prepare`, never a class-level statement, which would re-take the mutex the
120
+ * transaction holds and deadlock (see `SqliteDb.transaction`).
24
121
  */
25
- export class SqliteStoreDriver implements RawStoreDriver {
122
+ export class SqliteBatchBackend implements BatchBackend {
123
+ readonly durability: StoreDurability = 'power-loss';
124
+
125
+ // NOTE: identity is the HANDLE object, so two handles opened over the same SQLite FILE read
126
+ // as two identities. Resolving that would mean naming the underlying database file, which
127
+ // `SqliteDb` does not expose. The package opener (`ns-opener.ts`) hands out one handle per
128
+ // file in practice, so this is the reachable case, not the complete one.
129
+ private readonly identity: StoreIdentity;
130
+
131
+ /** One point read per store. Reads run off the mutex, so a class-level statement is right here. */
132
+ private readonly reads: Record<Store, SqliteStatement>;
26
133
  private readonly stmts: {
27
- getMetadata: SqliteStatement;
28
- saveMetadata: SqliteStatement;
29
- getRevision: SqliteStatement;
30
- saveRevision: SqliteStatement;
31
- listRevisionsAsc: SqliteStatement;
32
- listRevisionsDesc: SqliteStatement;
33
- getPending: SqliteStatement;
34
- savePending: SqliteStatement;
35
- deletePending: SqliteStatement;
134
+ listRevisions: SqliteStatement;
36
135
  listPending: SqliteStatement;
37
136
  listBlockIds: SqliteStatement;
38
- getTransaction: SqliteStatement;
39
- saveTransaction: SqliteStatement;
40
- getProof: SqliteStatement;
41
- saveProof: SqliteStatement;
42
- getMaterialized: SqliteStatement;
43
- saveMaterialized: SqliteStatement;
44
- deleteMaterialized: SqliteStatement;
45
137
  pageCount: SqliteStatement;
46
138
  pageSize: SqliteStatement;
47
139
  };
48
140
 
49
- // NOTE: identity is the HANDLE object, so two handles opened over the same SQLite FILE read
50
- // as two identities. Resolving that would mean naming the underlying database file, which
51
- // `SqliteDb` does not expose. The package opener (`ns-opener.ts`) hands out one handle per
52
- // file in practice, so this is the reachable case, not the complete one.
53
- private readonly identity: StoreIdentity;
141
+ /**
142
+ * Batches committed and not yet synced, in order, each with the `durable` the next sync settles;
143
+ * the earliest deadline among them, with a timer armed for it; and the `window-overrun` episode.
144
+ * A batch's payload is nothing: its transaction has already applied it.
145
+ */
146
+ private readonly deadlines: SyncDeadlines<undefined>;
147
+ private fault: { cause: unknown } | undefined;
54
148
 
55
- constructor(private readonly db: SqliteDb) {
149
+ constructor(private readonly db: SqliteDb, options: SqliteBatchBackendOptions = {}) {
56
150
  this.identity = identityForHandle('sqlite-handle', db);
151
+ this.reads = Object.fromEntries(STORES.map(store => [store, db.prepare(GET_SQL[store])])) as Record<Store, SqliteStatement>;
57
152
  this.stmts = {
58
- getMetadata: db.prepare('SELECT value FROM metadata WHERE block_id = ?'),
59
- saveMetadata: db.prepare('INSERT OR REPLACE INTO metadata (block_id, value) VALUES (?, ?)'),
60
- getRevision: db.prepare('SELECT action_id FROM revisions WHERE block_id = ? AND rev = ?'),
61
- saveRevision: db.prepare('INSERT OR REPLACE INTO revisions (block_id, rev, action_id) VALUES (?, ?, ?)'),
62
- listRevisionsAsc: db.prepare('SELECT rev, action_id FROM revisions WHERE block_id = ? AND rev BETWEEN ? AND ? ORDER BY rev ASC'),
63
- listRevisionsDesc: db.prepare('SELECT rev, action_id FROM revisions WHERE block_id = ? AND rev BETWEEN ? AND ? ORDER BY rev DESC'),
64
- getPending: db.prepare('SELECT value FROM pending WHERE block_id = ? AND action_id = ?'),
65
- savePending: db.prepare('INSERT OR REPLACE INTO pending (block_id, action_id, value) VALUES (?, ?, ?)'),
66
- deletePending: db.prepare('DELETE FROM pending WHERE block_id = ? AND action_id = ?'),
153
+ listRevisions: db.prepare('SELECT rev, action_id FROM revisions WHERE block_id = ? AND rev BETWEEN ? AND ? ORDER BY rev ASC'),
67
154
  listPending: db.prepare('SELECT action_id FROM pending WHERE block_id = ? ORDER BY action_id ASC'),
68
155
  // metadata.block_id is the PRIMARY KEY, so each row is a distinct block id —
69
156
  // no dedup needed. NOTE: drains the whole metadata table up front; if a peer
70
157
  // ever holds millions of blocks and this SELECT becomes a startup-latency
71
158
  // problem, page it (LIMIT/OFFSET or keyset) — fine at current scale.
72
159
  listBlockIds: db.prepare('SELECT block_id FROM metadata'),
73
- getTransaction: db.prepare('SELECT value FROM transactions WHERE block_id = ? AND action_id = ?'),
74
- saveTransaction: db.prepare('INSERT OR REPLACE INTO transactions (block_id, action_id, value) VALUES (?, ?, ?)'),
75
- getProof: db.prepare('SELECT value FROM proofs WHERE block_id = ? AND rev = ?'),
76
- saveProof: db.prepare('INSERT OR REPLACE INTO proofs (block_id, rev, value) VALUES (?, ?, ?)'),
77
- getMaterialized: db.prepare('SELECT value FROM materialized WHERE block_id = ? AND action_id = ?'),
78
- saveMaterialized: db.prepare('INSERT OR REPLACE INTO materialized (block_id, action_id, value) VALUES (?, ?, ?)'),
79
- deleteMaterialized: db.prepare('DELETE FROM materialized WHERE block_id = ? AND action_id = ?'),
80
160
  pageCount: db.prepare('PRAGMA page_count'),
81
161
  pageSize: db.prepare('PRAGMA page_size'),
82
162
  };
163
+ this.deadlines = new SyncDeadlines({
164
+ onDue: firedAt => this.syncInBackground(firedAt),
165
+ setTimer: options.setTimer,
166
+ log,
167
+ subject: this.identity,
168
+ });
169
+ }
170
+
171
+ /** True once a transaction or a sync failed; every later `commit` and `sync` then throws {@link StoreFailedError}. */
172
+ get failed(): boolean {
173
+ return this.fault !== undefined;
83
174
  }
84
175
 
85
176
  storeIdentity(): StoreIdentity {
86
177
  return this.identity;
87
178
  }
88
179
 
89
- // --- metadata ---
180
+ // --- reads ---
90
181
 
91
- async getMetadata(blockId: BlockId): Promise<Uint8Array | undefined> {
92
- const row = await this.stmts.getMetadata.get(blockId);
93
- return row ? (row.value as Uint8Array) : undefined;
182
+ async get(key: StoreKey): Promise<Uint8Array | undefined> {
183
+ const row = await this.reads[key.store].get(...keyParams(key));
184
+ return row ? (row[LAYOUTS[key.store].valueColumn] as Uint8Array) : undefined;
94
185
  }
95
186
 
96
- async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
97
- await this.stmts.saveMetadata.run(blockId, value);
187
+ async rangeRevisions(blockId: BlockId, lo: number, hi: number): Promise<[number, Uint8Array][]> {
188
+ const rows = await this.stmts.listRevisions.all(blockId, lo, hi);
189
+ return rows.map(row => [row.rev as number, row.action_id as Uint8Array]);
98
190
  }
99
191
 
100
- // --- revisions ---
101
-
102
- async getRevision(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
103
- const row = await this.stmts.getRevision.get(blockId, rev);
104
- return row ? (row.action_id as Uint8Array) : undefined;
105
- }
106
-
107
- async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
108
- await this.stmts.saveRevision.run(blockId, rev, value);
192
+ async listPendingActionIds(blockId: BlockId): Promise<ActionId[]> {
193
+ const rows = await this.stmts.listPending.all(blockId);
194
+ return rows.map(row => row.action_id as ActionId);
109
195
  }
110
196
 
111
- async *rangeRevisions(blockId: BlockId, lo: number, hi: number, reverse: boolean): AsyncIterable<[number, Uint8Array]> {
112
- // `.all(...)` materializes every row before we yield, so no SQLite cursor
113
- // straddles the consumer's awaits (the kernel's drain-before-yield contract).
114
- const stmt = reverse ? this.stmts.listRevisionsDesc : this.stmts.listRevisionsAsc;
115
- const rows = await stmt.all(blockId, lo, hi);
197
+ async *listBlockIds(): AsyncIterable<BlockId> {
198
+ // Drained by `.all(...)` before yielding, so no cursor straddles the consumer's awaits.
199
+ const rows = await this.stmts.listBlockIds.all();
116
200
  for (const row of rows) {
117
- yield [row.rev as number, row.action_id as Uint8Array];
201
+ yield row.block_id as BlockId;
118
202
  }
119
203
  }
120
204
 
121
- // --- pending ---
122
-
123
- async getPending(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
124
- const row = await this.stmts.getPending.get(blockId, actionId);
125
- return row ? (row.value as Uint8Array) : undefined;
126
- }
127
-
128
- async putPending(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
129
- await this.stmts.savePending.run(blockId, actionId, value);
130
- }
131
-
132
- async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
133
- await this.stmts.deletePending.run(blockId, actionId);
134
- }
135
-
136
- async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
137
- // Drained by `.all(...)` before yielding — same rationale as rangeRevisions.
138
- const rows = await this.stmts.listPending.all(blockId);
139
- for (const row of rows) {
140
- yield row.action_id as ActionId;
205
+ async approximateBytesUsed(): Promise<number> {
206
+ try {
207
+ const pageCountRow = await this.stmts.pageCount.get();
208
+ const pageSizeRow = await this.stmts.pageSize.get();
209
+ const pages = (pageCountRow?.page_count as number | undefined) ?? 0;
210
+ const size = (pageSizeRow?.page_size as number | undefined) ?? 0;
211
+ return pages * size;
212
+ } catch (err) {
213
+ log('PRAGMA page_count/page_size failed: %o', err);
214
+ return 0;
141
215
  }
142
216
  }
143
217
 
144
- // --- transactions ---
145
-
146
- async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
147
- const row = await this.stmts.getTransaction.get(blockId, actionId);
148
- return row ? (row.value as Uint8Array) : undefined;
218
+ // --- the write group's sink ---
219
+
220
+ /**
221
+ * Apply the batch as one transaction and resolve with its `durable`. `WriteGroupSink.commit`'s
222
+ * contract: with `syncWithinMs` 0, or a deadline already passed (`SyncDeadlines.mustSyncNow`),
223
+ * the transaction commits under `synchronous = FULL`, which syncs this batch and every one held,
224
+ * and `durable` is already settled; otherwise it commits under `NORMAL` and `durable` settles at
225
+ * the next sync, which the timer makes by the earliest deadline held. A rejection has latched the
226
+ * backend.
227
+ */
228
+ commit(entries: readonly BatchEntry[], { syncWithinMs }: SinkCommitOptions): Promise<CommittedBatch> {
229
+ if (!(syncWithinMs >= 0) || !Number.isFinite(syncWithinMs)) {
230
+ return Promise.reject(new RangeError(`syncWithinMs must be a finite number of milliseconds at or above 0, got ${syncWithinMs}`));
231
+ }
232
+ if (this.fault) return Promise.reject(new StoreFailedError(this.fault.cause));
233
+ const deadline = this.deadlines.deadlineFor(syncWithinMs);
234
+ return this.deadlines.mustSyncNow(deadline) ? this.commitSynced(entries) : this.commitDeferred(entries, deadline);
149
235
  }
150
236
 
151
- async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
152
- await this.stmts.saveTransaction.run(blockId, actionId, value);
237
+ /** Hasten: one synced commit covering every batch held when it runs; with nothing held, nothing is synced. `WriteGroupSink.sync`'s contract. */
238
+ sync(): Promise<void> {
239
+ return this.syncHeld();
153
240
  }
154
241
 
155
- // --- proofs (keyed (block_id, rev) like revisions; no delete — see RawStoreDriver.getProof) ---
156
-
157
- async getProof(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
158
- const row = await this.stmts.getProof.get(blockId, rev);
159
- return row ? (row.value as Uint8Array) : undefined;
242
+ /**
243
+ * The handle is shared, so it is not closed here: only the deadline timer must not outlive the
244
+ * backend. `WriteGroupDriver.close` flushes strictly first, which took and settled everything
245
+ * held and so disarmed the timer; a caller that did not flush gets the same here. After a latch
246
+ * nothing is held, since the latch took and rejected it.
247
+ */
248
+ async close(): Promise<void> {
249
+ if (this.fault) return;
250
+ await this.syncHeld();
160
251
  }
161
252
 
162
- async putProof(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
163
- await this.stmts.saveProof.run(blockId, rev, value);
253
+ private async commitDeferred(entries: readonly BatchEntry[], deadline: number | undefined): Promise<CommittedBatch> {
254
+ try {
255
+ return await this.db.transaction(async (tx) => {
256
+ this.throwIfFailed();
257
+ await this.applyEntries(tx, entries);
258
+ // Deferred under the mutex, so the next sync's cover, also taken under it, includes this
259
+ // batch only once its transaction has committed.
260
+ return { durable: this.deadlines.defer(undefined, deadline) };
261
+ });
262
+ } catch (err) {
263
+ throw this.latch(err);
264
+ }
164
265
  }
165
266
 
166
- // --- materialized ---
167
-
168
- async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
169
- const row = await this.stmts.getMaterialized.get(blockId, actionId);
170
- return row ? (row.value as Uint8Array) : undefined;
267
+ private async commitSynced(entries: readonly BatchEntry[]): Promise<CommittedBatch> {
268
+ await this.syncHeld(undefined, tx => this.applyEntries(tx, entries));
269
+ return { durable: Promise.resolve() };
171
270
  }
172
271
 
173
- async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
174
- await this.stmts.saveMaterialized.run(blockId, actionId, value);
272
+ /**
273
+ * The sync step: one synced transaction whose body runs `write`, if given, then takes the cover
274
+ * of every batch held — under the mutex, so the cover is exactly the batches whose transactions
275
+ * committed before this one — and, when there is a cover to settle and nothing of its own was
276
+ * written, bumps `durability_mark` so the commit has a frame to sync. The cover settles once the
277
+ * commit has resolved. A failure anywhere latches the backend and rejects every covered
278
+ * `durable`, so no flush waits for a sync the store will not make.
279
+ *
280
+ * `owedSince` is the moment the timer fired, when the timer's task runs this: `SyncDeadlines.take`
281
+ * counts the sync's lateness from it rather than from a deadline the timer's own slop missed.
282
+ */
283
+ private async syncHeld(owedSince?: number, write?: (tx: SqliteTransaction) => Promise<number>): Promise<void> {
284
+ // Assigned inside the body, so a failure after the take still reaches the cover it took.
285
+ let cover: SyncCover<undefined> | undefined;
286
+ try {
287
+ await this.db.transaction(async (tx) => {
288
+ this.throwIfFailed();
289
+ const written = write ? await write(tx) : 0;
290
+ cover = this.deadlines.take(owedSince);
291
+ if (written === 0 && cover.items.length > 0) await bumpDurabilityMark(tx);
292
+ }, { synced: true });
293
+ } catch (err) {
294
+ const failure = this.latch(err);
295
+ cover?.fail(failure.cause);
296
+ throw failure;
297
+ }
298
+ cover?.settle();
175
299
  }
176
300
 
177
- async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
178
- await this.stmts.deleteMaterialized.run(blockId, actionId);
301
+ /** The timer fired: run the sync step. Its failure has latched the backend, so it is only logged here. */
302
+ private syncInBackground(firedAt: number): void {
303
+ this.syncHeld(firedAt).catch((err: unknown) => {
304
+ log('deferred sync of %s failed; the store refuses every later write: %o', this.identity, err);
305
+ });
179
306
  }
180
307
 
181
- // --- promote (the only cross-key atomic op) ---
182
-
183
- async promote(blockId: BlockId, actionId: ActionId): Promise<void> {
184
- await this.db.transaction(async (tx) => {
185
- // Prepare against the open transaction so these three statements run on
186
- // the held mutex slot without re-locking the connection (which would
187
- // deadlock). Re-preparing is cheap — the driver caches by SQL text.
188
- const getPending = tx.prepare('SELECT value FROM pending WHERE block_id = ? AND action_id = ?');
189
- const saveTransaction = tx.prepare('INSERT OR REPLACE INTO transactions (block_id, action_id, value) VALUES (?, ?, ?)');
190
- const deletePending = tx.prepare('DELETE FROM pending WHERE block_id = ? AND action_id = ?');
191
- const row = await getPending.get(blockId, actionId);
192
- if (!row) {
193
- throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
308
+ /**
309
+ * Every entry in order — a put as `INSERT OR REPLACE`, a delete as `DELETE` — each statement
310
+ * prepared once per batch against the open transaction. Returns how many were applied.
311
+ */
312
+ private async applyEntries(tx: SqliteTransaction, entries: readonly BatchEntry[]): Promise<number> {
313
+ const prepared = new Map<string, SqliteStatement>();
314
+ const statement = (sql: string): SqliteStatement => {
315
+ let stmt = prepared.get(sql);
316
+ if (!stmt) {
317
+ stmt = tx.prepare(sql);
318
+ prepared.set(sql, stmt);
194
319
  }
195
- await saveTransaction.run(blockId, actionId, row.value as Uint8Array);
196
- await deletePending.run(blockId, actionId);
197
- });
320
+ return stmt;
321
+ };
322
+ for (const { key, value } of entries) {
323
+ if (value === undefined) await statement(DELETE_SQL[key.store]).run(...keyParams(key));
324
+ else await statement(PUT_SQL[key.store]).run(...keyParams(key), value);
325
+ }
326
+ return entries.length;
198
327
  }
199
328
 
200
- // --- optional passthroughs ---
201
-
202
- async *listBlockIds(): AsyncIterable<BlockId> {
203
- const rows = await this.stmts.listBlockIds.all();
204
- for (const row of rows) {
205
- yield row.block_id as BlockId;
206
- }
329
+ private throwIfFailed(): void {
330
+ if (this.fault) throw new StoreFailedError(this.fault.cause);
207
331
  }
208
332
 
209
- async approximateBytesUsed(): Promise<number> {
210
- try {
211
- const pageCountRow = await this.stmts.pageCount.get();
212
- const pageSizeRow = await this.stmts.pageSize.get();
213
- const pages = (pageCountRow?.page_count as number | undefined) ?? 0;
214
- const size = (pageSizeRow?.page_size as number | undefined) ?? 0;
215
- return pages * size;
216
- } catch (err) {
217
- log('PRAGMA page_count/page_size failed: %o', err);
218
- return 0;
219
- }
333
+ /**
334
+ * Latch the backend failed on its first fault and reject every batch waiting for a sync that will
335
+ * now never be made, so no caller waits on a `durable` for ever. Returns the error to throw.
336
+ */
337
+ private latch(err: unknown): StoreFailedError {
338
+ this.fault ??= { cause: err };
339
+ this.deadlines.failAll(err);
340
+ return new StoreFailedError(this.fault.cause);
220
341
  }
221
342
  }
222
343
 
344
+ /** The write group's options (`WriteGroupOptions`); the test-only `setTimer` arms both the group's idle flush and the backend's deadline timer. */
345
+ export type SqliteRawStorageOptions = WriteGroupOptions;
346
+
223
347
  /**
224
- * SQLite-backed {@link IRawStorage} for NativeScript peers, now a thin shell over
225
- * the shared {@link KvRawStorage} kernel driven by a {@link SqliteStoreDriver}.
226
- * The public name/constructor (`new SqliteRawStorage(db)`) is unchanged so
227
- * existing imports keep resolving; the kernel supplies the `IRawStorage` surface
228
- * and the driver supplies SQLite behavior.
348
+ * SQLite-backed {@link IRawStorage} for NativeScript peers: the shared {@link KvRawStorage} kernel
349
+ * over a {@link WriteGroupDriver} over a {@link SqliteBatchBackend}. Every write resolves into the
350
+ * driver's in-memory overlay; `flush()` hands the group to the backend as one batch, which applies
351
+ * it as one SQLite transaction, synced before the flush resolves or within the window the flush
352
+ * allows. The public name/constructor (`new SqliteRawStorage(db)`) is unchanged so existing imports
353
+ * keep resolving.
229
354
  *
230
- * `listBlockIds`/`getApproximateBytesUsed` are re-declared here as always-present
231
- * (the SQLite driver always implements them, so the kernel constructor always
232
- * wires them) — the base declares them optional, but every NS consumer relies on
233
- * them.
355
+ * `listBlockIds`/`getApproximateBytesUsed`/`getStoreIdentity`/`close` are re-declared here as
356
+ * always-present (the backend always implements the first three, so the driver and kernel always
357
+ * wire them, and the driver always has a `close`) — the base declares them optional, but every NS
358
+ * consumer relies on them. `close()` flushes what is still held, syncs it, and disarms the timers;
359
+ * it does not close the shared handle, which the host closes afterwards.
234
360
  */
235
361
  export class SqliteRawStorage extends KvRawStorage {
236
362
  declare listBlockIds: () => AsyncIterable<BlockId>;
237
363
  declare getApproximateBytesUsed: () => Promise<number>;
238
364
  declare getStoreIdentity: () => StoreIdentity;
365
+ declare close: () => Promise<void>;
239
366
 
240
- constructor(db: SqliteDb) {
241
- super(new SqliteStoreDriver(db));
367
+ constructor(db: SqliteDb, options: SqliteRawStorageOptions = {}) {
368
+ super(new WriteGroupDriver(new SqliteBatchBackend(db, options), options));
242
369
  }
243
370
  }
@@ -0,0 +1,24 @@
1
+ import type { SqliteDb, SqliteTransaction } from './db.js';
2
+
3
+ const BUMP_MARK_SQL = 'INSERT INTO durability_mark (id, n) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET n = n + 1';
4
+
5
+ /**
6
+ * Bump the one `durability_mark` row inside an open transaction, so that the transaction has a
7
+ * frame to commit whatever else it wrote. The row's count means nothing; a sync needs a change to
8
+ * commit, because a transaction that changed nothing writes no frame and syncs nothing.
9
+ */
10
+ export async function bumpDurabilityMark(tx: SqliteTransaction): Promise<void> {
11
+ await tx.prepare(BUMP_MARK_SQL).run();
12
+ }
13
+
14
+ /**
15
+ * Sync the database: one synced transaction (`SqliteTransactionOptions.synced`) that bumps the
16
+ * `durability_mark` row. Its commit syncs the write-ahead log, which is one append-only file, so
17
+ * every earlier commit on this database — the block store's, the key-value store's and the identity
18
+ * helper's, all on one connection — survives a power cut once this resolves. Every sync in the
19
+ * package goes through this helper or through a synced transaction that writes rows of its own
20
+ * (`SqliteBatchBackend`).
21
+ */
22
+ export async function syncSqliteDb(db: SqliteDb): Promise<void> {
23
+ await db.transaction(bumpDurabilityMark, { synced: true });
24
+ }