@chipmobilesdk/rn-local-db 0.1.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +726 -0
  3. package/package.json +61 -0
  4. package/src/adapter/budgets.ts +67 -0
  5. package/src/adapter/collection.ts +298 -0
  6. package/src/adapter/deletion.ts +238 -0
  7. package/src/adapter/index.ts +68 -0
  8. package/src/adapter/lifecycle.ts +444 -0
  9. package/src/adapter/transaction.ts +65 -0
  10. package/src/artifacts/availability.ts +69 -0
  11. package/src/artifacts/cascade.ts +78 -0
  12. package/src/artifacts/paths.ts +29 -0
  13. package/src/artifacts/reconcile.ts +63 -0
  14. package/src/artifacts/store.ts +323 -0
  15. package/src/contract/adapter.ts +78 -0
  16. package/src/contract/attachment.ts +72 -0
  17. package/src/contract/backup.ts +28 -0
  18. package/src/contract/config.ts +54 -0
  19. package/src/contract/encryption.ts +34 -0
  20. package/src/contract/errors.ts +76 -0
  21. package/src/contract/migration.ts +40 -0
  22. package/src/contract/query.ts +81 -0
  23. package/src/contract/record.ts +42 -0
  24. package/src/contract/schema.ts +36 -0
  25. package/src/contract/scope.ts +36 -0
  26. package/src/contract/transaction.ts +46 -0
  27. package/src/crypto/chunked-cipher.ts +144 -0
  28. package/src/crypto/derive.ts +63 -0
  29. package/src/crypto/key-provider.ts +78 -0
  30. package/src/crypto/provider.ts +81 -0
  31. package/src/diagnostics/dev-log.ts +108 -0
  32. package/src/diagnostics/errors.ts +66 -0
  33. package/src/diagnostics/faults.ts +64 -0
  34. package/src/diagnostics/map-native.ts +106 -0
  35. package/src/diagnostics/redact.ts +141 -0
  36. package/src/index.ts +110 -0
  37. package/src/migration/executor.ts +136 -0
  38. package/src/migration/ledger.ts +97 -0
  39. package/src/migration/validate-chain.ts +65 -0
  40. package/src/platform/backup-android-verify.ts +88 -0
  41. package/src/platform/backup-ios.ts +48 -0
  42. package/src/platform/fs.ts +89 -0
  43. package/src/platform/location.ts +135 -0
  44. package/src/query/budgets.ts +57 -0
  45. package/src/query/compare.ts +91 -0
  46. package/src/query/cursor.ts +99 -0
  47. package/src/query/filter.ts +82 -0
  48. package/src/query/paginate.ts +51 -0
  49. package/src/query/sort.ts +56 -0
  50. package/src/query/validate.ts +171 -0
  51. package/src/schema/declare.ts +45 -0
  52. package/src/schema/validate-config.ts +158 -0
  53. package/src/scope/hash.ts +134 -0
  54. package/src/scope/normalize.ts +90 -0
  55. package/src/scope/validate.ts +83 -0
  56. package/src/sqlite/adapter.ts +26 -0
  57. package/src/sqlite/batch.ts +232 -0
  58. package/src/sqlite/catalog.ts +108 -0
  59. package/src/sqlite/connection.ts +205 -0
  60. package/src/sqlite/index-coverage.ts +53 -0
  61. package/src/sqlite/native.ts +83 -0
  62. package/src/sqlite/row-codec.ts +201 -0
  63. package/src/sqlite/sql-builder.ts +244 -0
  64. package/src/sqlite/transactions.ts +33 -0
  65. package/src/sqlite/values.ts +24 -0
  66. package/src/testing/conformance/attachments.ts +136 -0
  67. package/src/testing/conformance/encryption.ts +150 -0
  68. package/src/testing/conformance/index.ts +161 -0
  69. package/src/testing/conformance/lifecycle.ts +99 -0
  70. package/src/testing/conformance/migrations.ts +177 -0
  71. package/src/testing/conformance/queries.ts +167 -0
  72. package/src/testing/conformance/records.ts +169 -0
  73. package/src/testing/conformance/scopes.ts +88 -0
  74. package/src/testing/conformance/transactions.ts +149 -0
  75. package/src/testing/faults.ts +21 -0
  76. package/src/testing/index.ts +21 -0
  77. package/src/testing/memory-adapter/collection.ts +219 -0
  78. package/src/testing/memory-adapter/index.ts +388 -0
  79. package/src/testing/memory-adapter/migrations.ts +119 -0
  80. package/src/testing/memory-adapter/reset.ts +50 -0
  81. package/src/testing/memory-adapter/store.ts +134 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The on-device adapter.
3
+ *
4
+ * Deliberately a seam rather than an implementation. Everything it composes
5
+ * lives in `adapter/`, `migration/`, `artifacts/`, and this directory, so the
6
+ * in-memory adapter can be held to the same contract by the same conformance
7
+ * suite (FR-092) without either one being the "real" version the other
8
+ * approximates.
9
+ *
10
+ * Reached only through the lazy require in `adapter/index.ts`, so a consumer
11
+ * that runs the in-memory adapter in tests never loads the native peers.
12
+ */
13
+
14
+ import type { StorageAdapter } from '../contract/adapter';
15
+ import { deleteDatabase, deleteScopeData, resetScope } from '../adapter/deletion';
16
+ import { openSqliteDatabase, type OpenOptions } from '../adapter/lifecycle';
17
+
18
+ export function createSqliteAdapter(options: OpenOptions = {}): StorageAdapter {
19
+ return {
20
+ name: 'sqlite',
21
+ open: config => openSqliteDatabase(config, options),
22
+ deleteScopeData,
23
+ deleteDatabase,
24
+ resetScope,
25
+ };
26
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Batch writes: many operations, one atomic unit, one outcome (FR-028).
3
+ *
4
+ * Every statement is resolved before any of them runs. That ordering is the
5
+ * point: a conflict or a missing record is reported with the database still
6
+ * untouched, so "the batch failed" and "the batch was partly applied" are
7
+ * never the same event. The statements then go to the engine's own
8
+ * `executeBatch`, which wraps them in a single transaction — the batch is
9
+ * atomic because SQLite makes it atomic, not because this file is careful.
10
+ *
11
+ * Resolution keeps an overlay of what earlier operations in the same batch
12
+ * did, so `insert` followed by `update` on the same id resolves against the
13
+ * batch's own effect rather than against the database as it was before it.
14
+ */
15
+
16
+ import { DiagnosticCodes } from '../contract/errors';
17
+ import type { WriteOutcome } from '../contract/record';
18
+ import type { CollectionSchema } from '../contract/schema';
19
+ import type { BatchOperation, BatchOutcome } from '../contract/transaction';
20
+ import { raise } from '../diagnostics/errors';
21
+ import { assertBatchWithinBudget, assertRecordWithinBudget } from '../query/budgets';
22
+ import { collectionTable, hasSoftDelete } from '../schema/declare';
23
+ import type { Connection } from './connection';
24
+ import type { SqlValue } from './native';
25
+ import { insertColumns, updateAssignments } from './row-codec';
26
+
27
+ export type BatchContext = {
28
+ connection: Connection;
29
+ /** Raises CONFIG_INVALID for a collection the schema never declared. */
30
+ schemaFor(name: string): CollectionSchema;
31
+ now(): number;
32
+ };
33
+
34
+ type Command = [string, SqlValue[]];
35
+
36
+ function quoted(schema: CollectionSchema): string {
37
+ return `"${collectionTable(schema.name)}"`;
38
+ }
39
+
40
+ function insertCommand(
41
+ schema: CollectionSchema,
42
+ id: string,
43
+ data: Parameters<typeof insertColumns>[2],
44
+ now: number,
45
+ ): Command {
46
+ const { columns, values } = insertColumns(schema, id, data, now);
47
+ return [
48
+ `INSERT INTO ${quoted(schema)} (${columns
49
+ .map(name => `"${name}"`)
50
+ .join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
51
+ values,
52
+ ];
53
+ }
54
+
55
+ function updateCommand(
56
+ schema: CollectionSchema,
57
+ id: string,
58
+ data: Parameters<typeof updateAssignments>[1],
59
+ now: number,
60
+ restore: boolean,
61
+ ): Command {
62
+ const { columns, values } = updateAssignments(schema, data, now);
63
+ const assignments = columns.map(name => `"${name}" = ?`);
64
+ if (restore && hasSoftDelete(schema)) {
65
+ assignments.push('deleted_at = NULL');
66
+ }
67
+ return [`UPDATE ${quoted(schema)} SET ${assignments.join(', ')} WHERE id = ?`, [...values, id]];
68
+ }
69
+
70
+ type Resolver = {
71
+ schema: CollectionSchema;
72
+ now: number;
73
+ /** Existence as this batch has left it so far, not as the database was. */
74
+ exists(id: string): Promise<boolean>;
75
+ stage(id: string, present: boolean): void;
76
+ };
77
+
78
+ type Resolved = { command: Command; outcome: WriteOutcome };
79
+
80
+ function conflict(schema: CollectionSchema, id: string): never {
81
+ raise(DiagnosticCodes.RECORD_CONFLICT, {
82
+ message: 'record already exists',
83
+ detail: { collection: schema.name, recordId: id },
84
+ });
85
+ }
86
+
87
+ function missing(schema: CollectionSchema, id: string): never {
88
+ raise(DiagnosticCodes.RECORD_NOT_FOUND, {
89
+ message: 'record not found',
90
+ detail: { collection: schema.name, recordId: id },
91
+ });
92
+ }
93
+
94
+ async function resolveInsert(
95
+ resolver: Resolver,
96
+ operation: Extract<BatchOperation, { type: 'insert' }>,
97
+ ): Promise<Resolved> {
98
+ const { schema, now } = resolver;
99
+ assertRecordWithinBudget(schema.name, operation.record.data);
100
+ if (await resolver.exists(operation.record.id)) {
101
+ conflict(schema, operation.record.id);
102
+ }
103
+ resolver.stage(operation.record.id, true);
104
+ return {
105
+ command: insertCommand(schema, operation.record.id, operation.record.data, now),
106
+ outcome: { operation: 'created', id: operation.record.id },
107
+ };
108
+ }
109
+
110
+ async function resolveUpdate(
111
+ resolver: Resolver,
112
+ operation: Extract<BatchOperation, { type: 'update' }>,
113
+ ): Promise<Resolved> {
114
+ const { schema, now } = resolver;
115
+ assertRecordWithinBudget(schema.name, operation.data);
116
+ if (!(await resolver.exists(operation.id))) {
117
+ missing(schema, operation.id);
118
+ }
119
+ return {
120
+ command: updateCommand(schema, operation.id, operation.data, now, false),
121
+ outcome: { operation: 'updated', id: operation.id },
122
+ };
123
+ }
124
+
125
+ async function resolveUpsert(
126
+ resolver: Resolver,
127
+ operation: Extract<BatchOperation, { type: 'upsert' }>,
128
+ ): Promise<Resolved> {
129
+ const { schema, now } = resolver;
130
+ assertRecordWithinBudget(schema.name, operation.record.data);
131
+ const present = await resolver.exists(operation.record.id);
132
+ resolver.stage(operation.record.id, true);
133
+ return {
134
+ command: present
135
+ ? updateCommand(schema, operation.record.id, operation.record.data, now, true)
136
+ : insertCommand(schema, operation.record.id, operation.record.data, now),
137
+ outcome: { operation: present ? 'updated' : 'created', id: operation.record.id },
138
+ };
139
+ }
140
+
141
+ async function resolveDelete(
142
+ resolver: Resolver,
143
+ operation: Extract<BatchOperation, { type: 'delete' }>,
144
+ ): Promise<Resolved> {
145
+ const { schema } = resolver;
146
+ if (!(await resolver.exists(operation.id))) {
147
+ missing(schema, operation.id);
148
+ }
149
+ resolver.stage(operation.id, false);
150
+ return {
151
+ command: [`DELETE FROM ${quoted(schema)} WHERE id = ?`, [operation.id]],
152
+ outcome: { operation: 'deleted', id: operation.id },
153
+ };
154
+ }
155
+
156
+ async function resolveSoftDelete(
157
+ resolver: Resolver,
158
+ operation: Extract<BatchOperation, { type: 'softDelete' }>,
159
+ ): Promise<Resolved> {
160
+ const { schema, now } = resolver;
161
+ if (!hasSoftDelete(schema)) {
162
+ raise(DiagnosticCodes.CONFIG_INVALID, {
163
+ message: 'collection does not declare soft delete',
164
+ detail: { collection: schema.name },
165
+ });
166
+ }
167
+ if (!(await resolver.exists(operation.id))) {
168
+ missing(schema, operation.id);
169
+ }
170
+ resolver.stage(operation.id, false);
171
+ return {
172
+ command: [`UPDATE ${quoted(schema)} SET deleted_at = ? WHERE id = ?`, [now, operation.id]],
173
+ outcome: { operation: 'deleted', id: operation.id },
174
+ };
175
+ }
176
+
177
+ function resolveOperation(resolver: Resolver, operation: BatchOperation): Promise<Resolved> {
178
+ switch (operation.type) {
179
+ case 'insert':
180
+ return resolveInsert(resolver, operation);
181
+ case 'update':
182
+ return resolveUpdate(resolver, operation);
183
+ case 'upsert':
184
+ return resolveUpsert(resolver, operation);
185
+ case 'delete':
186
+ return resolveDelete(resolver, operation);
187
+ case 'softDelete':
188
+ return resolveSoftDelete(resolver, operation);
189
+ default:
190
+ raise(DiagnosticCodes.CONFIG_INVALID, { message: 'unsupported batch operation' });
191
+ }
192
+ }
193
+
194
+ export async function runBatch(
195
+ context: BatchContext,
196
+ operations: BatchOperation[],
197
+ ): Promise<BatchOutcome> {
198
+ assertBatchWithinBudget(operations.length);
199
+
200
+ const commands: Command[] = [];
201
+ const outcomes: WriteOutcome[] = [];
202
+ const overlay = new Map<string, boolean>();
203
+
204
+ for (const operation of operations) {
205
+ const schema = context.schemaFor(operation.collection);
206
+ const resolver: Resolver = {
207
+ schema,
208
+ now: context.now(),
209
+ async exists(id) {
210
+ const staged = overlay.get(`${schema.name}:${id}`);
211
+ if (staged !== undefined) {
212
+ return staged;
213
+ }
214
+ const result = await context.connection.execute(
215
+ `SELECT 1 AS present FROM ${quoted(schema)} WHERE id = ? AND deleted_at IS NULL`,
216
+ [id],
217
+ );
218
+ return result.rows.length > 0;
219
+ },
220
+ stage(id, present) {
221
+ overlay.set(`${schema.name}:${id}`, present);
222
+ },
223
+ };
224
+
225
+ const resolved = await resolveOperation(resolver, operation);
226
+ commands.push(resolved.command);
227
+ outcomes.push(resolved.outcome);
228
+ }
229
+
230
+ await context.connection.executeBatch(commands);
231
+ return { applied: outcomes.length, outcomes };
232
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The catalog: one registry per logical database, listing the scopes that
3
+ * exist.
4
+ *
5
+ * This is the only structure that spans scopes, so it is deliberately the
6
+ * smallest thing that answers the questions the package must answer without
7
+ * opening every scope file. It holds a digest, timestamps, and two posture
8
+ * flags — **no raw scope components** (FR-014), so a device with full disk
9
+ * access cannot mine it for identities or tenant names even though the
10
+ * catalog itself is never encrypted.
11
+ *
12
+ * The `deleting` flag is the load-bearing part. It is written before a scope
13
+ * deletion begins and cleared last, so a crash at any point leaves the
14
+ * interruption visible on the next open. Clearing it first would make an
15
+ * interrupted deletion look complete — the exact failure FR-063 exists to
16
+ * prevent.
17
+ */
18
+
19
+ import type { ScopeDeletionState } from '../contract/scope';
20
+ import type { Connection } from './connection';
21
+ import { num, text } from './values';
22
+
23
+ export type CatalogEntry = {
24
+ scopeKey: string;
25
+ createdAt: number;
26
+ deletionState: ScopeDeletionState;
27
+ backupPosture: 'excluded' | 'included';
28
+ encrypted: boolean;
29
+ };
30
+
31
+ const CREATE_SCOPES = `CREATE TABLE IF NOT EXISTS scopes (
32
+ scope_key TEXT PRIMARY KEY,
33
+ created_at INTEGER NOT NULL,
34
+ deletion_state TEXT NOT NULL,
35
+ backup_posture TEXT NOT NULL,
36
+ encrypted INTEGER NOT NULL
37
+ )`;
38
+
39
+ export async function ensureCatalog(connection: Connection): Promise<void> {
40
+ await connection.execute(CREATE_SCOPES);
41
+ }
42
+
43
+ export async function readEntry(
44
+ connection: Connection,
45
+ scopeKey: string,
46
+ ): Promise<CatalogEntry | null> {
47
+ const result = await connection.execute(
48
+ 'SELECT scope_key, created_at, deletion_state, backup_posture, encrypted FROM scopes WHERE scope_key = ?',
49
+ [scopeKey],
50
+ );
51
+ const row = result.rows[0];
52
+ if (!row) {
53
+ return null;
54
+ }
55
+ return {
56
+ scopeKey: text(row.scope_key),
57
+ createdAt: num(row.created_at),
58
+ deletionState: text(row.deletion_state) as ScopeDeletionState,
59
+ backupPosture: text(row.backup_posture) as 'excluded' | 'included',
60
+ encrypted: num(row.encrypted) === 1,
61
+ };
62
+ }
63
+
64
+ export async function listEntries(connection: Connection): Promise<CatalogEntry[]> {
65
+ const result = await connection.execute(
66
+ 'SELECT scope_key, created_at, deletion_state, backup_posture, encrypted FROM scopes',
67
+ );
68
+ return result.rows.map(row => ({
69
+ scopeKey: text(row.scope_key),
70
+ createdAt: num(row.created_at),
71
+ deletionState: text(row.deletion_state) as ScopeDeletionState,
72
+ backupPosture: text(row.backup_posture) as 'excluded' | 'included',
73
+ encrypted: num(row.encrypted) === 1,
74
+ }));
75
+ }
76
+
77
+ export async function registerScope(
78
+ connection: Connection,
79
+ entry: CatalogEntry,
80
+ ): Promise<void> {
81
+ await connection.execute(
82
+ `INSERT INTO scopes (scope_key, created_at, deletion_state, backup_posture, encrypted)
83
+ VALUES (?, ?, ?, ?, ?)
84
+ ON CONFLICT(scope_key) DO UPDATE SET
85
+ deletion_state = excluded.deletion_state,
86
+ backup_posture = excluded.backup_posture,
87
+ encrypted = excluded.encrypted`,
88
+ [
89
+ entry.scopeKey,
90
+ entry.createdAt,
91
+ entry.deletionState,
92
+ entry.backupPosture,
93
+ entry.encrypted ? 1 : 0,
94
+ ],
95
+ );
96
+ }
97
+
98
+ export async function markDeleting(connection: Connection, scopeKey: string): Promise<void> {
99
+ await connection.execute('UPDATE scopes SET deletion_state = ? WHERE scope_key = ?', [
100
+ 'deleting',
101
+ scopeKey,
102
+ ]);
103
+ }
104
+
105
+ /** Cleared last, on purpose. See the note at the top of this file. */
106
+ export async function clearScope(connection: Connection, scopeKey: string): Promise<void> {
107
+ await connection.execute('DELETE FROM scopes WHERE scope_key = ?', [scopeKey]);
108
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * One connection per database file.
3
+ *
4
+ * Two things about this file carry design weight:
5
+ *
6
+ * **Scope isolation is the file boundary.** A connection is opened against
7
+ * one scope's `data.db`, whose directory is named by the scope digest and
8
+ * never by the components (FR-014). No SQL this package could generate — and
9
+ * none a future bug could generate — reaches another scope's rows, because
10
+ * they are in a file this connection never opened (research.md R2).
11
+ *
12
+ * **A transaction owns the connection while it runs.** `execute` routes to
13
+ * the in-flight transaction rather than around it. That is what makes an
14
+ * attachment reference written during a transaction roll back with it
15
+ * (FR-072); routing around would leave the reference behind and the artifact
16
+ * orphaned with no record of why.
17
+ */
18
+
19
+ import { DiagnosticCodes } from '../contract/errors';
20
+ import { raise } from '../diagnostics/errors';
21
+ import { raiseNative } from '../diagnostics/map-native';
22
+ import { fileSystem } from '../platform/fs';
23
+ import {
24
+ engine,
25
+ type ExecuteResult,
26
+ type NativeDatabase,
27
+ type NativeTransaction,
28
+ type SqlValue,
29
+ } from './native';
30
+
31
+ export type Executor = {
32
+ execute(sql: string, params?: SqlValue[]): Promise<ExecuteResult>;
33
+ };
34
+
35
+ /**
36
+ * The executor a transaction body runs against, plus the one control it
37
+ * needs. `rollback` is handed to the body rather than returned from
38
+ * `transaction`, so an explicit abort cannot outlive the transaction it
39
+ * belongs to.
40
+ */
41
+ export type TransactionExecutor = Executor & {
42
+ rollback(): Promise<void>;
43
+ };
44
+
45
+ export type Connection = Executor & {
46
+ readonly path: string;
47
+ readonly encrypted: boolean;
48
+ transaction<R>(fn: (tx: TransactionExecutor) => Promise<R>): Promise<R>;
49
+ executeBatch(commands: [string, SqlValue[]?][]): Promise<void>;
50
+ /** Full-database check, for the diagnostics surface. Never run at open. */
51
+ integrityCheck(): Promise<boolean>;
52
+ close(): Promise<void>;
53
+ };
54
+
55
+ export type OpenConnectionOptions = {
56
+ /** Absolute path to the database file, already inside the managed area. */
57
+ path: string;
58
+ encryptionKey?: string;
59
+ };
60
+
61
+ function splitPath(path: string): { location: string; name: string } {
62
+ const index = path.lastIndexOf('/');
63
+ return index === -1
64
+ ? { location: '', name: path }
65
+ : { location: path.slice(0, index), name: path.slice(index + 1) };
66
+ }
67
+
68
+ export async function openConnection(options: OpenConnectionOptions): Promise<Connection> {
69
+ const { location, name } = splitPath(options.path);
70
+ const encrypted = options.encryptionKey !== undefined;
71
+
72
+ try {
73
+ await fileSystem().mkdir(location);
74
+ } catch (error) {
75
+ raiseNative(error, { operation: 'open' });
76
+ }
77
+
78
+ let database: NativeDatabase;
79
+ try {
80
+ database = engine().open({
81
+ name,
82
+ location,
83
+ // Passing the key only when encryption is on keeps an unencrypted
84
+ // database from being opened as an encrypted one with an empty key,
85
+ // which SQLCipher accepts and which would produce a file neither
86
+ // posture can reopen.
87
+ ...(encrypted ? { encryptionKey: options.encryptionKey } : {}),
88
+ });
89
+ } catch (error) {
90
+ raiseNative(error, { operation: 'open', encrypted });
91
+ }
92
+
93
+ let closed = false;
94
+ let active: NativeTransaction | null = null;
95
+
96
+ const assertOpen = (): void => {
97
+ if (closed) {
98
+ raise(DiagnosticCodes.CLOSED_HANDLE, { message: 'connection is closed' });
99
+ }
100
+ };
101
+
102
+ const connection: Connection = {
103
+ path: options.path,
104
+ encrypted,
105
+
106
+ async execute(sql, params = []) {
107
+ assertOpen();
108
+ try {
109
+ // The in-flight transaction, when there is one. See the note at
110
+ // the top of this file.
111
+ return await (active ?? database).execute(sql, params);
112
+ } catch (error) {
113
+ raiseNative(error, {
114
+ operation: /^\s*select|^\s*pragma|^\s*explain/i.test(sql) ? 'read' : 'write',
115
+ encrypted,
116
+ });
117
+ }
118
+ },
119
+
120
+ async transaction(fn) {
121
+ assertOpen();
122
+ if (active) {
123
+ // Nested transactions would need savepoints and would let a
124
+ // partial rollback look like a full one.
125
+ raise(DiagnosticCodes.TRANSACTION_STATE, {
126
+ message: 'a transaction is already in flight on this connection',
127
+ });
128
+ }
129
+ try {
130
+ return await database.transaction(async tx => {
131
+ active = tx;
132
+ try {
133
+ return await fn({
134
+ execute: (sql, params = []) => tx.execute(sql, params),
135
+ rollback: async () => {
136
+ await tx.rollback();
137
+ // Cleared immediately: anything that runs after an
138
+ // abort must not be routed into a settled
139
+ // transaction and silently discarded.
140
+ active = null;
141
+ },
142
+ });
143
+ } finally {
144
+ active = null;
145
+ }
146
+ });
147
+ } catch (error) {
148
+ raiseNative(error, { operation: 'transaction', encrypted });
149
+ }
150
+ },
151
+
152
+ async executeBatch(commands) {
153
+ assertOpen();
154
+ try {
155
+ await database.executeBatch(commands);
156
+ } catch (error) {
157
+ raiseNative(error, { operation: 'write', encrypted });
158
+ }
159
+ },
160
+
161
+ async integrityCheck() {
162
+ assertOpen();
163
+ const result = await connection.execute('PRAGMA quick_check(1)');
164
+ const first = result.rows[0];
165
+ return first ? Object.values(first)[0] === 'ok' : false;
166
+ },
167
+
168
+ async close() {
169
+ if (closed) {
170
+ return;
171
+ }
172
+ closed = true;
173
+ try {
174
+ await database.close();
175
+ } catch {
176
+ // A close that fails leaves nothing for a caller to do, and
177
+ // reporting it would turn an orderly sign-out into an error.
178
+ }
179
+ },
180
+ };
181
+
182
+ // Write-ahead logging so a reader is not blocked by the writer — FR-045's
183
+ // "reads proceed while a write is in flight" is this pragma plus op-sqlite's
184
+ // single-writer serialization, not a lock this package maintains.
185
+ await connection.execute('PRAGMA journal_mode = WAL');
186
+ await connection.execute('PRAGMA foreign_keys = ON');
187
+ // Durability over speed: a half-written record after a crash is exactly
188
+ // what FR-039 promises never happens.
189
+ await connection.execute('PRAGMA synchronous = FULL');
190
+
191
+ return connection;
192
+ }
193
+
194
+ /**
195
+ * Confirm a freshly opened database is readable.
196
+ *
197
+ * Deliberately not `PRAGMA integrity_check`: that walks the whole file and
198
+ * would put open time far past the 150-millisecond budget on a large scope.
199
+ * One trivial read is enough to surface the failures that matter at open —
200
+ * a wrong key, a truncated file, a malformed header — because all three fail
201
+ * it, and the error mapping keeps them distinguishable (FR-076, FR-078).
202
+ */
203
+ export async function assertReadable(connection: Connection): Promise<void> {
204
+ await connection.execute('SELECT count(*) AS n FROM sqlite_master');
205
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * "Is this query covered by an index?", answered by the engine rather than by
3
+ * inspection of the declarations (FR-037).
4
+ *
5
+ * Reading the schema would answer what the developer intended; `EXPLAIN QUERY
6
+ * PLAN` answers what SQLite will actually do, and the two diverge exactly
7
+ * where it matters — a filter on an indexed field that the planner declines
8
+ * because of a type mismatch or a leading wildcard still reports as "indexed"
9
+ * if you only read declarations.
10
+ *
11
+ * Exposed as a development-mode diagnostic. It runs an extra statement, so an
12
+ * app calling it on every list would pay for it.
13
+ */
14
+
15
+ import type { IndexCoverageReport, Query, SortKey } from '../contract/query';
16
+ import type { CollectionSchema } from '../contract/schema';
17
+ import { collectionTable } from '../schema/declare';
18
+ import type { Executor } from './connection';
19
+ import { buildFilter, buildOrderBy } from './sql-builder';
20
+
21
+ const SCAN = /\bSCAN\b/i;
22
+ const USING_INDEX = /USING\s+(COVERING\s+)?INDEX\s+"?([A-Za-z0-9_]+)"?/i;
23
+
24
+ export async function explainQuery(
25
+ executor: Executor,
26
+ schema: CollectionSchema,
27
+ query: Query | undefined,
28
+ sort: SortKey[],
29
+ ): Promise<IndexCoverageReport> {
30
+ const filter = buildFilter(query?.filter);
31
+ const table = collectionTable(schema.name);
32
+ const sql = `EXPLAIN QUERY PLAN SELECT id FROM "${table}" WHERE ${filter.sql} ORDER BY ${buildOrderBy(
33
+ sort,
34
+ )}`;
35
+
36
+ const result = await executor.execute(sql, filter.params);
37
+ const detail = result.rows
38
+ .map(row => String(row.detail ?? row.DETAIL ?? ''))
39
+ .filter(Boolean)
40
+ .join('; ');
41
+
42
+ const match = USING_INDEX.exec(detail);
43
+ return {
44
+ collection: schema.name,
45
+ usesIndex: match !== null,
46
+ indexName: match?.[2],
47
+ detail:
48
+ detail ||
49
+ (SCAN.test(detail)
50
+ ? 'full table scan'
51
+ : 'the engine returned no plan for this query'),
52
+ };
53
+ }