@chipmobilesdk/rn-local-db 0.3.0 → 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.
@@ -11,6 +11,7 @@ import { Budgets, type DatabaseConfig } from '../contract/config';
11
11
  import { DiagnosticCodes } from '../contract/errors';
12
12
  import type { CollectionSchema } from '../contract/schema';
13
13
  import { raise } from '../diagnostics/errors';
14
+ import { validateMigrationChain } from '../migration/validate-chain';
14
15
  import { validateDescriptor } from '../scope/validate';
15
16
 
16
17
  const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]{0,62}$/;
@@ -71,58 +72,6 @@ function validateCollection(collection: CollectionSchema, seen: Set<string>): vo
71
72
  }
72
73
  }
73
74
 
74
- function validateMigrations(config: DatabaseConfig): void {
75
- const migrations = config.migrations ?? [];
76
- if (!Number.isInteger(config.schemaVersion) || config.schemaVersion < 1) {
77
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
78
- message: 'schemaVersion must be a positive integer',
79
- });
80
- }
81
-
82
- const versions = new Set<number>();
83
- for (const migration of migrations) {
84
- if (!Number.isInteger(migration.version) || migration.version < 1) {
85
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
86
- message: 'migration version must be a positive integer',
87
- });
88
- }
89
- if (versions.has(migration.version)) {
90
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
91
- message: 'duplicate migration version',
92
- detail: { version: migration.version },
93
- });
94
- }
95
- versions.add(migration.version);
96
- if (typeof migration.migrate !== 'function') {
97
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
98
- message: 'migration has no migrate function',
99
- detail: { version: migration.version },
100
- });
101
- }
102
- }
103
-
104
- // A chain must be able to carry a database from 1 to schemaVersion. A gap
105
- // means some upgrading user has no path across it, which is worse than
106
- // refusing to open.
107
- for (let version = 1; version <= config.schemaVersion; version += 1) {
108
- if (!versions.has(version)) {
109
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
110
- message: 'migration chain has a gap',
111
- detail: { version },
112
- });
113
- }
114
- }
115
-
116
- for (const version of versions) {
117
- if (version > config.schemaVersion) {
118
- raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
119
- message: 'migration version exceeds the declared schemaVersion',
120
- detail: { version },
121
- });
122
- }
123
- }
124
- }
125
-
126
75
  export function validateConfig(config: DatabaseConfig): void {
127
76
  if (!config || typeof config !== 'object') {
128
77
  configInvalid('database config is required');
@@ -148,7 +97,8 @@ export function validateConfig(config: DatabaseConfig): void {
148
97
  validateCollection(collection, seen);
149
98
  }
150
99
 
151
- validateMigrations(config);
100
+ // The one definition of a valid chain, shared with the executor (LDB-12).
101
+ validateMigrationChain(config);
152
102
 
153
103
  if (config.encryption && config.encryption.enabled) {
154
104
  if (typeof config.encryption.keyProvider?.getKey !== 'function') {
@@ -21,8 +21,7 @@ import { raise } from '../diagnostics/errors';
21
21
  import { assertBatchWithinBudget, assertRecordWithinBudget } from '../query/budgets';
22
22
  import { collectionTable, hasSoftDelete } from '../schema/declare';
23
23
  import type { Connection } from './connection';
24
- import type { SqlValue } from './native';
25
- import { insertColumns, updateAssignments } from './row-codec';
24
+ import { insertStatement, updateStatement, type Statement } from './statements';
26
25
 
27
26
  export type BatchContext = {
28
27
  connection: Connection;
@@ -31,42 +30,12 @@ export type BatchContext = {
31
30
  now(): number;
32
31
  };
33
32
 
34
- type Command = [string, SqlValue[]];
33
+ type Command = Statement;
35
34
 
36
35
  function quoted(schema: CollectionSchema): string {
37
36
  return `"${collectionTable(schema.name)}"`;
38
37
  }
39
38
 
40
- function insertCommand(
41
- schema: CollectionSchema,
42
- id: string,
43
- data: Parameters<typeof insertColumns>[2],
44
- now: number,
45
- ): Command {
46
- const { columns, values } = insertColumns(schema, id, data, now);
47
- return [
48
- `INSERT INTO ${quoted(schema)} (${columns
49
- .map(name => `"${name}"`)
50
- .join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`,
51
- values,
52
- ];
53
- }
54
-
55
- function updateCommand(
56
- schema: CollectionSchema,
57
- id: string,
58
- data: Parameters<typeof updateAssignments>[1],
59
- now: number,
60
- restore: boolean,
61
- ): Command {
62
- const { columns, values } = updateAssignments(schema, data, now);
63
- const assignments = columns.map(name => `"${name}" = ?`);
64
- if (restore && hasSoftDelete(schema)) {
65
- assignments.push('deleted_at = NULL');
66
- }
67
- return [`UPDATE ${quoted(schema)} SET ${assignments.join(', ')} WHERE id = ?`, [...values, id]];
68
- }
69
-
70
39
  /**
71
40
  * Where a record stands, as this batch has left it so far. Three states, not
72
41
  * two: a soft-deleted row is invisible to reads but still holds its primary
@@ -104,13 +73,13 @@ async function resolveInsert(
104
73
  operation: Extract<BatchOperation, { type: 'insert' }>,
105
74
  ): Promise<Resolved> {
106
75
  const { schema, now } = resolver;
107
- assertRecordWithinBudget(schema.name, operation.record.data);
76
+ const payload = assertRecordWithinBudget(schema.name, operation.record.data);
108
77
  if ((await resolver.state(operation.record.id)) !== 'absent') {
109
78
  conflict(schema, operation.record.id);
110
79
  }
111
80
  resolver.stage(operation.record.id, 'live');
112
81
  return {
113
- command: insertCommand(schema, operation.record.id, operation.record.data, now),
82
+ command: insertStatement(schema, operation.record.id, operation.record.data, now, payload),
114
83
  outcome: { operation: 'created', id: operation.record.id },
115
84
  };
116
85
  }
@@ -120,12 +89,14 @@ async function resolveUpdate(
120
89
  operation: Extract<BatchOperation, { type: 'update' }>,
121
90
  ): Promise<Resolved> {
122
91
  const { schema, now } = resolver;
123
- assertRecordWithinBudget(schema.name, operation.data);
92
+ const payload = assertRecordWithinBudget(schema.name, operation.data);
124
93
  if ((await resolver.state(operation.id)) !== 'live') {
125
94
  missing(schema, operation.id);
126
95
  }
127
96
  return {
128
- command: updateCommand(schema, operation.id, operation.data, now, false),
97
+ command: updateStatement(schema, operation.id, operation.data, now, payload, {
98
+ restore: false,
99
+ }),
129
100
  outcome: { operation: 'updated', id: operation.id },
130
101
  };
131
102
  }
@@ -135,13 +106,15 @@ async function resolveUpsert(
135
106
  operation: Extract<BatchOperation, { type: 'upsert' }>,
136
107
  ): Promise<Resolved> {
137
108
  const { schema, now } = resolver;
138
- assertRecordWithinBudget(schema.name, operation.record.data);
109
+ const payload = assertRecordWithinBudget(schema.name, operation.record.data);
139
110
  const present = (await resolver.state(operation.record.id)) !== 'absent';
140
111
  resolver.stage(operation.record.id, 'live');
141
112
  return {
142
113
  command: present
143
- ? updateCommand(schema, operation.record.id, operation.record.data, now, true)
144
- : insertCommand(schema, operation.record.id, operation.record.data, now),
114
+ ? updateStatement(schema, operation.record.id, operation.record.data, now, payload, {
115
+ restore: true,
116
+ })
117
+ : insertStatement(schema, operation.record.id, operation.record.data, now, payload),
145
118
  outcome: { operation: present ? 'updated' : 'created', id: operation.record.id },
146
119
  };
147
120
  }
@@ -209,23 +182,39 @@ export async function runBatch(
209
182
  const outcomes: WriteOutcome[] = [];
210
183
  const overlay = new Map<string, RowState>();
211
184
 
185
+ // What the batch will touch, read once per collection (LDB-08). A batch of a
186
+ // thousand operations used to make a thousand SELECTs before its one write.
187
+ // The ids travel as one JSON parameter, so no variable limit applies.
188
+ const known = new Map<string, RowState>();
189
+ const idsByCollection = new Map<string, Set<string>>();
190
+ for (const operation of operations) {
191
+ const id = 'record' in operation ? operation.record.id : operation.id;
192
+ const ids = idsByCollection.get(operation.collection) ?? new Set<string>();
193
+ ids.add(id);
194
+ idsByCollection.set(operation.collection, ids);
195
+ }
196
+ for (const [collection, ids] of idsByCollection) {
197
+ const schema = context.schemaFor(collection);
198
+ const result = await context.connection.execute(
199
+ `SELECT id, deleted_at FROM ${quoted(schema)} WHERE id IN (SELECT value FROM json_each(?))`,
200
+ [JSON.stringify([...ids])],
201
+ );
202
+ for (const row of result.rows) {
203
+ known.set(
204
+ `${schema.name}:${String(row.id)}`,
205
+ row.deleted_at === null || row.deleted_at === undefined ? 'live' : 'softDeleted',
206
+ );
207
+ }
208
+ }
209
+
212
210
  for (const operation of operations) {
213
211
  const schema = context.schemaFor(operation.collection);
214
212
  const resolver: Resolver = {
215
213
  schema,
216
214
  now: context.now(),
217
215
  async state(id) {
218
- const staged = overlay.get(`${schema.name}:${id}`);
219
- if (staged !== undefined) {
220
- return staged;
221
- }
222
- const result = await context.connection.execute(
223
- `SELECT deleted_at FROM ${quoted(schema)} WHERE id = ?`,
224
- [id],
225
- );
226
- const row = result.rows[0];
227
- if (!row) return 'absent';
228
- return row.deleted_at === null || row.deleted_at === undefined ? 'live' : 'softDeleted';
216
+ const key = `${schema.name}:${id}`;
217
+ return overlay.get(key) ?? known.get(key) ?? 'absent';
229
218
  },
230
219
  stage(id, state) {
231
220
  overlay.set(`${schema.name}:${id}`, state);
@@ -52,8 +52,6 @@ export type Connection = Executor & {
52
52
  readonly encrypted: boolean;
53
53
  transaction<R>(fn: (tx: TransactionExecutor) => Promise<R>): Promise<R>;
54
54
  executeBatch(commands: [string, SqlValue[]?][]): Promise<void>;
55
- /** Full-database check, for the diagnostics surface. Never run at open. */
56
- integrityCheck(): Promise<boolean>;
57
55
  close(): Promise<void>;
58
56
  };
59
57
 
@@ -180,13 +178,6 @@ export async function openConnection(options: OpenConnectionOptions): Promise<Co
180
178
  });
181
179
  },
182
180
 
183
- async integrityCheck() {
184
- assertOpen();
185
- const result = await connection.execute('PRAGMA quick_check(1)');
186
- const first = result.rows[0];
187
- return first ? Object.values(first)[0] === 'ok' : false;
188
- },
189
-
190
181
  async close() {
191
182
  if (closed) {
192
183
  return;
@@ -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
+ }
@@ -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
+ }
@@ -6,16 +6,8 @@
6
6
  * (research.md R8). Documented divergences are listed in the package README.
7
7
  */
8
8
 
9
- import { base64ToBytes, bytesToBase64 } from '../../platform/bytes';
10
9
  import { createTransactionQueue } from '../../sqlite/transaction-queue';
11
- import type {
12
- AttachmentInput,
13
- AttachmentReference,
14
- AttachmentOwner,
15
- ByteRange,
16
- DeletionOutcome,
17
- ReconciliationReport,
18
- } from '../../contract/attachment';
10
+ import type { DeletionOutcome } from '../../contract/attachment';
19
11
  import type { EffectivePosture } from '../../contract/backup';
20
12
  import type {
21
13
  DatabaseConfig,
@@ -32,12 +24,11 @@ import type {
32
24
  CollectionOps,
33
25
  Transaction,
34
26
  } from '../../contract/transaction';
35
- import type { Attachments, DatabaseHandle, StorageAdapter } from '../../contract/adapter';
27
+ import type { DatabaseHandle, StorageAdapter } from '../../contract/adapter';
36
28
  import { acquireKey } from '../../crypto/key-provider';
37
29
  import { instrument } from '../../diagnostics/dev-log';
38
30
  import { raise } from '../../diagnostics/errors';
39
- import { assertValidAttachmentId } from '../../platform/location';
40
- import { assertBatchWithinBudget, assertArtifactWithinBudget } from '../../query/budgets';
31
+ import { assertBatchWithinBudget } from '../../query/budgets';
41
32
  import { validateConfig } from '../../schema/validate-config';
42
33
  import { resolveScope } from '../../scope/validate';
43
34
  import { runMigrations } from './migrations';
@@ -50,11 +41,18 @@ import {
50
41
  scopeStoreFor,
51
42
  type MemoryScopeStore,
52
43
  } from './store';
44
+ import { createMemoryAttachments } from './attachments';
53
45
  import { throwIfFaulted } from '../faults';
54
46
 
55
47
  export type MemoryAdapterOptions = {
56
48
  /** Fixed clock, so timestamp assertions are deterministic. */
57
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;
58
56
  };
59
57
 
60
58
  function schemaIndex(collections: CollectionSchema[]): Map<string, CollectionSchema> {
@@ -189,11 +187,8 @@ export function createMemoryAdapter(options: MemoryAdapterOptions = {}): Storage
189
187
  const ops = instrument(
190
188
  name,
191
189
  'memory',
192
- createMemoryCollection<T>({ store, schema, now, assertOpen }) as unknown as Record<
193
- string,
194
- (...args: never[]) => unknown
195
- >,
196
- ) as unknown as CollectionOps<T>;
190
+ createMemoryCollection<T>({ store, schema, now, assertOpen }),
191
+ );
197
192
  if (config.cascadeAttachments !== true) {
198
193
  return ops;
199
194
  }
@@ -215,100 +210,7 @@ export function createMemoryAdapter(options: MemoryAdapterOptions = {}): Storage
215
210
  enforcedBy: 'package',
216
211
  };
217
212
 
218
- const rawAttachments = (deferred?: Deferred): Attachments => ({
219
- async write(input: AttachmentInput): Promise<AttachmentReference> {
220
- assertOpen();
221
- throwIfFaulted('attachmentWrite');
222
- assertValidAttachmentId(input.id);
223
- const bytes = base64ToBytes(input.content);
224
- assertArtifactWithinBudget(input.id, bytes.length);
225
- const reference: AttachmentReference = {
226
- id: input.id,
227
- owner: input.owner,
228
- contentType: input.contentType,
229
- size: bytes.length,
230
- createdAt: now(),
231
- };
232
- store.artifacts.set(input.id, input.content);
233
- store.attachments.set(input.id, reference);
234
- return reference;
235
- },
236
- async read(id: string, range?: ByteRange): Promise<string> {
237
- assertOpen();
238
- // No paths here to escape, but an id the device build would refuse
239
- // has to be refused here too, or a consumer's passing tests stop
240
- // predicting the device (FR-096).
241
- assertValidAttachmentId(id);
242
- const content = store.artifacts.get(id);
243
- if (content === undefined) {
244
- raise(DiagnosticCodes.ATTACHMENT_MISSING, {
245
- message: 'artifact is missing',
246
- detail: { attachmentId: id },
247
- });
248
- }
249
- if (!range) {
250
- return content;
251
- }
252
- const bytes = base64ToBytes(content);
253
- return bytesToBase64(bytes.subarray(range.offset, range.offset + range.length));
254
- },
255
- async availability(id) {
256
- assertOpen();
257
- const reference = store.attachments.get(id);
258
- if (!reference) {
259
- return { id, state: 'missing' };
260
- }
261
- return store.artifacts.has(id)
262
- ? { id, state: 'available', size: reference.size }
263
- : { id, state: 'missing' };
264
- },
265
- async remove(id: string): Promise<DeletionOutcome> {
266
- assertOpen();
267
- assertValidAttachmentId(id);
268
- if (deferred) {
269
- const hadReference = store.attachments.delete(id);
270
- if (hadReference) deferred.add(id);
271
- return {
272
- removedRecords: 0,
273
- removedArtifacts: hadReference ? [id] : [],
274
- orphanedReferences: [],
275
- failedRemovals: [],
276
- };
277
- }
278
- const removed = store.artifacts.delete(id);
279
- store.attachments.delete(id);
280
- return {
281
- removedRecords: 0,
282
- removedArtifacts: removed ? [id] : [],
283
- orphanedReferences: [],
284
- failedRemovals: [],
285
- };
286
- },
287
- async listFor(owner: AttachmentOwner): Promise<AttachmentReference[]> {
288
- assertOpen();
289
- return [...store.attachments.values()].filter(
290
- reference =>
291
- reference.owner?.collection === owner.collection && reference.owner?.id === owner.id,
292
- );
293
- },
294
- async reconcile(reconcileOptions): Promise<ReconciliationReport> {
295
- assertOpen();
296
- const orphanedArtifacts = [...store.artifacts.keys()].filter(
297
- id => !store.attachments.has(id),
298
- );
299
- const danglingReferences = [...store.attachments.keys()].filter(
300
- id => !store.artifacts.has(id),
301
- );
302
- const removedArtifacts: string[] = [];
303
- if (reconcileOptions?.removeOrphans) {
304
- for (const id of orphanedArtifacts) {
305
- store.artifacts.delete(id);
306
- removedArtifacts.push(id);
307
- }
308
- }
309
- return { orphanedArtifacts, danglingReferences, removedArtifacts };
310
- },
311
- });
213
+ const rawAttachments = createMemoryAttachments({ store, options, now, assertOpen });
312
214
  const attachments = queued(rawAttachments());
313
215
 
314
216
  /**