@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.
- package/CHANGELOG.md +50 -0
- package/README.md +75 -73
- package/package.json +1 -1
- package/skills/sdk-rn-local-db/SKILL.md +11 -3
- package/src/adapter/deletion.ts +24 -10
- package/src/adapter/lifecycle.ts +121 -45
- package/src/adapter/transaction.ts +6 -0
- package/src/artifacts/availability.ts +3 -5
- package/src/artifacts/cascade.ts +42 -6
- package/src/artifacts/store.ts +34 -12
- package/src/contract/attachment.ts +6 -0
- package/src/contract/migration.ts +3 -1
- package/src/contract/record.ts +9 -0
- package/src/contract/transaction.ts +8 -0
- package/src/crypto/chunked-cipher.ts +28 -22
- package/src/crypto/derive.ts +2 -1
- package/src/platform/bytes.ts +144 -0
- package/src/query/budgets.ts +2 -5
- package/src/query/cursor.ts +3 -2
- package/src/sqlite/batch.ts +28 -18
- package/src/sqlite/connection.ts +66 -44
- package/src/sqlite/index-coverage.ts +4 -1
- package/src/sqlite/row-codec.ts +23 -3
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/testing/conformance/attachments.ts +1 -1
- package/src/testing/memory-adapter/index.ts +176 -72
package/src/sqlite/batch.ts
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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.
|
|
108
|
+
if ((await resolver.state(operation.record.id)) !== 'absent') {
|
|
101
109
|
conflict(schema, operation.record.id);
|
|
102
110
|
}
|
|
103
|
-
resolver.stage(operation.record.id,
|
|
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 (
|
|
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.
|
|
132
|
-
resolver.stage(operation.record.id,
|
|
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 (
|
|
154
|
+
if ((await resolver.state(operation.id)) !== 'live') {
|
|
147
155
|
missing(schema, operation.id);
|
|
148
156
|
}
|
|
149
|
-
resolver.stage(operation.id,
|
|
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 (
|
|
175
|
+
if ((await resolver.state(operation.id)) !== 'live') {
|
|
168
176
|
missing(schema, operation.id);
|
|
169
177
|
}
|
|
170
|
-
resolver.stage(operation.id,
|
|
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,
|
|
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
|
|
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
|
|
223
|
+
`SELECT deleted_at FROM ${quoted(schema)} WHERE id = ?`,
|
|
216
224
|
[id],
|
|
217
225
|
);
|
|
218
|
-
|
|
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,
|
|
221
|
-
overlay.set(`${schema.name}:${id}`,
|
|
230
|
+
stage(id, state) {
|
|
231
|
+
overlay.set(`${schema.name}:${id}`, state);
|
|
222
232
|
},
|
|
223
233
|
};
|
|
224
234
|
|
package/src/sqlite/connection.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
115
|
+
execute(sql, params = []) {
|
|
107
116
|
assertOpen();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
operation:
|
|
115
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
172
|
+
executeBatch(commands) {
|
|
153
173
|
assertOpen();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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)
|
package/src/sqlite/row-codec.ts
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
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
|
|
98
|
+
await tx.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
99
99
|
throw new Error('injected');
|
|
100
100
|
});
|
|
101
101
|
} catch {
|