@chipmobilesdk/rn-local-db 0.2.0 → 0.3.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 +191 -102
- package/README.md +728 -728
- package/package.json +62 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -252
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -520
- package/src/adapter/transaction.ts +71 -71
- package/src/artifacts/availability.ts +67 -67
- package/src/artifacts/cascade.ts +114 -114
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -345
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -78
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -42
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -51
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -54
- package/src/crypto/chunked-cipher.ts +153 -150
- package/src/crypto/derive.ts +64 -64
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +75 -81
- package/src/diagnostics/dev-log.ts +132 -108
- package/src/diagnostics/errors.ts +63 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +119 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +49 -48
- package/src/platform/bytes.ts +157 -144
- package/src/platform/fs.ts +90 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -54
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -100
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -242
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -227
- package/src/sqlite/index-coverage.ts +54 -56
- package/src/sqlite/native.ts +84 -83
- package/src/sqlite/row-codec.ts +217 -221
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -87
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +139 -136
- package/src/testing/conformance/encryption.ts +151 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +511 -492
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
|
@@ -1,119 +1,117 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migration execution for the in-memory adapter.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the on-device rules exactly, because migration behaviour is the
|
|
5
|
-
* thing consumers most need to test without a device: ordered, at most once
|
|
6
|
-
* per scope, atomic per step, and loud on failure (FR-047 to FR-057).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { DatabaseConfig } from '../../contract/config';
|
|
10
|
-
import { DiagnosticCodes } from '../../contract/errors';
|
|
11
|
-
import type { MigrationContext } from '../../contract/migration';
|
|
12
|
-
import { raise } from '../../diagnostics/errors';
|
|
13
|
-
import { throwIfFaulted } from '../faults';
|
|
14
|
-
import type { MemoryScopeStore } from './store';
|
|
15
|
-
|
|
16
|
-
export async function runMigrations(
|
|
17
|
-
store: MemoryScopeStore,
|
|
18
|
-
config: DatabaseConfig,
|
|
19
|
-
now: () => number,
|
|
20
|
-
): Promise<void> {
|
|
21
|
-
const migrations = [...(config.migrations ?? [])].sort((a, b) => a.version - b.version);
|
|
22
|
-
|
|
23
|
-
if (store.schemaVersion > config.schemaVersion) {
|
|
24
|
-
// Opening anyway would mean a newer database read by older code, which
|
|
25
|
-
// silently drops whatever the newer schema added.
|
|
26
|
-
raise(DiagnosticCodes.SCHEMA_DOWNGRADE, {
|
|
27
|
-
message: 'recorded schema version is newer than the declared one',
|
|
28
|
-
detail: {
|
|
29
|
-
recordedVersion: store.schemaVersion,
|
|
30
|
-
version: config.schemaVersion,
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
for (const migration of migrations) {
|
|
36
|
-
if (migration.version <= store.schemaVersion) {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const snapshot = new Map(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
};
|
|
119
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Migration execution for the in-memory adapter.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the on-device rules exactly, because migration behaviour is the
|
|
5
|
+
* thing consumers most need to test without a device: ordered, at most once
|
|
6
|
+
* per scope, atomic per step, and loud on failure (FR-047 to FR-057).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DatabaseConfig } from '../../contract/config';
|
|
10
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
11
|
+
import type { MigrationContext } from '../../contract/migration';
|
|
12
|
+
import { raise } from '../../diagnostics/errors';
|
|
13
|
+
import { throwIfFaulted } from '../faults';
|
|
14
|
+
import type { MemoryScopeStore } from './store';
|
|
15
|
+
|
|
16
|
+
export async function runMigrations(
|
|
17
|
+
store: MemoryScopeStore,
|
|
18
|
+
config: DatabaseConfig,
|
|
19
|
+
now: () => number,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const migrations = [...(config.migrations ?? [])].sort((a, b) => a.version - b.version);
|
|
22
|
+
|
|
23
|
+
if (store.schemaVersion > config.schemaVersion) {
|
|
24
|
+
// Opening anyway would mean a newer database read by older code, which
|
|
25
|
+
// silently drops whatever the newer schema added.
|
|
26
|
+
raise(DiagnosticCodes.SCHEMA_DOWNGRADE, {
|
|
27
|
+
message: 'recorded schema version is newer than the declared one',
|
|
28
|
+
detail: {
|
|
29
|
+
recordedVersion: store.schemaVersion,
|
|
30
|
+
version: config.schemaVersion,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const migration of migrations) {
|
|
36
|
+
if (migration.version <= store.schemaVersion) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const snapshot = new Map([...store.collections].map(([name, rows]) => [name, new Map(rows)]));
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
throwIfFaulted('migration');
|
|
44
|
+
// A migration gets collection access but no handle-level surface:
|
|
45
|
+
// it must not open another scope or start a nested transaction.
|
|
46
|
+
const context = {
|
|
47
|
+
collection: (name: string) => {
|
|
48
|
+
if (!store.collections.has(name)) {
|
|
49
|
+
store.collections.set(name, new Map());
|
|
50
|
+
}
|
|
51
|
+
return migrationCollection(store, name);
|
|
52
|
+
},
|
|
53
|
+
abort: async () => {
|
|
54
|
+
throw new Error('migration aborted');
|
|
55
|
+
},
|
|
56
|
+
execute: async () => {
|
|
57
|
+
// The memory adapter has no SQL. Documented divergence.
|
|
58
|
+
},
|
|
59
|
+
} as unknown as MigrationContext;
|
|
60
|
+
|
|
61
|
+
await migration.migrate(context);
|
|
62
|
+
|
|
63
|
+
store.schemaVersion = migration.version;
|
|
64
|
+
store.ledger.set(migration.version, {
|
|
65
|
+
version: migration.version,
|
|
66
|
+
attemptedAt: now(),
|
|
67
|
+
outcome: 'applied',
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// Leave the recorded version untouched so the next open retries
|
|
71
|
+
// from the same place rather than skipping ahead (FR-051).
|
|
72
|
+
store.collections = snapshot;
|
|
73
|
+
store.ledger.set(migration.version, {
|
|
74
|
+
version: migration.version,
|
|
75
|
+
attemptedAt: now(),
|
|
76
|
+
outcome: 'failed',
|
|
77
|
+
failureCode: (error as { code?: string }).code ?? DiagnosticCodes.MIGRATION_FAILED,
|
|
78
|
+
});
|
|
79
|
+
raise(DiagnosticCodes.MIGRATION_FAILED, {
|
|
80
|
+
message: 'migration failed',
|
|
81
|
+
migration: {
|
|
82
|
+
recordedVersion: store.schemaVersion,
|
|
83
|
+
failingVersion: migration.version,
|
|
84
|
+
outcome: 'failed',
|
|
85
|
+
},
|
|
86
|
+
cause: error,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (migrations.length === 0) {
|
|
92
|
+
store.schemaVersion = config.schemaVersion;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function migrationCollection(store: MemoryScopeStore, name: string) {
|
|
97
|
+
const rows = store.collections.get(name)!;
|
|
98
|
+
return {
|
|
99
|
+
async insert(record: { id: string; data: Record<string, unknown> }) {
|
|
100
|
+
rows.set(record.id, { id: record.id, payload: JSON.stringify(record.data) });
|
|
101
|
+
return { operation: 'created' as const, id: record.id };
|
|
102
|
+
},
|
|
103
|
+
async upsert(record: { id: string; data: Record<string, unknown> }) {
|
|
104
|
+
const existed = rows.has(record.id);
|
|
105
|
+
rows.set(record.id, { id: record.id, payload: JSON.stringify(record.data) });
|
|
106
|
+
return { operation: existed ? ('updated' as const) : ('created' as const), id: record.id };
|
|
107
|
+
},
|
|
108
|
+
async delete(id: string) {
|
|
109
|
+
rows.delete(id);
|
|
110
|
+
return { operation: 'deleted' as const, id };
|
|
111
|
+
},
|
|
112
|
+
async find(id: string) {
|
|
113
|
+
const row = rows.get(id);
|
|
114
|
+
return row ? { id: row.id, data: JSON.parse(row.payload) } : null;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test lifecycle helpers.
|
|
3
|
-
*
|
|
4
|
-
* `seedRecords` deliberately goes through the same write operations
|
|
5
|
-
* production code uses (FR-094). Seeding by poking the store directly would
|
|
6
|
-
* skip budget checks and timestamp handling, so tests would pass against data
|
|
7
|
-
* the package would have refused to store.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { StorageAdapter } from '../../contract/adapter';
|
|
11
|
-
import type { DatabaseConfig } from '../../contract/config';
|
|
12
|
-
import type { RecordInput, StorableRecordData } from '../../contract/record';
|
|
13
|
-
import { clearFaults } from '../faults';
|
|
14
|
-
import { existingScopeStore, resetAllMemoryStores } from './store';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The conformance suite's "reach past the package" hooks, implemented for the
|
|
18
|
-
* in-memory store. On device the equivalent unlinks the file directly.
|
|
19
|
-
*/
|
|
20
|
-
export const memoryConformanceHooks = {
|
|
21
|
-
async removeArtifactBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void> {
|
|
22
|
-
existingScopeStore('conformance', scopeKey)?.artifacts.delete(attachmentId);
|
|
23
|
-
},
|
|
24
|
-
async removeReferenceBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void> {
|
|
25
|
-
existingScopeStore('conformance', scopeKey)?.attachments.delete(attachmentId);
|
|
26
|
-
},
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/** Return every in-memory database to a known-empty state (FR-093). */
|
|
30
|
-
export async function resetMemoryAdapter(_adapter?: StorageAdapter): Promise<void> {
|
|
31
|
-
resetAllMemoryStores();
|
|
32
|
-
clearFaults();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function seedRecords<T extends StorableRecordData>(
|
|
36
|
-
adapter: StorageAdapter,
|
|
37
|
-
config: DatabaseConfig,
|
|
38
|
-
collection: string,
|
|
39
|
-
records: RecordInput<T>[],
|
|
40
|
-
): Promise<void> {
|
|
41
|
-
const handle = await adapter.open(config);
|
|
42
|
-
try {
|
|
43
|
-
const ops = handle.collection<T>(collection);
|
|
44
|
-
for (const record of records) {
|
|
45
|
-
await ops.upsert(record);
|
|
46
|
-
}
|
|
47
|
-
} finally {
|
|
48
|
-
await handle.close();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Test lifecycle helpers.
|
|
3
|
+
*
|
|
4
|
+
* `seedRecords` deliberately goes through the same write operations
|
|
5
|
+
* production code uses (FR-094). Seeding by poking the store directly would
|
|
6
|
+
* skip budget checks and timestamp handling, so tests would pass against data
|
|
7
|
+
* the package would have refused to store.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { StorageAdapter } from '../../contract/adapter';
|
|
11
|
+
import type { DatabaseConfig } from '../../contract/config';
|
|
12
|
+
import type { RecordInput, StorableRecordData } from '../../contract/record';
|
|
13
|
+
import { clearFaults } from '../faults';
|
|
14
|
+
import { existingScopeStore, resetAllMemoryStores } from './store';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The conformance suite's "reach past the package" hooks, implemented for the
|
|
18
|
+
* in-memory store. On device the equivalent unlinks the file directly.
|
|
19
|
+
*/
|
|
20
|
+
export const memoryConformanceHooks = {
|
|
21
|
+
async removeArtifactBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void> {
|
|
22
|
+
existingScopeStore('conformance', scopeKey)?.artifacts.delete(attachmentId);
|
|
23
|
+
},
|
|
24
|
+
async removeReferenceBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void> {
|
|
25
|
+
existingScopeStore('conformance', scopeKey)?.attachments.delete(attachmentId);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Return every in-memory database to a known-empty state (FR-093). */
|
|
30
|
+
export async function resetMemoryAdapter(_adapter?: StorageAdapter): Promise<void> {
|
|
31
|
+
resetAllMemoryStores();
|
|
32
|
+
clearFaults();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function seedRecords<T extends StorableRecordData>(
|
|
36
|
+
adapter: StorageAdapter,
|
|
37
|
+
config: DatabaseConfig,
|
|
38
|
+
collection: string,
|
|
39
|
+
records: RecordInput<T>[],
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const handle = await adapter.open(config);
|
|
42
|
+
try {
|
|
43
|
+
const ops = handle.collection<T>(collection);
|
|
44
|
+
for (const record of records) {
|
|
45
|
+
await ops.upsert(record);
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
await handle.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,134 +1,128 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The in-memory store behind the test adapter.
|
|
3
|
-
*
|
|
4
|
-
* Scope isolation is structural here for the same reason it is on device: one
|
|
5
|
-
* store per scope, held in a registry keyed by scope digest. A handle holds a
|
|
6
|
-
* reference to its own store and has no way to reach another (FR-011).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { AttachmentReference } from '../../contract/attachment';
|
|
10
|
-
import type { MigrationLedgerEntry } from '../../contract/migration';
|
|
11
|
-
import type { StorableRecordData, StoredRecord } from '../../contract/record';
|
|
12
|
-
import type { ScopeDeletionState } from '../../contract/scope';
|
|
13
|
-
|
|
14
|
-
export type MemoryRow = {
|
|
15
|
-
id: string;
|
|
16
|
-
/** Serialized on write, parsed on read — the same payload-as-truth rule
|
|
17
|
-
* the SQLite adapter follows, so a mutation after write cannot leak in. */
|
|
18
|
-
payload: string;
|
|
19
|
-
createdAt?: number;
|
|
20
|
-
updatedAt?: number;
|
|
21
|
-
deletedAt?: number | null;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export type MemoryScopeStore = {
|
|
25
|
-
scopeKey: string;
|
|
26
|
-
schemaVersion: number;
|
|
27
|
-
ledger: Map<number, MigrationLedgerEntry>;
|
|
28
|
-
collections: Map<string, Map<string, MemoryRow>>;
|
|
29
|
-
attachments: Map<string, AttachmentReference>;
|
|
30
|
-
artifacts: Map<string, string>;
|
|
31
|
-
deletionState: ScopeDeletionState;
|
|
32
|
-
encrypted: boolean;
|
|
33
|
-
backupIncluded: boolean;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export type MemoryDatabase = {
|
|
37
|
-
name: string;
|
|
38
|
-
scopes: Map<string, MemoryScopeStore>;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const databases = new Map<string, MemoryDatabase>();
|
|
42
|
-
|
|
43
|
-
export function databaseFor(name: string): MemoryDatabase {
|
|
44
|
-
let database = databases.get(name);
|
|
45
|
-
if (!database) {
|
|
46
|
-
database = { name, scopes: new Map() };
|
|
47
|
-
databases.set(name, database);
|
|
48
|
-
}
|
|
49
|
-
return database;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function scopeStoreFor(name: string, scopeKey: string): MemoryScopeStore {
|
|
53
|
-
const database = databaseFor(name);
|
|
54
|
-
let store = database.scopes.get(scopeKey);
|
|
55
|
-
if (!store) {
|
|
56
|
-
store = {
|
|
57
|
-
scopeKey,
|
|
58
|
-
schemaVersion: 0,
|
|
59
|
-
ledger: new Map(),
|
|
60
|
-
collections: new Map(),
|
|
61
|
-
attachments: new Map(),
|
|
62
|
-
artifacts: new Map(),
|
|
63
|
-
deletionState: 'active',
|
|
64
|
-
encrypted: false,
|
|
65
|
-
backupIncluded: false,
|
|
66
|
-
};
|
|
67
|
-
database.scopes.set(scopeKey, store);
|
|
68
|
-
}
|
|
69
|
-
return store;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function existingScopeStore(
|
|
73
|
-
name
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
if (row.
|
|
125
|
-
record.
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
if (row.deletedAt !== undefined) {
|
|
131
|
-
record.deletedAt = row.deletedAt;
|
|
132
|
-
}
|
|
133
|
-
return record;
|
|
134
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory store behind the test adapter.
|
|
3
|
+
*
|
|
4
|
+
* Scope isolation is structural here for the same reason it is on device: one
|
|
5
|
+
* store per scope, held in a registry keyed by scope digest. A handle holds a
|
|
6
|
+
* reference to its own store and has no way to reach another (FR-011).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AttachmentReference } from '../../contract/attachment';
|
|
10
|
+
import type { MigrationLedgerEntry } from '../../contract/migration';
|
|
11
|
+
import type { StorableRecordData, StoredRecord } from '../../contract/record';
|
|
12
|
+
import type { ScopeDeletionState } from '../../contract/scope';
|
|
13
|
+
|
|
14
|
+
export type MemoryRow = {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Serialized on write, parsed on read — the same payload-as-truth rule
|
|
17
|
+
* the SQLite adapter follows, so a mutation after write cannot leak in. */
|
|
18
|
+
payload: string;
|
|
19
|
+
createdAt?: number | undefined;
|
|
20
|
+
updatedAt?: number | undefined;
|
|
21
|
+
deletedAt?: number | null | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type MemoryScopeStore = {
|
|
25
|
+
scopeKey: string;
|
|
26
|
+
schemaVersion: number;
|
|
27
|
+
ledger: Map<number, MigrationLedgerEntry>;
|
|
28
|
+
collections: Map<string, Map<string, MemoryRow>>;
|
|
29
|
+
attachments: Map<string, AttachmentReference>;
|
|
30
|
+
artifacts: Map<string, string>;
|
|
31
|
+
deletionState: ScopeDeletionState;
|
|
32
|
+
encrypted: boolean;
|
|
33
|
+
backupIncluded: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type MemoryDatabase = {
|
|
37
|
+
name: string;
|
|
38
|
+
scopes: Map<string, MemoryScopeStore>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const databases = new Map<string, MemoryDatabase>();
|
|
42
|
+
|
|
43
|
+
export function databaseFor(name: string): MemoryDatabase {
|
|
44
|
+
let database = databases.get(name);
|
|
45
|
+
if (!database) {
|
|
46
|
+
database = { name, scopes: new Map() };
|
|
47
|
+
databases.set(name, database);
|
|
48
|
+
}
|
|
49
|
+
return database;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function scopeStoreFor(name: string, scopeKey: string): MemoryScopeStore {
|
|
53
|
+
const database = databaseFor(name);
|
|
54
|
+
let store = database.scopes.get(scopeKey);
|
|
55
|
+
if (!store) {
|
|
56
|
+
store = {
|
|
57
|
+
scopeKey,
|
|
58
|
+
schemaVersion: 0,
|
|
59
|
+
ledger: new Map(),
|
|
60
|
+
collections: new Map(),
|
|
61
|
+
attachments: new Map(),
|
|
62
|
+
artifacts: new Map(),
|
|
63
|
+
deletionState: 'active',
|
|
64
|
+
encrypted: false,
|
|
65
|
+
backupIncluded: false,
|
|
66
|
+
};
|
|
67
|
+
database.scopes.set(scopeKey, store);
|
|
68
|
+
}
|
|
69
|
+
return store;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function existingScopeStore(name: string, scopeKey: string): MemoryScopeStore | undefined {
|
|
73
|
+
return databases.get(name)?.scopes.get(scopeKey);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function dropScope(name: string, scopeKey: string): void {
|
|
77
|
+
databases.get(name)?.scopes.delete(scopeKey);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function dropDatabase(name: string): void {
|
|
81
|
+
databases.delete(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Between tests (FR-093). */
|
|
85
|
+
export function resetAllMemoryStores(): void {
|
|
86
|
+
databases.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function collectionMap(store: MemoryScopeStore, collection: string): Map<string, MemoryRow> {
|
|
90
|
+
let rows = store.collections.get(collection);
|
|
91
|
+
if (!rows) {
|
|
92
|
+
rows = new Map();
|
|
93
|
+
store.collections.set(collection, rows);
|
|
94
|
+
}
|
|
95
|
+
return rows;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Deep copy through serialization on the way in and out.
|
|
100
|
+
*
|
|
101
|
+
* The same discipline as the SQLite adapter's payload column: a consumer that
|
|
102
|
+
* mutates the object it handed us must not change what is stored (FR-022),
|
|
103
|
+
* and what we hand back must not be a live reference into the store.
|
|
104
|
+
*/
|
|
105
|
+
export function encodePayload(data: StorableRecordData): string {
|
|
106
|
+
return JSON.stringify(data ?? {});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function decodePayload<T extends StorableRecordData>(payload: string): T {
|
|
110
|
+
return JSON.parse(payload) as T;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function toStoredRecord<T extends StorableRecordData>(row: MemoryRow): StoredRecord<T> {
|
|
114
|
+
const record: StoredRecord<T> = {
|
|
115
|
+
id: row.id,
|
|
116
|
+
data: decodePayload<T>(row.payload),
|
|
117
|
+
};
|
|
118
|
+
if (row.createdAt !== undefined) {
|
|
119
|
+
record.createdAt = row.createdAt;
|
|
120
|
+
}
|
|
121
|
+
if (row.updatedAt !== undefined) {
|
|
122
|
+
record.updatedAt = row.updatedAt;
|
|
123
|
+
}
|
|
124
|
+
if (row.deletedAt !== undefined) {
|
|
125
|
+
record.deletedAt = row.deletedAt;
|
|
126
|
+
}
|
|
127
|
+
return record;
|
|
128
|
+
}
|