@chipmobilesdk/rn-local-db 0.2.1 → 0.4.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +31 -12
  3. package/package.json +5 -2
  4. package/skills/sdk-rn-local-db/SKILL.md +1 -1
  5. package/src/adapter/budgets.ts +1 -10
  6. package/src/adapter/collection.ts +38 -66
  7. package/src/adapter/deletion.ts +48 -0
  8. package/src/adapter/handle.ts +311 -0
  9. package/src/adapter/lifecycle.ts +10 -338
  10. package/src/adapter/schema-sync.ts +155 -0
  11. package/src/artifacts/cascade.ts +0 -14
  12. package/src/artifacts/store.ts +60 -10
  13. package/src/contract/attachment.ts +26 -5
  14. package/src/contract/config.ts +2 -2
  15. package/src/contract/migration.ts +4 -1
  16. package/src/contract/query.ts +6 -6
  17. package/src/crypto/provider.ts +21 -27
  18. package/src/diagnostics/dev-log.ts +28 -4
  19. package/src/diagnostics/errors.ts +17 -16
  20. package/src/diagnostics/redact.ts +5 -23
  21. package/src/migration/executor.ts +27 -6
  22. package/src/migration/ledger.ts +18 -27
  23. package/src/platform/backup-ios.ts +4 -3
  24. package/src/platform/bytes.ts +21 -8
  25. package/src/platform/fs.ts +22 -27
  26. package/src/platform/location.ts +0 -3
  27. package/src/query/budgets.ts +32 -5
  28. package/src/query/cursor.ts +5 -5
  29. package/src/schema/validate-config.ts +3 -53
  30. package/src/sqlite/batch.ts +40 -51
  31. package/src/sqlite/connection.ts +0 -9
  32. package/src/sqlite/native.ts +20 -19
  33. package/src/sqlite/row-codec.ts +5 -2
  34. package/src/sqlite/statements.ts +74 -0
  35. package/src/testing/conformance/attachments.ts +5 -4
  36. package/src/testing/conformance/encryption.ts +3 -2
  37. package/src/testing/conformance/index.ts +1 -1
  38. package/src/testing/index.ts +2 -0
  39. package/src/testing/memory-adapter/attachments.ts +146 -0
  40. package/src/testing/memory-adapter/index.ts +13 -110
  41. package/src/testing/memory-adapter/store.ts +3 -3
@@ -8,8 +8,10 @@
8
8
  * express the synchronous call is a stronger guarantee than a review comment.
9
9
  */
10
10
 
11
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
12
+
11
13
  import { DiagnosticCodes } from '../contract/errors';
12
- import { raise } from '../diagnostics/errors';
14
+ import { StorageError } from '../diagnostics/errors';
13
15
 
14
16
  export type SqlValue = string | number | boolean | null | undefined;
15
17
 
@@ -44,30 +46,29 @@ export type Engine = {
44
46
  isSQLCipher?: () => boolean;
45
47
  };
46
48
 
47
- let override: Engine | null = null;
48
- let cached: Engine | null = null;
49
+ /** Loaded on first use (rn-core's lazy peer); a missing peer is `OPEN_FAILED`. */
50
+ const sqlite = lazyPeer('@op-engineering/op-sqlite', () => {
51
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
52
+ try {
53
+ return require('@op-engineering/op-sqlite') as Engine;
54
+ } catch (cause) {
55
+ throw cause;
56
+ }
57
+ });
49
58
 
50
- /** Swap the engine. Tests only. */
59
+ /** Swap the engine. Tests only; `null` goes back to the real one. */
51
60
  export function setEngine(next: Engine | null): void {
52
- override = next;
53
- cached = null;
61
+ sqlite.override(next ?? undefined);
54
62
  }
55
63
 
56
64
  export function engine(): Engine {
57
- if (override) {
58
- return override;
59
- }
60
- if (!cached) {
61
- try {
62
- cached = require('@op-engineering/op-sqlite') as Engine;
63
- } catch (error) {
64
- raise(DiagnosticCodes.OPEN_FAILED, {
65
+ return sqlite.require(
66
+ (_peer, cause) =>
67
+ new StorageError(DiagnosticCodes.OPEN_FAILED, {
65
68
  message: 'storage engine peer is not installed',
66
- cause: error,
67
- });
68
- }
69
- }
70
- return cached!;
69
+ cause,
70
+ }),
71
+ );
71
72
  }
72
73
 
73
74
  /**
@@ -176,9 +176,11 @@ export function insertColumns(
176
176
  id: string,
177
177
  data: StorableRecordData,
178
178
  now: number,
179
+ /** The payload already serialized by the budget check, to avoid a second pass. */
180
+ payload: string = encodePayload(data),
179
181
  ): WriteColumns {
180
182
  const columns = ['id', 'payload'];
181
- const values: SqlValue[] = [id, encodePayload(data)];
183
+ const values: SqlValue[] = [id, payload];
182
184
 
183
185
  if (hasTimestamps(schema)) {
184
186
  columns.push('created_at', 'updated_at');
@@ -200,9 +202,10 @@ export function updateAssignments(
200
202
  schema: CollectionSchema,
201
203
  data: StorableRecordData,
202
204
  now: number,
205
+ payload: string = encodePayload(data),
203
206
  ): WriteColumns {
204
207
  const columns = ['payload'];
205
- const values: SqlValue[] = [encodePayload(data)];
208
+ const values: SqlValue[] = [payload];
206
209
 
207
210
  if (hasTimestamps(schema)) {
208
211
  columns.push('updated_at');
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The write statements, built in one place (LDB-12).
3
+ *
4
+ * The collection API and the batch each used to assemble their own INSERT and
5
+ * UPDATE text. Two copies of the same rule is how a batch upsert came to treat
6
+ * a soft-deleted row differently from a collection upsert.
7
+ */
8
+ import type { CollectionSchema } from '../contract/schema';
9
+ import { collectionTable, hasSoftDelete } from '../schema/declare';
10
+ import type { SqlValue } from './native';
11
+ import { insertColumns, updateAssignments } from './row-codec';
12
+
13
+ export type Statement = [sql: string, params: SqlValue[]];
14
+
15
+ type RecordData = Parameters<typeof insertColumns>[2];
16
+
17
+ // Built once per table and column list rather than on every write (PERF-10).
18
+ const insertTexts = new Map<string, string>();
19
+
20
+ function insertText(table: string, columns: readonly string[]): string {
21
+ const key = `${table}|${columns.join(',')}`;
22
+ let sql = insertTexts.get(key);
23
+ if (sql === undefined) {
24
+ sql = `INSERT INTO "${table}" (${columns.map(name => `"${name}"`).join(', ')}) VALUES (${columns
25
+ .map(() => '?')
26
+ .join(', ')})`;
27
+ insertTexts.set(key, sql);
28
+ }
29
+ return sql;
30
+ }
31
+
32
+ /**
33
+ * `ignore` leaves a row that already holds the id untouched and reports zero
34
+ * rows affected, so a conflict is detected without a SELECT beforehand.
35
+ */
36
+ export function insertStatement(
37
+ schema: CollectionSchema,
38
+ id: string,
39
+ data: RecordData,
40
+ now: number,
41
+ payload: string,
42
+ onConflict: 'fail' | 'ignore' = 'fail',
43
+ ): Statement {
44
+ const { columns, values } = insertColumns(schema, id, data, now, payload);
45
+ const sql = insertText(collectionTable(schema.name), columns);
46
+ return [onConflict === 'ignore' ? `${sql} ON CONFLICT(id) DO NOTHING` : sql, values];
47
+ }
48
+
49
+ export type UpdateOptions = {
50
+ /** Clear `deleted_at`: an upsert over a soft-deleted record brings it back. */
51
+ restore?: boolean;
52
+ /** Match only a record that is not soft-deleted. */
53
+ liveOnly?: boolean;
54
+ };
55
+
56
+ export function updateStatement(
57
+ schema: CollectionSchema,
58
+ id: string,
59
+ data: RecordData,
60
+ now: number,
61
+ payload: string,
62
+ options: UpdateOptions = {},
63
+ ): Statement {
64
+ const { columns, values } = updateAssignments(schema, data, now, payload);
65
+ const assignments = columns.map(name => `"${name}" = ?`);
66
+ if (options.restore === true && hasSoftDelete(schema)) {
67
+ assignments.push('deleted_at = NULL');
68
+ }
69
+ const live = options.liveOnly === true ? ' AND deleted_at IS NULL' : '';
70
+ return [
71
+ `UPDATE "${collectionTable(schema.name)}" SET ${assignments.join(', ')} WHERE id = ?${live}`,
72
+ [...values, id],
73
+ ];
74
+ }
@@ -1,5 +1,6 @@
1
1
  import { DiagnosticCodes } from '../../contract/errors';
2
2
  import { isCode } from '../../diagnostics/errors';
3
+ import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../../platform/bytes';
3
4
  import type { ConformanceContext } from './index';
4
5
 
5
6
  /** US7: artifacts the package owns, and the reconciliation that keeps them honest. */
@@ -8,7 +9,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
8
9
  beforeEach(() => context.reset());
9
10
 
10
11
  const scope = { kind: 'guest' } as const;
11
- const content = Buffer.from('hello attachment', 'utf8').toString('base64');
12
+ const content = bytesToBase64(utf8Bytes('hello attachment'));
12
13
 
13
14
  it('stores a reference alongside the artifact', async () => {
14
15
  const handle = await context.adapter.open(context.configFor(scope));
@@ -20,7 +21,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
20
21
  content,
21
22
  });
22
23
 
23
- expect(reference.size).toBe(Buffer.from(content, 'base64').length);
24
+ expect(reference.size).toBe(base64ToBytes(content).length);
24
25
  expect(await attachments.read('a1')).toBe(content);
25
26
  expect((await attachments.availability('a1')).state).toBe('available');
26
27
  await handle.close();
@@ -32,7 +33,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
32
33
  await attachments.write({ id: 'a1', contentType: 'text/plain', content });
33
34
 
34
35
  const slice = await attachments.read('a1', { offset: 0, length: 5 });
35
- expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('hello');
36
+ expect(utf8String(base64ToBytes(slice))).toBe('hello');
36
37
  await handle.close();
37
38
  });
38
39
 
@@ -112,7 +113,7 @@ export function registerAttachmentConformance(context: ConformanceContext): void
112
113
 
113
114
  it('rejects an artifact over the size budget', async () => {
114
115
  const handle = await context.adapter.open(context.configFor(scope));
115
- const huge = Buffer.alloc(101 * 1024 * 1024).toString('base64');
116
+ const huge = bytesToBase64(new Uint8Array(101 * 1024 * 1024));
116
117
 
117
118
  let thrown: unknown;
118
119
  try {
@@ -1,6 +1,7 @@
1
1
  import { DiagnosticCodes } from '../../contract/errors';
2
2
  import type { KeyProvider } from '../../contract/encryption';
3
3
  import { isCode } from '../../diagnostics/errors';
4
+ import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../../platform/bytes';
4
5
  import type { ConformanceContext } from './index';
5
6
 
6
7
  /** US10: opt-in encryption, and failures that stay apart from each other. */
@@ -47,12 +48,12 @@ export function registerEncryptionConformance(context: ConformanceContext): void
47
48
  const handle = await context.adapter.open(
48
49
  context.configFor(scope, { encryption: encrypted }),
49
50
  );
50
- const content = Buffer.from('hello encrypted attachment', 'utf8').toString('base64');
51
+ const content = bytesToBase64(utf8Bytes('hello encrypted attachment'));
51
52
  await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
52
53
 
53
54
  expect(await handle.attachments().read('a1')).toBe(content);
54
55
  const slice = await handle.attachments().read('a1', { offset: 6, length: 9 });
55
- expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('encrypted');
56
+ expect(utf8String(base64ToBytes(slice))).toBe('encrypted');
56
57
  expect((await handle.attachments().availability('a1')).state).toBe('available');
57
58
  await handle.close();
58
59
  });
@@ -59,7 +59,7 @@ export type ConformanceOptions = {
59
59
  * — an encrypted database that quietly behaved differently would be a
60
60
  * second implementation nobody tested.
61
61
  */
62
- encryption?: EncryptionConfig;
62
+ encryption?: EncryptionConfig | undefined;
63
63
  };
64
64
 
65
65
  export type ConformanceSection =
@@ -8,6 +8,8 @@
8
8
  export { createMemoryAdapter, type MemoryAdapterOptions } from './memory-adapter';
9
9
  export { memoryConformanceHooks, resetMemoryAdapter, seedRecords } from './memory-adapter/reset';
10
10
  export { clearFaults, injectFault, type FaultPoint, type FaultSpec } from './faults';
11
+ /** Makes `openDatabase` and the deletion functions use the given adapter; `null` restores the device one. */
12
+ export { setStorageAdapter } from '../adapter';
11
13
  export {
12
14
  runConformanceSuite,
13
15
  type ConformanceContext,
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The in-memory adapter's attachments (FR-091).
3
+ *
4
+ * The same surface the SQLite adapter exposes, over the store's maps: written
5
+ * whole rather than chunked, no filesystem, and a `sourcePath` read through the
6
+ * `readSourceFile` option a test supplies. Divergences are documented in the
7
+ * package README.
8
+ */
9
+ import type { Attachments } from '../../contract/adapter';
10
+ import type {
11
+ AttachmentInput,
12
+ AttachmentOwner,
13
+ AttachmentReference,
14
+ ByteRange,
15
+ DeletionOutcome,
16
+ ReconciliationReport,
17
+ } from '../../contract/attachment';
18
+ import { DiagnosticCodes } from '../../contract/errors';
19
+ import { raise } from '../../diagnostics/errors';
20
+ import { base64ToBytes, bytesToBase64 } from '../../platform/bytes';
21
+ import { assertArtifactWithinBudget } from '../../query/budgets';
22
+ import { assertValidAttachmentId } from '../../platform/location';
23
+ import { throwIfFaulted } from '../faults';
24
+ import type { MemoryScopeStore } from './store';
25
+ import type { MemoryAdapterOptions } from './index';
26
+
27
+ /** What one artifact removal has to wait for: the transaction that caused it. */
28
+ export interface Deferred {
29
+ add(id: string): void;
30
+ }
31
+
32
+ export interface MemoryAttachmentDeps {
33
+ store: MemoryScopeStore;
34
+ options: MemoryAdapterOptions;
35
+ now: () => number;
36
+ assertOpen: () => void;
37
+ }
38
+
39
+ export function createMemoryAttachments(deps: MemoryAttachmentDeps) {
40
+ const { store, options, now, assertOpen } = deps;
41
+
42
+ return (deferred?: Deferred): Attachments => ({
43
+ async write(input: AttachmentInput): Promise<AttachmentReference> {
44
+ assertOpen();
45
+ throwIfFaulted('attachmentWrite');
46
+ assertValidAttachmentId(input.id);
47
+ let content = input.content;
48
+ if (content === undefined) {
49
+ if (!options.readSourceFile) {
50
+ raise(DiagnosticCodes.STORAGE_ACCESS, {
51
+ message: 'the memory adapter was not given readSourceFile',
52
+ detail: { attachmentId: input.id },
53
+ });
54
+ }
55
+ content = await options.readSourceFile(input.sourcePath ?? '');
56
+ }
57
+ const bytes = base64ToBytes(content);
58
+ assertArtifactWithinBudget(input.id, bytes.length);
59
+ const reference: AttachmentReference = {
60
+ id: input.id,
61
+ owner: input.owner,
62
+ contentType: input.contentType,
63
+ size: bytes.length,
64
+ createdAt: now(),
65
+ };
66
+ store.artifacts.set(input.id, content);
67
+ store.attachments.set(input.id, reference);
68
+ return reference;
69
+ },
70
+ async read(id: string, range?: ByteRange): Promise<string> {
71
+ assertOpen();
72
+ // No paths here to escape, but an id the device build would refuse
73
+ // has to be refused here too, or a consumer's passing tests stop
74
+ // predicting the device (FR-096).
75
+ assertValidAttachmentId(id);
76
+ const content = store.artifacts.get(id);
77
+ if (content === undefined) {
78
+ raise(DiagnosticCodes.ATTACHMENT_MISSING, {
79
+ message: 'artifact is missing',
80
+ detail: { attachmentId: id },
81
+ });
82
+ }
83
+ if (!range) {
84
+ return content;
85
+ }
86
+ const bytes = base64ToBytes(content);
87
+ return bytesToBase64(bytes.subarray(range.offset, range.offset + range.length));
88
+ },
89
+ async availability(id) {
90
+ assertOpen();
91
+ const reference = store.attachments.get(id);
92
+ if (!reference) {
93
+ return { id, state: 'missing' };
94
+ }
95
+ return store.artifacts.has(id)
96
+ ? { id, state: 'available', size: reference.size }
97
+ : { id, state: 'missing' };
98
+ },
99
+ async remove(id: string): Promise<DeletionOutcome> {
100
+ assertOpen();
101
+ assertValidAttachmentId(id);
102
+ if (deferred) {
103
+ const hadReference = store.attachments.delete(id);
104
+ if (hadReference) deferred.add(id);
105
+ return {
106
+ removedRecords: 0,
107
+ removedArtifacts: hadReference ? [id] : [],
108
+ orphanedReferences: [],
109
+ failedRemovals: [],
110
+ };
111
+ }
112
+ const removed = store.artifacts.delete(id);
113
+ store.attachments.delete(id);
114
+ return {
115
+ removedRecords: 0,
116
+ removedArtifacts: removed ? [id] : [],
117
+ orphanedReferences: [],
118
+ failedRemovals: [],
119
+ };
120
+ },
121
+ async listFor(owner: AttachmentOwner): Promise<AttachmentReference[]> {
122
+ assertOpen();
123
+ return [...store.attachments.values()].filter(
124
+ reference =>
125
+ reference.owner?.collection === owner.collection && reference.owner?.id === owner.id,
126
+ );
127
+ },
128
+ async reconcile(reconcileOptions): Promise<ReconciliationReport> {
129
+ assertOpen();
130
+ const orphanedArtifacts = [...store.artifacts.keys()].filter(
131
+ id => !store.attachments.has(id),
132
+ );
133
+ const danglingReferences = [...store.attachments.keys()].filter(
134
+ id => !store.artifacts.has(id),
135
+ );
136
+ const removedArtifacts: string[] = [];
137
+ if (reconcileOptions?.removeOrphans) {
138
+ for (const id of orphanedArtifacts) {
139
+ store.artifacts.delete(id);
140
+ removedArtifacts.push(id);
141
+ }
142
+ }
143
+ return { orphanedArtifacts, danglingReferences, removedArtifacts };
144
+ },
145
+ });
146
+ }
@@ -7,14 +7,7 @@
7
7
  */
8
8
 
9
9
  import { createTransactionQueue } from '../../sqlite/transaction-queue';
10
- import type {
11
- AttachmentInput,
12
- AttachmentReference,
13
- AttachmentOwner,
14
- ByteRange,
15
- DeletionOutcome,
16
- ReconciliationReport,
17
- } from '../../contract/attachment';
10
+ import type { DeletionOutcome } from '../../contract/attachment';
18
11
  import type { EffectivePosture } from '../../contract/backup';
19
12
  import type {
20
13
  DatabaseConfig,
@@ -31,12 +24,11 @@ import type {
31
24
  CollectionOps,
32
25
  Transaction,
33
26
  } from '../../contract/transaction';
34
- import type { Attachments, DatabaseHandle, StorageAdapter } from '../../contract/adapter';
27
+ import type { DatabaseHandle, StorageAdapter } from '../../contract/adapter';
35
28
  import { acquireKey } from '../../crypto/key-provider';
36
29
  import { instrument } from '../../diagnostics/dev-log';
37
30
  import { raise } from '../../diagnostics/errors';
38
- import { assertValidAttachmentId } from '../../platform/location';
39
- import { assertBatchWithinBudget, assertArtifactWithinBudget } from '../../query/budgets';
31
+ import { assertBatchWithinBudget } from '../../query/budgets';
40
32
  import { validateConfig } from '../../schema/validate-config';
41
33
  import { resolveScope } from '../../scope/validate';
42
34
  import { runMigrations } from './migrations';
@@ -49,11 +41,18 @@ import {
49
41
  scopeStoreFor,
50
42
  type MemoryScopeStore,
51
43
  } from './store';
44
+ import { createMemoryAttachments } from './attachments';
52
45
  import { throwIfFaulted } from '../faults';
53
46
 
54
47
  export type MemoryAdapterOptions = {
55
48
  /** Fixed clock, so timestamp assertions are deterministic. */
56
49
  now?: () => number;
50
+ /**
51
+ * What a `sourcePath` attachment reads, as base64. There is no filesystem
52
+ * behind this adapter, so a test that writes attachments by path supplies
53
+ * the bytes here; without it such a write is refused with `STORAGE_ACCESS`.
54
+ */
55
+ readSourceFile?: (path: string) => Promise<string> | string;
57
56
  };
58
57
 
59
58
  function schemaIndex(collections: CollectionSchema[]): Map<string, CollectionSchema> {
@@ -188,11 +187,8 @@ export function createMemoryAdapter(options: MemoryAdapterOptions = {}): Storage
188
187
  const ops = instrument(
189
188
  name,
190
189
  'memory',
191
- createMemoryCollection<T>({ store, schema, now, assertOpen }) as unknown as Record<
192
- string,
193
- (...args: never[]) => unknown
194
- >,
195
- ) as unknown as CollectionOps<T>;
190
+ createMemoryCollection<T>({ store, schema, now, assertOpen }),
191
+ );
196
192
  if (config.cascadeAttachments !== true) {
197
193
  return ops;
198
194
  }
@@ -214,100 +210,7 @@ export function createMemoryAdapter(options: MemoryAdapterOptions = {}): Storage
214
210
  enforcedBy: 'package',
215
211
  };
216
212
 
217
- const rawAttachments = (deferred?: Deferred): Attachments => ({
218
- async write(input: AttachmentInput): Promise<AttachmentReference> {
219
- assertOpen();
220
- throwIfFaulted('attachmentWrite');
221
- assertValidAttachmentId(input.id);
222
- const bytes = Buffer.from(input.content, 'base64');
223
- assertArtifactWithinBudget(input.id, bytes.length);
224
- const reference: AttachmentReference = {
225
- id: input.id,
226
- owner: input.owner,
227
- contentType: input.contentType,
228
- size: bytes.length,
229
- createdAt: now(),
230
- };
231
- store.artifacts.set(input.id, input.content);
232
- store.attachments.set(input.id, reference);
233
- return reference;
234
- },
235
- async read(id: string, range?: ByteRange): Promise<string> {
236
- assertOpen();
237
- // No paths here to escape, but an id the device build would refuse
238
- // has to be refused here too, or a consumer's passing tests stop
239
- // predicting the device (FR-096).
240
- assertValidAttachmentId(id);
241
- const content = store.artifacts.get(id);
242
- if (content === undefined) {
243
- raise(DiagnosticCodes.ATTACHMENT_MISSING, {
244
- message: 'artifact is missing',
245
- detail: { attachmentId: id },
246
- });
247
- }
248
- if (!range) {
249
- return content;
250
- }
251
- const bytes = Buffer.from(content, 'base64');
252
- return bytes.subarray(range.offset, range.offset + range.length).toString('base64');
253
- },
254
- async availability(id) {
255
- assertOpen();
256
- const reference = store.attachments.get(id);
257
- if (!reference) {
258
- return { id, state: 'missing' };
259
- }
260
- return store.artifacts.has(id)
261
- ? { id, state: 'available', size: reference.size }
262
- : { id, state: 'missing' };
263
- },
264
- async remove(id: string): Promise<DeletionOutcome> {
265
- assertOpen();
266
- assertValidAttachmentId(id);
267
- if (deferred) {
268
- const hadReference = store.attachments.delete(id);
269
- if (hadReference) deferred.add(id);
270
- return {
271
- removedRecords: 0,
272
- removedArtifacts: hadReference ? [id] : [],
273
- orphanedReferences: [],
274
- failedRemovals: [],
275
- };
276
- }
277
- const removed = store.artifacts.delete(id);
278
- store.attachments.delete(id);
279
- return {
280
- removedRecords: 0,
281
- removedArtifacts: removed ? [id] : [],
282
- orphanedReferences: [],
283
- failedRemovals: [],
284
- };
285
- },
286
- async listFor(owner: AttachmentOwner): Promise<AttachmentReference[]> {
287
- assertOpen();
288
- return [...store.attachments.values()].filter(
289
- reference =>
290
- reference.owner?.collection === owner.collection && reference.owner?.id === owner.id,
291
- );
292
- },
293
- async reconcile(reconcileOptions): Promise<ReconciliationReport> {
294
- assertOpen();
295
- const orphanedArtifacts = [...store.artifacts.keys()].filter(
296
- id => !store.attachments.has(id),
297
- );
298
- const danglingReferences = [...store.attachments.keys()].filter(
299
- id => !store.artifacts.has(id),
300
- );
301
- const removedArtifacts: string[] = [];
302
- if (reconcileOptions?.removeOrphans) {
303
- for (const id of orphanedArtifacts) {
304
- store.artifacts.delete(id);
305
- removedArtifacts.push(id);
306
- }
307
- }
308
- return { orphanedArtifacts, danglingReferences, removedArtifacts };
309
- },
310
- });
213
+ const rawAttachments = createMemoryAttachments({ store, options, now, assertOpen });
311
214
  const attachments = queued(rawAttachments());
312
215
 
313
216
  /**
@@ -16,9 +16,9 @@ export type MemoryRow = {
16
16
  /** Serialized on write, parsed on read — the same payload-as-truth rule
17
17
  * the SQLite adapter follows, so a mutation after write cannot leak in. */
18
18
  payload: string;
19
- createdAt?: number;
20
- updatedAt?: number;
21
- deletedAt?: number | null;
19
+ createdAt?: number | undefined;
20
+ updatedAt?: number | undefined;
21
+ deletedAt?: number | null | undefined;
22
22
  };
23
23
 
24
24
  export type MemoryScopeStore = {