@chipmobilesdk/rn-local-db 0.2.0 → 0.2.1

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 (84) hide show
  1. package/CHANGELOG.md +122 -102
  2. package/README.md +728 -728
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -252
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -520
  10. package/src/adapter/transaction.ts +71 -71
  11. package/src/artifacts/availability.ts +67 -67
  12. package/src/artifacts/cascade.ts +114 -114
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -345
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -78
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -42
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -51
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -54
  28. package/src/crypto/chunked-cipher.ts +153 -150
  29. package/src/crypto/derive.ts +64 -64
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -144
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -54
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -100
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -242
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -227
  62. package/src/sqlite/index-coverage.ts +54 -56
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -221
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -87
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -492
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
@@ -1,244 +1,244 @@
1
- /**
2
- * Filter tree to parameterized SQL.
3
- *
4
- * Nothing a consumer supplies is ever concatenated into a statement. Field
5
- * names are resolved through the schema first — validation has already run
6
- * and rejected anything undeclared (FR-034) — so the only identifiers that
7
- * reach SQL are ones this package generated from a declaration it checked.
8
- * Values bind as parameters, without exception. Injection stops being a thing
9
- * to remember and becomes a thing the shape of the code prevents.
10
- *
11
- * The other half of this file is keyset pagination. `OFFSET` was rejected for
12
- * two measured reasons (research.md R6): it degrades linearly at the 100,000
13
- * record budget because the engine still walks the skipped rows, and it
14
- * shifts pages under concurrent writes so a record can be skipped or returned
15
- * twice — the exact failure SC-007 forbids.
16
- */
17
-
18
- import type { FilterNode, FilterValue, SortKey } from '../contract/query';
19
- import { projectedColumn } from '../schema/declare';
20
- import type { SqlValue } from './native';
21
-
22
- export type SqlFragment = {
23
- sql: string;
24
- params: SqlValue[];
25
- };
26
-
27
- const TRUE: SqlFragment = { sql: '1', params: [] };
28
- const FALSE: SqlFragment = { sql: '0', params: [] };
29
-
30
- function columnFor(field: string): string {
31
- return field === 'id' ? 'id' : `"${projectedColumn(field)}"`;
32
- }
33
-
34
- /**
35
- * The JSON path for a declared field.
36
- *
37
- * Safe to interpolate: the field name came from a schema declaration that
38
- * validation already matched against `^[A-Za-z][A-Za-z0-9_]{0,62}$`, so it
39
- * cannot carry a quote, a dollar sign, or a path separator.
40
- */
41
- function jsonType(field: string): string {
42
- return `json_type(payload, '$.${field}')`;
43
- }
44
-
45
- /** Present, and holding something a comparison can be defined over. */
46
- function presentPrimitive(field: string): string {
47
- return `(${jsonType(field)} IS NOT NULL AND ${jsonType(field)} NOT IN ('object', 'array'))`;
48
- }
49
-
50
- /** Present and explicitly null — the condition `isNull` actually means. */
51
- function isExplicitNull(field: string): string {
52
- return `${jsonType(field)} = 'null'`;
53
- }
54
-
55
- /** Booleans project to 0/1, so a bound parameter has to match. */
56
- function bind(value: FilterValue): SqlValue {
57
- return typeof value === 'boolean' ? (value ? 1 : 0) : value;
58
- }
59
-
60
- function comparison(node: Extract<FilterNode, { field: string }>): SqlFragment {
61
- const field = node.field;
62
- const column = columnFor(field);
63
-
64
- switch (node.op) {
65
- case 'exists':
66
- return {
67
- sql: node.value ? `${jsonType(field)} IS NOT NULL` : `${jsonType(field)} IS NULL`,
68
- params: [],
69
- };
70
-
71
- case 'isNull':
72
- return { sql: isExplicitNull(field), params: [] };
73
-
74
- case 'isNotNull':
75
- return {
76
- sql: `(${jsonType(field)} IS NOT NULL AND ${jsonType(field)} != 'null')`,
77
- params: [],
78
- };
79
-
80
- case 'eq':
81
- // Matching an explicit null needs the JSON type: `col = NULL` is
82
- // NULL in SQL, which is not true, and a projected NULL cannot tell
83
- // "absent" from "null" on its own.
84
- return node.value === null
85
- ? { sql: isExplicitNull(field), params: [] }
86
- : { sql: `${column} = ?`, params: [bind(node.value)] };
87
-
88
- case 'ne':
89
- return node.value === null
90
- ? {
91
- sql: `(${presentPrimitive(field)} AND ${jsonType(field)} != 'null')`,
92
- params: [],
93
- }
94
- : {
95
- sql: `(${presentPrimitive(field)} AND ${column} IS NOT ?)`,
96
- params: [bind(node.value)],
97
- };
98
-
99
- case 'lt':
100
- case 'lte':
101
- case 'gt':
102
- case 'gte': {
103
- if (node.value === null) {
104
- // An ordered comparison against null has no answer; SQL would
105
- // return NULL and the in-memory adapter returns false. Being
106
- // explicit keeps the two identical.
107
- return FALSE;
108
- }
109
- const operator = { lt: '<', lte: '<=', gt: '>', gte: '>=' }[node.op];
110
- return {
111
- sql: `(${column} IS NOT NULL AND ${column} ${operator} ?)`,
112
- params: [bind(node.value)],
113
- };
114
- }
115
-
116
- case 'between': {
117
- const [low, high] = node.value;
118
- if (low === null || high === null) {
119
- return FALSE;
120
- }
121
- return {
122
- sql: `(${column} IS NOT NULL AND ${column} BETWEEN ? AND ?)`,
123
- params: [bind(low), bind(high)],
124
- };
125
- }
126
-
127
- case 'in':
128
- case 'nin': {
129
- const values = node.value;
130
- const nonNull = values.filter((value): value is Exclude<FilterValue, null> => value !== null);
131
- const acceptsNull = values.length !== nonNull.length;
132
-
133
- const clauses: string[] = [];
134
- const params: SqlValue[] = [];
135
- if (nonNull.length > 0) {
136
- clauses.push(`${column} IN (${nonNull.map(() => '?').join(', ')})`);
137
- params.push(...nonNull.map(value => bind(value)));
138
- }
139
- if (acceptsNull) {
140
- clauses.push(isExplicitNull(field));
141
- }
142
- const membership = clauses.length > 0 ? `(${clauses.join(' OR ')})` : '0';
143
-
144
- if (node.op === 'in') {
145
- return { sql: membership, params };
146
- }
147
- // `nin` excludes, but only among records that have the field at
148
- // all: an absent field satisfies no comparison, negated or not.
149
- return {
150
- sql: `(${presentPrimitive(field)} AND NOT IFNULL(${membership}, 0))`,
151
- params,
152
- };
153
- }
154
-
155
- default:
156
- return FALSE;
157
- }
158
- }
159
-
160
- export function buildFilter(node: FilterNode | undefined): SqlFragment {
161
- if (!node) {
162
- return TRUE;
163
- }
164
- switch (node.op) {
165
- case 'and':
166
- case 'or': {
167
- const parts = node.nodes.map(child => buildFilter(child));
168
- return {
169
- sql: `(${parts.map(part => part.sql).join(node.op === 'and' ? ' AND ' : ' OR ')})`,
170
- params: parts.flatMap(part => part.params),
171
- };
172
- }
173
- case 'not': {
174
- const inner = buildFilter(node.node);
175
- // IFNULL, because NOT NULL is NULL in SQL and would quietly drop
176
- // rows the in-memory adapter keeps.
177
- return { sql: `(NOT IFNULL(${inner.sql}, 0))`, params: inner.params };
178
- }
179
- default:
180
- return comparison(node);
181
- }
182
- }
183
-
184
- export function buildOrderBy(sort: SortKey[]): string {
185
- return sort
186
- .map(key => `${columnFor(key.field)} ${key.direction === 'desc' ? 'DESC' : 'ASC'}`)
187
- .join(', ');
188
- }
189
-
190
- /**
191
- * The keyset predicate: everything strictly after the cursor position.
192
- *
193
- * Lexicographic across the sort keys — equal on the first means the second
194
- * decides, down to the `id` tiebreaker that guarantees a total order. NULL is
195
- * treated as smaller than every value, matching both SQLite's default
196
- * ordering and `compareValues`, so the two adapters page identically.
197
- */
198
- export function buildKeysetPredicate(sort: SortKey[], anchor: FilterValue[]): SqlFragment {
199
- const params: SqlValue[] = [];
200
-
201
- const build = (index: number): string => {
202
- if (index >= sort.length) {
203
- return '0';
204
- }
205
- const key = sort[index]!;
206
- const column = columnFor(key.field);
207
- const value = anchor[index] ?? null;
208
- const descending = key.direction === 'desc';
209
-
210
- let strictly: string;
211
- if (descending) {
212
- if (value === null) {
213
- // Nothing sorts before null, so a descending walk anchored on
214
- // null has nothing left ahead of it on this key.
215
- strictly = '0';
216
- } else {
217
- strictly = `(${column} IS NULL OR ${column} < ?)`;
218
- params.push(bind(value));
219
- }
220
- } else if (value === null) {
221
- strictly = `${column} IS NOT NULL`;
222
- } else {
223
- strictly = `${column} > ?`;
224
- params.push(bind(value));
225
- }
226
-
227
- const equal = `${column} IS ?`;
228
- const equalParam = bind(value);
229
-
230
- if (index === sort.length - 1) {
231
- return strictly;
232
- }
233
-
234
- // Params are pushed in the order the fragments are concatenated, so the
235
- // equality parameter has to land between this key's comparison and the
236
- // next key's fragment.
237
- const head = `(${strictly} OR (${equal} AND `;
238
- params.push(equalParam);
239
- const tail = build(index + 1);
240
- return `${head}${tail}))`;
241
- };
242
-
243
- return { sql: build(0), params };
244
- }
1
+ /**
2
+ * Filter tree to parameterized SQL.
3
+ *
4
+ * Nothing a consumer supplies is ever concatenated into a statement. Field
5
+ * names are resolved through the schema first — validation has already run
6
+ * and rejected anything undeclared (FR-034) — so the only identifiers that
7
+ * reach SQL are ones this package generated from a declaration it checked.
8
+ * Values bind as parameters, without exception. Injection stops being a thing
9
+ * to remember and becomes a thing the shape of the code prevents.
10
+ *
11
+ * The other half of this file is keyset pagination. `OFFSET` was rejected for
12
+ * two measured reasons (research.md R6): it degrades linearly at the 100,000
13
+ * record budget because the engine still walks the skipped rows, and it
14
+ * shifts pages under concurrent writes so a record can be skipped or returned
15
+ * twice — the exact failure SC-007 forbids.
16
+ */
17
+
18
+ import type { FilterNode, FilterValue, SortKey } from '../contract/query';
19
+ import { projectedColumn } from '../schema/declare';
20
+ import type { SqlValue } from './native';
21
+
22
+ export type SqlFragment = {
23
+ sql: string;
24
+ params: SqlValue[];
25
+ };
26
+
27
+ const TRUE: SqlFragment = { sql: '1', params: [] };
28
+ const FALSE: SqlFragment = { sql: '0', params: [] };
29
+
30
+ function columnFor(field: string): string {
31
+ return field === 'id' ? 'id' : `"${projectedColumn(field)}"`;
32
+ }
33
+
34
+ /**
35
+ * The JSON path for a declared field.
36
+ *
37
+ * Safe to interpolate: the field name came from a schema declaration that
38
+ * validation already matched against `^[A-Za-z][A-Za-z0-9_]{0,62}$`, so it
39
+ * cannot carry a quote, a dollar sign, or a path separator.
40
+ */
41
+ function jsonType(field: string): string {
42
+ return `json_type(payload, '$.${field}')`;
43
+ }
44
+
45
+ /** Present, and holding something a comparison can be defined over. */
46
+ function presentPrimitive(field: string): string {
47
+ return `(${jsonType(field)} IS NOT NULL AND ${jsonType(field)} NOT IN ('object', 'array'))`;
48
+ }
49
+
50
+ /** Present and explicitly null — the condition `isNull` actually means. */
51
+ function isExplicitNull(field: string): string {
52
+ return `${jsonType(field)} = 'null'`;
53
+ }
54
+
55
+ /** Booleans project to 0/1, so a bound parameter has to match. */
56
+ function bind(value: FilterValue): SqlValue {
57
+ return typeof value === 'boolean' ? (value ? 1 : 0) : value;
58
+ }
59
+
60
+ function comparison(node: Extract<FilterNode, { field: string }>): SqlFragment {
61
+ const field = node.field;
62
+ const column = columnFor(field);
63
+
64
+ switch (node.op) {
65
+ case 'exists':
66
+ return {
67
+ sql: node.value ? `${jsonType(field)} IS NOT NULL` : `${jsonType(field)} IS NULL`,
68
+ params: [],
69
+ };
70
+
71
+ case 'isNull':
72
+ return { sql: isExplicitNull(field), params: [] };
73
+
74
+ case 'isNotNull':
75
+ return {
76
+ sql: `(${jsonType(field)} IS NOT NULL AND ${jsonType(field)} != 'null')`,
77
+ params: [],
78
+ };
79
+
80
+ case 'eq':
81
+ // Matching an explicit null needs the JSON type: `col = NULL` is
82
+ // NULL in SQL, which is not true, and a projected NULL cannot tell
83
+ // "absent" from "null" on its own.
84
+ return node.value === null
85
+ ? { sql: isExplicitNull(field), params: [] }
86
+ : { sql: `${column} = ?`, params: [bind(node.value)] };
87
+
88
+ case 'ne':
89
+ return node.value === null
90
+ ? {
91
+ sql: `(${presentPrimitive(field)} AND ${jsonType(field)} != 'null')`,
92
+ params: [],
93
+ }
94
+ : {
95
+ sql: `(${presentPrimitive(field)} AND ${column} IS NOT ?)`,
96
+ params: [bind(node.value)],
97
+ };
98
+
99
+ case 'lt':
100
+ case 'lte':
101
+ case 'gt':
102
+ case 'gte': {
103
+ if (node.value === null) {
104
+ // An ordered comparison against null has no answer; SQL would
105
+ // return NULL and the in-memory adapter returns false. Being
106
+ // explicit keeps the two identical.
107
+ return FALSE;
108
+ }
109
+ const operator = { lt: '<', lte: '<=', gt: '>', gte: '>=' }[node.op];
110
+ return {
111
+ sql: `(${column} IS NOT NULL AND ${column} ${operator} ?)`,
112
+ params: [bind(node.value)],
113
+ };
114
+ }
115
+
116
+ case 'between': {
117
+ const [low, high] = node.value;
118
+ if (low === null || high === null) {
119
+ return FALSE;
120
+ }
121
+ return {
122
+ sql: `(${column} IS NOT NULL AND ${column} BETWEEN ? AND ?)`,
123
+ params: [bind(low), bind(high)],
124
+ };
125
+ }
126
+
127
+ case 'in':
128
+ case 'nin': {
129
+ const values = node.value;
130
+ const nonNull = values.filter((value): value is Exclude<FilterValue, null> => value !== null);
131
+ const acceptsNull = values.length !== nonNull.length;
132
+
133
+ const clauses: string[] = [];
134
+ const params: SqlValue[] = [];
135
+ if (nonNull.length > 0) {
136
+ clauses.push(`${column} IN (${nonNull.map(() => '?').join(', ')})`);
137
+ params.push(...nonNull.map(value => bind(value)));
138
+ }
139
+ if (acceptsNull) {
140
+ clauses.push(isExplicitNull(field));
141
+ }
142
+ const membership = clauses.length > 0 ? `(${clauses.join(' OR ')})` : '0';
143
+
144
+ if (node.op === 'in') {
145
+ return { sql: membership, params };
146
+ }
147
+ // `nin` excludes, but only among records that have the field at
148
+ // all: an absent field satisfies no comparison, negated or not.
149
+ return {
150
+ sql: `(${presentPrimitive(field)} AND NOT IFNULL(${membership}, 0))`,
151
+ params,
152
+ };
153
+ }
154
+
155
+ default:
156
+ return FALSE;
157
+ }
158
+ }
159
+
160
+ export function buildFilter(node: FilterNode | undefined): SqlFragment {
161
+ if (!node) {
162
+ return TRUE;
163
+ }
164
+ switch (node.op) {
165
+ case 'and':
166
+ case 'or': {
167
+ const parts = node.nodes.map(child => buildFilter(child));
168
+ return {
169
+ sql: `(${parts.map(part => part.sql).join(node.op === 'and' ? ' AND ' : ' OR ')})`,
170
+ params: parts.flatMap(part => part.params),
171
+ };
172
+ }
173
+ case 'not': {
174
+ const inner = buildFilter(node.node);
175
+ // IFNULL, because NOT NULL is NULL in SQL and would quietly drop
176
+ // rows the in-memory adapter keeps.
177
+ return { sql: `(NOT IFNULL(${inner.sql}, 0))`, params: inner.params };
178
+ }
179
+ default:
180
+ return comparison(node);
181
+ }
182
+ }
183
+
184
+ export function buildOrderBy(sort: SortKey[]): string {
185
+ return sort
186
+ .map(key => `${columnFor(key.field)} ${key.direction === 'desc' ? 'DESC' : 'ASC'}`)
187
+ .join(', ');
188
+ }
189
+
190
+ /**
191
+ * The keyset predicate: everything strictly after the cursor position.
192
+ *
193
+ * Lexicographic across the sort keys — equal on the first means the second
194
+ * decides, down to the `id` tiebreaker that guarantees a total order. NULL is
195
+ * treated as smaller than every value, matching both SQLite's default
196
+ * ordering and `compareValues`, so the two adapters page identically.
197
+ */
198
+ export function buildKeysetPredicate(sort: SortKey[], anchor: FilterValue[]): SqlFragment {
199
+ const params: SqlValue[] = [];
200
+
201
+ const build = (index: number): string => {
202
+ if (index >= sort.length) {
203
+ return '0';
204
+ }
205
+ const key = sort[index]!;
206
+ const column = columnFor(key.field);
207
+ const value = anchor[index] ?? null;
208
+ const descending = key.direction === 'desc';
209
+
210
+ let strictly: string;
211
+ if (descending) {
212
+ if (value === null) {
213
+ // Nothing sorts before null, so a descending walk anchored on
214
+ // null has nothing left ahead of it on this key.
215
+ strictly = '0';
216
+ } else {
217
+ strictly = `(${column} IS NULL OR ${column} < ?)`;
218
+ params.push(bind(value));
219
+ }
220
+ } else if (value === null) {
221
+ strictly = `${column} IS NOT NULL`;
222
+ } else {
223
+ strictly = `${column} > ?`;
224
+ params.push(bind(value));
225
+ }
226
+
227
+ const equal = `${column} IS ?`;
228
+ const equalParam = bind(value);
229
+
230
+ if (index === sort.length - 1) {
231
+ return strictly;
232
+ }
233
+
234
+ // Params are pushed in the order the fragments are concatenated, so the
235
+ // equality parameter has to land between this key's comparison and the
236
+ // next key's fragment.
237
+ const head = `(${strictly} OR (${equal} AND `;
238
+ params.push(equalParam);
239
+ const tail = build(index + 1);
240
+ return `${head}${tail}))`;
241
+ };
242
+
243
+ return { sql: build(0), params };
244
+ }
@@ -1,87 +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
- }
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
+ }