@chipmobilesdk/rn-local-db 0.1.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 +52 -0
- package/README.md +726 -0
- package/package.json +61 -0
- package/src/adapter/budgets.ts +67 -0
- package/src/adapter/collection.ts +298 -0
- package/src/adapter/deletion.ts +238 -0
- package/src/adapter/index.ts +68 -0
- package/src/adapter/lifecycle.ts +444 -0
- package/src/adapter/transaction.ts +65 -0
- package/src/artifacts/availability.ts +69 -0
- package/src/artifacts/cascade.ts +78 -0
- package/src/artifacts/paths.ts +29 -0
- package/src/artifacts/reconcile.ts +63 -0
- package/src/artifacts/store.ts +323 -0
- package/src/contract/adapter.ts +78 -0
- package/src/contract/attachment.ts +72 -0
- package/src/contract/backup.ts +28 -0
- package/src/contract/config.ts +54 -0
- package/src/contract/encryption.ts +34 -0
- package/src/contract/errors.ts +76 -0
- package/src/contract/migration.ts +40 -0
- package/src/contract/query.ts +81 -0
- package/src/contract/record.ts +42 -0
- package/src/contract/schema.ts +36 -0
- package/src/contract/scope.ts +36 -0
- package/src/contract/transaction.ts +46 -0
- package/src/crypto/chunked-cipher.ts +144 -0
- package/src/crypto/derive.ts +63 -0
- package/src/crypto/key-provider.ts +78 -0
- package/src/crypto/provider.ts +81 -0
- package/src/diagnostics/dev-log.ts +108 -0
- package/src/diagnostics/errors.ts +66 -0
- package/src/diagnostics/faults.ts +64 -0
- package/src/diagnostics/map-native.ts +106 -0
- package/src/diagnostics/redact.ts +141 -0
- package/src/index.ts +110 -0
- package/src/migration/executor.ts +136 -0
- package/src/migration/ledger.ts +97 -0
- package/src/migration/validate-chain.ts +65 -0
- package/src/platform/backup-android-verify.ts +88 -0
- package/src/platform/backup-ios.ts +48 -0
- package/src/platform/fs.ts +89 -0
- package/src/platform/location.ts +135 -0
- package/src/query/budgets.ts +57 -0
- package/src/query/compare.ts +91 -0
- package/src/query/cursor.ts +99 -0
- package/src/query/filter.ts +82 -0
- package/src/query/paginate.ts +51 -0
- package/src/query/sort.ts +56 -0
- package/src/query/validate.ts +171 -0
- package/src/schema/declare.ts +45 -0
- package/src/schema/validate-config.ts +158 -0
- package/src/scope/hash.ts +134 -0
- package/src/scope/normalize.ts +90 -0
- package/src/scope/validate.ts +83 -0
- package/src/sqlite/adapter.ts +26 -0
- package/src/sqlite/batch.ts +232 -0
- package/src/sqlite/catalog.ts +108 -0
- package/src/sqlite/connection.ts +205 -0
- package/src/sqlite/index-coverage.ts +53 -0
- package/src/sqlite/native.ts +83 -0
- package/src/sqlite/row-codec.ts +201 -0
- package/src/sqlite/sql-builder.ts +244 -0
- package/src/sqlite/transactions.ts +33 -0
- package/src/sqlite/values.ts +24 -0
- package/src/testing/conformance/attachments.ts +136 -0
- package/src/testing/conformance/encryption.ts +150 -0
- package/src/testing/conformance/index.ts +161 -0
- package/src/testing/conformance/lifecycle.ts +99 -0
- package/src/testing/conformance/migrations.ts +177 -0
- package/src/testing/conformance/queries.ts +167 -0
- package/src/testing/conformance/records.ts +169 -0
- package/src/testing/conformance/scopes.ts +88 -0
- package/src/testing/conformance/transactions.ts +149 -0
- package/src/testing/faults.ts +21 -0
- package/src/testing/index.ts +21 -0
- package/src/testing/memory-adapter/collection.ts +219 -0
- package/src/testing/memory-adapter/index.ts +388 -0
- package/src/testing/memory-adapter/migrations.ts +119 -0
- package/src/testing/memory-adapter/reset.ts +50 -0
- package/src/testing/memory-adapter/store.ts +134 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
+
import { isCode } from '../../diagnostics/errors';
|
|
3
|
+
import type { ConformanceContext } from './index';
|
|
4
|
+
|
|
5
|
+
/** US3: filter, sort, page, count — and paging that loses nothing. */
|
|
6
|
+
export function registerQueryConformance(context: ConformanceContext): void {
|
|
7
|
+
describe('conformance: queries', () => {
|
|
8
|
+
beforeEach(() => context.reset());
|
|
9
|
+
|
|
10
|
+
const scope = { kind: 'guest' } as const;
|
|
11
|
+
const SIZE = 40;
|
|
12
|
+
|
|
13
|
+
async function seeded() {
|
|
14
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
15
|
+
const notes = handle.collection('notes');
|
|
16
|
+
for (let i = 0; i < SIZE; i += 1) {
|
|
17
|
+
await notes.insert({
|
|
18
|
+
id: `id-${String(i).padStart(3, '0')}`,
|
|
19
|
+
data: { title: `note ${i}`, score: i % 10, done: i % 2 === 0 },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return handle;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('filters to the matching records only', async () => {
|
|
26
|
+
const handle = await seeded();
|
|
27
|
+
const page = await handle
|
|
28
|
+
.collection('notes')
|
|
29
|
+
.list({ filter: { op: 'eq', field: 'score', value: 3 }, page: { size: 100 } });
|
|
30
|
+
expect(page.records).toHaveLength(4);
|
|
31
|
+
expect(page.records.every(r => r.data.score === 3)).toBe(true);
|
|
32
|
+
await handle.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('agrees between count and a full listing', async () => {
|
|
36
|
+
const handle = await seeded();
|
|
37
|
+
const notes = handle.collection('notes');
|
|
38
|
+
const filter = { op: 'gte', field: 'score', value: 5 } as const;
|
|
39
|
+
|
|
40
|
+
const counted = await notes.count(filter);
|
|
41
|
+
const listed = await notes.list({ filter, page: { size: 500 } });
|
|
42
|
+
expect(counted).toBe(listed.records.length);
|
|
43
|
+
await handle.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('walks every record exactly once with no gaps or duplicates', async () => {
|
|
47
|
+
const handle = await seeded();
|
|
48
|
+
const notes = handle.collection('notes');
|
|
49
|
+
const seen: string[] = [];
|
|
50
|
+
let cursor: string | undefined;
|
|
51
|
+
let guard = 0;
|
|
52
|
+
|
|
53
|
+
do {
|
|
54
|
+
const page = await notes.list({
|
|
55
|
+
sort: [{ field: 'score' }],
|
|
56
|
+
page: { size: 7, cursor },
|
|
57
|
+
});
|
|
58
|
+
seen.push(...page.records.map(r => r.id));
|
|
59
|
+
cursor = page.cursor;
|
|
60
|
+
guard += 1;
|
|
61
|
+
} while (cursor && guard < 50);
|
|
62
|
+
|
|
63
|
+
expect(seen).toHaveLength(SIZE);
|
|
64
|
+
expect(new Set(seen).size).toBe(SIZE);
|
|
65
|
+
await handle.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('sorts, and breaks ties deterministically', async () => {
|
|
69
|
+
const handle = await seeded();
|
|
70
|
+
const page = await handle
|
|
71
|
+
.collection('notes')
|
|
72
|
+
.list({ sort: [{ field: 'score' }], page: { size: 500 } });
|
|
73
|
+
|
|
74
|
+
const scores = page.records.map(r => r.data.score as number);
|
|
75
|
+
expect([...scores].sort((a, b) => a - b)).toEqual(scores);
|
|
76
|
+
|
|
77
|
+
const again = await handle
|
|
78
|
+
.collection('notes')
|
|
79
|
+
.list({ sort: [{ field: 'score' }], page: { size: 500 } });
|
|
80
|
+
expect(again.records.map(r => r.id)).toEqual(page.records.map(r => r.id));
|
|
81
|
+
await handle.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('sorts descending when asked', async () => {
|
|
85
|
+
const handle = await seeded();
|
|
86
|
+
const page = await handle
|
|
87
|
+
.collection('notes')
|
|
88
|
+
.list({ sort: [{ field: 'score', direction: 'desc' }], page: { size: 5 } });
|
|
89
|
+
expect(page.records[0]!.data.score).toBe(9);
|
|
90
|
+
await handle.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns an empty result rather than an error when nothing matches', async () => {
|
|
94
|
+
const handle = await seeded();
|
|
95
|
+
const page = await handle
|
|
96
|
+
.collection('notes')
|
|
97
|
+
.list({ filter: { op: 'eq', field: 'score', value: 999 } });
|
|
98
|
+
expect(page.records).toEqual([]);
|
|
99
|
+
expect(page.hasMore).toBe(false);
|
|
100
|
+
await handle.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a filter on an undeclared field', async () => {
|
|
104
|
+
const handle = await seeded();
|
|
105
|
+
let thrown: unknown;
|
|
106
|
+
try {
|
|
107
|
+
await handle.collection('notes').list({ filter: { op: 'eq', field: 'nope', value: 1 } });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
thrown = error;
|
|
110
|
+
}
|
|
111
|
+
expect(isCode(thrown, DiagnosticCodes.QUERY_INVALID)).toBe(true);
|
|
112
|
+
await handle.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it.each([0, -1, 501])('rejects page size %p', async size => {
|
|
116
|
+
const handle = await seeded();
|
|
117
|
+
let thrown: unknown;
|
|
118
|
+
try {
|
|
119
|
+
await handle.collection('notes').list({ page: { size } });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
thrown = error;
|
|
122
|
+
}
|
|
123
|
+
expect(isCode(thrown, DiagnosticCodes.PAGE_SIZE_INVALID)).toBe(true);
|
|
124
|
+
await handle.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('excludes soft-deleted records from queries and counts by default', async () => {
|
|
128
|
+
const handle = await seeded();
|
|
129
|
+
const notes = handle.collection('notes');
|
|
130
|
+
await notes.softDelete('id-000');
|
|
131
|
+
|
|
132
|
+
expect(await notes.count()).toBe(SIZE - 1);
|
|
133
|
+
const page = await notes.list({ page: { size: 500 } });
|
|
134
|
+
expect(page.records.some(r => r.id === 'id-000')).toBe(false);
|
|
135
|
+
await handle.close();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('supports the full operator set', async () => {
|
|
139
|
+
const handle = await seeded();
|
|
140
|
+
const notes = handle.collection('notes');
|
|
141
|
+
|
|
142
|
+
expect((await notes.list({ filter: { op: 'ne', field: 'score', value: 0 }, page: { size: 500 } })).records).toHaveLength(36);
|
|
143
|
+
expect((await notes.list({ filter: { op: 'in', field: 'score', value: [0, 1] }, page: { size: 500 } })).records).toHaveLength(8);
|
|
144
|
+
expect((await notes.list({ filter: { op: 'nin', field: 'score', value: [0] }, page: { size: 500 } })).records).toHaveLength(36);
|
|
145
|
+
expect((await notes.list({ filter: { op: 'between', field: 'score', value: [0, 1] }, page: { size: 500 } })).records).toHaveLength(8);
|
|
146
|
+
expect((await notes.list({ filter: { op: 'lt', field: 'score', value: 1 }, page: { size: 500 } })).records).toHaveLength(4);
|
|
147
|
+
expect((await notes.list({ filter: { op: 'exists', field: 'title', value: true }, page: { size: 500 } })).records).toHaveLength(SIZE);
|
|
148
|
+
expect((await notes.list({ filter: { op: 'isNull', field: 'title' }, page: { size: 500 } })).records).toHaveLength(0);
|
|
149
|
+
expect(
|
|
150
|
+
(
|
|
151
|
+
await notes.list({
|
|
152
|
+
filter: {
|
|
153
|
+
op: 'and',
|
|
154
|
+
nodes: [
|
|
155
|
+
{ op: 'gte', field: 'score', value: 5 },
|
|
156
|
+
{ op: 'eq', field: 'done', value: true },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
page: { size: 500 },
|
|
160
|
+
})
|
|
161
|
+
).records.length,
|
|
162
|
+
).toBeGreaterThan(0);
|
|
163
|
+
|
|
164
|
+
await handle.close();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { DiagnosticCodes } from '../../contract/errors';
|
|
2
|
+
import { isCode } from '../../diagnostics/errors';
|
|
3
|
+
import type { ConformanceContext } from './index';
|
|
4
|
+
|
|
5
|
+
/** US1: durable CRUD, and the four value conditions staying distinguishable. */
|
|
6
|
+
export function registerRecordConformance(context: ConformanceContext): void {
|
|
7
|
+
describe('conformance: records', () => {
|
|
8
|
+
beforeEach(() => context.reset());
|
|
9
|
+
|
|
10
|
+
const scope = { kind: 'guest' } as const;
|
|
11
|
+
|
|
12
|
+
it('returns exactly what was stored', async () => {
|
|
13
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
14
|
+
const notes = handle.collection('notes');
|
|
15
|
+
const data = {
|
|
16
|
+
title: 'alpha',
|
|
17
|
+
score: 1.5,
|
|
18
|
+
nested: { deep: [1, 'two', null, { three: true }] },
|
|
19
|
+
explicitNull: null,
|
|
20
|
+
};
|
|
21
|
+
await notes.insert({ id: 'a', data });
|
|
22
|
+
|
|
23
|
+
expect((await notes.get('a')).data).toEqual(data);
|
|
24
|
+
await handle.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not mutate the object the caller passed in', async () => {
|
|
28
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
29
|
+
const data = { title: 'alpha' };
|
|
30
|
+
await handle.collection('notes').insert({ id: 'a', data });
|
|
31
|
+
expect(data).toEqual({ title: 'alpha' });
|
|
32
|
+
await handle.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does not hand back a live reference into the store', async () => {
|
|
36
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
37
|
+
const notes = handle.collection('notes');
|
|
38
|
+
await notes.insert({ id: 'a', data: { title: 'alpha' } });
|
|
39
|
+
|
|
40
|
+
const first = await notes.get('a');
|
|
41
|
+
(first.data as { title: string }).title = 'mutated';
|
|
42
|
+
|
|
43
|
+
expect((await notes.get('a')).data).toEqual({ title: 'alpha' });
|
|
44
|
+
await handle.close();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('distinguishes absent record, explicit null, and absent field', async () => {
|
|
48
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
49
|
+
const notes = handle.collection('notes');
|
|
50
|
+
await notes.insert({ id: 'null-field', data: { title: null } });
|
|
51
|
+
await notes.insert({ id: 'absent-field', data: {} });
|
|
52
|
+
|
|
53
|
+
const withNull = await notes.get('null-field');
|
|
54
|
+
expect('title' in withNull.data).toBe(true);
|
|
55
|
+
expect(withNull.data.title).toBeNull();
|
|
56
|
+
|
|
57
|
+
const withAbsent = await notes.get('absent-field');
|
|
58
|
+
expect('title' in withAbsent.data).toBe(false);
|
|
59
|
+
|
|
60
|
+
expect(await notes.find('never-existed')).toBeNull();
|
|
61
|
+
await handle.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reports a conflict rather than overwriting on insert', async () => {
|
|
65
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
66
|
+
const notes = handle.collection('notes');
|
|
67
|
+
await notes.insert({ id: 'a', data: { title: 'first' } });
|
|
68
|
+
|
|
69
|
+
let thrown: unknown;
|
|
70
|
+
try {
|
|
71
|
+
await notes.insert({ id: 'a', data: { title: 'second' } });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
thrown = error;
|
|
74
|
+
}
|
|
75
|
+
expect(isCode(thrown, DiagnosticCodes.RECORD_CONFLICT)).toBe(true);
|
|
76
|
+
expect((await notes.get('a')).data.title).toBe('first');
|
|
77
|
+
await handle.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reports whether an upsert created or updated', async () => {
|
|
81
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
82
|
+
const notes = handle.collection('notes');
|
|
83
|
+
expect((await notes.upsert({ id: 'a', data: { title: 'x' } })).operation).toBe('created');
|
|
84
|
+
expect((await notes.upsert({ id: 'a', data: { title: 'y' } })).operation).toBe('updated');
|
|
85
|
+
await handle.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports record-not-found rather than creating on update', async () => {
|
|
89
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
90
|
+
let thrown: unknown;
|
|
91
|
+
try {
|
|
92
|
+
await handle.collection('notes').update('ghost', { data: { title: 'x' } });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
thrown = error;
|
|
95
|
+
}
|
|
96
|
+
expect(isCode(thrown, DiagnosticCodes.RECORD_NOT_FOUND)).toBe(true);
|
|
97
|
+
expect(await handle.collection('notes').find('ghost')).toBeNull();
|
|
98
|
+
await handle.close();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('makes a hard-deleted record not-found', async () => {
|
|
102
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
103
|
+
const notes = handle.collection('notes');
|
|
104
|
+
await notes.insert({ id: 'a', data: { title: 'x' } });
|
|
105
|
+
await notes.delete('a');
|
|
106
|
+
expect(await notes.find('a')).toBeNull();
|
|
107
|
+
await handle.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('hides a soft-deleted record by default and restores it', async () => {
|
|
111
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
112
|
+
const notes = handle.collection('notes');
|
|
113
|
+
await notes.insert({ id: 'a', data: { title: 'x' } });
|
|
114
|
+
await notes.softDelete('a');
|
|
115
|
+
|
|
116
|
+
expect(await notes.find('a')).toBeNull();
|
|
117
|
+
expect((await notes.list()).records).toHaveLength(0);
|
|
118
|
+
expect((await notes.list({ includeSoftDeleted: true })).records).toHaveLength(1);
|
|
119
|
+
|
|
120
|
+
await notes.restore('a');
|
|
121
|
+
expect(await notes.find('a')).not.toBeNull();
|
|
122
|
+
await handle.close();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects a record over the size budget rather than truncating', async () => {
|
|
126
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
127
|
+
let thrown: unknown;
|
|
128
|
+
try {
|
|
129
|
+
await handle
|
|
130
|
+
.collection('notes')
|
|
131
|
+
.insert({ id: 'big', data: { title: 'x'.repeat(300_000) } });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
thrown = error;
|
|
134
|
+
}
|
|
135
|
+
expect(isCode(thrown, DiagnosticCodes.RECORD_TOO_LARGE)).toBe(true);
|
|
136
|
+
expect(await handle.collection('notes').find('big')).toBeNull();
|
|
137
|
+
await handle.close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rejects operations on a closed handle', async () => {
|
|
141
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
142
|
+
await handle.close();
|
|
143
|
+
|
|
144
|
+
let thrown: unknown;
|
|
145
|
+
try {
|
|
146
|
+
await handle.collection('notes').find('a');
|
|
147
|
+
} catch (error) {
|
|
148
|
+
thrown = error;
|
|
149
|
+
}
|
|
150
|
+
expect(isCode(thrown, DiagnosticCodes.CLOSED_HANDLE)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('persists across a close and reopen', async () => {
|
|
154
|
+
const first = await context.adapter.open(context.configFor(scope));
|
|
155
|
+
await first.collection('notes').insert({ id: 'a', data: { title: 'kept' } });
|
|
156
|
+
await first.close();
|
|
157
|
+
|
|
158
|
+
const second = await context.adapter.open(context.configFor(scope));
|
|
159
|
+
expect((await second.collection('notes').get('a')).data.title).toBe('kept');
|
|
160
|
+
await second.close();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects a collection that was never declared', async () => {
|
|
164
|
+
const handle = await context.adapter.open(context.configFor(scope));
|
|
165
|
+
expect(() => handle.collection('nope')).toThrow();
|
|
166
|
+
await handle.close();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ScopeDescriptor } from '../../contract/scope';
|
|
2
|
+
import type { ConformanceContext } from './index';
|
|
3
|
+
|
|
4
|
+
/** US2: scope isolation, proven with unfiltered reads and colliding ids. */
|
|
5
|
+
export function registerScopeConformance(context: ConformanceContext): void {
|
|
6
|
+
describe('conformance: scopes', () => {
|
|
7
|
+
beforeEach(() => context.reset());
|
|
8
|
+
|
|
9
|
+
const guest: ScopeDescriptor = { kind: 'guest' };
|
|
10
|
+
const alice: ScopeDescriptor = { kind: 'identity', id: 'alice' };
|
|
11
|
+
const bob: ScopeDescriptor = { kind: 'identity', id: 'bob' };
|
|
12
|
+
|
|
13
|
+
it('returns nothing from another scope, even unfiltered', async () => {
|
|
14
|
+
const a = await context.adapter.open(context.configFor(alice));
|
|
15
|
+
await a.collection('notes').insert({ id: 'shared-id', data: { title: 'alice' } });
|
|
16
|
+
await a.close();
|
|
17
|
+
|
|
18
|
+
const b = await context.adapter.open(context.configFor(bob));
|
|
19
|
+
expect((await b.collection('notes').list()).records).toHaveLength(0);
|
|
20
|
+
expect(await b.collection('notes').count()).toBe(0);
|
|
21
|
+
expect(await b.collection('notes').find('shared-id')).toBeNull();
|
|
22
|
+
await b.close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('lets the same id exist independently in two scopes', async () => {
|
|
26
|
+
const a = await context.adapter.open(context.configFor(alice));
|
|
27
|
+
const b = await context.adapter.open(context.configFor(bob));
|
|
28
|
+
|
|
29
|
+
await a.collection('notes').insert({ id: 'same', data: { title: 'alice' } });
|
|
30
|
+
await b.collection('notes').insert({ id: 'same', data: { title: 'bob' } });
|
|
31
|
+
|
|
32
|
+
expect((await a.collection('notes').get('same')).data.title).toBe('alice');
|
|
33
|
+
expect((await b.collection('notes').get('same')).data.title).toBe('bob');
|
|
34
|
+
await a.close();
|
|
35
|
+
await b.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps three concurrently open handles independent', async () => {
|
|
39
|
+
const handles = await Promise.all(
|
|
40
|
+
[guest, alice, bob].map(scope => context.adapter.open(context.configFor(scope))),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await Promise.all(
|
|
44
|
+
handles.map((handle, index) =>
|
|
45
|
+
handle.collection('notes').insert({ id: 'x', data: { score: index } }),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
for (const [index, handle] of handles.entries()) {
|
|
50
|
+
expect((await handle.collection('notes').get('x')).data.score).toBe(index);
|
|
51
|
+
expect(await handle.collection('notes').count()).toBe(1);
|
|
52
|
+
}
|
|
53
|
+
await Promise.all(handles.map(handle => handle.close()));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('exposes the scope digest rather than the components', async () => {
|
|
57
|
+
const handle = await context.adapter.open(context.configFor(alice));
|
|
58
|
+
expect(handle.scopeKey).toMatch(/^[0-9a-f]{64}$/);
|
|
59
|
+
expect(handle.scopeKey).not.toContain('alice');
|
|
60
|
+
await handle.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('resolves the same descriptor to the same scope across opens', async () => {
|
|
64
|
+
const first = await context.adapter.open(context.configFor(alice));
|
|
65
|
+
const key = first.scopeKey;
|
|
66
|
+
await first.close();
|
|
67
|
+
|
|
68
|
+
const second = await context.adapter.open(
|
|
69
|
+
context.configFor({ kind: 'identity', id: 'alice' }),
|
|
70
|
+
);
|
|
71
|
+
expect(second.scopeKey).toBe(key);
|
|
72
|
+
await second.close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('treats a differing component as a different scope', async () => {
|
|
76
|
+
const plain = await context.adapter.open(context.configFor(alice));
|
|
77
|
+
const tenanted = await context.adapter.open(
|
|
78
|
+
context.configFor({ ...alice, tenant: 'acme' }),
|
|
79
|
+
);
|
|
80
|
+
expect(tenanted.scopeKey).not.toBe(plain.scopeKey);
|
|
81
|
+
|
|
82
|
+
await plain.collection('notes').insert({ id: 'x', data: { title: 'plain' } });
|
|
83
|
+
expect(await tenanted.collection('notes').find('x')).toBeNull();
|
|
84
|
+
await plain.close();
|
|
85
|
+
await tenanted.close();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|