@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.
- package/CHANGELOG.md +92 -1
- package/README.md +31 -12
- package/package.json +5 -2
- package/skills/sdk-rn-local-db/SKILL.md +1 -1
- package/src/adapter/budgets.ts +1 -10
- package/src/adapter/collection.ts +38 -66
- package/src/adapter/deletion.ts +48 -0
- package/src/adapter/handle.ts +311 -0
- package/src/adapter/lifecycle.ts +10 -338
- package/src/adapter/schema-sync.ts +155 -0
- package/src/artifacts/cascade.ts +0 -14
- package/src/artifacts/store.ts +60 -10
- package/src/contract/attachment.ts +26 -5
- package/src/contract/config.ts +2 -2
- package/src/contract/migration.ts +4 -1
- package/src/contract/query.ts +6 -6
- package/src/crypto/provider.ts +21 -27
- package/src/diagnostics/dev-log.ts +28 -4
- package/src/diagnostics/errors.ts +17 -16
- package/src/diagnostics/redact.ts +5 -23
- package/src/migration/executor.ts +27 -6
- package/src/migration/ledger.ts +18 -27
- package/src/platform/backup-ios.ts +4 -3
- package/src/platform/bytes.ts +21 -8
- package/src/platform/fs.ts +22 -27
- package/src/platform/location.ts +0 -3
- package/src/query/budgets.ts +32 -5
- package/src/query/cursor.ts +5 -5
- package/src/schema/validate-config.ts +3 -53
- package/src/sqlite/batch.ts +40 -51
- package/src/sqlite/connection.ts +0 -9
- package/src/sqlite/native.ts +20 -19
- package/src/sqlite/row-codec.ts +5 -2
- package/src/sqlite/statements.ts +74 -0
- package/src/testing/conformance/attachments.ts +5 -4
- package/src/testing/conformance/encryption.ts +3 -2
- package/src/testing/conformance/index.ts +1 -1
- package/src/testing/index.ts +2 -0
- package/src/testing/memory-adapter/attachments.ts +146 -0
- package/src/testing/memory-adapter/index.ts +13 -110
- package/src/testing/memory-adapter/store.ts +3 -3
package/src/sqlite/native.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
53
|
-
cached = null;
|
|
61
|
+
sqlite.override(next ?? undefined);
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export function engine(): Engine {
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
return cached!;
|
|
69
|
+
cause,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/**
|
package/src/sqlite/row-codec.ts
CHANGED
|
@@ -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,
|
|
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[] = [
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
package/src/testing/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 })
|
|
192
|
-
|
|
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 = (
|
|
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 = {
|