@optimystic/db-p2p-storage-ns 1.5.0 → 1.5.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/sqlite-storage.ts +243 -243
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimystic/db-p2p-storage-ns",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "type": "module",
5
5
  "description": "NativeScript SQLite storage backend for @optimystic/db-p2p",
6
6
  "main": "dist/src/index.js",
@@ -53,8 +53,8 @@
53
53
  "typescript": "^5.9.3"
54
54
  },
55
55
  "dependencies": {
56
- "@optimystic/db-core": "^1.5.0",
57
- "@optimystic/db-p2p": "^1.5.0",
56
+ "@optimystic/db-core": "^1.5.1",
57
+ "@optimystic/db-p2p": "^1.5.1",
58
58
  "debug": "^4.4.3"
59
59
  },
60
60
  "peerDependencies": {
@@ -1,243 +1,243 @@
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';
4
- import { createLogger } from './logger.js';
5
-
6
- const log = createLogger('storage:sqlite');
7
-
8
- /**
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.
12
- *
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`).
24
- */
25
- export class SqliteStoreDriver implements RawStoreDriver {
26
- 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;
36
- listPending: SqliteStatement;
37
- listBlockIds: SqliteStatement;
38
- getTransaction: SqliteStatement;
39
- saveTransaction: SqliteStatement;
40
- getProof: SqliteStatement;
41
- saveProof: SqliteStatement;
42
- getMaterialized: SqliteStatement;
43
- saveMaterialized: SqliteStatement;
44
- deleteMaterialized: SqliteStatement;
45
- pageCount: SqliteStatement;
46
- pageSize: SqliteStatement;
47
- };
48
-
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;
54
-
55
- constructor(private readonly db: SqliteDb) {
56
- this.identity = identityForHandle('sqlite-handle', db);
57
- 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 = ?'),
67
- listPending: db.prepare('SELECT action_id FROM pending WHERE block_id = ? ORDER BY action_id ASC'),
68
- // metadata.block_id is the PRIMARY KEY, so each row is a distinct block id —
69
- // no dedup needed. NOTE: drains the whole metadata table up front; if a peer
70
- // ever holds millions of blocks and this SELECT becomes a startup-latency
71
- // problem, page it (LIMIT/OFFSET or keyset) — fine at current scale.
72
- 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
- pageCount: db.prepare('PRAGMA page_count'),
81
- pageSize: db.prepare('PRAGMA page_size'),
82
- };
83
- }
84
-
85
- storeIdentity(): StoreIdentity {
86
- return this.identity;
87
- }
88
-
89
- // --- metadata ---
90
-
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;
94
- }
95
-
96
- async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
97
- await this.stmts.saveMetadata.run(blockId, value);
98
- }
99
-
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);
109
- }
110
-
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);
116
- for (const row of rows) {
117
- yield [row.rev as number, row.action_id as Uint8Array];
118
- }
119
- }
120
-
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;
141
- }
142
- }
143
-
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;
149
- }
150
-
151
- async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
152
- await this.stmts.saveTransaction.run(blockId, actionId, value);
153
- }
154
-
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;
160
- }
161
-
162
- async putProof(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
163
- await this.stmts.saveProof.run(blockId, rev, value);
164
- }
165
-
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;
171
- }
172
-
173
- async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
174
- await this.stmts.saveMaterialized.run(blockId, actionId, value);
175
- }
176
-
177
- async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
178
- await this.stmts.deleteMaterialized.run(blockId, actionId);
179
- }
180
-
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}`);
194
- }
195
- await saveTransaction.run(blockId, actionId, row.value as Uint8Array);
196
- await deletePending.run(blockId, actionId);
197
- });
198
- }
199
-
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
- }
207
- }
208
-
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
- }
220
- }
221
- }
222
-
223
- /**
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.
229
- *
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.
234
- */
235
- export class SqliteRawStorage extends KvRawStorage {
236
- declare listBlockIds: () => AsyncIterable<BlockId>;
237
- declare getApproximateBytesUsed: () => Promise<number>;
238
- declare getStoreIdentity: () => StoreIdentity;
239
-
240
- constructor(db: SqliteDb) {
241
- super(new SqliteStoreDriver(db));
242
- }
243
- }
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';
4
+ import { createLogger } from './logger.js';
5
+
6
+ const log = createLogger('storage:sqlite');
7
+
8
+ /**
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.
12
+ *
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`).
24
+ */
25
+ export class SqliteStoreDriver implements RawStoreDriver {
26
+ 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;
36
+ listPending: SqliteStatement;
37
+ listBlockIds: SqliteStatement;
38
+ getTransaction: SqliteStatement;
39
+ saveTransaction: SqliteStatement;
40
+ getProof: SqliteStatement;
41
+ saveProof: SqliteStatement;
42
+ getMaterialized: SqliteStatement;
43
+ saveMaterialized: SqliteStatement;
44
+ deleteMaterialized: SqliteStatement;
45
+ pageCount: SqliteStatement;
46
+ pageSize: SqliteStatement;
47
+ };
48
+
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;
54
+
55
+ constructor(private readonly db: SqliteDb) {
56
+ this.identity = identityForHandle('sqlite-handle', db);
57
+ 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 = ?'),
67
+ listPending: db.prepare('SELECT action_id FROM pending WHERE block_id = ? ORDER BY action_id ASC'),
68
+ // metadata.block_id is the PRIMARY KEY, so each row is a distinct block id —
69
+ // no dedup needed. NOTE: drains the whole metadata table up front; if a peer
70
+ // ever holds millions of blocks and this SELECT becomes a startup-latency
71
+ // problem, page it (LIMIT/OFFSET or keyset) — fine at current scale.
72
+ 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
+ pageCount: db.prepare('PRAGMA page_count'),
81
+ pageSize: db.prepare('PRAGMA page_size'),
82
+ };
83
+ }
84
+
85
+ storeIdentity(): StoreIdentity {
86
+ return this.identity;
87
+ }
88
+
89
+ // --- metadata ---
90
+
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;
94
+ }
95
+
96
+ async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
97
+ await this.stmts.saveMetadata.run(blockId, value);
98
+ }
99
+
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);
109
+ }
110
+
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);
116
+ for (const row of rows) {
117
+ yield [row.rev as number, row.action_id as Uint8Array];
118
+ }
119
+ }
120
+
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;
141
+ }
142
+ }
143
+
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;
149
+ }
150
+
151
+ async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
152
+ await this.stmts.saveTransaction.run(blockId, actionId, value);
153
+ }
154
+
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;
160
+ }
161
+
162
+ async putProof(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
163
+ await this.stmts.saveProof.run(blockId, rev, value);
164
+ }
165
+
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;
171
+ }
172
+
173
+ async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
174
+ await this.stmts.saveMaterialized.run(blockId, actionId, value);
175
+ }
176
+
177
+ async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
178
+ await this.stmts.deleteMaterialized.run(blockId, actionId);
179
+ }
180
+
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}`);
194
+ }
195
+ await saveTransaction.run(blockId, actionId, row.value as Uint8Array);
196
+ await deletePending.run(blockId, actionId);
197
+ });
198
+ }
199
+
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
+ }
207
+ }
208
+
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
+ }
220
+ }
221
+ }
222
+
223
+ /**
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.
229
+ *
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.
234
+ */
235
+ export class SqliteRawStorage extends KvRawStorage {
236
+ declare listBlockIds: () => AsyncIterable<BlockId>;
237
+ declare getApproximateBytesUsed: () => Promise<number>;
238
+ declare getStoreIdentity: () => StoreIdentity;
239
+
240
+ constructor(db: SqliteDb) {
241
+ super(new SqliteStoreDriver(db));
242
+ }
243
+ }