@chipmobilesdk/rn-local-db 0.1.2 → 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 -52
- package/README.md +663 -661
- 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 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- 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 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- 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 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- 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 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- 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 -388
- 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,167 +1,204 @@
|
|
|
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(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
expect(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
143
|
+
(await notes.list({ filter: { op: 'ne', field: 'score', value: 0 }, page: { size: 500 } }))
|
|
144
|
+
.records,
|
|
145
|
+
).toHaveLength(36);
|
|
146
|
+
expect(
|
|
147
|
+
(
|
|
148
|
+
await notes.list({
|
|
149
|
+
filter: { op: 'in', field: 'score', value: [0, 1] },
|
|
150
|
+
page: { size: 500 },
|
|
151
|
+
})
|
|
152
|
+
).records,
|
|
153
|
+
).toHaveLength(8);
|
|
154
|
+
expect(
|
|
155
|
+
(
|
|
156
|
+
await notes.list({
|
|
157
|
+
filter: { op: 'nin', field: 'score', value: [0] },
|
|
158
|
+
page: { size: 500 },
|
|
159
|
+
})
|
|
160
|
+
).records,
|
|
161
|
+
).toHaveLength(36);
|
|
162
|
+
expect(
|
|
163
|
+
(
|
|
164
|
+
await notes.list({
|
|
165
|
+
filter: { op: 'between', field: 'score', value: [0, 1] },
|
|
166
|
+
page: { size: 500 },
|
|
167
|
+
})
|
|
168
|
+
).records,
|
|
169
|
+
).toHaveLength(8);
|
|
170
|
+
expect(
|
|
171
|
+
(await notes.list({ filter: { op: 'lt', field: 'score', value: 1 }, page: { size: 500 } }))
|
|
172
|
+
.records,
|
|
173
|
+
).toHaveLength(4);
|
|
174
|
+
expect(
|
|
175
|
+
(
|
|
176
|
+
await notes.list({
|
|
177
|
+
filter: { op: 'exists', field: 'title', value: true },
|
|
178
|
+
page: { size: 500 },
|
|
179
|
+
})
|
|
180
|
+
).records,
|
|
181
|
+
).toHaveLength(SIZE);
|
|
182
|
+
expect(
|
|
183
|
+
(await notes.list({ filter: { op: 'isNull', field: 'title' }, page: { size: 500 } }))
|
|
184
|
+
.records,
|
|
185
|
+
).toHaveLength(0);
|
|
186
|
+
expect(
|
|
187
|
+
(
|
|
188
|
+
await notes.list({
|
|
189
|
+
filter: {
|
|
190
|
+
op: 'and',
|
|
191
|
+
nodes: [
|
|
192
|
+
{ op: 'gte', field: 'score', value: 5 },
|
|
193
|
+
{ op: 'eq', field: 'done', value: true },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
page: { size: 500 },
|
|
197
|
+
})
|
|
198
|
+
).records.length,
|
|
199
|
+
).toBeGreaterThan(0);
|
|
200
|
+
|
|
201
|
+
await handle.close();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|