@chipmobilesdk/rn-local-db 0.2.0 → 0.2.1
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 +122 -102
- package/README.md +728 -728
- package/package.json +59 -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 +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -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 +48 -48
- package/src/platform/bytes.ts +144 -144
- package/src/platform/fs.ts +89 -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 +83 -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 +138 -136
- package/src/testing/conformance/encryption.ts +150 -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 +510 -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,150 +1,150 @@
|
|
|
1
|
-
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
-
import type { KeyProvider } from '../../contract/encryption';
|
|
3
|
-
import { isCode } from '../../diagnostics/errors';
|
|
4
|
-
import type { ConformanceContext } from './index';
|
|
5
|
-
|
|
6
|
-
/** US10: opt-in encryption, and failures that stay apart from each other. */
|
|
7
|
-
export function registerEncryptionConformance(context: ConformanceContext): void {
|
|
8
|
-
describe('conformance: encryption', () => {
|
|
9
|
-
beforeEach(() => context.reset());
|
|
10
|
-
|
|
11
|
-
const scope = { kind: 'identity', id: 'encrypted' } as const;
|
|
12
|
-
const keyProvider: KeyProvider = { getKey: async () => 'a-correct-key-32-bytes-long-value' };
|
|
13
|
-
|
|
14
|
-
const encrypted = { enabled: true as const, keyProvider };
|
|
15
|
-
|
|
16
|
-
it('reports its own posture', async () => {
|
|
17
|
-
const handle = await context.adapter.open(
|
|
18
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
19
|
-
);
|
|
20
|
-
expect(handle.isEncrypted).toBe(true);
|
|
21
|
-
await handle.close();
|
|
22
|
-
|
|
23
|
-
const plain = await context.adapter.open(
|
|
24
|
-
context.configFor({ kind: 'identity', id: 'plain' }, { encryption: undefined }),
|
|
25
|
-
);
|
|
26
|
-
expect(plain.isEncrypted).toBe(false);
|
|
27
|
-
await plain.close();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('stores and returns records unchanged under encryption', async () => {
|
|
31
|
-
const handle = await context.adapter.open(
|
|
32
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
33
|
-
);
|
|
34
|
-
const data = { title: 'secret', score: 1, nested: { deep: [1, null, true] } };
|
|
35
|
-
await handle.collection('notes').insert({ id: 'a', data });
|
|
36
|
-
expect((await handle.collection('notes').get('a')).data).toEqual(data);
|
|
37
|
-
await handle.close();
|
|
38
|
-
|
|
39
|
-
const reopened = await context.adapter.open(
|
|
40
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
41
|
-
);
|
|
42
|
-
expect((await reopened.collection('notes').get('a')).data).toEqual(data);
|
|
43
|
-
await reopened.close();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('round-trips an artifact under encryption, including a byte range', async () => {
|
|
47
|
-
const handle = await context.adapter.open(
|
|
48
|
-
context.configFor(scope, { encryption: encrypted }),
|
|
49
|
-
);
|
|
50
|
-
const content = Buffer.from('hello encrypted attachment', 'utf8').toString('base64');
|
|
51
|
-
await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
52
|
-
|
|
53
|
-
expect(await handle.attachments().read('a1')).toBe(content);
|
|
54
|
-
const slice = await handle.attachments().read('a1', { offset: 6, length: 9 });
|
|
55
|
-
expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('encrypted');
|
|
56
|
-
expect((await handle.attachments().availability('a1')).state).toBe('available');
|
|
57
|
-
await handle.close();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('reports a key-provider failure separately from an invalid key', async () => {
|
|
61
|
-
let thrown: unknown;
|
|
62
|
-
try {
|
|
63
|
-
await context.adapter.open(
|
|
64
|
-
context.configFor(scope, {
|
|
65
|
-
encryption: {
|
|
66
|
-
enabled: true,
|
|
67
|
-
keyProvider: {
|
|
68
|
-
getKey: async () => {
|
|
69
|
-
throw new Error('device is locked');
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
}),
|
|
74
|
-
);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
thrown = error;
|
|
77
|
-
}
|
|
78
|
-
expect(isCode(thrown, DiagnosticCodes.KEY_PROVIDER_FAILED)).toBe(true);
|
|
79
|
-
|
|
80
|
-
let empty: unknown;
|
|
81
|
-
try {
|
|
82
|
-
await context.adapter.open(
|
|
83
|
-
context.configFor(scope, {
|
|
84
|
-
encryption: { enabled: true, keyProvider: { getKey: async () => '' } },
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
87
|
-
} catch (error) {
|
|
88
|
-
empty = error;
|
|
89
|
-
}
|
|
90
|
-
expect(isCode(empty, DiagnosticCodes.KEY_INVALID)).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('reports a posture mismatch rather than opening or converting', async () => {
|
|
94
|
-
const plain = await context.adapter.open(context.configFor(scope));
|
|
95
|
-
await plain.collection('notes').insert({ id: 'a', data: { title: 'plaintext' } });
|
|
96
|
-
await plain.close();
|
|
97
|
-
|
|
98
|
-
let thrown: unknown;
|
|
99
|
-
try {
|
|
100
|
-
await context.adapter.open(context.configFor(scope, { encryption: encrypted }));
|
|
101
|
-
} catch (error) {
|
|
102
|
-
thrown = error;
|
|
103
|
-
}
|
|
104
|
-
expect(isCode(thrown, DiagnosticCodes.ENCRYPTION_MISMATCH)).toBe(true);
|
|
105
|
-
|
|
106
|
-
// Nothing was converted, truncated, or discarded.
|
|
107
|
-
const reopened = await context.adapter.open(context.configFor(scope));
|
|
108
|
-
expect((await reopened.collection('notes').get('a')).data.title).toBe('plaintext');
|
|
109
|
-
await reopened.close();
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('gives the key provider the scope, so an app can key per identity', async () => {
|
|
113
|
-
const seen: string[] = [];
|
|
114
|
-
const recording: KeyProvider = {
|
|
115
|
-
getKey: async resolved => {
|
|
116
|
-
seen.push(resolved.key);
|
|
117
|
-
return `key-for-${resolved.key}`;
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
for (const id of ['alice', 'bob']) {
|
|
122
|
-
const handle = await context.adapter.open(
|
|
123
|
-
context.configFor(
|
|
124
|
-
{ kind: 'identity', id },
|
|
125
|
-
{ encryption: { enabled: true, keyProvider: recording } },
|
|
126
|
-
),
|
|
127
|
-
);
|
|
128
|
-
await handle.close();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
expect(seen).toHaveLength(2);
|
|
132
|
-
expect(new Set(seen).size).toBe(2);
|
|
133
|
-
for (const key of seen) {
|
|
134
|
-
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('keeps an encrypted scope isolated from an unencrypted sibling', async () => {
|
|
139
|
-
const secure = await context.adapter.open(
|
|
140
|
-
context.configFor({ kind: 'identity', id: 'secure' }, { encryption: encrypted }),
|
|
141
|
-
);
|
|
142
|
-
await secure.collection('notes').insert({ id: 'shared', data: { title: 'secure' } });
|
|
143
|
-
await secure.close();
|
|
144
|
-
|
|
145
|
-
const open = await context.adapter.open(context.configFor({ kind: 'identity', id: 'open' }));
|
|
146
|
-
expect(await open.collection('notes').find('shared')).toBeNull();
|
|
147
|
-
await open.close();
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
}
|
|
1
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
+
import type { KeyProvider } from '../../contract/encryption';
|
|
3
|
+
import { isCode } from '../../diagnostics/errors';
|
|
4
|
+
import type { ConformanceContext } from './index';
|
|
5
|
+
|
|
6
|
+
/** US10: opt-in encryption, and failures that stay apart from each other. */
|
|
7
|
+
export function registerEncryptionConformance(context: ConformanceContext): void {
|
|
8
|
+
describe('conformance: encryption', () => {
|
|
9
|
+
beforeEach(() => context.reset());
|
|
10
|
+
|
|
11
|
+
const scope = { kind: 'identity', id: 'encrypted' } as const;
|
|
12
|
+
const keyProvider: KeyProvider = { getKey: async () => 'a-correct-key-32-bytes-long-value' };
|
|
13
|
+
|
|
14
|
+
const encrypted = { enabled: true as const, keyProvider };
|
|
15
|
+
|
|
16
|
+
it('reports its own posture', async () => {
|
|
17
|
+
const handle = await context.adapter.open(
|
|
18
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
19
|
+
);
|
|
20
|
+
expect(handle.isEncrypted).toBe(true);
|
|
21
|
+
await handle.close();
|
|
22
|
+
|
|
23
|
+
const plain = await context.adapter.open(
|
|
24
|
+
context.configFor({ kind: 'identity', id: 'plain' }, { encryption: undefined }),
|
|
25
|
+
);
|
|
26
|
+
expect(plain.isEncrypted).toBe(false);
|
|
27
|
+
await plain.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('stores and returns records unchanged under encryption', async () => {
|
|
31
|
+
const handle = await context.adapter.open(
|
|
32
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
33
|
+
);
|
|
34
|
+
const data = { title: 'secret', score: 1, nested: { deep: [1, null, true] } };
|
|
35
|
+
await handle.collection('notes').insert({ id: 'a', data });
|
|
36
|
+
expect((await handle.collection('notes').get('a')).data).toEqual(data);
|
|
37
|
+
await handle.close();
|
|
38
|
+
|
|
39
|
+
const reopened = await context.adapter.open(
|
|
40
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
41
|
+
);
|
|
42
|
+
expect((await reopened.collection('notes').get('a')).data).toEqual(data);
|
|
43
|
+
await reopened.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('round-trips an artifact under encryption, including a byte range', async () => {
|
|
47
|
+
const handle = await context.adapter.open(
|
|
48
|
+
context.configFor(scope, { encryption: encrypted }),
|
|
49
|
+
);
|
|
50
|
+
const content = Buffer.from('hello encrypted attachment', 'utf8').toString('base64');
|
|
51
|
+
await handle.attachments().write({ id: 'a1', contentType: 'text/plain', content });
|
|
52
|
+
|
|
53
|
+
expect(await handle.attachments().read('a1')).toBe(content);
|
|
54
|
+
const slice = await handle.attachments().read('a1', { offset: 6, length: 9 });
|
|
55
|
+
expect(Buffer.from(slice, 'base64').toString('utf8')).toBe('encrypted');
|
|
56
|
+
expect((await handle.attachments().availability('a1')).state).toBe('available');
|
|
57
|
+
await handle.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('reports a key-provider failure separately from an invalid key', async () => {
|
|
61
|
+
let thrown: unknown;
|
|
62
|
+
try {
|
|
63
|
+
await context.adapter.open(
|
|
64
|
+
context.configFor(scope, {
|
|
65
|
+
encryption: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
keyProvider: {
|
|
68
|
+
getKey: async () => {
|
|
69
|
+
throw new Error('device is locked');
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
thrown = error;
|
|
77
|
+
}
|
|
78
|
+
expect(isCode(thrown, DiagnosticCodes.KEY_PROVIDER_FAILED)).toBe(true);
|
|
79
|
+
|
|
80
|
+
let empty: unknown;
|
|
81
|
+
try {
|
|
82
|
+
await context.adapter.open(
|
|
83
|
+
context.configFor(scope, {
|
|
84
|
+
encryption: { enabled: true, keyProvider: { getKey: async () => '' } },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
empty = error;
|
|
89
|
+
}
|
|
90
|
+
expect(isCode(empty, DiagnosticCodes.KEY_INVALID)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports a posture mismatch rather than opening or converting', async () => {
|
|
94
|
+
const plain = await context.adapter.open(context.configFor(scope));
|
|
95
|
+
await plain.collection('notes').insert({ id: 'a', data: { title: 'plaintext' } });
|
|
96
|
+
await plain.close();
|
|
97
|
+
|
|
98
|
+
let thrown: unknown;
|
|
99
|
+
try {
|
|
100
|
+
await context.adapter.open(context.configFor(scope, { encryption: encrypted }));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
thrown = error;
|
|
103
|
+
}
|
|
104
|
+
expect(isCode(thrown, DiagnosticCodes.ENCRYPTION_MISMATCH)).toBe(true);
|
|
105
|
+
|
|
106
|
+
// Nothing was converted, truncated, or discarded.
|
|
107
|
+
const reopened = await context.adapter.open(context.configFor(scope));
|
|
108
|
+
expect((await reopened.collection('notes').get('a')).data.title).toBe('plaintext');
|
|
109
|
+
await reopened.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('gives the key provider the scope, so an app can key per identity', async () => {
|
|
113
|
+
const seen: string[] = [];
|
|
114
|
+
const recording: KeyProvider = {
|
|
115
|
+
getKey: async resolved => {
|
|
116
|
+
seen.push(resolved.key);
|
|
117
|
+
return `key-for-${resolved.key}`;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const id of ['alice', 'bob']) {
|
|
122
|
+
const handle = await context.adapter.open(
|
|
123
|
+
context.configFor(
|
|
124
|
+
{ kind: 'identity', id },
|
|
125
|
+
{ encryption: { enabled: true, keyProvider: recording } },
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
await handle.close();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
expect(seen).toHaveLength(2);
|
|
132
|
+
expect(new Set(seen).size).toBe(2);
|
|
133
|
+
for (const key of seen) {
|
|
134
|
+
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps an encrypted scope isolated from an unencrypted sibling', async () => {
|
|
139
|
+
const secure = await context.adapter.open(
|
|
140
|
+
context.configFor({ kind: 'identity', id: 'secure' }, { encryption: encrypted }),
|
|
141
|
+
);
|
|
142
|
+
await secure.collection('notes').insert({ id: 'shared', data: { title: 'secure' } });
|
|
143
|
+
await secure.close();
|
|
144
|
+
|
|
145
|
+
const open = await context.adapter.open(context.configFor({ kind: 'identity', id: 'open' }));
|
|
146
|
+
expect(await open.collection('notes').find('shared')).toBeNull();
|
|
147
|
+
await open.close();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -1,161 +1,161 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The conformance suite: the executable definition of what the package
|
|
3
|
-
* guarantees (FR-092).
|
|
4
|
-
*
|
|
5
|
-
* Exported rather than kept internal so a consuming team can run it against
|
|
6
|
-
* their own adapter if one ever exists, and so both of ours are held to
|
|
7
|
-
* exactly the same standard rather than to two similar-looking test files.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { StorageAdapter } from '../../contract/adapter';
|
|
11
|
-
import type { DatabaseConfig, ScopeTargetConfig } from '../../contract/config';
|
|
12
|
-
import type { EncryptionConfig } from '../../contract/encryption';
|
|
13
|
-
import type { ScopeDescriptor } from '../../contract/scope';
|
|
14
|
-
import { registerRecordConformance } from './records';
|
|
15
|
-
import { registerScopeConformance } from './scopes';
|
|
16
|
-
import { registerQueryConformance } from './queries';
|
|
17
|
-
import { registerTransactionConformance } from './transactions';
|
|
18
|
-
import { registerMigrationConformance } from './migrations';
|
|
19
|
-
import { registerLifecycleConformance } from './lifecycle';
|
|
20
|
-
import { registerAttachmentConformance } from './attachments';
|
|
21
|
-
import { registerEncryptionConformance } from './encryption';
|
|
22
|
-
|
|
23
|
-
export type ConformanceContext = {
|
|
24
|
-
adapter: StorageAdapter;
|
|
25
|
-
/** A fresh config for the given scope, using the standard test schema. */
|
|
26
|
-
configFor(scope: ScopeDescriptor, overrides?: Partial<DatabaseConfig>): DatabaseConfig;
|
|
27
|
-
/**
|
|
28
|
-
* How an app names a scope for deletion.
|
|
29
|
-
*
|
|
30
|
-
* Carries the encryption config when the run is encrypted, because that is
|
|
31
|
-
* how a real consumer calls it: `ScopeTargetConfig.encryption` exists so
|
|
32
|
-
* deletion can report what it removed rather than guessing at it.
|
|
33
|
-
*/
|
|
34
|
-
scopeTargetFor(scope: ScopeDescriptor): ScopeTargetConfig;
|
|
35
|
-
reset(): Promise<void>;
|
|
36
|
-
/**
|
|
37
|
-
* Reach past the package and delete an artifact the way the operating
|
|
38
|
-
* system does when it reclaims storage. Each adapter supplies its own,
|
|
39
|
-
* because the suite cannot simulate this through the public API without
|
|
40
|
-
* asking the package to break its own guarantee.
|
|
41
|
-
*/
|
|
42
|
-
removeArtifactBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void>;
|
|
43
|
-
/** The other direction: a reference whose artifact survives. */
|
|
44
|
-
removeReferenceBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void>;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export type ConformanceHooks = Pick<
|
|
48
|
-
ConformanceContext,
|
|
49
|
-
'removeArtifactBehindTheScenes' | 'removeReferenceBehindTheScenes'
|
|
50
|
-
>;
|
|
51
|
-
|
|
52
|
-
export type ConformanceOptions = {
|
|
53
|
-
/** Sections the adapter knowingly cannot satisfy, with a documented reason. */
|
|
54
|
-
skip?: ConformanceSection[];
|
|
55
|
-
/**
|
|
56
|
-
* Run every section against an encrypted database (FR-105).
|
|
57
|
-
*
|
|
58
|
-
* The suite is the contract, so the contract has to hold in both postures
|
|
59
|
-
* — an encrypted database that quietly behaved differently would be a
|
|
60
|
-
* second implementation nobody tested.
|
|
61
|
-
*/
|
|
62
|
-
encryption?: EncryptionConfig;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export type ConformanceSection =
|
|
66
|
-
| 'records'
|
|
67
|
-
| 'scopes'
|
|
68
|
-
| 'queries'
|
|
69
|
-
| 'transactions'
|
|
70
|
-
| 'migrations'
|
|
71
|
-
| 'lifecycle'
|
|
72
|
-
| 'attachments'
|
|
73
|
-
| 'encryption';
|
|
74
|
-
|
|
75
|
-
export const TEST_COLLECTIONS: DatabaseConfig['collections'] = [
|
|
76
|
-
{
|
|
77
|
-
name: 'notes',
|
|
78
|
-
fields: [
|
|
79
|
-
{ name: 'title', type: 'string' },
|
|
80
|
-
{ name: 'score', type: 'number' },
|
|
81
|
-
{ name: 'done', type: 'boolean' },
|
|
82
|
-
{ name: 'updated', type: 'timestamp' },
|
|
83
|
-
],
|
|
84
|
-
indexes: [{ name: 'notes_by_score', fields: ['score'] }],
|
|
85
|
-
softDelete: true,
|
|
86
|
-
timestamps: true,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
name: 'plain',
|
|
90
|
-
fields: [{ name: 'label', type: 'string' }],
|
|
91
|
-
},
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
export function makeConformanceContext(
|
|
95
|
-
adapter: StorageAdapter,
|
|
96
|
-
reset: () => Promise<void>,
|
|
97
|
-
hooks: ConformanceHooks,
|
|
98
|
-
defaults: Partial<DatabaseConfig> = {},
|
|
99
|
-
): ConformanceContext {
|
|
100
|
-
return {
|
|
101
|
-
adapter,
|
|
102
|
-
reset,
|
|
103
|
-
...hooks,
|
|
104
|
-
configFor(scope, overrides = {}) {
|
|
105
|
-
return {
|
|
106
|
-
name: 'conformance',
|
|
107
|
-
scope,
|
|
108
|
-
schemaVersion: 1,
|
|
109
|
-
collections: TEST_COLLECTIONS,
|
|
110
|
-
migrations: [{ version: 1, migrate: async () => {} }],
|
|
111
|
-
...defaults,
|
|
112
|
-
...overrides,
|
|
113
|
-
};
|
|
114
|
-
},
|
|
115
|
-
scopeTargetFor(scope) {
|
|
116
|
-
return {
|
|
117
|
-
name: 'conformance',
|
|
118
|
-
scope,
|
|
119
|
-
...(defaults.encryption ? { encryption: defaults.encryption } : {}),
|
|
120
|
-
};
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Registers the whole suite against one adapter. Both adapters call this with
|
|
127
|
-
* the same arguments; any difference in outcome is a divergence that must be
|
|
128
|
-
* documented under FR-096 or fixed.
|
|
129
|
-
*/
|
|
130
|
-
export function runConformanceSuite(
|
|
131
|
-
factory: () => StorageAdapter,
|
|
132
|
-
reset: () => Promise<void>,
|
|
133
|
-
hooks: ConformanceHooks,
|
|
134
|
-
options: ConformanceOptions = {},
|
|
135
|
-
): void {
|
|
136
|
-
const skip = new Set(options.skip ?? []);
|
|
137
|
-
const context = makeConformanceContext(
|
|
138
|
-
factory(),
|
|
139
|
-
reset,
|
|
140
|
-
hooks,
|
|
141
|
-
options.encryption ? { encryption: options.encryption } : {},
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
const sections: [ConformanceSection, (c: ConformanceContext) => void][] = [
|
|
145
|
-
['records', registerRecordConformance],
|
|
146
|
-
['scopes', registerScopeConformance],
|
|
147
|
-
['queries', registerQueryConformance],
|
|
148
|
-
['transactions', registerTransactionConformance],
|
|
149
|
-
['migrations', registerMigrationConformance],
|
|
150
|
-
['lifecycle', registerLifecycleConformance],
|
|
151
|
-
['attachments', registerAttachmentConformance],
|
|
152
|
-
['encryption', registerEncryptionConformance],
|
|
153
|
-
];
|
|
154
|
-
|
|
155
|
-
for (const [name, register] of sections) {
|
|
156
|
-
if (skip.has(name)) {
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
register(context);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The conformance suite: the executable definition of what the package
|
|
3
|
+
* guarantees (FR-092).
|
|
4
|
+
*
|
|
5
|
+
* Exported rather than kept internal so a consuming team can run it against
|
|
6
|
+
* their own adapter if one ever exists, and so both of ours are held to
|
|
7
|
+
* exactly the same standard rather than to two similar-looking test files.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { StorageAdapter } from '../../contract/adapter';
|
|
11
|
+
import type { DatabaseConfig, ScopeTargetConfig } from '../../contract/config';
|
|
12
|
+
import type { EncryptionConfig } from '../../contract/encryption';
|
|
13
|
+
import type { ScopeDescriptor } from '../../contract/scope';
|
|
14
|
+
import { registerRecordConformance } from './records';
|
|
15
|
+
import { registerScopeConformance } from './scopes';
|
|
16
|
+
import { registerQueryConformance } from './queries';
|
|
17
|
+
import { registerTransactionConformance } from './transactions';
|
|
18
|
+
import { registerMigrationConformance } from './migrations';
|
|
19
|
+
import { registerLifecycleConformance } from './lifecycle';
|
|
20
|
+
import { registerAttachmentConformance } from './attachments';
|
|
21
|
+
import { registerEncryptionConformance } from './encryption';
|
|
22
|
+
|
|
23
|
+
export type ConformanceContext = {
|
|
24
|
+
adapter: StorageAdapter;
|
|
25
|
+
/** A fresh config for the given scope, using the standard test schema. */
|
|
26
|
+
configFor(scope: ScopeDescriptor, overrides?: Partial<DatabaseConfig>): DatabaseConfig;
|
|
27
|
+
/**
|
|
28
|
+
* How an app names a scope for deletion.
|
|
29
|
+
*
|
|
30
|
+
* Carries the encryption config when the run is encrypted, because that is
|
|
31
|
+
* how a real consumer calls it: `ScopeTargetConfig.encryption` exists so
|
|
32
|
+
* deletion can report what it removed rather than guessing at it.
|
|
33
|
+
*/
|
|
34
|
+
scopeTargetFor(scope: ScopeDescriptor): ScopeTargetConfig;
|
|
35
|
+
reset(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Reach past the package and delete an artifact the way the operating
|
|
38
|
+
* system does when it reclaims storage. Each adapter supplies its own,
|
|
39
|
+
* because the suite cannot simulate this through the public API without
|
|
40
|
+
* asking the package to break its own guarantee.
|
|
41
|
+
*/
|
|
42
|
+
removeArtifactBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void>;
|
|
43
|
+
/** The other direction: a reference whose artifact survives. */
|
|
44
|
+
removeReferenceBehindTheScenes(scopeKey: string, attachmentId: string): Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ConformanceHooks = Pick<
|
|
48
|
+
ConformanceContext,
|
|
49
|
+
'removeArtifactBehindTheScenes' | 'removeReferenceBehindTheScenes'
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
export type ConformanceOptions = {
|
|
53
|
+
/** Sections the adapter knowingly cannot satisfy, with a documented reason. */
|
|
54
|
+
skip?: ConformanceSection[];
|
|
55
|
+
/**
|
|
56
|
+
* Run every section against an encrypted database (FR-105).
|
|
57
|
+
*
|
|
58
|
+
* The suite is the contract, so the contract has to hold in both postures
|
|
59
|
+
* — an encrypted database that quietly behaved differently would be a
|
|
60
|
+
* second implementation nobody tested.
|
|
61
|
+
*/
|
|
62
|
+
encryption?: EncryptionConfig;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ConformanceSection =
|
|
66
|
+
| 'records'
|
|
67
|
+
| 'scopes'
|
|
68
|
+
| 'queries'
|
|
69
|
+
| 'transactions'
|
|
70
|
+
| 'migrations'
|
|
71
|
+
| 'lifecycle'
|
|
72
|
+
| 'attachments'
|
|
73
|
+
| 'encryption';
|
|
74
|
+
|
|
75
|
+
export const TEST_COLLECTIONS: DatabaseConfig['collections'] = [
|
|
76
|
+
{
|
|
77
|
+
name: 'notes',
|
|
78
|
+
fields: [
|
|
79
|
+
{ name: 'title', type: 'string' },
|
|
80
|
+
{ name: 'score', type: 'number' },
|
|
81
|
+
{ name: 'done', type: 'boolean' },
|
|
82
|
+
{ name: 'updated', type: 'timestamp' },
|
|
83
|
+
],
|
|
84
|
+
indexes: [{ name: 'notes_by_score', fields: ['score'] }],
|
|
85
|
+
softDelete: true,
|
|
86
|
+
timestamps: true,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'plain',
|
|
90
|
+
fields: [{ name: 'label', type: 'string' }],
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export function makeConformanceContext(
|
|
95
|
+
adapter: StorageAdapter,
|
|
96
|
+
reset: () => Promise<void>,
|
|
97
|
+
hooks: ConformanceHooks,
|
|
98
|
+
defaults: Partial<DatabaseConfig> = {},
|
|
99
|
+
): ConformanceContext {
|
|
100
|
+
return {
|
|
101
|
+
adapter,
|
|
102
|
+
reset,
|
|
103
|
+
...hooks,
|
|
104
|
+
configFor(scope, overrides = {}) {
|
|
105
|
+
return {
|
|
106
|
+
name: 'conformance',
|
|
107
|
+
scope,
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
collections: TEST_COLLECTIONS,
|
|
110
|
+
migrations: [{ version: 1, migrate: async () => {} }],
|
|
111
|
+
...defaults,
|
|
112
|
+
...overrides,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
scopeTargetFor(scope) {
|
|
116
|
+
return {
|
|
117
|
+
name: 'conformance',
|
|
118
|
+
scope,
|
|
119
|
+
...(defaults.encryption ? { encryption: defaults.encryption } : {}),
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Registers the whole suite against one adapter. Both adapters call this with
|
|
127
|
+
* the same arguments; any difference in outcome is a divergence that must be
|
|
128
|
+
* documented under FR-096 or fixed.
|
|
129
|
+
*/
|
|
130
|
+
export function runConformanceSuite(
|
|
131
|
+
factory: () => StorageAdapter,
|
|
132
|
+
reset: () => Promise<void>,
|
|
133
|
+
hooks: ConformanceHooks,
|
|
134
|
+
options: ConformanceOptions = {},
|
|
135
|
+
): void {
|
|
136
|
+
const skip = new Set(options.skip ?? []);
|
|
137
|
+
const context = makeConformanceContext(
|
|
138
|
+
factory(),
|
|
139
|
+
reset,
|
|
140
|
+
hooks,
|
|
141
|
+
options.encryption ? { encryption: options.encryption } : {},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const sections: [ConformanceSection, (c: ConformanceContext) => void][] = [
|
|
145
|
+
['records', registerRecordConformance],
|
|
146
|
+
['scopes', registerScopeConformance],
|
|
147
|
+
['queries', registerQueryConformance],
|
|
148
|
+
['transactions', registerTransactionConformance],
|
|
149
|
+
['migrations', registerMigrationConformance],
|
|
150
|
+
['lifecycle', registerLifecycleConformance],
|
|
151
|
+
['attachments', registerAttachmentConformance],
|
|
152
|
+
['encryption', registerEncryptionConformance],
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (const [name, register] of sections) {
|
|
156
|
+
if (skip.has(name)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
register(context);
|
|
160
|
+
}
|
|
161
|
+
}
|