@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.
package/src/db.ts CHANGED
@@ -11,6 +11,10 @@
11
11
  * consumers; the `SqliteDb` interface is an internal seam for tests.
12
12
  */
13
13
 
14
+ import { createLogger } from './logger.js';
15
+
16
+ const log = createLogger('db');
17
+
14
18
  /** SQL parameter value — covers everything `IRawStorage` and `IKVStore` write. */
15
19
  export type SqliteParam = string | number | Uint8Array | null;
16
20
 
@@ -49,6 +53,18 @@ export interface SqliteTransaction {
49
53
  prepare(sql: string): SqliteStatement;
50
54
  }
51
55
 
56
+ /** Options of {@link SqliteDb.transaction}. */
57
+ export interface SqliteTransactionOptions {
58
+ /**
59
+ * Commit under `PRAGMA synchronous = FULL`, which in write-ahead-log mode syncs the log file at
60
+ * this commit. The log is one append-only file, so that sync covers every frame before it: every
61
+ * earlier `NORMAL` commit on this database, whichever store or helper made it. The pragma is set
62
+ * inside the mutex slot the transaction holds and restored to `NORMAL` after the commit, or after
63
+ * the rollback, so no other write on the connection pays for the sync by accident. Default false.
64
+ */
65
+ readonly synced?: boolean;
66
+ }
67
+
52
68
  /**
53
69
  * Minimal SQLite driver surface used by this package.
54
70
  *
@@ -70,6 +86,10 @@ export interface SqliteDb {
70
86
  * Run `fn` inside `BEGIN IMMEDIATE ... COMMIT` (rolls back on throw), holding
71
87
  * the connection mutex for the whole body. `fn` receives a `SqliteTransaction`
72
88
  * whose statements run directly on the open transaction without re-locking.
89
+ * With `options.synced` the commit also syncs the write-ahead log, which makes
90
+ * this transaction and every earlier commit on the database survive a power
91
+ * cut (see {@link SqliteTransactionOptions.synced}); without it the commit
92
+ * survives a crash of this process only, until a later synced commit.
73
93
  *
74
94
  * NOTE: `fn` must issue its writes through the provided `tx`, never through a
75
95
  * class-level statement from `db.prepare(...)` (those re-acquire the mutex and
@@ -77,11 +97,70 @@ export interface SqliteDb {
77
97
  * `db.transaction(...)` again — nested calls deadlock for the same reason. No
78
98
  * current caller does either; guard here if that ever changes.
79
99
  */
80
- transaction<T>(fn: (tx: SqliteTransaction) => Promise<T>): Promise<T>;
100
+ transaction<T>(fn: (tx: SqliteTransaction) => Promise<T>, options?: SqliteTransactionOptions): Promise<T>;
81
101
  /** Release the underlying handle. */
82
102
  close(): Promise<void>;
83
103
  }
84
104
 
105
+ /** The raw connection surface {@link runTransaction} drives: one statement at a time, no rows back. */
106
+ export interface TransactionConnection {
107
+ execute(sql: string): Promise<void> | void;
108
+ }
109
+
110
+ /**
111
+ * The transaction protocol both `SqliteDb` implementations run inside their mutex slot: with
112
+ * `synced`, `PRAGMA synchronous = FULL` first; then `BEGIN IMMEDIATE`, the body, and `COMMIT`, or
113
+ * `ROLLBACK` when the body or the commit throws; then, synced, `PRAGMA synchronous = NORMAL`
114
+ * whichever way the transaction ended, so the connection's default is restored before the slot is
115
+ * released. `BEGIN IMMEDIATE` takes the write lock up front rather than deferring it to the first
116
+ * write, so contention surfaces here and not mid-body.
117
+ *
118
+ * A failure of the restore after a failed body is logged rather than thrown, so the body's error,
119
+ * the one that matters, is the one the caller sees; after a committed body it is thrown, since the
120
+ * connection would otherwise sync every later commit unasked.
121
+ */
122
+ export async function runTransaction<T>(
123
+ conn: TransactionConnection,
124
+ tx: SqliteTransaction,
125
+ fn: (tx: SqliteTransaction) => Promise<T>,
126
+ options?: SqliteTransactionOptions,
127
+ ): Promise<T> {
128
+ const synced = options?.synced === true;
129
+ if (synced) await conn.execute('PRAGMA synchronous = FULL');
130
+ let result: T;
131
+ try {
132
+ result = await commitOrRollBack(conn, tx, fn);
133
+ } catch (err) {
134
+ if (synced) {
135
+ try {
136
+ await conn.execute('PRAGMA synchronous = NORMAL');
137
+ } catch (restoreErr) {
138
+ log('restoring synchronous = NORMAL failed after a failed transaction: %o', restoreErr);
139
+ }
140
+ }
141
+ throw err;
142
+ }
143
+ if (synced) await conn.execute('PRAGMA synchronous = NORMAL');
144
+ return result;
145
+ }
146
+
147
+ async function commitOrRollBack<T>(conn: TransactionConnection, tx: SqliteTransaction, fn: (tx: SqliteTransaction) => Promise<T>): Promise<T> {
148
+ await conn.execute('BEGIN IMMEDIATE');
149
+ try {
150
+ const result = await fn(tx);
151
+ await conn.execute('COMMIT');
152
+ return result;
153
+ } catch (err) {
154
+ try {
155
+ await conn.execute('ROLLBACK');
156
+ } catch (rollbackErr) {
157
+ // The original error is the one to surface; the rollback's is only logged.
158
+ log('rollback failed after a failed transaction: %o', rollbackErr);
159
+ }
160
+ throw err;
161
+ }
162
+ }
163
+
85
164
  export const DEFAULT_DB_NAME = 'optimystic.sqlite';
86
165
  export const DEFAULT_DB_VERSION = 1;
87
166
 
@@ -90,11 +169,11 @@ export const DEFAULT_DB_VERSION = 1;
90
169
  *
91
170
  * Mirrors the IndexedDB object stores 1:1. The six block-storage stores keep
92
171
  * their original columns and keys, but their value columns are now BLOB: the
93
- * shared `KvRawStorage` kernel (driven by `SqliteStoreDriver`) owns all JSON /
94
- * UTF-8 (de)serialization, so the driver only ever binds/reads the kernel's raw
95
- * `Uint8Array` values. SQLite stores a `Uint8Array` as a BLOB and returns it as
96
- * a `Uint8Array`, so bytes round-trip exactly (a TEXT column would risk UTF-8
97
- * coercion corrupting non-ASCII JSON bytes). Keys stay TEXT/INTEGER.
172
+ * shared `KvRawStorage` kernel (over `WriteGroupDriver` over `SqliteBatchBackend`)
173
+ * owns all JSON / UTF-8 (de)serialization, so the backend only ever binds/reads
174
+ * the kernel's raw `Uint8Array` values. SQLite stores a `Uint8Array` as a BLOB
175
+ * and returns it as a `Uint8Array`, so bytes round-trip exactly (a TEXT column
176
+ * would risk UTF-8 coercion corrupting non-ASCII JSON bytes). Keys stay TEXT/INTEGER.
98
177
  * - `metadata` → per-block `BlockMetadata` bytes keyed by `block_id`.
99
178
  * - `revisions` → revision lookup keyed by `(block_id, rev)`; `action_id` column
100
179
  * holds the kernel's encoded `ActionId` bytes (BLOB).
@@ -107,6 +186,12 @@ export const DEFAULT_DB_VERSION = 1;
107
186
  * - `kv` → generic string keyspace shared by `IKVStore` (`s_val`) and the
108
187
  * identity helper (`b_val`). NOT kernel-backed; unchanged. The two columns
109
188
  * avoid a base64 round-trip for the libp2p private key.
189
+ * - `durability_mark` → one row, bumped by `syncSqliteDb` (`sqlite-sync.ts`) so a
190
+ * sync that has no rows of its own to write still has a frame to commit.
191
+ *
192
+ * NOTE: this package's SQL is uppercase throughout, predating the lowercase rule in
193
+ * AGENTS.md, and new statements have kept to it rather than mix cases within one file;
194
+ * convert the package in one pass rather than statement by statement.
110
195
  *
111
196
  * NOTE: value columns changed TEXT→BLOB with the kernel refactor, but every
112
197
  * statement is `CREATE TABLE IF NOT EXISTS` — so a database file created by a
@@ -163,6 +248,11 @@ CREATE TABLE IF NOT EXISTS kv (
163
248
  s_val TEXT,
164
249
  b_val BLOB
165
250
  );
251
+
252
+ CREATE TABLE IF NOT EXISTS durability_mark (
253
+ id INTEGER PRIMARY KEY CHECK (id = 1),
254
+ n INTEGER NOT NULL
255
+ );
166
256
  `;
167
257
 
168
258
  // Assignment-form pragmas return no rows, so they go through exec()/execute().
package/src/identity.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { generateKeyPair, privateKeyFromProtobuf, privateKeyToProtobuf } from '@libp2p/crypto/keys';
2
2
  import type { PrivateKey } from '@libp2p/interface';
3
3
  import type { SqliteDb } from './db.js';
4
+ import { syncSqliteDb } from './sqlite-sync.js';
4
5
 
5
6
  export const DEFAULT_PEER_KEY_NAME = 'peer-private-key';
6
7
 
7
8
  /**
8
9
  * Loads the persisted NativeScript peer's libp2p private key, or generates a
9
- * fresh Ed25519 key on first call and persists it.
10
+ * fresh Ed25519 key on first call, persists it, and syncs the database before
11
+ * returning it, so a power cut right after first start cannot give the node a
12
+ * different peer id next time. An existing key is read as it is.
10
13
  *
11
14
  * The key is stored as raw protobuf bytes in the `kv` table's `b_val` column
12
15
  * under `keyName`. SQLite stores BLOB natively, so there is no base64
@@ -30,5 +33,6 @@ export async function loadOrCreateNSPeerKey(
30
33
  await db
31
34
  .prepare('INSERT INTO kv (key, s_val, b_val) VALUES (?, NULL, ?) ON CONFLICT(key) DO UPDATE SET b_val = excluded.b_val')
32
35
  .run(keyName, bytes);
36
+ await syncSqliteDb(db);
33
37
  return key;
34
38
  }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
- export type { OptimysticNSDBHandle, SqliteDb, SqliteStatement, SqliteParam, SqliteRow } from './db.js';
1
+ export type { OptimysticNSDBHandle, SqliteDb, SqliteStatement, SqliteTransaction, SqliteTransactionOptions, SqliteParam, SqliteRow } from './db.js';
2
2
  export { DEFAULT_DB_NAME, DEFAULT_DB_VERSION } from './db.js';
3
3
  export { openOptimysticNSDb, wrapNSPluginDb } from './ns-opener.js';
4
- export { SqliteRawStorage, SqliteStoreDriver } from './sqlite-storage.js';
4
+ export { SqliteRawStorage, SqliteBatchBackend } from './sqlite-storage.js';
5
+ export type { SqliteRawStorageOptions, SqliteBatchBackendOptions } from './sqlite-storage.js';
6
+ export { syncSqliteDb } from './sqlite-sync.js';
5
7
  export { SqliteKVStore } from './sqlite-kv-store.js';
6
8
  export { loadOrCreateNSPeerKey, DEFAULT_PEER_KEY_NAME } from './identity.js';
package/src/ns-opener.ts CHANGED
@@ -1,5 +1,5 @@
1
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
+ import { applySchema, runTransaction, DEFAULT_DB_NAME, DEFAULT_DB_VERSION, type OptimysticNSDBHandle, type SqliteDb, type SqliteParam, type SqliteRow, type SqliteStatement, type SqliteTransaction, type SqliteTransactionOptions } from './db.js';
3
3
 
4
4
  /**
5
5
  * Minimal subset of `@nativescript-community/sqlite`'s `Db` we depend on.
@@ -86,29 +86,15 @@ class NSPluginDbWrapper implements SqliteDb {
86
86
  return new NSPluginStatement(this.raw, sql, this.mutex);
87
87
  }
88
88
 
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');
94
- try {
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;
110
- }
111
- });
89
+ async transaction<T>(fn: (tx: SqliteTransaction) => Promise<T>, options?: SqliteTransactionOptions): Promise<T> {
90
+ return this.mutex.serialize(() => runTransaction(
91
+ { execute: async (sql) => { await this.raw.execute(sql); } },
92
+ // Statements bound to the open transaction bypass the mutex — we
93
+ // already hold the slot; re-locking would deadlock.
94
+ { prepare: (sql: string) => new NSPluginStatement(this.raw, sql) },
95
+ fn,
96
+ options,
97
+ ));
112
98
  }
113
99
 
114
100
  async close(): Promise<void> {
@@ -141,11 +127,16 @@ class NSPluginStatement implements SqliteStatement {
141
127
  // NOTE: reads run directly on the connection, unserialized, to preserve read
142
128
  // concurrency. A read issued while a write transaction is open on this same
143
129
  // 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 block's write 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.
130
+ // semantics). Fine today: the one transaction writer of the block tables is
131
+ // `SqliteBatchBackend.commit`, and every row it is writing is held by the
132
+ // `WriteGroupDriver` overlay above it until the batch is durable. A point read of
133
+ // such a row is answered by the overlay and never reaches this connection; a
134
+ // listing does reach it, and may see the batch's uncommitted rows, but merges the
135
+ // overlay over them, so it answers the same whether the transaction has committed
136
+ // or not (and a rolled-back batch latches the store). The key-value store and the
137
+ // identity helper write other tables, by autocommit statements, and the sync's
138
+ // `durability_mark` row is read by nobody. If a reader of a batch's rows ever
139
+ // bypasses the overlay, serialize reads too.
149
140
  const row = await this.raw.get(this.sql, params);
150
141
  if (row === null || row === undefined) return undefined;
151
142
  return row as SqliteRow;
@@ -1,5 +1,6 @@
1
- import type { IKVStore } from '@optimystic/db-p2p';
1
+ import { StoreFailedError, type IKVStore } from '@optimystic/db-p2p';
2
2
  import type { SqliteDb, SqliteStatement } from './db.js';
3
+ import { syncSqliteDb } from './sqlite-sync.js';
3
4
 
4
5
  /**
5
6
  * SQLite-backed `IKVStore` adapter for NativeScript peers.
@@ -12,6 +13,13 @@ import type { SqliteDb, SqliteStatement } from './db.js';
12
13
  *
13
14
  * Only the `s_val` column is read or written here; the `b_val` column is
14
15
  * reserved for binary identity material.
16
+ *
17
+ * Writes are autocommit statements: each is visible, and survives a crash of this process, once it
18
+ * resolves, and the write-ahead log keeps them in order. `flush()` makes them survive a power cut:
19
+ * when anything was written since the last flush it runs `syncSqliteDb`, one synced commit that
20
+ * syncs the shared log and with it every earlier write on the connection; otherwise it resolves at
21
+ * once. A failed sync latches the store, after which every write and flush throws
22
+ * `StoreFailedError` (rule 5 of the flush contract on `RawStoreDriver.flush`); reads keep working.
15
23
  */
16
24
  export class SqliteKVStore implements IKVStore {
17
25
  private readonly stmts: {
@@ -21,8 +29,11 @@ export class SqliteKVStore implements IKVStore {
21
29
  list: SqliteStatement;
22
30
  };
23
31
  private readonly prefix: string;
32
+ /** Set once a write has resolved, cleared by the flush that syncs it. */
33
+ private dirty = false;
34
+ private fault: { cause: unknown } | undefined;
24
35
 
25
- constructor(db: SqliteDb, prefix: string = 'optimystic:txn:') {
36
+ constructor(private readonly db: SqliteDb, prefix: string = 'optimystic:txn:') {
26
37
  this.prefix = prefix;
27
38
  this.stmts = {
28
39
  get: db.prepare('SELECT s_val FROM kv WHERE key = ?'),
@@ -40,11 +51,15 @@ export class SqliteKVStore implements IKVStore {
40
51
  }
41
52
 
42
53
  async set(key: string, value: string): Promise<void> {
54
+ this.throwIfFailed();
43
55
  await this.stmts.set.run(this.prefix + key, value);
56
+ this.dirty = true;
44
57
  }
45
58
 
46
59
  async delete(key: string): Promise<void> {
60
+ this.throwIfFailed();
47
61
  await this.stmts.delete.run(this.prefix + key);
62
+ this.dirty = true;
48
63
  }
49
64
 
50
65
  async list(prefix: string): Promise<string[]> {
@@ -58,4 +73,23 @@ export class SqliteKVStore implements IKVStore {
58
73
  const rows = await this.stmts.list.all(fullPrefix, upper);
59
74
  return rows.map(row => (row.key as string).slice(this.prefix.length));
60
75
  }
76
+
77
+ /** `IKVStore.flush`: one synced commit when this store wrote since its last flush, otherwise nothing. */
78
+ async flush(): Promise<void> {
79
+ this.throwIfFailed();
80
+ if (!this.dirty) return;
81
+ // Cleared before the sync, so a write resolving while the sync runs is covered by the next
82
+ // flush whether or not this sync's commit happened to come after it.
83
+ this.dirty = false;
84
+ try {
85
+ await syncSqliteDb(this.db);
86
+ } catch (err) {
87
+ this.fault ??= { cause: err };
88
+ throw new StoreFailedError(this.fault.cause);
89
+ }
90
+ }
91
+
92
+ private throwIfFailed(): void {
93
+ if (this.fault) throw new StoreFailedError(this.fault.cause);
94
+ }
61
95
  }