@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,99 +1,99 @@
|
|
|
1
|
-
import type { ScopeDescriptor } from '../../contract/scope';
|
|
2
|
-
import type { ConformanceContext } from './index';
|
|
3
|
-
|
|
4
|
-
/** US6: sign-out retains; deletion happens only when asked, and only where asked. */
|
|
5
|
-
export function registerLifecycleConformance(context: ConformanceContext): void {
|
|
6
|
-
describe('conformance: lifecycle', () => {
|
|
7
|
-
beforeEach(() => context.reset());
|
|
8
|
-
|
|
9
|
-
const alice: ScopeDescriptor = { kind: 'identity', id: 'alice' };
|
|
10
|
-
const bob: ScopeDescriptor = { kind: 'identity', id: 'bob' };
|
|
11
|
-
|
|
12
|
-
it('retains data when a handle is closed without a delete request', async () => {
|
|
13
|
-
const first = await context.adapter.open(context.configFor(alice));
|
|
14
|
-
await first.collection('notes').insert({ id: 'a', data: { title: 'kept' } });
|
|
15
|
-
// Closing the handle is what a sign-out looks like to this package.
|
|
16
|
-
await first.close();
|
|
17
|
-
|
|
18
|
-
const second = await context.adapter.open(context.configFor(alice));
|
|
19
|
-
expect((await second.collection('notes').get('a')).data.title).toBe('kept');
|
|
20
|
-
await second.close();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('removes every record in the target scope and nothing else', async () => {
|
|
24
|
-
const a = await context.adapter.open(context.configFor(alice));
|
|
25
|
-
await a.collection('notes').insert({ id: 'x', data: { title: 'alice' } });
|
|
26
|
-
await a.collection('plain').insert({ id: 'y', data: { label: 'alice' } });
|
|
27
|
-
await a.close();
|
|
28
|
-
|
|
29
|
-
const b = await context.adapter.open(context.configFor(bob));
|
|
30
|
-
await b.collection('notes').insert({ id: 'x', data: { title: 'bob' } });
|
|
31
|
-
await b.close();
|
|
32
|
-
|
|
33
|
-
const outcome = await context.adapter.deleteScopeData(context.scopeTargetFor(alice));
|
|
34
|
-
expect(outcome.removedRecords).toBe(2);
|
|
35
|
-
|
|
36
|
-
const reopenedAlice = await context.adapter.open(context.configFor(alice));
|
|
37
|
-
expect(await reopenedAlice.collection('notes').count()).toBe(0);
|
|
38
|
-
expect(await reopenedAlice.collection('plain').count()).toBe(0);
|
|
39
|
-
await reopenedAlice.close();
|
|
40
|
-
|
|
41
|
-
const reopenedBob = await context.adapter.open(context.configFor(bob));
|
|
42
|
-
expect((await reopenedBob.collection('notes').get('x')).data.title).toBe('bob');
|
|
43
|
-
await reopenedBob.close();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('reports success for a scope holding no data', async () => {
|
|
47
|
-
const outcome = await context.adapter.deleteScopeData(
|
|
48
|
-
context.scopeTargetFor({ kind: 'identity', id: 'never-used' }),
|
|
49
|
-
);
|
|
50
|
-
expect(outcome.removedRecords).toBe(0);
|
|
51
|
-
expect(outcome.failedRemovals).toEqual([]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('reports what it removed rather than a bare success', async () => {
|
|
55
|
-
const handle = await context.adapter.open(context.configFor(alice));
|
|
56
|
-
await handle.collection('notes').insert({ id: 'a', data: { title: 'x' } });
|
|
57
|
-
await handle.close();
|
|
58
|
-
|
|
59
|
-
const outcome = await context.adapter.deleteScopeData(context.scopeTargetFor(alice));
|
|
60
|
-
expect(outcome).toMatchObject({
|
|
61
|
-
removedRecords: 1,
|
|
62
|
-
removedArtifacts: [],
|
|
63
|
-
orphanedReferences: [],
|
|
64
|
-
failedRemovals: [],
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('makes the whole database behave as a first install after deleteDatabase', async () => {
|
|
69
|
-
const handle = await context.adapter.open(context.configFor(alice));
|
|
70
|
-
await handle.collection('notes').insert({ id: 'a', data: { title: 'x' } });
|
|
71
|
-
await handle.close();
|
|
72
|
-
|
|
73
|
-
await context.adapter.deleteDatabase({ name: 'conformance' });
|
|
74
|
-
|
|
75
|
-
const reopened = await context.adapter.open(context.configFor(alice));
|
|
76
|
-
expect(await reopened.collection('notes').count()).toBe(0);
|
|
77
|
-
await reopened.close();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('recovers exactly one scope with resetScope', async () => {
|
|
81
|
-
const a = await context.adapter.open(context.configFor(alice));
|
|
82
|
-
await a.collection('notes').insert({ id: 'x', data: { title: 'alice' } });
|
|
83
|
-
await a.close();
|
|
84
|
-
const b = await context.adapter.open(context.configFor(bob));
|
|
85
|
-
await b.collection('notes').insert({ id: 'x', data: { title: 'bob' } });
|
|
86
|
-
await b.close();
|
|
87
|
-
|
|
88
|
-
await context.adapter.resetScope(context.scopeTargetFor(alice));
|
|
89
|
-
|
|
90
|
-
const reopenedAlice = await context.adapter.open(context.configFor(alice));
|
|
91
|
-
expect(await reopenedAlice.collection('notes').count()).toBe(0);
|
|
92
|
-
await reopenedAlice.close();
|
|
93
|
-
|
|
94
|
-
const reopenedBob = await context.adapter.open(context.configFor(bob));
|
|
95
|
-
expect(await reopenedBob.collection('notes').count()).toBe(1);
|
|
96
|
-
await reopenedBob.close();
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
}
|
|
1
|
+
import type { ScopeDescriptor } from '../../contract/scope';
|
|
2
|
+
import type { ConformanceContext } from './index';
|
|
3
|
+
|
|
4
|
+
/** US6: sign-out retains; deletion happens only when asked, and only where asked. */
|
|
5
|
+
export function registerLifecycleConformance(context: ConformanceContext): void {
|
|
6
|
+
describe('conformance: lifecycle', () => {
|
|
7
|
+
beforeEach(() => context.reset());
|
|
8
|
+
|
|
9
|
+
const alice: ScopeDescriptor = { kind: 'identity', id: 'alice' };
|
|
10
|
+
const bob: ScopeDescriptor = { kind: 'identity', id: 'bob' };
|
|
11
|
+
|
|
12
|
+
it('retains data when a handle is closed without a delete request', async () => {
|
|
13
|
+
const first = await context.adapter.open(context.configFor(alice));
|
|
14
|
+
await first.collection('notes').insert({ id: 'a', data: { title: 'kept' } });
|
|
15
|
+
// Closing the handle is what a sign-out looks like to this package.
|
|
16
|
+
await first.close();
|
|
17
|
+
|
|
18
|
+
const second = await context.adapter.open(context.configFor(alice));
|
|
19
|
+
expect((await second.collection('notes').get('a')).data.title).toBe('kept');
|
|
20
|
+
await second.close();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('removes every record in the target scope and nothing else', async () => {
|
|
24
|
+
const a = await context.adapter.open(context.configFor(alice));
|
|
25
|
+
await a.collection('notes').insert({ id: 'x', data: { title: 'alice' } });
|
|
26
|
+
await a.collection('plain').insert({ id: 'y', data: { label: 'alice' } });
|
|
27
|
+
await a.close();
|
|
28
|
+
|
|
29
|
+
const b = await context.adapter.open(context.configFor(bob));
|
|
30
|
+
await b.collection('notes').insert({ id: 'x', data: { title: 'bob' } });
|
|
31
|
+
await b.close();
|
|
32
|
+
|
|
33
|
+
const outcome = await context.adapter.deleteScopeData(context.scopeTargetFor(alice));
|
|
34
|
+
expect(outcome.removedRecords).toBe(2);
|
|
35
|
+
|
|
36
|
+
const reopenedAlice = await context.adapter.open(context.configFor(alice));
|
|
37
|
+
expect(await reopenedAlice.collection('notes').count()).toBe(0);
|
|
38
|
+
expect(await reopenedAlice.collection('plain').count()).toBe(0);
|
|
39
|
+
await reopenedAlice.close();
|
|
40
|
+
|
|
41
|
+
const reopenedBob = await context.adapter.open(context.configFor(bob));
|
|
42
|
+
expect((await reopenedBob.collection('notes').get('x')).data.title).toBe('bob');
|
|
43
|
+
await reopenedBob.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reports success for a scope holding no data', async () => {
|
|
47
|
+
const outcome = await context.adapter.deleteScopeData(
|
|
48
|
+
context.scopeTargetFor({ kind: 'identity', id: 'never-used' }),
|
|
49
|
+
);
|
|
50
|
+
expect(outcome.removedRecords).toBe(0);
|
|
51
|
+
expect(outcome.failedRemovals).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reports what it removed rather than a bare success', async () => {
|
|
55
|
+
const handle = await context.adapter.open(context.configFor(alice));
|
|
56
|
+
await handle.collection('notes').insert({ id: 'a', data: { title: 'x' } });
|
|
57
|
+
await handle.close();
|
|
58
|
+
|
|
59
|
+
const outcome = await context.adapter.deleteScopeData(context.scopeTargetFor(alice));
|
|
60
|
+
expect(outcome).toMatchObject({
|
|
61
|
+
removedRecords: 1,
|
|
62
|
+
removedArtifacts: [],
|
|
63
|
+
orphanedReferences: [],
|
|
64
|
+
failedRemovals: [],
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('makes the whole database behave as a first install after deleteDatabase', async () => {
|
|
69
|
+
const handle = await context.adapter.open(context.configFor(alice));
|
|
70
|
+
await handle.collection('notes').insert({ id: 'a', data: { title: 'x' } });
|
|
71
|
+
await handle.close();
|
|
72
|
+
|
|
73
|
+
await context.adapter.deleteDatabase({ name: 'conformance' });
|
|
74
|
+
|
|
75
|
+
const reopened = await context.adapter.open(context.configFor(alice));
|
|
76
|
+
expect(await reopened.collection('notes').count()).toBe(0);
|
|
77
|
+
await reopened.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('recovers exactly one scope with resetScope', async () => {
|
|
81
|
+
const a = await context.adapter.open(context.configFor(alice));
|
|
82
|
+
await a.collection('notes').insert({ id: 'x', data: { title: 'alice' } });
|
|
83
|
+
await a.close();
|
|
84
|
+
const b = await context.adapter.open(context.configFor(bob));
|
|
85
|
+
await b.collection('notes').insert({ id: 'x', data: { title: 'bob' } });
|
|
86
|
+
await b.close();
|
|
87
|
+
|
|
88
|
+
await context.adapter.resetScope(context.scopeTargetFor(alice));
|
|
89
|
+
|
|
90
|
+
const reopenedAlice = await context.adapter.open(context.configFor(alice));
|
|
91
|
+
expect(await reopenedAlice.collection('notes').count()).toBe(0);
|
|
92
|
+
await reopenedAlice.close();
|
|
93
|
+
|
|
94
|
+
const reopenedBob = await context.adapter.open(context.configFor(bob));
|
|
95
|
+
expect(await reopenedBob.collection('notes').count()).toBe(1);
|
|
96
|
+
await reopenedBob.close();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -1,177 +1,177 @@
|
|
|
1
|
-
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
-
import type { Migration } from '../../contract/migration';
|
|
3
|
-
import { isCode } from '../../diagnostics/errors';
|
|
4
|
-
import type { ConformanceContext } from './index';
|
|
5
|
-
|
|
6
|
-
/** US5: ordered, once-only migrations that fail loudly. */
|
|
7
|
-
export function registerMigrationConformance(context: ConformanceContext): void {
|
|
8
|
-
describe('conformance: migrations', () => {
|
|
9
|
-
beforeEach(() => context.reset());
|
|
10
|
-
|
|
11
|
-
const scope = { kind: 'guest' } as const;
|
|
12
|
-
|
|
13
|
-
function chain(order: number[], onRun: (version: number) => void): Migration[] {
|
|
14
|
-
return order.map(version => ({
|
|
15
|
-
version,
|
|
16
|
-
migrate: async () => {
|
|
17
|
-
onRun(version);
|
|
18
|
-
},
|
|
19
|
-
}));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
it('runs pending migrations in ascending order', async () => {
|
|
23
|
-
const ran: number[] = [];
|
|
24
|
-
const handle = await context.adapter.open(
|
|
25
|
-
context.configFor(scope, {
|
|
26
|
-
schemaVersion: 3,
|
|
27
|
-
migrations: chain([3, 1, 2], v => ran.push(v)),
|
|
28
|
-
}),
|
|
29
|
-
);
|
|
30
|
-
expect(ran).toEqual([1, 2, 3]);
|
|
31
|
-
expect(handle.schemaVersion).toBe(3);
|
|
32
|
-
await handle.close();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('runs each migration at most once across repeated opens', async () => {
|
|
36
|
-
const ran: number[] = [];
|
|
37
|
-
const config = context.configFor(scope, {
|
|
38
|
-
schemaVersion: 2,
|
|
39
|
-
migrations: chain([1, 2], v => ran.push(v)),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
for (let i = 0; i < 3; i += 1) {
|
|
43
|
-
const handle = await context.adapter.open(config);
|
|
44
|
-
await handle.close();
|
|
45
|
-
}
|
|
46
|
-
expect(ran).toEqual([1, 2]);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('returns no handle and keeps the version when a migration fails', async () => {
|
|
50
|
-
const config = context.configFor(scope, {
|
|
51
|
-
schemaVersion: 2,
|
|
52
|
-
migrations: [
|
|
53
|
-
{ version: 1, migrate: async () => {} },
|
|
54
|
-
{
|
|
55
|
-
version: 2,
|
|
56
|
-
migrate: async () => {
|
|
57
|
-
throw new Error('boom');
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
let thrown: unknown;
|
|
64
|
-
try {
|
|
65
|
-
await context.adapter.open(config);
|
|
66
|
-
} catch (error) {
|
|
67
|
-
thrown = error;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
expect(isCode(thrown, DiagnosticCodes.MIGRATION_FAILED)).toBe(true);
|
|
71
|
-
expect((thrown as { migration?: unknown }).migration).toEqual({
|
|
72
|
-
recordedVersion: 1,
|
|
73
|
-
failingVersion: 2,
|
|
74
|
-
outcome: 'failed',
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('retries from the same version rather than skipping ahead', async () => {
|
|
79
|
-
let attempts = 0;
|
|
80
|
-
const config = context.configFor(scope, {
|
|
81
|
-
schemaVersion: 1,
|
|
82
|
-
migrations: [
|
|
83
|
-
{
|
|
84
|
-
version: 1,
|
|
85
|
-
migrate: async () => {
|
|
86
|
-
attempts += 1;
|
|
87
|
-
if (attempts < 3) {
|
|
88
|
-
throw new Error('not yet');
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
],
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
for (let i = 0; i < 2; i += 1) {
|
|
96
|
-
await expect(context.adapter.open(config)).rejects.toBeDefined();
|
|
97
|
-
}
|
|
98
|
-
const handle = await context.adapter.open(config);
|
|
99
|
-
expect(attempts).toBe(3);
|
|
100
|
-
expect(handle.schemaVersion).toBe(1);
|
|
101
|
-
await handle.close();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('alters nothing across repeated failed opens', async () => {
|
|
105
|
-
const seedConfig = context.configFor(scope, {
|
|
106
|
-
schemaVersion: 1,
|
|
107
|
-
migrations: [{ version: 1, migrate: async () => {} }],
|
|
108
|
-
});
|
|
109
|
-
const seeded = await context.adapter.open(seedConfig);
|
|
110
|
-
await seeded.collection('notes').insert({ id: 'keep', data: { title: 'keep' } });
|
|
111
|
-
await seeded.close();
|
|
112
|
-
|
|
113
|
-
const brokenConfig = context.configFor(scope, {
|
|
114
|
-
schemaVersion: 2,
|
|
115
|
-
migrations: [
|
|
116
|
-
{ version: 1, migrate: async () => {} },
|
|
117
|
-
{
|
|
118
|
-
version: 2,
|
|
119
|
-
migrate: async () => {
|
|
120
|
-
throw new Error('permanently broken');
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
for (let i = 0; i < 10; i += 1) {
|
|
127
|
-
await expect(context.adapter.open(brokenConfig)).rejects.toBeDefined();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const recovered = await context.adapter.open(seedConfig);
|
|
131
|
-
expect((await recovered.collection('notes').get('keep')).data.title).toBe('keep');
|
|
132
|
-
await recovered.close();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('reports a downgrade rather than opening or discarding', async () => {
|
|
136
|
-
const newer = await context.adapter.open(
|
|
137
|
-
context.configFor(scope, {
|
|
138
|
-
schemaVersion: 3,
|
|
139
|
-
migrations: chain([1, 2, 3], () => {}),
|
|
140
|
-
}),
|
|
141
|
-
);
|
|
142
|
-
await newer.close();
|
|
143
|
-
|
|
144
|
-
let thrown: unknown;
|
|
145
|
-
try {
|
|
146
|
-
await context.adapter.open(
|
|
147
|
-
context.configFor(scope, {
|
|
148
|
-
schemaVersion: 1,
|
|
149
|
-
migrations: chain([1], () => {}),
|
|
150
|
-
}),
|
|
151
|
-
);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
thrown = error;
|
|
154
|
-
}
|
|
155
|
-
expect(isCode(thrown, DiagnosticCodes.SCHEMA_DOWNGRADE)).toBe(true);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('migrates a scope created after the migration shipped', async () => {
|
|
159
|
-
const ran: number[] = [];
|
|
160
|
-
const config = (id: string) =>
|
|
161
|
-
context.configFor(
|
|
162
|
-
{ kind: 'identity', id },
|
|
163
|
-
{ schemaVersion: 2, migrations: chain([1, 2], v => ran.push(v)) },
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
const first = await context.adapter.open(config('alice'));
|
|
167
|
-
await first.close();
|
|
168
|
-
ran.length = 0;
|
|
169
|
-
|
|
170
|
-
// A brand-new scope must not be assumed already current.
|
|
171
|
-
const second = await context.adapter.open(config('bob'));
|
|
172
|
-
expect(ran).toEqual([1, 2]);
|
|
173
|
-
expect(second.schemaVersion).toBe(2);
|
|
174
|
-
await second.close();
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
}
|
|
1
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
+
import type { Migration } from '../../contract/migration';
|
|
3
|
+
import { isCode } from '../../diagnostics/errors';
|
|
4
|
+
import type { ConformanceContext } from './index';
|
|
5
|
+
|
|
6
|
+
/** US5: ordered, once-only migrations that fail loudly. */
|
|
7
|
+
export function registerMigrationConformance(context: ConformanceContext): void {
|
|
8
|
+
describe('conformance: migrations', () => {
|
|
9
|
+
beforeEach(() => context.reset());
|
|
10
|
+
|
|
11
|
+
const scope = { kind: 'guest' } as const;
|
|
12
|
+
|
|
13
|
+
function chain(order: number[], onRun: (version: number) => void): Migration[] {
|
|
14
|
+
return order.map(version => ({
|
|
15
|
+
version,
|
|
16
|
+
migrate: async () => {
|
|
17
|
+
onRun(version);
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('runs pending migrations in ascending order', async () => {
|
|
23
|
+
const ran: number[] = [];
|
|
24
|
+
const handle = await context.adapter.open(
|
|
25
|
+
context.configFor(scope, {
|
|
26
|
+
schemaVersion: 3,
|
|
27
|
+
migrations: chain([3, 1, 2], v => ran.push(v)),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
expect(ran).toEqual([1, 2, 3]);
|
|
31
|
+
expect(handle.schemaVersion).toBe(3);
|
|
32
|
+
await handle.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('runs each migration at most once across repeated opens', async () => {
|
|
36
|
+
const ran: number[] = [];
|
|
37
|
+
const config = context.configFor(scope, {
|
|
38
|
+
schemaVersion: 2,
|
|
39
|
+
migrations: chain([1, 2], v => ran.push(v)),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < 3; i += 1) {
|
|
43
|
+
const handle = await context.adapter.open(config);
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
expect(ran).toEqual([1, 2]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns no handle and keeps the version when a migration fails', async () => {
|
|
50
|
+
const config = context.configFor(scope, {
|
|
51
|
+
schemaVersion: 2,
|
|
52
|
+
migrations: [
|
|
53
|
+
{ version: 1, migrate: async () => {} },
|
|
54
|
+
{
|
|
55
|
+
version: 2,
|
|
56
|
+
migrate: async () => {
|
|
57
|
+
throw new Error('boom');
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let thrown: unknown;
|
|
64
|
+
try {
|
|
65
|
+
await context.adapter.open(config);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
thrown = error;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
expect(isCode(thrown, DiagnosticCodes.MIGRATION_FAILED)).toBe(true);
|
|
71
|
+
expect((thrown as { migration?: unknown }).migration).toEqual({
|
|
72
|
+
recordedVersion: 1,
|
|
73
|
+
failingVersion: 2,
|
|
74
|
+
outcome: 'failed',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('retries from the same version rather than skipping ahead', async () => {
|
|
79
|
+
let attempts = 0;
|
|
80
|
+
const config = context.configFor(scope, {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
migrations: [
|
|
83
|
+
{
|
|
84
|
+
version: 1,
|
|
85
|
+
migrate: async () => {
|
|
86
|
+
attempts += 1;
|
|
87
|
+
if (attempts < 3) {
|
|
88
|
+
throw new Error('not yet');
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < 2; i += 1) {
|
|
96
|
+
await expect(context.adapter.open(config)).rejects.toBeDefined();
|
|
97
|
+
}
|
|
98
|
+
const handle = await context.adapter.open(config);
|
|
99
|
+
expect(attempts).toBe(3);
|
|
100
|
+
expect(handle.schemaVersion).toBe(1);
|
|
101
|
+
await handle.close();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('alters nothing across repeated failed opens', async () => {
|
|
105
|
+
const seedConfig = context.configFor(scope, {
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
migrations: [{ version: 1, migrate: async () => {} }],
|
|
108
|
+
});
|
|
109
|
+
const seeded = await context.adapter.open(seedConfig);
|
|
110
|
+
await seeded.collection('notes').insert({ id: 'keep', data: { title: 'keep' } });
|
|
111
|
+
await seeded.close();
|
|
112
|
+
|
|
113
|
+
const brokenConfig = context.configFor(scope, {
|
|
114
|
+
schemaVersion: 2,
|
|
115
|
+
migrations: [
|
|
116
|
+
{ version: 1, migrate: async () => {} },
|
|
117
|
+
{
|
|
118
|
+
version: 2,
|
|
119
|
+
migrate: async () => {
|
|
120
|
+
throw new Error('permanently broken');
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < 10; i += 1) {
|
|
127
|
+
await expect(context.adapter.open(brokenConfig)).rejects.toBeDefined();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const recovered = await context.adapter.open(seedConfig);
|
|
131
|
+
expect((await recovered.collection('notes').get('keep')).data.title).toBe('keep');
|
|
132
|
+
await recovered.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('reports a downgrade rather than opening or discarding', async () => {
|
|
136
|
+
const newer = await context.adapter.open(
|
|
137
|
+
context.configFor(scope, {
|
|
138
|
+
schemaVersion: 3,
|
|
139
|
+
migrations: chain([1, 2, 3], () => {}),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
await newer.close();
|
|
143
|
+
|
|
144
|
+
let thrown: unknown;
|
|
145
|
+
try {
|
|
146
|
+
await context.adapter.open(
|
|
147
|
+
context.configFor(scope, {
|
|
148
|
+
schemaVersion: 1,
|
|
149
|
+
migrations: chain([1], () => {}),
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
thrown = error;
|
|
154
|
+
}
|
|
155
|
+
expect(isCode(thrown, DiagnosticCodes.SCHEMA_DOWNGRADE)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('migrates a scope created after the migration shipped', async () => {
|
|
159
|
+
const ran: number[] = [];
|
|
160
|
+
const config = (id: string) =>
|
|
161
|
+
context.configFor(
|
|
162
|
+
{ kind: 'identity', id },
|
|
163
|
+
{ schemaVersion: 2, migrations: chain([1, 2], v => ran.push(v)) },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const first = await context.adapter.open(config('alice'));
|
|
167
|
+
await first.close();
|
|
168
|
+
ran.length = 0;
|
|
169
|
+
|
|
170
|
+
// A brand-new scope must not be assumed already current.
|
|
171
|
+
const second = await context.adapter.open(config('bob'));
|
|
172
|
+
expect(ran).toEqual([1, 2]);
|
|
173
|
+
expect(second.schemaVersion).toBe(2);
|
|
174
|
+
await second.close();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|