@chipmobilesdk/rn-local-db 0.2.0 → 0.3.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 +191 -102
- package/README.md +728 -728
- package/package.json +62 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +75 -81
- package/src/diagnostics/dev-log.ts +132 -108
- package/src/diagnostics/errors.ts +63 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +119 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +49 -48
- package/src/platform/bytes.ts +157 -144
- package/src/platform/fs.ts +90 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +84 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +139 -136
- package/src/testing/conformance/encryption.ts +151 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +511 -492
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
package/src/artifacts/store.ts
CHANGED
|
@@ -1,345 +1,334 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Artifacts: the file on disk and the reference to it.
|
|
3
|
-
*
|
|
4
|
-
* The reference lives in the scope's own database, so a reference created
|
|
5
|
-
* inside a transaction rolls back with it (FR-072) — no bookkeeping required,
|
|
6
|
-
* because the connection routes through the transaction in flight.
|
|
7
|
-
*
|
|
8
|
-
* The file does not roll back. A filesystem write is not part of a SQL
|
|
9
|
-
* transaction and cannot be made part of one, so a rolled-back transaction
|
|
10
|
-
* leaves an artifact on disk with nothing pointing at it. That is not a bug
|
|
11
|
-
* to be engineered away; it is why reconciliation exists (FR-073d), and why
|
|
12
|
-
* the write order is deliberate: **the file completes first, then the
|
|
13
|
-
* reference**. The reverse order would produce a reference to a file that
|
|
14
|
-
* does not exist, which is the failure a consumer cannot recover from — it
|
|
15
|
-
* looks like data the app has and does not.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
AttachmentInput,
|
|
20
|
-
AttachmentOwner,
|
|
21
|
-
AttachmentReference,
|
|
22
|
-
ByteRange,
|
|
23
|
-
DeletionOutcome,
|
|
24
|
-
} from '../contract/attachment';
|
|
25
|
-
import { Budgets } from '../contract/config';
|
|
26
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
27
|
-
import { isStorageError, raise } from '../diagnostics/errors';
|
|
28
|
-
import { throwIfFaulted } from '../diagnostics/faults';
|
|
29
|
-
import { raiseNative } from '../diagnostics/map-native';
|
|
30
|
-
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
31
|
-
import { base64ToBytes, bytesToBase64, concatBytes } from '../platform/bytes';
|
|
32
|
-
import type { StorageLayout } from '../platform/location';
|
|
33
|
-
import { assertArtifactWithinBudget } from '../query/budgets';
|
|
34
|
-
import {
|
|
35
|
-
HEADER_BYTES,
|
|
36
|
-
TAG_BYTES,
|
|
37
|
-
chunkOffset,
|
|
38
|
-
chunkPlaintextLength,
|
|
39
|
-
decodeHeader,
|
|
40
|
-
decryptChunk,
|
|
41
|
-
encodeHeader,
|
|
42
|
-
encryptChunk,
|
|
43
|
-
headerFor,
|
|
44
|
-
} from '../crypto/chunked-cipher';
|
|
45
|
-
import type { Executor } from '../sqlite/connection';
|
|
46
|
-
import { maybeNum, num, text } from '../sqlite/values';
|
|
47
|
-
import { artifactPath, artifactsDirectory } from './paths';
|
|
48
|
-
|
|
49
|
-
const CREATE_ATTACHMENTS = `CREATE TABLE IF NOT EXISTS _attachments (
|
|
50
|
-
id TEXT PRIMARY KEY,
|
|
51
|
-
owner_collection TEXT,
|
|
52
|
-
owner_id TEXT,
|
|
53
|
-
content_type TEXT NOT NULL,
|
|
54
|
-
size INTEGER NOT NULL,
|
|
55
|
-
chunk_size INTEGER,
|
|
56
|
-
chunk_count INTEGER,
|
|
57
|
-
created_at INTEGER NOT NULL
|
|
58
|
-
)`;
|
|
59
|
-
|
|
60
|
-
const CREATE_OWNER_INDEX =
|
|
61
|
-
'CREATE INDEX IF NOT EXISTS _attachments_owner ON _attachments (owner_collection, owner_id)';
|
|
62
|
-
|
|
63
|
-
export type ArtifactContext = {
|
|
64
|
-
executor(): Executor;
|
|
65
|
-
layout: StorageLayout;
|
|
66
|
-
scopeKey: string;
|
|
67
|
-
/** Null when the database is unencrypted. */
|
|
68
|
-
artifactKey(attachmentId: string): Uint8Array | null;
|
|
69
|
-
now(): number;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export async function ensureAttachmentTable(executor: Executor): Promise<void> {
|
|
73
|
-
await executor.execute(CREATE_ATTACHMENTS);
|
|
74
|
-
await executor.execute(CREATE_OWNER_INDEX);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function toReference(row: Record<string, unknown>): AttachmentReference {
|
|
78
|
-
const reference: AttachmentReference = {
|
|
79
|
-
id: text(row.id),
|
|
80
|
-
contentType: text(row.content_type),
|
|
81
|
-
size: num(row.size),
|
|
82
|
-
createdAt: num(row.created_at),
|
|
83
|
-
};
|
|
84
|
-
if (row.owner_collection != null && row.owner_id != null) {
|
|
85
|
-
reference.owner = { collection: text(row.owner_collection), id: text(row.owner_id) };
|
|
86
|
-
}
|
|
87
|
-
const chunkSize = maybeNum(row.chunk_size);
|
|
88
|
-
if (chunkSize !== null) {
|
|
89
|
-
reference.chunkSize = chunkSize;
|
|
90
|
-
reference.chunkCount = num(row.chunk_count);
|
|
91
|
-
}
|
|
92
|
-
return reference;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export async function readReference(
|
|
96
|
-
executor: Executor,
|
|
97
|
-
id: string,
|
|
98
|
-
): Promise<AttachmentReference | null> {
|
|
99
|
-
const result = await executor.execute('SELECT * FROM _attachments WHERE id = ?', [id]);
|
|
100
|
-
const row = result.rows[0];
|
|
101
|
-
return row ? toReference(row) : null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export async function listReferences(executor: Executor): Promise<AttachmentReference[]> {
|
|
105
|
-
const result = await executor.execute('SELECT * FROM _attachments ORDER BY id ASC');
|
|
106
|
-
return result.rows.map(toReference);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export async function listReferencesFor(
|
|
110
|
-
executor: Executor,
|
|
111
|
-
owner: AttachmentOwner,
|
|
112
|
-
): Promise<AttachmentReference[]> {
|
|
113
|
-
const result = await executor.execute(
|
|
114
|
-
'SELECT * FROM _attachments WHERE owner_collection = ? AND owner_id = ? ORDER BY id ASC',
|
|
115
|
-
[owner.collection, owner.id],
|
|
116
|
-
);
|
|
117
|
-
return result.rows.map(toReference);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function insertReference(
|
|
121
|
-
executor
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
reference.
|
|
138
|
-
reference.
|
|
139
|
-
reference.
|
|
140
|
-
reference.
|
|
141
|
-
reference.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
context
|
|
190
|
-
input
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const hadReference = await deleteReference(context.executor(), id);
|
|
336
|
-
|
|
337
|
-
return {
|
|
338
|
-
removedRecords: 0,
|
|
339
|
-
removedArtifacts: removed ? [id] : [],
|
|
340
|
-
// A reference with no file left behind is worth reporting rather than
|
|
341
|
-
// rounding up to success (FR-071).
|
|
342
|
-
orphanedReferences: !removed && hadReference ? [id] : [],
|
|
343
|
-
failedRemovals: [],
|
|
344
|
-
};
|
|
345
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Artifacts: the file on disk and the reference to it.
|
|
3
|
+
*
|
|
4
|
+
* The reference lives in the scope's own database, so a reference created
|
|
5
|
+
* inside a transaction rolls back with it (FR-072) — no bookkeeping required,
|
|
6
|
+
* because the connection routes through the transaction in flight.
|
|
7
|
+
*
|
|
8
|
+
* The file does not roll back. A filesystem write is not part of a SQL
|
|
9
|
+
* transaction and cannot be made part of one, so a rolled-back transaction
|
|
10
|
+
* leaves an artifact on disk with nothing pointing at it. That is not a bug
|
|
11
|
+
* to be engineered away; it is why reconciliation exists (FR-073d), and why
|
|
12
|
+
* the write order is deliberate: **the file completes first, then the
|
|
13
|
+
* reference**. The reverse order would produce a reference to a file that
|
|
14
|
+
* does not exist, which is the failure a consumer cannot recover from — it
|
|
15
|
+
* looks like data the app has and does not.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AttachmentInput,
|
|
20
|
+
AttachmentOwner,
|
|
21
|
+
AttachmentReference,
|
|
22
|
+
ByteRange,
|
|
23
|
+
DeletionOutcome,
|
|
24
|
+
} from '../contract/attachment';
|
|
25
|
+
import { Budgets } from '../contract/config';
|
|
26
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
27
|
+
import { isStorageError, raise } from '../diagnostics/errors';
|
|
28
|
+
import { throwIfFaulted } from '../diagnostics/faults';
|
|
29
|
+
import { raiseNative } from '../diagnostics/map-native';
|
|
30
|
+
import { fileSystem, removeIfPresent } from '../platform/fs';
|
|
31
|
+
import { base64ToBytes, bytesToBase64, concatBytes } from '../platform/bytes';
|
|
32
|
+
import type { StorageLayout } from '../platform/location';
|
|
33
|
+
import { assertArtifactWithinBudget } from '../query/budgets';
|
|
34
|
+
import {
|
|
35
|
+
HEADER_BYTES,
|
|
36
|
+
TAG_BYTES,
|
|
37
|
+
chunkOffset,
|
|
38
|
+
chunkPlaintextLength,
|
|
39
|
+
decodeHeader,
|
|
40
|
+
decryptChunk,
|
|
41
|
+
encodeHeader,
|
|
42
|
+
encryptChunk,
|
|
43
|
+
headerFor,
|
|
44
|
+
} from '../crypto/chunked-cipher';
|
|
45
|
+
import type { Executor } from '../sqlite/connection';
|
|
46
|
+
import { maybeNum, num, text } from '../sqlite/values';
|
|
47
|
+
import { artifactPath, artifactsDirectory } from './paths';
|
|
48
|
+
|
|
49
|
+
const CREATE_ATTACHMENTS = `CREATE TABLE IF NOT EXISTS _attachments (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
owner_collection TEXT,
|
|
52
|
+
owner_id TEXT,
|
|
53
|
+
content_type TEXT NOT NULL,
|
|
54
|
+
size INTEGER NOT NULL,
|
|
55
|
+
chunk_size INTEGER,
|
|
56
|
+
chunk_count INTEGER,
|
|
57
|
+
created_at INTEGER NOT NULL
|
|
58
|
+
)`;
|
|
59
|
+
|
|
60
|
+
const CREATE_OWNER_INDEX =
|
|
61
|
+
'CREATE INDEX IF NOT EXISTS _attachments_owner ON _attachments (owner_collection, owner_id)';
|
|
62
|
+
|
|
63
|
+
export type ArtifactContext = {
|
|
64
|
+
executor(): Executor;
|
|
65
|
+
layout: StorageLayout;
|
|
66
|
+
scopeKey: string;
|
|
67
|
+
/** Null when the database is unencrypted. */
|
|
68
|
+
artifactKey(attachmentId: string): Uint8Array | null;
|
|
69
|
+
now(): number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export async function ensureAttachmentTable(executor: Executor): Promise<void> {
|
|
73
|
+
await executor.execute(CREATE_ATTACHMENTS);
|
|
74
|
+
await executor.execute(CREATE_OWNER_INDEX);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toReference(row: Record<string, unknown>): AttachmentReference {
|
|
78
|
+
const reference: AttachmentReference = {
|
|
79
|
+
id: text(row.id),
|
|
80
|
+
contentType: text(row.content_type),
|
|
81
|
+
size: num(row.size),
|
|
82
|
+
createdAt: num(row.created_at),
|
|
83
|
+
};
|
|
84
|
+
if (row.owner_collection != null && row.owner_id != null) {
|
|
85
|
+
reference.owner = { collection: text(row.owner_collection), id: text(row.owner_id) };
|
|
86
|
+
}
|
|
87
|
+
const chunkSize = maybeNum(row.chunk_size);
|
|
88
|
+
if (chunkSize !== null) {
|
|
89
|
+
reference.chunkSize = chunkSize;
|
|
90
|
+
reference.chunkCount = num(row.chunk_count);
|
|
91
|
+
}
|
|
92
|
+
return reference;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function readReference(
|
|
96
|
+
executor: Executor,
|
|
97
|
+
id: string,
|
|
98
|
+
): Promise<AttachmentReference | null> {
|
|
99
|
+
const result = await executor.execute('SELECT * FROM _attachments WHERE id = ?', [id]);
|
|
100
|
+
const row = result.rows[0];
|
|
101
|
+
return row ? toReference(row) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function listReferences(executor: Executor): Promise<AttachmentReference[]> {
|
|
105
|
+
const result = await executor.execute('SELECT * FROM _attachments ORDER BY id ASC');
|
|
106
|
+
return result.rows.map(toReference);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function listReferencesFor(
|
|
110
|
+
executor: Executor,
|
|
111
|
+
owner: AttachmentOwner,
|
|
112
|
+
): Promise<AttachmentReference[]> {
|
|
113
|
+
const result = await executor.execute(
|
|
114
|
+
'SELECT * FROM _attachments WHERE owner_collection = ? AND owner_id = ? ORDER BY id ASC',
|
|
115
|
+
[owner.collection, owner.id],
|
|
116
|
+
);
|
|
117
|
+
return result.rows.map(toReference);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function insertReference(executor: Executor, reference: AttachmentReference): Promise<void> {
|
|
121
|
+
await executor.execute(
|
|
122
|
+
`INSERT INTO _attachments
|
|
123
|
+
(id, owner_collection, owner_id, content_type, size, chunk_size, chunk_count, created_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
125
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
126
|
+
owner_collection = excluded.owner_collection,
|
|
127
|
+
owner_id = excluded.owner_id,
|
|
128
|
+
content_type = excluded.content_type,
|
|
129
|
+
size = excluded.size,
|
|
130
|
+
chunk_size = excluded.chunk_size,
|
|
131
|
+
chunk_count = excluded.chunk_count,
|
|
132
|
+
created_at = excluded.created_at`,
|
|
133
|
+
[
|
|
134
|
+
reference.id,
|
|
135
|
+
reference.owner?.collection ?? null,
|
|
136
|
+
reference.owner?.id ?? null,
|
|
137
|
+
reference.contentType,
|
|
138
|
+
reference.size,
|
|
139
|
+
reference.chunkSize ?? null,
|
|
140
|
+
reference.chunkCount ?? null,
|
|
141
|
+
reference.createdAt,
|
|
142
|
+
],
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function deleteReference(executor: Executor, id: string): Promise<boolean> {
|
|
147
|
+
const result = await executor.execute('DELETE FROM _attachments WHERE id = ?', [id]);
|
|
148
|
+
return result.rowsAffected > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function writePlaintext(path: string, content: string): Promise<void> {
|
|
152
|
+
await fileSystem().writeFile(path, content, 'base64');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Encrypted write, one chunk in flight.
|
|
157
|
+
*
|
|
158
|
+
* The header goes down first because its contents — chunk size, chunk count,
|
|
159
|
+
* plaintext length — are all known from the input length before any
|
|
160
|
+
* encryption happens. Only the tags depend on the ciphertext, and they follow
|
|
161
|
+
* their own chunk, so the whole write is append-only and peak memory is one
|
|
162
|
+
* chunk regardless of artifact size (FR-107a).
|
|
163
|
+
*/
|
|
164
|
+
async function writeEncrypted(
|
|
165
|
+
path: string,
|
|
166
|
+
key: Uint8Array,
|
|
167
|
+
plaintext: Uint8Array,
|
|
168
|
+
): Promise<{ chunkSize: number; chunkCount: number }> {
|
|
169
|
+
const header = headerFor(plaintext.length, Budgets.ARTIFACT_CHUNK_BYTES);
|
|
170
|
+
await fileSystem().writeFile(path, bytesToBase64(encodeHeader(header)), 'base64');
|
|
171
|
+
|
|
172
|
+
for (let index = 0; index < header.chunkCount; index += 1) {
|
|
173
|
+
const start = index * header.chunkSize;
|
|
174
|
+
const chunk = plaintext.subarray(start, start + chunkPlaintextLength(header, index));
|
|
175
|
+
await fileSystem().appendFile(path, bytesToBase64(encryptChunk(key, index, chunk)), 'base64');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { chunkSize: header.chunkSize, chunkCount: header.chunkCount };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function writeArtifact(
|
|
182
|
+
context: ArtifactContext,
|
|
183
|
+
input: AttachmentInput,
|
|
184
|
+
): Promise<AttachmentReference> {
|
|
185
|
+
throwIfFaulted('attachmentWrite');
|
|
186
|
+
const plaintext = base64ToBytes(input.content);
|
|
187
|
+
assertArtifactWithinBudget(input.id, plaintext.length);
|
|
188
|
+
|
|
189
|
+
const path = artifactPath(context.layout, context.scopeKey, input.id);
|
|
190
|
+
const key = context.artifactKey(input.id);
|
|
191
|
+
|
|
192
|
+
let chunking: { chunkSize: number; chunkCount: number } | undefined;
|
|
193
|
+
try {
|
|
194
|
+
await fileSystem().mkdir(artifactsDirectory(context.layout, context.scopeKey));
|
|
195
|
+
if (key) {
|
|
196
|
+
chunking = await writeEncrypted(path, key, plaintext);
|
|
197
|
+
} else {
|
|
198
|
+
await writePlaintext(path, input.content);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// A partial file with no reference is an orphan reconciliation can
|
|
202
|
+
// find; a reference to a partial file is data the app believes it has.
|
|
203
|
+
await removeIfPresent(path);
|
|
204
|
+
raiseNative(error, { operation: 'attachment' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const reference: AttachmentReference = {
|
|
208
|
+
id: input.id,
|
|
209
|
+
contentType: input.contentType,
|
|
210
|
+
size: plaintext.length,
|
|
211
|
+
createdAt: context.now(),
|
|
212
|
+
};
|
|
213
|
+
if (input.owner) {
|
|
214
|
+
reference.owner = input.owner;
|
|
215
|
+
}
|
|
216
|
+
if (chunking) {
|
|
217
|
+
reference.chunkSize = chunking.chunkSize;
|
|
218
|
+
reference.chunkCount = chunking.chunkCount;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Only now. See the note at the top of this file.
|
|
222
|
+
await insertReference(context.executor(), reference);
|
|
223
|
+
return reference;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readPlaintext(path: string, range?: ByteRange): Promise<string> {
|
|
227
|
+
if (!range) {
|
|
228
|
+
return fileSystem().readFile(path, 'base64');
|
|
229
|
+
}
|
|
230
|
+
return fileSystem().read(path, range.length, range.offset, 'base64');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function readEncrypted(path: string, key: Uint8Array, range?: ByteRange): Promise<string> {
|
|
234
|
+
const headerBytes = base64ToBytes(await fileSystem().read(path, HEADER_BYTES, 0, 'base64'));
|
|
235
|
+
const header = decodeHeader(headerBytes);
|
|
236
|
+
|
|
237
|
+
const from = range?.offset ?? 0;
|
|
238
|
+
const to = range ? Math.min(header.plaintextSize, from + range.length) : header.plaintextSize;
|
|
239
|
+
if (to <= from) {
|
|
240
|
+
return '';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const first = Math.floor(from / header.chunkSize);
|
|
244
|
+
const last = Math.floor((to - 1) / header.chunkSize);
|
|
245
|
+
const pieces: Uint8Array[] = [];
|
|
246
|
+
|
|
247
|
+
for (let index = first; index <= last; index += 1) {
|
|
248
|
+
const stored = base64ToBytes(
|
|
249
|
+
await fileSystem().read(
|
|
250
|
+
path,
|
|
251
|
+
chunkPlaintextLength(header, index) + TAG_BYTES,
|
|
252
|
+
chunkOffset(header, index),
|
|
253
|
+
'base64',
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
const decrypted = decryptChunk(key, index, stored);
|
|
257
|
+
const chunkStart = index * header.chunkSize;
|
|
258
|
+
pieces.push(
|
|
259
|
+
decrypted.subarray(
|
|
260
|
+
Math.max(0, from - chunkStart),
|
|
261
|
+
Math.min(decrypted.length, to - chunkStart),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return bytesToBase64(concatBytes(pieces));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function readArtifact(
|
|
270
|
+
context: ArtifactContext,
|
|
271
|
+
id: string,
|
|
272
|
+
range?: ByteRange,
|
|
273
|
+
): Promise<string> {
|
|
274
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
275
|
+
const key = context.artifactKey(id);
|
|
276
|
+
try {
|
|
277
|
+
return key ? await readEncrypted(path, key, range) : await readPlaintext(path, range);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// A typed error the package already classified — an unreadable header,
|
|
280
|
+
// a failing tag — says something more specific than "missing" and must
|
|
281
|
+
// survive. Anything else reaching here is the file not being there.
|
|
282
|
+
if (isStorageError(error)) {
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
raise(DiagnosticCodes.ATTACHMENT_MISSING, {
|
|
286
|
+
message: 'artifact is missing',
|
|
287
|
+
detail: { attachmentId: id },
|
|
288
|
+
cause: error,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Collects file removals until the transaction that deleted their references
|
|
295
|
+
* commits. A file unlinked inside the transaction is gone even if the
|
|
296
|
+
* transaction then rolls back and the reference returns — a dangling
|
|
297
|
+
* reference to data nobody can recover.
|
|
298
|
+
*/
|
|
299
|
+
export type DeferredUnlinks = {
|
|
300
|
+
add(path: string): void;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export async function removeArtifact(
|
|
304
|
+
context: ArtifactContext,
|
|
305
|
+
id: string,
|
|
306
|
+
deferred?: DeferredUnlinks,
|
|
307
|
+
): Promise<DeletionOutcome> {
|
|
308
|
+
const path = artifactPath(context.layout, context.scopeKey, id);
|
|
309
|
+
if (deferred) {
|
|
310
|
+
// Inside a transaction: the reference goes now and rolls back with it;
|
|
311
|
+
// the file goes once the transaction commits.
|
|
312
|
+
const hadReference = await deleteReference(context.executor(), id);
|
|
313
|
+
if (hadReference) {
|
|
314
|
+
deferred.add(path);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
removedRecords: 0,
|
|
318
|
+
removedArtifacts: hadReference ? [id] : [],
|
|
319
|
+
orphanedReferences: [],
|
|
320
|
+
failedRemovals: [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const removed = await removeIfPresent(path);
|
|
324
|
+
const hadReference = await deleteReference(context.executor(), id);
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
removedRecords: 0,
|
|
328
|
+
removedArtifacts: removed ? [id] : [],
|
|
329
|
+
// A reference with no file left behind is worth reporting rather than
|
|
330
|
+
// rounding up to success (FR-071).
|
|
331
|
+
orphanedReferences: !removed && hadReference ? [id] : [],
|
|
332
|
+
failedRemovals: [],
|
|
333
|
+
};
|
|
334
|
+
}
|