@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.
- package/CHANGELOG.md +23 -1
- package/README.md +30 -11
- package/package.json +1 -1
- 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 +24 -3
- package/src/contract/migration.ts +4 -1
- package/src/diagnostics/dev-log.ts +5 -1
- package/src/migration/executor.ts +27 -6
- package/src/migration/ledger.ts +18 -27
- package/src/platform/fs.ts +2 -8
- package/src/platform/location.ts +0 -3
- package/src/query/budgets.ts +32 -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/row-codec.ts +5 -2
- package/src/sqlite/statements.ts +74 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/memory-adapter/attachments.ts +146 -0
- package/src/testing/memory-adapter/index.ts +13 -111
|
@@ -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
|
-
|
|
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') {
|
package/src/sqlite/batch.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
144
|
-
|
|
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
|
|
219
|
-
|
|
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);
|
package/src/sqlite/connection.ts
CHANGED
|
@@ -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;
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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 {
|
|
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 {
|
|
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 })
|
|
193
|
-
|
|
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 = (
|
|
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
|
/**
|