@optimystic/db-p2p-storage-ns 0.14.1 → 0.16.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/src/ns-opener.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { applySchema, DEFAULT_DB_NAME, DEFAULT_DB_VERSION, type OptimysticNSDBHandle, type SqliteDb, type SqliteParam, type SqliteRow, type SqliteStatement } from './db.js';
1
+ import { ConnectionMutex } from './connection-mutex.js';
2
+ import { applySchema, DEFAULT_DB_NAME, DEFAULT_DB_VERSION, type OptimysticNSDBHandle, type SqliteDb, type SqliteParam, type SqliteRow, type SqliteStatement, type SqliteTransaction } from './db.js';
2
3
 
3
4
  /**
4
5
  * Minimal subset of `@nativescript-community/sqlite`'s `Db` we depend on.
@@ -7,7 +8,7 @@ import { applySchema, DEFAULT_DB_NAME, DEFAULT_DB_VERSION, type OptimysticNSDBHa
7
8
  * at typecheck time on non-NativeScript consumers).
8
9
  */
9
10
  interface NSPluginDb {
10
- execSQL(sql: string, params?: ReadonlyArray<unknown>): unknown;
11
+ execute(sql: string, params?: ReadonlyArray<unknown>): unknown;
11
12
  get(sql: string, params?: ReadonlyArray<unknown>): Promise<Record<string, unknown> | null | undefined> | Record<string, unknown> | null | undefined;
12
13
  select(sql: string, params?: ReadonlyArray<unknown>): Promise<Array<Record<string, unknown>>> | Array<Record<string, unknown>>;
13
14
  close(): void | Promise<void>;
@@ -23,9 +24,13 @@ interface NSPluginModule {
23
24
  * the migration to `version`.
24
25
  *
25
26
  * The returned `SqliteDb` handle is safe to share across `SqliteRawStorage`,
26
- * `SqliteKVStore`, and `loadOrCreateNSPeerKey` — SQLite serializes writes
27
- * inside the connection, and our reads/writes are short-lived enough that
28
- * single-connection contention is a non-issue for a client peer.
27
+ * `SqliteKVStore`, and `loadOrCreateNSPeerKey`. Because SQLite allows at most
28
+ * one open transaction per connection, the wrapper serializes every mutating
29
+ * operation — `exec`, statement `run`, and whole `transaction` bodies — through
30
+ * a per-connection FIFO mutex. Without it, two concurrent `transaction` bodies
31
+ * would each `BEGIN` on the shared connection; the second would nest and its
32
+ * rollback would silently discard the first's still-open writes. Reads
33
+ * (`get`/`all`) stay off the mutex to preserve read concurrency.
29
34
  *
30
35
  * `path` may be passed in as the full filesystem path if the caller wants
31
36
  * to control file placement; otherwise the plugin's documents-directory
@@ -47,44 +52,63 @@ export async function openOptimysticNSDb(
47
52
  * internal `SqliteDb` interface used by the storage classes. Exported only
48
53
  * for callers that already hold an open NS-plugin handle and want to skip
49
54
  * the opener (rare — typically users just call `openOptimysticNSDb`).
55
+ *
56
+ * NOTE: the serialization mutex lives on the wrapper, not the raw handle. Wrap
57
+ * a given raw connection exactly once — two wrappers over one handle each hold
58
+ * their own mutex and would not serialize against each other, reintroducing the
59
+ * cross-rollback this fix closes.
50
60
  */
51
61
  export function wrapNSPluginDb(raw: NSPluginDb): SqliteDb {
52
62
  return new NSPluginDbWrapper(raw);
53
63
  }
54
64
 
55
65
  class NSPluginDbWrapper implements SqliteDb {
66
+ private readonly mutex = new ConnectionMutex();
67
+
56
68
  constructor(private readonly raw: NSPluginDb) {}
57
69
 
58
70
  async exec(sql: string): Promise<void> {
59
- // The plugin's execSQL accepts a single statement; split semicolon-
71
+ // The plugin's execute accepts a single statement; split semicolon-
60
72
  // separated DDL so callers can pass the full schema in one go.
61
73
  const statements = sql
62
74
  .split(';')
63
75
  .map(s => s.trim())
64
76
  .filter(s => s.length > 0);
65
- for (const statement of statements) {
66
- await this.raw.execSQL(statement);
67
- }
77
+ await this.mutex.serialize(async () => {
78
+ for (const statement of statements) {
79
+ await this.raw.execute(statement);
80
+ }
81
+ });
68
82
  }
69
83
 
70
84
  prepare(sql: string): SqliteStatement {
71
- return new NSPluginStatement(this.raw, sql);
85
+ // Outside-transaction statement: writes go through the mutex.
86
+ return new NSPluginStatement(this.raw, sql, this.mutex);
72
87
  }
73
88
 
74
- async transaction<T>(fn: () => Promise<T>): Promise<T> {
75
- await this.raw.execSQL('BEGIN');
76
- try {
77
- const result = await fn();
78
- await this.raw.execSQL('COMMIT');
79
- return result;
80
- } catch (err) {
89
+ async transaction<T>(fn: (tx: SqliteTransaction) => Promise<T>): Promise<T> {
90
+ return this.mutex.serialize(async () => {
91
+ // BEGIN IMMEDIATE takes the write lock up front rather than deferring
92
+ // it to the first write, so contention surfaces here and not mid-body.
93
+ await this.raw.execute('BEGIN IMMEDIATE');
81
94
  try {
82
- await this.raw.execSQL('ROLLBACK');
83
- } catch {
84
- // Swallow rollback failures so we surface the original error.
95
+ // Statements bound to the open transaction bypass the mutex — we
96
+ // already hold the slot; re-locking would deadlock.
97
+ const tx: SqliteTransaction = {
98
+ prepare: (sql: string) => new NSPluginStatement(this.raw, sql),
99
+ };
100
+ const result = await fn(tx);
101
+ await this.raw.execute('COMMIT');
102
+ return result;
103
+ } catch (err) {
104
+ try {
105
+ await this.raw.execute('ROLLBACK');
106
+ } catch {
107
+ // Swallow rollback failures so we surface the original error.
108
+ }
109
+ throw err;
85
110
  }
86
- throw err;
87
- }
111
+ });
88
112
  }
89
113
 
90
114
  async close(): Promise<void> {
@@ -93,19 +117,42 @@ class NSPluginDbWrapper implements SqliteDb {
93
117
  }
94
118
 
95
119
  class NSPluginStatement implements SqliteStatement {
96
- constructor(private readonly raw: NSPluginDb, private readonly sql: string) {}
120
+ /**
121
+ * @param mutex When present, `run` is serialized on the connection mutex
122
+ * (outside-transaction writes). When absent, `run` executes directly on the
123
+ * raw connection — used for statements bound to an already-open transaction
124
+ * that already holds the mutex slot.
125
+ */
126
+ constructor(
127
+ private readonly raw: NSPluginDb,
128
+ private readonly sql: string,
129
+ private readonly mutex?: ConnectionMutex,
130
+ ) {}
97
131
 
98
132
  async run(...params: SqliteParam[]): Promise<void> {
99
- await this.raw.execSQL(this.sql, params);
133
+ if (this.mutex) {
134
+ await this.mutex.serialize(() => this.raw.execute(this.sql, params));
135
+ } else {
136
+ await this.raw.execute(this.sql, params);
137
+ }
100
138
  }
101
139
 
102
140
  async get(...params: SqliteParam[]): Promise<SqliteRow | undefined> {
141
+ // NOTE: reads run directly on the connection, unserialized, to preserve read
142
+ // concurrency. A read issued while a write transaction is open on this same
143
+ // connection observes that transaction's UNCOMMITTED rows (read-your-connection
144
+ // semantics). Fine today: the only transaction writer is same-block promote
145
+ // under the commit latch, and cross-block reads are independent. If a future
146
+ // caller reads a block on this connection while another op's transaction on the
147
+ // same rows is mid-flight, it may see uncommitted state — serialize reads too if
148
+ // that ever matters.
103
149
  const row = await this.raw.get(this.sql, params);
104
150
  if (row === null || row === undefined) return undefined;
105
151
  return row as SqliteRow;
106
152
  }
107
153
 
108
154
  async all(...params: SqliteParam[]): Promise<SqliteRow[]> {
155
+ // NOTE: unserialized read — see the note on `get` above re: uncommitted reads.
109
156
  const rows = await this.raw.select(this.sql, params);
110
157
  return rows as SqliteRow[];
111
158
  }
@@ -1,25 +1,29 @@
1
- import type { ActionId, ActionRev, BlockId, IBlock, Transform } from '@optimystic/db-core';
2
- import type { BlockMetadata, IRawStorage } from '@optimystic/db-p2p';
1
+ import type { ActionId, BlockId } from '@optimystic/db-core';
2
+ import { KvRawStorage, type RawStoreDriver } from '@optimystic/db-p2p';
3
3
  import type { SqliteDb, SqliteStatement } from './db.js';
4
4
  import { createLogger } from './logger.js';
5
5
 
6
6
  const log = createLogger('storage:sqlite');
7
7
 
8
8
  /**
9
- * SQLite-backed `IRawStorage` implementation for NativeScript peers.
9
+ * SQLite {@link RawStoreDriver}: the five logical block-storage stores mapped to
10
+ * the five relational tables (`metadata`, `revisions`, `pending`, `transactions`,
11
+ * `materialized`) with their original columns and keys. This is a code refactor,
12
+ * not a storage-format change at the SQL level.
10
13
  *
11
- * Every CRUD operation goes through a prepared statement bound once in the
12
- * constructor. `listRevisions` and `listPendingTransactions` issue
13
- * `SELECT … ORDER BY` queries bounded by `block_id` (and `rev` for revisions)
14
- * and drain the results into an array before yielding — matching the
15
- * `IndexedDBRawStorage` pattern, where holding a cursor live across consumer
16
- * awaits would invalidate the underlying transaction.
17
- *
18
- * `promotePendingTransaction` runs as a single `BEGIN; INSERT…; DELETE…; COMMIT;`
19
- * via `SqliteDb.transaction(fn)`, so the move is atomic across crashes —
20
- * unlike the MMKV adapter, which has to maintain a separate pending-index row.
14
+ * `KvRawStorage` now owns all JSON serialization, so this driver only ever
15
+ * reads/writes `Uint8Array` values: the value columns are BLOB and SQLite binds
16
+ * a `Uint8Array` as a BLOB and returns it as a `Uint8Array`, so no codec lives
17
+ * here (a TEXT column would risk UTF-8 coercion corrupting non-ASCII JSON bytes).
18
+ * Everything SQLite-specific stays: each CRUD op goes through a prepared
19
+ * statement bound once in the constructor; range/list queries drain their rows
20
+ * (`.all(...)`) before yielding so no cursor straddles a consumer's `await`; and
21
+ * `promote` runs as a single `db.transaction(fn)` — `BEGIN IMMEDIATE; INSERT…;
22
+ * DELETE…; COMMIT;` — whose three statements are re-prepared against the OPEN
23
+ * transaction so they run on the held mutex slot without re-locking (which would
24
+ * deadlock — see `st-nativescript-sqlite-transaction-mutex`).
21
25
  */
22
- export class SqliteRawStorage implements IRawStorage {
26
+ export class SqliteStoreDriver implements RawStoreDriver {
23
27
  private readonly stmts: {
24
28
  getMetadata: SqliteStatement;
25
29
  saveMetadata: SqliteStatement;
@@ -31,6 +35,7 @@ export class SqliteRawStorage implements IRawStorage {
31
35
  savePending: SqliteStatement;
32
36
  deletePending: SqliteStatement;
33
37
  listPending: SqliteStatement;
38
+ listBlockIds: SqliteStatement;
34
39
  getTransaction: SqliteStatement;
35
40
  saveTransaction: SqliteStatement;
36
41
  getMaterialized: SqliteStatement;
@@ -52,6 +57,11 @@ export class SqliteRawStorage implements IRawStorage {
52
57
  savePending: db.prepare('INSERT OR REPLACE INTO pending (block_id, action_id, value) VALUES (?, ?, ?)'),
53
58
  deletePending: db.prepare('DELETE FROM pending WHERE block_id = ? AND action_id = ?'),
54
59
  listPending: db.prepare('SELECT action_id FROM pending WHERE block_id = ? ORDER BY action_id ASC'),
60
+ // metadata.block_id is the PRIMARY KEY, so each row is a distinct block id —
61
+ // no dedup needed. NOTE: drains the whole metadata table up front; if a peer
62
+ // ever holds millions of blocks and this SELECT becomes a startup-latency
63
+ // problem, page it (LIMIT/OFFSET or keyset) — fine at current scale.
64
+ listBlockIds: db.prepare('SELECT block_id FROM metadata'),
55
65
  getTransaction: db.prepare('SELECT value FROM transactions WHERE block_id = ? AND action_id = ?'),
56
66
  saveTransaction: db.prepare('INSERT OR REPLACE INTO transactions (block_id, action_id, value) VALUES (?, ?, ?)'),
57
67
  getMaterialized: db.prepare('SELECT value FROM materialized WHERE block_id = ? AND action_id = ?'),
@@ -62,82 +72,116 @@ export class SqliteRawStorage implements IRawStorage {
62
72
  };
63
73
  }
64
74
 
65
- async getMetadata(blockId: BlockId): Promise<BlockMetadata | undefined> {
75
+ // --- metadata ---
76
+
77
+ async getMetadata(blockId: BlockId): Promise<Uint8Array | undefined> {
66
78
  const row = await this.stmts.getMetadata.get(blockId);
67
- if (!row) return undefined;
68
- return JSON.parse(row.value as string) as BlockMetadata;
79
+ return row ? (row.value as Uint8Array) : undefined;
69
80
  }
70
81
 
71
- async saveMetadata(blockId: BlockId, metadata: BlockMetadata): Promise<void> {
72
- await this.stmts.saveMetadata.run(blockId, JSON.stringify(metadata));
82
+ async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
83
+ await this.stmts.saveMetadata.run(blockId, value);
73
84
  }
74
85
 
75
- async getRevision(blockId: BlockId, rev: number): Promise<ActionId | undefined> {
86
+ // --- revisions ---
87
+
88
+ async getRevision(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
76
89
  const row = await this.stmts.getRevision.get(blockId, rev);
77
- return row ? (row.action_id as ActionId) : undefined;
90
+ return row ? (row.action_id as Uint8Array) : undefined;
78
91
  }
79
92
 
80
- async saveRevision(blockId: BlockId, rev: number, actionId: ActionId): Promise<void> {
81
- await this.stmts.saveRevision.run(blockId, rev, actionId);
93
+ async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
94
+ await this.stmts.saveRevision.run(blockId, rev, value);
82
95
  }
83
96
 
84
- async *listRevisions(blockId: BlockId, startRev: number, endRev: number): AsyncIterable<ActionRev> {
85
- const ascending = startRev <= endRev;
86
- const lo = ascending ? startRev : endRev;
87
- const hi = ascending ? endRev : startRev;
88
- const stmt = ascending ? this.stmts.listRevisionsAsc : this.stmts.listRevisionsDesc;
97
+ async *rangeRevisions(blockId: BlockId, lo: number, hi: number, reverse: boolean): AsyncIterable<[number, Uint8Array]> {
98
+ // `.all(...)` materializes every row before we yield, so no SQLite cursor
99
+ // straddles the consumer's awaits (the kernel's drain-before-yield contract).
100
+ const stmt = reverse ? this.stmts.listRevisionsDesc : this.stmts.listRevisionsAsc;
89
101
  const rows = await stmt.all(blockId, lo, hi);
90
102
  for (const row of rows) {
91
- yield { rev: row.rev as number, actionId: row.action_id as ActionId };
103
+ yield [row.rev as number, row.action_id as Uint8Array];
92
104
  }
93
105
  }
94
106
 
95
- async getPendingTransaction(blockId: BlockId, actionId: ActionId): Promise<Transform | undefined> {
107
+ // --- pending ---
108
+
109
+ async getPending(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
96
110
  const row = await this.stmts.getPending.get(blockId, actionId);
97
- if (!row) return undefined;
98
- return JSON.parse(row.value as string) as Transform;
111
+ return row ? (row.value as Uint8Array) : undefined;
99
112
  }
100
113
 
101
- async savePendingTransaction(blockId: BlockId, actionId: ActionId, transform: Transform): Promise<void> {
102
- await this.stmts.savePending.run(blockId, actionId, JSON.stringify(transform));
114
+ async putPending(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
115
+ await this.stmts.savePending.run(blockId, actionId, value);
103
116
  }
104
117
 
105
- async deletePendingTransaction(blockId: BlockId, actionId: ActionId): Promise<void> {
118
+ async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
106
119
  await this.stmts.deletePending.run(blockId, actionId);
107
120
  }
108
121
 
109
- async *listPendingTransactions(blockId: BlockId): AsyncIterable<ActionId> {
122
+ async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
123
+ // Drained by `.all(...)` before yielding — same rationale as rangeRevisions.
110
124
  const rows = await this.stmts.listPending.all(blockId);
111
125
  for (const row of rows) {
112
126
  yield row.action_id as ActionId;
113
127
  }
114
128
  }
115
129
 
116
- async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Transform | undefined> {
130
+ // --- transactions ---
131
+
132
+ async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
117
133
  const row = await this.stmts.getTransaction.get(blockId, actionId);
118
- if (!row) return undefined;
119
- return JSON.parse(row.value as string) as Transform;
134
+ return row ? (row.value as Uint8Array) : undefined;
120
135
  }
121
136
 
122
- async saveTransaction(blockId: BlockId, actionId: ActionId, transform: Transform): Promise<void> {
123
- await this.stmts.saveTransaction.run(blockId, actionId, JSON.stringify(transform));
137
+ async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
138
+ await this.stmts.saveTransaction.run(blockId, actionId, value);
124
139
  }
125
140
 
126
- async getMaterializedBlock(blockId: BlockId, actionId: ActionId): Promise<IBlock | undefined> {
141
+ // --- materialized ---
142
+
143
+ async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
127
144
  const row = await this.stmts.getMaterialized.get(blockId, actionId);
128
- if (!row) return undefined;
129
- return JSON.parse(row.value as string) as IBlock;
145
+ return row ? (row.value as Uint8Array) : undefined;
146
+ }
147
+
148
+ async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
149
+ await this.stmts.saveMaterialized.run(blockId, actionId, value);
150
+ }
151
+
152
+ async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
153
+ await this.stmts.deleteMaterialized.run(blockId, actionId);
154
+ }
155
+
156
+ // --- promote (the only cross-key atomic op) ---
157
+
158
+ async promote(blockId: BlockId, actionId: ActionId): Promise<void> {
159
+ await this.db.transaction(async (tx) => {
160
+ // Prepare against the open transaction so these three statements run on
161
+ // the held mutex slot without re-locking the connection (which would
162
+ // deadlock). Re-preparing is cheap — the driver caches by SQL text.
163
+ const getPending = tx.prepare('SELECT value FROM pending WHERE block_id = ? AND action_id = ?');
164
+ const saveTransaction = tx.prepare('INSERT OR REPLACE INTO transactions (block_id, action_id, value) VALUES (?, ?, ?)');
165
+ const deletePending = tx.prepare('DELETE FROM pending WHERE block_id = ? AND action_id = ?');
166
+ const row = await getPending.get(blockId, actionId);
167
+ if (!row) {
168
+ throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
169
+ }
170
+ await saveTransaction.run(blockId, actionId, row.value as Uint8Array);
171
+ await deletePending.run(blockId, actionId);
172
+ });
130
173
  }
131
174
 
132
- async saveMaterializedBlock(blockId: BlockId, actionId: ActionId, block?: IBlock): Promise<void> {
133
- if (block) {
134
- await this.stmts.saveMaterialized.run(blockId, actionId, JSON.stringify(block));
135
- } else {
136
- await this.stmts.deleteMaterialized.run(blockId, actionId);
175
+ // --- optional passthroughs ---
176
+
177
+ async *listBlockIds(): AsyncIterable<BlockId> {
178
+ const rows = await this.stmts.listBlockIds.all();
179
+ for (const row of rows) {
180
+ yield row.block_id as BlockId;
137
181
  }
138
182
  }
139
183
 
140
- async getApproximateBytesUsed(): Promise<number> {
184
+ async approximateBytesUsed(): Promise<number> {
141
185
  try {
142
186
  const pageCountRow = await this.stmts.pageCount.get();
143
187
  const pageSizeRow = await this.stmts.pageSize.get();
@@ -149,15 +193,25 @@ export class SqliteRawStorage implements IRawStorage {
149
193
  return 0;
150
194
  }
151
195
  }
196
+ }
152
197
 
153
- async promotePendingTransaction(blockId: BlockId, actionId: ActionId): Promise<void> {
154
- await this.db.transaction(async () => {
155
- const row = await this.stmts.getPending.get(blockId, actionId);
156
- if (!row) {
157
- throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
158
- }
159
- await this.stmts.saveTransaction.run(blockId, actionId, row.value as string);
160
- await this.stmts.deletePending.run(blockId, actionId);
161
- });
198
+ /**
199
+ * SQLite-backed {@link IRawStorage} for NativeScript peers, now a thin shell over
200
+ * the shared {@link KvRawStorage} kernel driven by a {@link SqliteStoreDriver}.
201
+ * The public name/constructor (`new SqliteRawStorage(db)`) is unchanged so
202
+ * existing imports keep resolving; the kernel supplies the `IRawStorage` surface
203
+ * and the driver supplies SQLite behavior.
204
+ *
205
+ * `listBlockIds`/`getApproximateBytesUsed` are re-declared here as always-present
206
+ * (the SQLite driver always implements them, so the kernel constructor always
207
+ * wires them) — the base declares them optional, but every NS consumer relies on
208
+ * them.
209
+ */
210
+ export class SqliteRawStorage extends KvRawStorage {
211
+ declare listBlockIds: () => AsyncIterable<BlockId>;
212
+ declare getApproximateBytesUsed: () => Promise<number>;
213
+
214
+ constructor(db: SqliteDb) {
215
+ super(new SqliteStoreDriver(db));
162
216
  }
163
217
  }