@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,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: Executor,
122
- reference: AttachmentReference,
123
- ): Promise<void> {
124
- await executor.execute(
125
- `INSERT INTO _attachments
126
- (id, owner_collection, owner_id, content_type, size, chunk_size, chunk_count, created_at)
127
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
128
- ON CONFLICT(id) DO UPDATE SET
129
- owner_collection = excluded.owner_collection,
130
- owner_id = excluded.owner_id,
131
- content_type = excluded.content_type,
132
- size = excluded.size,
133
- chunk_size = excluded.chunk_size,
134
- chunk_count = excluded.chunk_count,
135
- created_at = excluded.created_at`,
136
- [
137
- reference.id,
138
- reference.owner?.collection ?? null,
139
- reference.owner?.id ?? null,
140
- reference.contentType,
141
- reference.size,
142
- reference.chunkSize ?? null,
143
- reference.chunkCount ?? null,
144
- reference.createdAt,
145
- ],
146
- );
147
- }
148
-
149
- export async function deleteReference(executor: Executor, id: string): Promise<boolean> {
150
- const result = await executor.execute('DELETE FROM _attachments WHERE id = ?', [id]);
151
- return result.rowsAffected > 0;
152
- }
153
-
154
- async function writePlaintext(path: string, content: string): Promise<void> {
155
- await fileSystem().writeFile(path, content, 'base64');
156
- }
157
-
158
- /**
159
- * Encrypted write, one chunk in flight.
160
- *
161
- * The header goes down first because its contents — chunk size, chunk count,
162
- * plaintext length — are all known from the input length before any
163
- * encryption happens. Only the tags depend on the ciphertext, and they follow
164
- * their own chunk, so the whole write is append-only and peak memory is one
165
- * chunk regardless of artifact size (FR-107a).
166
- */
167
- async function writeEncrypted(
168
- path: string,
169
- key: Uint8Array,
170
- plaintext: Uint8Array,
171
- ): Promise<{ chunkSize: number; chunkCount: number }> {
172
- const header = headerFor(plaintext.length, Budgets.ARTIFACT_CHUNK_BYTES);
173
- await fileSystem().writeFile(path, bytesToBase64(encodeHeader(header)), 'base64');
174
-
175
- for (let index = 0; index < header.chunkCount; index += 1) {
176
- const start = index * header.chunkSize;
177
- const chunk = plaintext.subarray(start, start + chunkPlaintextLength(header, index));
178
- await fileSystem().appendFile(
179
- path,
180
- bytesToBase64(encryptChunk(key, index, chunk)),
181
- 'base64',
182
- );
183
- }
184
-
185
- return { chunkSize: header.chunkSize, chunkCount: header.chunkCount };
186
- }
187
-
188
- export async function writeArtifact(
189
- context: ArtifactContext,
190
- input: AttachmentInput,
191
- ): Promise<AttachmentReference> {
192
- throwIfFaulted('attachmentWrite');
193
- const plaintext = base64ToBytes(input.content);
194
- assertArtifactWithinBudget(input.id, plaintext.length);
195
-
196
- const path = artifactPath(context.layout, context.scopeKey, input.id);
197
- const key = context.artifactKey(input.id);
198
-
199
- let chunking: { chunkSize: number; chunkCount: number } | undefined;
200
- try {
201
- await fileSystem().mkdir(artifactsDirectory(context.layout, context.scopeKey));
202
- if (key) {
203
- chunking = await writeEncrypted(path, key, plaintext);
204
- } else {
205
- await writePlaintext(path, input.content);
206
- }
207
- } catch (error) {
208
- // A partial file with no reference is an orphan reconciliation can
209
- // find; a reference to a partial file is data the app believes it has.
210
- await removeIfPresent(path);
211
- raiseNative(error, { operation: 'attachment' });
212
- }
213
-
214
- const reference: AttachmentReference = {
215
- id: input.id,
216
- contentType: input.contentType,
217
- size: plaintext.length,
218
- createdAt: context.now(),
219
- };
220
- if (input.owner) {
221
- reference.owner = input.owner;
222
- }
223
- if (chunking) {
224
- reference.chunkSize = chunking.chunkSize;
225
- reference.chunkCount = chunking.chunkCount;
226
- }
227
-
228
- // Only now. See the note at the top of this file.
229
- await insertReference(context.executor(), reference);
230
- return reference;
231
- }
232
-
233
- async function readPlaintext(path: string, range?: ByteRange): Promise<string> {
234
- if (!range) {
235
- return fileSystem().readFile(path, 'base64');
236
- }
237
- return fileSystem().read(path, range.length, range.offset, 'base64');
238
- }
239
-
240
- async function readEncrypted(
241
- path: string,
242
- key: Uint8Array,
243
- range?: ByteRange,
244
- ): Promise<string> {
245
- const headerBytes = base64ToBytes(await fileSystem().read(path, HEADER_BYTES, 0, 'base64'));
246
- const header = decodeHeader(headerBytes);
247
-
248
- const from = range?.offset ?? 0;
249
- const to = range ? Math.min(header.plaintextSize, from + range.length) : header.plaintextSize;
250
- if (to <= from) {
251
- return '';
252
- }
253
-
254
- const first = Math.floor(from / header.chunkSize);
255
- const last = Math.floor((to - 1) / header.chunkSize);
256
- const pieces: Uint8Array[] = [];
257
-
258
- for (let index = first; index <= last; index += 1) {
259
- const stored = base64ToBytes(
260
- await fileSystem().read(
261
- path,
262
- chunkPlaintextLength(header, index) + TAG_BYTES,
263
- chunkOffset(header, index),
264
- 'base64',
265
- ),
266
- );
267
- const decrypted = decryptChunk(key, index, stored);
268
- const chunkStart = index * header.chunkSize;
269
- pieces.push(
270
- decrypted.subarray(
271
- Math.max(0, from - chunkStart),
272
- Math.min(decrypted.length, to - chunkStart),
273
- ),
274
- );
275
- }
276
-
277
- return bytesToBase64(concatBytes(pieces));
278
- }
279
-
280
- export async function readArtifact(
281
- context: ArtifactContext,
282
- id: string,
283
- range?: ByteRange,
284
- ): Promise<string> {
285
- const path = artifactPath(context.layout, context.scopeKey, id);
286
- const key = context.artifactKey(id);
287
- try {
288
- return key ? await readEncrypted(path, key, range) : await readPlaintext(path, range);
289
- } catch (error) {
290
- // A typed error the package already classified — an unreadable header,
291
- // a failing tag — says something more specific than "missing" and must
292
- // survive. Anything else reaching here is the file not being there.
293
- if (isStorageError(error)) {
294
- throw error;
295
- }
296
- raise(DiagnosticCodes.ATTACHMENT_MISSING, {
297
- message: 'artifact is missing',
298
- detail: { attachmentId: id },
299
- cause: error,
300
- });
301
- }
302
- }
303
-
304
- /**
305
- * Collects file removals until the transaction that deleted their references
306
- * commits. A file unlinked inside the transaction is gone even if the
307
- * transaction then rolls back and the reference returns — a dangling
308
- * reference to data nobody can recover.
309
- */
310
- export type DeferredUnlinks = {
311
- add(path: string): void;
312
- };
313
-
314
- export async function removeArtifact(
315
- context: ArtifactContext,
316
- id: string,
317
- deferred?: DeferredUnlinks,
318
- ): Promise<DeletionOutcome> {
319
- const path = artifactPath(context.layout, context.scopeKey, id);
320
- if (deferred) {
321
- // Inside a transaction: the reference goes now and rolls back with it;
322
- // the file goes once the transaction commits.
323
- const hadReference = await deleteReference(context.executor(), id);
324
- if (hadReference) {
325
- deferred.add(path);
326
- }
327
- return {
328
- removedRecords: 0,
329
- removedArtifacts: hadReference ? [id] : [],
330
- orphanedReferences: [],
331
- failedRemovals: [],
332
- };
333
- }
334
- const removed = await removeIfPresent(path);
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
+ }