@chipmobilesdk/rn-local-db 0.1.1 → 0.2.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.
@@ -67,12 +67,20 @@ function updateCommand(
67
67
  return [`UPDATE ${quoted(schema)} SET ${assignments.join(', ')} WHERE id = ?`, [...values, id]];
68
68
  }
69
69
 
70
+ /**
71
+ * Where a record stands, as this batch has left it so far. Three states, not
72
+ * two: a soft-deleted row is invisible to reads but still holds its primary
73
+ * key, so an insert over it conflicts and an upsert over it restores it — the
74
+ * same rules the collection API applies. Treating it as absent made a batch
75
+ * upsert issue an INSERT and fail the whole batch on the primary key.
76
+ */
77
+ type RowState = 'live' | 'softDeleted' | 'absent';
78
+
70
79
  type Resolver = {
71
80
  schema: CollectionSchema;
72
81
  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;
82
+ state(id: string): Promise<RowState>;
83
+ stage(id: string, state: RowState): void;
76
84
  };
77
85
 
78
86
  type Resolved = { command: Command; outcome: WriteOutcome };
@@ -97,10 +105,10 @@ async function resolveInsert(
97
105
  ): Promise<Resolved> {
98
106
  const { schema, now } = resolver;
99
107
  assertRecordWithinBudget(schema.name, operation.record.data);
100
- if (await resolver.exists(operation.record.id)) {
108
+ if ((await resolver.state(operation.record.id)) !== 'absent') {
101
109
  conflict(schema, operation.record.id);
102
110
  }
103
- resolver.stage(operation.record.id, true);
111
+ resolver.stage(operation.record.id, 'live');
104
112
  return {
105
113
  command: insertCommand(schema, operation.record.id, operation.record.data, now),
106
114
  outcome: { operation: 'created', id: operation.record.id },
@@ -113,7 +121,7 @@ async function resolveUpdate(
113
121
  ): Promise<Resolved> {
114
122
  const { schema, now } = resolver;
115
123
  assertRecordWithinBudget(schema.name, operation.data);
116
- if (!(await resolver.exists(operation.id))) {
124
+ if ((await resolver.state(operation.id)) !== 'live') {
117
125
  missing(schema, operation.id);
118
126
  }
119
127
  return {
@@ -128,8 +136,8 @@ async function resolveUpsert(
128
136
  ): Promise<Resolved> {
129
137
  const { schema, now } = resolver;
130
138
  assertRecordWithinBudget(schema.name, operation.record.data);
131
- const present = await resolver.exists(operation.record.id);
132
- resolver.stage(operation.record.id, true);
139
+ const present = (await resolver.state(operation.record.id)) !== 'absent';
140
+ resolver.stage(operation.record.id, 'live');
133
141
  return {
134
142
  command: present
135
143
  ? updateCommand(schema, operation.record.id, operation.record.data, now, true)
@@ -143,10 +151,10 @@ async function resolveDelete(
143
151
  operation: Extract<BatchOperation, { type: 'delete' }>,
144
152
  ): Promise<Resolved> {
145
153
  const { schema } = resolver;
146
- if (!(await resolver.exists(operation.id))) {
154
+ if ((await resolver.state(operation.id)) !== 'live') {
147
155
  missing(schema, operation.id);
148
156
  }
149
- resolver.stage(operation.id, false);
157
+ resolver.stage(operation.id, 'absent');
150
158
  return {
151
159
  command: [`DELETE FROM ${quoted(schema)} WHERE id = ?`, [operation.id]],
152
160
  outcome: { operation: 'deleted', id: operation.id },
@@ -164,10 +172,10 @@ async function resolveSoftDelete(
164
172
  detail: { collection: schema.name },
165
173
  });
166
174
  }
167
- if (!(await resolver.exists(operation.id))) {
175
+ if ((await resolver.state(operation.id)) !== 'live') {
168
176
  missing(schema, operation.id);
169
177
  }
170
- resolver.stage(operation.id, false);
178
+ resolver.stage(operation.id, 'softDeleted');
171
179
  return {
172
180
  command: [`UPDATE ${quoted(schema)} SET deleted_at = ? WHERE id = ?`, [now, operation.id]],
173
181
  outcome: { operation: 'deleted', id: operation.id },
@@ -199,26 +207,28 @@ export async function runBatch(
199
207
 
200
208
  const commands: Command[] = [];
201
209
  const outcomes: WriteOutcome[] = [];
202
- const overlay = new Map<string, boolean>();
210
+ const overlay = new Map<string, RowState>();
203
211
 
204
212
  for (const operation of operations) {
205
213
  const schema = context.schemaFor(operation.collection);
206
214
  const resolver: Resolver = {
207
215
  schema,
208
216
  now: context.now(),
209
- async exists(id) {
217
+ async state(id) {
210
218
  const staged = overlay.get(`${schema.name}:${id}`);
211
219
  if (staged !== undefined) {
212
220
  return staged;
213
221
  }
214
222
  const result = await context.connection.execute(
215
- `SELECT 1 AS present FROM ${quoted(schema)} WHERE id = ? AND deleted_at IS NULL`,
223
+ `SELECT deleted_at FROM ${quoted(schema)} WHERE id = ?`,
216
224
  [id],
217
225
  );
218
- return result.rows.length > 0;
226
+ const row = result.rows[0];
227
+ if (!row) return 'absent';
228
+ return row.deleted_at === null || row.deleted_at === undefined ? 'live' : 'softDeleted';
219
229
  },
220
- stage(id, present) {
221
- overlay.set(`${schema.name}:${id}`, present);
230
+ stage(id, state) {
231
+ overlay.set(`${schema.name}:${id}`, state);
222
232
  },
223
233
  };
224
234
 
@@ -9,17 +9,22 @@
9
9
  * none a future bug could generate — reaches another scope's rows, because
10
10
  * they are in a file this connection never opened (research.md R2).
11
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.
12
+ * **A transaction owns the connection while it runs, and only its own
13
+ * executor reaches it.** Every operation queues on the connection, and a
14
+ * transaction holds the queue until it settles. Work issued through the
15
+ * connection meanwhile — a background sync, a second screen — waits and then
16
+ * runs on its own, rather than joining the transaction and vanishing with its
17
+ * rollback. Until 0.2.0 `execute` routed into whatever transaction was in
18
+ * flight, which is how an unrelated write was lost. Work meant to be part of
19
+ * the transaction goes through the executor the transaction hands out
20
+ * (`tx.collection`, `tx.attachments`).
17
21
  */
18
22
 
19
23
  import { DiagnosticCodes } from '../contract/errors';
20
24
  import { raise } from '../diagnostics/errors';
21
25
  import { raiseNative } from '../diagnostics/map-native';
22
26
  import { fileSystem } from '../platform/fs';
27
+ import { createTransactionQueue } from './transaction-queue';
23
28
  import {
24
29
  engine,
25
30
  type ExecuteResult,
@@ -92,6 +97,7 @@ export async function openConnection(options: OpenConnectionOptions): Promise<Co
92
97
 
93
98
  let closed = false;
94
99
  let active: NativeTransaction | null = null;
100
+ const queue = createTransactionQueue();
95
101
 
96
102
  const assertOpen = (): void => {
97
103
  if (closed) {
@@ -99,63 +105,79 @@ export async function openConnection(options: OpenConnectionOptions): Promise<Co
99
105
  }
100
106
  };
101
107
 
108
+ const classify = (sql: string) =>
109
+ /^\s*select|^\s*pragma|^\s*explain/i.test(sql) ? 'read' : 'write';
110
+
102
111
  const connection: Connection = {
103
112
  path: options.path,
104
113
  encrypted,
105
114
 
106
- async execute(sql, params = []) {
115
+ execute(sql, params = []) {
107
116
  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
- }
117
+ // Never into an in-flight transaction: queued behind it instead. See
118
+ // the note at the top of this file.
119
+ return queue.run(async () => {
120
+ try {
121
+ return await database.execute(sql, params);
122
+ } catch (error) {
123
+ raiseNative(error, { operation: classify(sql), encrypted });
124
+ }
125
+ });
118
126
  },
119
127
 
120
128
  async transaction(fn) {
121
129
  assertOpen();
122
130
  if (active) {
123
131
  // Nested transactions would need savepoints and would let a
124
- // partial rollback look like a full one.
132
+ // partial rollback look like a full one. Refused rather than
133
+ // queued: a body that opened a second transaction would otherwise
134
+ // wait on itself forever.
125
135
  raise(DiagnosticCodes.TRANSACTION_STATE, {
126
136
  message: 'a transaction is already in flight on this connection',
127
137
  });
128
138
  }
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
- }
139
+ return queue.runTransaction(async () => {
140
+ try {
141
+ return await database.transaction(async tx => {
142
+ active = tx;
143
+ let settled = false;
144
+ try {
145
+ return await fn({
146
+ execute: (sql, params = []) => {
147
+ if (settled) {
148
+ // After an explicit rollback the body is still running
149
+ // inside the engine's callback. Its remaining statements
150
+ // are refused, not run in autocommit (LDB-V1).
151
+ raise(DiagnosticCodes.TRANSACTION_STATE, {
152
+ message: 'transaction already rolled back',
153
+ });
154
+ }
155
+ return tx.execute(sql, params);
156
+ },
157
+ rollback: async () => {
158
+ await tx.rollback();
159
+ settled = true;
160
+ },
161
+ });
162
+ } finally {
163
+ active = null;
164
+ }
165
+ });
166
+ } catch (error) {
167
+ raiseNative(error, { operation: 'transaction', encrypted });
168
+ }
169
+ });
150
170
  },
151
171
 
152
- async executeBatch(commands) {
172
+ executeBatch(commands) {
153
173
  assertOpen();
154
- try {
155
- await database.executeBatch(commands);
156
- } catch (error) {
157
- raiseNative(error, { operation: 'write', encrypted });
158
- }
174
+ return queue.run(async () => {
175
+ try {
176
+ await database.executeBatch(commands);
177
+ } catch (error) {
178
+ raiseNative(error, { operation: 'write', encrypted });
179
+ }
180
+ });
159
181
  },
160
182
 
161
183
  async integrityCheck() {
@@ -17,6 +17,7 @@ import type { CollectionSchema } from '../contract/schema';
17
17
  import { collectionTable } from '../schema/declare';
18
18
  import type { Executor } from './connection';
19
19
  import { buildFilter, buildOrderBy } from './sql-builder';
20
+ import { declaredIndexName } from './row-codec';
20
21
 
21
22
  const SCAN = /\bSCAN\b/i;
22
23
  const USING_INDEX = /USING\s+(COVERING\s+)?INDEX\s+"?([A-Za-z0-9_]+)"?/i;
@@ -43,7 +44,9 @@ export async function explainQuery(
43
44
  return {
44
45
  collection: schema.name,
45
46
  usesIndex: match !== null,
46
- indexName: match?.[2],
47
+ // Reported under the name the app declared, not the collection-scoped
48
+ // name it has in the file.
49
+ indexName: match?.[2] === undefined ? undefined : declaredIndexName(schema, match[2]),
47
50
  detail:
48
51
  detail ||
49
52
  (SCAN.test(detail)
@@ -54,13 +54,33 @@ export function addColumnSql(schema: CollectionSchema, field: FieldDeclaration):
54
54
  )}" ${affinityOf(field)}`;
55
55
  }
56
56
 
57
+ /**
58
+ * The name an index has in the database file.
59
+ *
60
+ * Index names are database-wide in SQLite, but declared per collection. Until
61
+ * 0.2.0 the declared name was used as is, so a second collection declaring an
62
+ * index of the same name hit `IF NOT EXISTS` and silently got no index — a
63
+ * UNIQUE constraint included. The double underscore keeps a declared name from
64
+ * colliding with the package's own `<table>_live` index.
65
+ */
66
+ export function physicalIndexName(schema: CollectionSchema, declaredName: string): string {
67
+ return `${collectionTable(schema.name)}__${declaredName}`;
68
+ }
69
+
70
+ /** The declared name back from a physical one, for reporting to the app. */
71
+ export function declaredIndexName(schema: CollectionSchema, physicalName: string): string {
72
+ const prefix = `${collectionTable(schema.name)}__`;
73
+ return physicalName.startsWith(prefix) ? physicalName.slice(prefix.length) : physicalName;
74
+ }
75
+
57
76
  export function createIndexSql(schema: CollectionSchema): string[] {
58
77
  const table = collectionTable(schema.name);
59
78
  const statements = (schema.indexes ?? []).map(index => {
60
79
  const columns = index.fields.map(field => `"${projectedColumn(field)}"`).join(', ');
61
- return `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS "${
62
- index.name
63
- }" ON "${table}" (${columns})`;
80
+ return `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS "${physicalIndexName(
81
+ schema,
82
+ index.name,
83
+ )}" ON "${table}" (${columns})`;
64
84
  });
65
85
  if (hasSoftDelete(schema)) {
66
86
  // Every default query carries `deleted_at IS NULL`, so without this the
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The per-handle queue that keeps a transaction to itself.
3
+ *
4
+ * A transaction holds the queue until it settles; work arriving meanwhile
5
+ * through the handle waits its turn and then runs on its own, instead of
6
+ * joining the transaction and vanishing with its rollback (LDB-01). Shared by
7
+ * the SQLite connection and the memory adapter so both isolate the same way.
8
+ *
9
+ * Pure: no engine, no React, so the memory adapter can use it without loading
10
+ * native code.
11
+ */
12
+
13
+ import { DiagnosticCodes } from '../contract/errors';
14
+ import { raise } from '../diagnostics/errors';
15
+
16
+ /**
17
+ * How long work through the handle waits behind a transaction before it is
18
+ * refused.
19
+ *
20
+ * Not a performance budget: it exists for the one wait that never ends. A
21
+ * transaction body that awaits the handle's own `collection()` or
22
+ * `attachments()` is waiting on work queued behind itself. Until 0.2.0 that
23
+ * work ran inside the transaction; now it is refused after this long with a
24
+ * message naming `tx.collection()` / `tx.attachments()`, and the transaction
25
+ * rolls back, rather than both waiting forever.
26
+ */
27
+ export const TRANSACTION_WAIT_LIMIT_MS = 10_000;
28
+
29
+ export type TransactionQueue = {
30
+ /** Ordinary work. Bounded by `TRANSACTION_WAIT_LIMIT_MS` when it queues behind a transaction. */
31
+ run<T>(work: () => Promise<T>): Promise<T>;
32
+ /** A transaction: holds the queue for as long as `work` runs. */
33
+ runTransaction<T>(work: () => Promise<T>): Promise<T>;
34
+ };
35
+
36
+ export function createTransactionQueue(
37
+ waitLimitMs: number = TRANSACTION_WAIT_LIMIT_MS,
38
+ ): TransactionQueue {
39
+ /** Tail of the queue. Never rejects, so one failure cannot stall the rest. */
40
+ let tail: Promise<unknown> = Promise.resolve();
41
+ /** Transactions queued or running. Only work behind one of them can deadlock. */
42
+ let transactions = 0;
43
+
44
+ const enqueue = <T>(work: () => Promise<T>): Promise<T> => {
45
+ const run = tail.then(work, work);
46
+ tail = run.catch(() => undefined);
47
+ return run;
48
+ };
49
+
50
+ return {
51
+ run<T>(work: () => Promise<T>): Promise<T> {
52
+ if (transactions === 0) {
53
+ return enqueue(work);
54
+ }
55
+
56
+ let abandoned = false;
57
+ let timer: ReturnType<typeof setTimeout> | undefined;
58
+ const started = enqueue(() => {
59
+ clearTimeout(timer);
60
+ // Refused while waiting: it must not run late, after its caller was
61
+ // already told it failed.
62
+ return abandoned ? Promise.resolve(undefined as T) : work();
63
+ });
64
+ const refused = new Promise<never>((_, reject) => {
65
+ timer = setTimeout(() => {
66
+ abandoned = true;
67
+ try {
68
+ raise(DiagnosticCodes.TRANSACTION_STATE, {
69
+ message:
70
+ 'waited too long behind an in-flight transaction; inside a transaction body use tx.collection() and tx.attachments(), not the handle',
71
+ });
72
+ } catch (error) {
73
+ reject(error);
74
+ }
75
+ }, waitLimitMs);
76
+ });
77
+ return Promise.race([started, refused]);
78
+ },
79
+
80
+ runTransaction<T>(work: () => Promise<T>): Promise<T> {
81
+ transactions += 1;
82
+ return enqueue(work).finally(() => {
83
+ transactions -= 1;
84
+ });
85
+ },
86
+ };
87
+ }
@@ -95,7 +95,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
95
95
  try {
96
96
  await handle.transaction(async tx => {
97
97
  await tx.collection('notes').insert({ id: 'n1', data: { title: 'x' } });
98
- await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
98
+ await tx.attachments().write({ id: 'a1', contentType: 'text/plain', content });
99
99
  throw new Error('injected');
100
100
  });
101
101
  } catch {