@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
package/src/query/cursor.ts
CHANGED
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keyset pagination cursors.
|
|
3
|
-
*
|
|
4
|
-
* Keyset rather than OFFSET for two reasons, both of which SC-007 measures:
|
|
5
|
-
* OFFSET degrades linearly at the 100,000-record budget because the engine
|
|
6
|
-
* still walks the skipped rows, and it shifts pages under concurrent writes
|
|
7
|
-
* so a record can be skipped or returned twice. A cursor anchored to the last
|
|
8
|
-
* row's sort values has neither problem.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
-
import type { FilterValue, SortKey } from '../contract/query';
|
|
13
|
-
import type { StoredRecord } from '../contract/record';
|
|
14
|
-
import { raise } from '../diagnostics/errors';
|
|
15
|
-
import { compareValues } from './compare';
|
|
16
|
-
import { sortValueOf } from './sort';
|
|
17
|
-
import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../platform/bytes';
|
|
18
|
-
|
|
19
|
-
export type CursorPayload = {
|
|
20
|
-
/** Sort key fields and directions, so a cursor cannot be reused across shapes. */
|
|
21
|
-
k: string;
|
|
22
|
-
/** The last row's values, in sort-key order. */
|
|
23
|
-
v: FilterValue[];
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function shapeOf(sort: SortKey[]): string {
|
|
27
|
-
return sort.map(key => `${key.field}:${key.direction === 'desc' ? 'd' : 'a'}`).join(',');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function toBase64(value: string): string {
|
|
31
|
-
if (typeof globalThis.btoa === 'function') {
|
|
32
|
-
return globalThis.btoa(unescape(encodeURIComponent(value)));
|
|
33
|
-
}
|
|
34
|
-
return bytesToBase64(utf8Bytes(value));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function fromBase64(value: string): string {
|
|
38
|
-
if (typeof globalThis.atob === 'function') {
|
|
39
|
-
return decodeURIComponent(escape(globalThis.atob(value)));
|
|
40
|
-
}
|
|
41
|
-
return utf8String(base64ToBytes(value));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function encodeCursor(record: StoredRecord, sort: SortKey[]): string {
|
|
45
|
-
const payload: CursorPayload = {
|
|
46
|
-
k: shapeOf(sort),
|
|
47
|
-
v: sort.map(key => sortValueOf(record, key.field) ?? null),
|
|
48
|
-
};
|
|
49
|
-
return toBase64(JSON.stringify(payload));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function decodeCursor(cursor: string, sort: SortKey[]): FilterValue[] {
|
|
53
|
-
let payload: CursorPayload;
|
|
54
|
-
try {
|
|
55
|
-
payload = JSON.parse(fromBase64(cursor)) as CursorPayload;
|
|
56
|
-
} catch {
|
|
57
|
-
raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor is not decodable' });
|
|
58
|
-
}
|
|
59
|
-
if (!payload || typeof payload.k !== 'string' || !Array.isArray(payload.v)) {
|
|
60
|
-
raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor payload is malformed' });
|
|
61
|
-
}
|
|
62
|
-
// A cursor from a differently sorted query would produce a silently wrong
|
|
63
|
-
// page rather than an error, so the shape is part of the cursor.
|
|
64
|
-
if (payload.k !== shapeOf(sort)) {
|
|
65
|
-
raise(DiagnosticCodes.CURSOR_INVALID, {
|
|
66
|
-
message: 'cursor does not match this query shape',
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
if (payload.v.length !== sort.length) {
|
|
70
|
-
raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor arity does not match sort' });
|
|
71
|
-
}
|
|
72
|
-
return payload.v;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Is this record strictly after the cursor position, under the sort order?
|
|
77
|
-
*
|
|
78
|
-
* Lexicographic across the sort keys: equal on the first key means the second
|
|
79
|
-
* decides, and so on down to the id tiebreaker that guarantees a total order.
|
|
80
|
-
*/
|
|
81
|
-
export function isAfterCursor(
|
|
82
|
-
record: StoredRecord,
|
|
83
|
-
sort: SortKey[],
|
|
84
|
-
anchor: FilterValue[],
|
|
85
|
-
): boolean {
|
|
86
|
-
for (let i = 0; i < sort.length; i += 1) {
|
|
87
|
-
const key = sort[i]!;
|
|
88
|
-
const direction = key.direction === 'desc' ? -1 : 1;
|
|
89
|
-
const value = sortValueOf(record, key.field) ?? null;
|
|
90
|
-
const result = compareValues(value, anchor[i] ?? null) * direction;
|
|
91
|
-
if (result > 0) {
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
if (result < 0) {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Equal on every key including id — this is the anchor row itself.
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Keyset pagination cursors.
|
|
3
|
+
*
|
|
4
|
+
* Keyset rather than OFFSET for two reasons, both of which SC-007 measures:
|
|
5
|
+
* OFFSET degrades linearly at the 100,000-record budget because the engine
|
|
6
|
+
* still walks the skipped rows, and it shifts pages under concurrent writes
|
|
7
|
+
* so a record can be skipped or returned twice. A cursor anchored to the last
|
|
8
|
+
* row's sort values has neither problem.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
+
import type { FilterValue, SortKey } from '../contract/query';
|
|
13
|
+
import type { StoredRecord } from '../contract/record';
|
|
14
|
+
import { raise } from '../diagnostics/errors';
|
|
15
|
+
import { compareValues } from './compare';
|
|
16
|
+
import { sortValueOf } from './sort';
|
|
17
|
+
import { base64ToBytes, bytesToBase64, utf8Bytes, utf8String } from '../platform/bytes';
|
|
18
|
+
|
|
19
|
+
export type CursorPayload = {
|
|
20
|
+
/** Sort key fields and directions, so a cursor cannot be reused across shapes. */
|
|
21
|
+
k: string;
|
|
22
|
+
/** The last row's values, in sort-key order. */
|
|
23
|
+
v: FilterValue[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function shapeOf(sort: SortKey[]): string {
|
|
27
|
+
return sort.map(key => `${key.field}:${key.direction === 'desc' ? 'd' : 'a'}`).join(',');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toBase64(value: string): string {
|
|
31
|
+
if (typeof globalThis.btoa === 'function') {
|
|
32
|
+
return globalThis.btoa(unescape(encodeURIComponent(value)));
|
|
33
|
+
}
|
|
34
|
+
return bytesToBase64(utf8Bytes(value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fromBase64(value: string): string {
|
|
38
|
+
if (typeof globalThis.atob === 'function') {
|
|
39
|
+
return decodeURIComponent(escape(globalThis.atob(value)));
|
|
40
|
+
}
|
|
41
|
+
return utf8String(base64ToBytes(value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encodeCursor(record: StoredRecord, sort: SortKey[]): string {
|
|
45
|
+
const payload: CursorPayload = {
|
|
46
|
+
k: shapeOf(sort),
|
|
47
|
+
v: sort.map(key => sortValueOf(record, key.field) ?? null),
|
|
48
|
+
};
|
|
49
|
+
return toBase64(JSON.stringify(payload));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function decodeCursor(cursor: string, sort: SortKey[]): FilterValue[] {
|
|
53
|
+
let payload: CursorPayload;
|
|
54
|
+
try {
|
|
55
|
+
payload = JSON.parse(fromBase64(cursor)) as CursorPayload;
|
|
56
|
+
} catch {
|
|
57
|
+
raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor is not decodable' });
|
|
58
|
+
}
|
|
59
|
+
if (!payload || typeof payload.k !== 'string' || !Array.isArray(payload.v)) {
|
|
60
|
+
raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor payload is malformed' });
|
|
61
|
+
}
|
|
62
|
+
// A cursor from a differently sorted query would produce a silently wrong
|
|
63
|
+
// page rather than an error, so the shape is part of the cursor.
|
|
64
|
+
if (payload.k !== shapeOf(sort)) {
|
|
65
|
+
raise(DiagnosticCodes.CURSOR_INVALID, {
|
|
66
|
+
message: 'cursor does not match this query shape',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (payload.v.length !== sort.length) {
|
|
70
|
+
raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor arity does not match sort' });
|
|
71
|
+
}
|
|
72
|
+
return payload.v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Is this record strictly after the cursor position, under the sort order?
|
|
77
|
+
*
|
|
78
|
+
* Lexicographic across the sort keys: equal on the first key means the second
|
|
79
|
+
* decides, and so on down to the id tiebreaker that guarantees a total order.
|
|
80
|
+
*/
|
|
81
|
+
export function isAfterCursor(
|
|
82
|
+
record: StoredRecord,
|
|
83
|
+
sort: SortKey[],
|
|
84
|
+
anchor: FilterValue[],
|
|
85
|
+
): boolean {
|
|
86
|
+
for (let i = 0; i < sort.length; i += 1) {
|
|
87
|
+
const key = sort[i]!;
|
|
88
|
+
const direction = key.direction === 'desc' ? -1 : 1;
|
|
89
|
+
const value = sortValueOf(record, key.field) ?? null;
|
|
90
|
+
const result = compareValues(value, anchor[i] ?? null) * direction;
|
|
91
|
+
if (result > 0) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (result < 0) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Equal on every key including id — this is the anchor row itself.
|
|
99
|
+
return false;
|
|
100
|
+
}
|
package/src/query/filter.ts
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filter evaluation over a plain record.
|
|
3
|
-
*
|
|
4
|
-
* The in-memory adapter runs this directly. The SQLite adapter translates the
|
|
5
|
-
* same validated tree to SQL. Both must agree, which is why the semantics
|
|
6
|
-
* live here rather than in either of them.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { FilterNode, FilterValue } from '../contract/query';
|
|
10
|
-
import type { StorableRecordData } from '../contract/record';
|
|
11
|
-
import { asFilterValue, compareValues, lookupField } from './compare';
|
|
12
|
-
|
|
13
|
-
function equals(a: FilterValue, b: FilterValue): boolean {
|
|
14
|
-
return compareValues(a, b) === 0 && (a === null) === (b === null);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function evaluateFilter(data: StorableRecordData, node: FilterNode): boolean {
|
|
18
|
-
switch (node.op) {
|
|
19
|
-
case 'and':
|
|
20
|
-
return node.nodes.every(child => evaluateFilter(data, child));
|
|
21
|
-
case 'or':
|
|
22
|
-
return node.nodes.some(child => evaluateFilter(data, child));
|
|
23
|
-
case 'not':
|
|
24
|
-
return !evaluateFilter(data, node.node);
|
|
25
|
-
default:
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const lookup = lookupField(data, node.field);
|
|
30
|
-
|
|
31
|
-
// `exists` is the only operator that answers a question about presence,
|
|
32
|
-
// which is what keeps "absent" distinguishable from "null" (FR-019).
|
|
33
|
-
if (node.op === 'exists') {
|
|
34
|
-
return lookup.present === node.value;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (!lookup.present) {
|
|
38
|
-
// An absent field satisfies nothing else — not even `ne` or `isNull`.
|
|
39
|
-
// SQL agrees: a comparison against a missing column yields NULL, which
|
|
40
|
-
// is not true.
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const value = asFilterValue(lookup.value);
|
|
45
|
-
if (value === undefined) {
|
|
46
|
-
// A nested object or array is not comparable. Matching it would be a
|
|
47
|
-
// guess, so it simply does not satisfy a comparison.
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
switch (node.op) {
|
|
52
|
-
case 'isNull':
|
|
53
|
-
return value === null;
|
|
54
|
-
case 'isNotNull':
|
|
55
|
-
return value !== null;
|
|
56
|
-
case 'eq':
|
|
57
|
-
return equals(value, node.value);
|
|
58
|
-
case 'ne':
|
|
59
|
-
return !equals(value, node.value);
|
|
60
|
-
case 'lt':
|
|
61
|
-
return value !== null && node.value !== null && compareValues(value, node.value) < 0;
|
|
62
|
-
case 'lte':
|
|
63
|
-
return value !== null && node.value !== null && compareValues(value, node.value) <= 0;
|
|
64
|
-
case 'gt':
|
|
65
|
-
return value !== null && node.value !== null && compareValues(value, node.value) > 0;
|
|
66
|
-
case 'gte':
|
|
67
|
-
return value !== null && node.value !== null && compareValues(value, node.value) >= 0;
|
|
68
|
-
case 'in':
|
|
69
|
-
return node.value.some(candidate => equals(value, candidate));
|
|
70
|
-
case 'nin':
|
|
71
|
-
return !node.value.some(candidate => equals(value, candidate));
|
|
72
|
-
case 'between': {
|
|
73
|
-
const [low, high] = node.value;
|
|
74
|
-
if (value === null || low === null || high === null) {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
return compareValues(value, low) >= 0 && compareValues(value, high) <= 0;
|
|
78
|
-
}
|
|
79
|
-
default:
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Filter evaluation over a plain record.
|
|
3
|
+
*
|
|
4
|
+
* The in-memory adapter runs this directly. The SQLite adapter translates the
|
|
5
|
+
* same validated tree to SQL. Both must agree, which is why the semantics
|
|
6
|
+
* live here rather than in either of them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FilterNode, FilterValue } from '../contract/query';
|
|
10
|
+
import type { StorableRecordData } from '../contract/record';
|
|
11
|
+
import { asFilterValue, compareValues, lookupField } from './compare';
|
|
12
|
+
|
|
13
|
+
function equals(a: FilterValue, b: FilterValue): boolean {
|
|
14
|
+
return compareValues(a, b) === 0 && (a === null) === (b === null);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function evaluateFilter(data: StorableRecordData, node: FilterNode): boolean {
|
|
18
|
+
switch (node.op) {
|
|
19
|
+
case 'and':
|
|
20
|
+
return node.nodes.every(child => evaluateFilter(data, child));
|
|
21
|
+
case 'or':
|
|
22
|
+
return node.nodes.some(child => evaluateFilter(data, child));
|
|
23
|
+
case 'not':
|
|
24
|
+
return !evaluateFilter(data, node.node);
|
|
25
|
+
default:
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lookup = lookupField(data, node.field);
|
|
30
|
+
|
|
31
|
+
// `exists` is the only operator that answers a question about presence,
|
|
32
|
+
// which is what keeps "absent" distinguishable from "null" (FR-019).
|
|
33
|
+
if (node.op === 'exists') {
|
|
34
|
+
return lookup.present === node.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!lookup.present) {
|
|
38
|
+
// An absent field satisfies nothing else — not even `ne` or `isNull`.
|
|
39
|
+
// SQL agrees: a comparison against a missing column yields NULL, which
|
|
40
|
+
// is not true.
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const value = asFilterValue(lookup.value);
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
// A nested object or array is not comparable. Matching it would be a
|
|
47
|
+
// guess, so it simply does not satisfy a comparison.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch (node.op) {
|
|
52
|
+
case 'isNull':
|
|
53
|
+
return value === null;
|
|
54
|
+
case 'isNotNull':
|
|
55
|
+
return value !== null;
|
|
56
|
+
case 'eq':
|
|
57
|
+
return equals(value, node.value);
|
|
58
|
+
case 'ne':
|
|
59
|
+
return !equals(value, node.value);
|
|
60
|
+
case 'lt':
|
|
61
|
+
return value !== null && node.value !== null && compareValues(value, node.value) < 0;
|
|
62
|
+
case 'lte':
|
|
63
|
+
return value !== null && node.value !== null && compareValues(value, node.value) <= 0;
|
|
64
|
+
case 'gt':
|
|
65
|
+
return value !== null && node.value !== null && compareValues(value, node.value) > 0;
|
|
66
|
+
case 'gte':
|
|
67
|
+
return value !== null && node.value !== null && compareValues(value, node.value) >= 0;
|
|
68
|
+
case 'in':
|
|
69
|
+
return node.value.some(candidate => equals(value, candidate));
|
|
70
|
+
case 'nin':
|
|
71
|
+
return !node.value.some(candidate => equals(value, candidate));
|
|
72
|
+
case 'between': {
|
|
73
|
+
const [low, high] = node.value;
|
|
74
|
+
if (value === null || low === null || high === null) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return compareValues(value, low) >= 0 && compareValues(value, high) <= 0;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/query/paginate.ts
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Page assembly over already-filtered, already-sorted records.
|
|
3
|
-
*
|
|
4
|
-
* Shared so the in-memory adapter and the SQLite adapter produce identical
|
|
5
|
-
* pages, including the `hasMore` signal and when a cursor is emitted.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { Budgets } from '../contract/config';
|
|
9
|
-
import type { PageRequest, ResultPage, SortKey } from '../contract/query';
|
|
10
|
-
import type { StorableRecordData, StoredRecord } from '../contract/record';
|
|
11
|
-
import { decodeCursor, encodeCursor, isAfterCursor } from './cursor';
|
|
12
|
-
|
|
13
|
-
export const DEFAULT_PAGE_SIZE = 50;
|
|
14
|
-
|
|
15
|
-
export function resolvePageSize(page: PageRequest | undefined): number {
|
|
16
|
-
return page?.size ?? DEFAULT_PAGE_SIZE;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Fetch one extra row to answer `hasMore` without a second count query. */
|
|
20
|
-
export function fetchLimitFor(size: number): number {
|
|
21
|
-
return Math.min(size, Budgets.MAX_PAGE_SIZE) + 1;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function applyCursor<T extends StoredRecord>(
|
|
25
|
-
records: T[],
|
|
26
|
-
sort: SortKey[],
|
|
27
|
-
cursor: string | undefined,
|
|
28
|
-
): T[] {
|
|
29
|
-
if (!cursor) {
|
|
30
|
-
return records;
|
|
31
|
-
}
|
|
32
|
-
const anchor = decodeCursor(cursor, sort);
|
|
33
|
-
return records.filter(record => isAfterCursor(record, sort, anchor));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function buildPage<T extends StorableRecordData>(
|
|
37
|
-
records: StoredRecord<T>[],
|
|
38
|
-
sort: SortKey[],
|
|
39
|
-
size: number,
|
|
40
|
-
): ResultPage<T> {
|
|
41
|
-
const hasMore = records.length > size;
|
|
42
|
-
const page = hasMore ? records.slice(0, size) : records;
|
|
43
|
-
const last = page[page.length - 1];
|
|
44
|
-
return {
|
|
45
|
-
records: page,
|
|
46
|
-
// No cursor on the final page: handing one back invites a consumer to
|
|
47
|
-
// keep paging past the end and treat an empty page as an error.
|
|
48
|
-
cursor: hasMore && last ? encodeCursor(last, sort) : undefined,
|
|
49
|
-
hasMore,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Page assembly over already-filtered, already-sorted records.
|
|
3
|
+
*
|
|
4
|
+
* Shared so the in-memory adapter and the SQLite adapter produce identical
|
|
5
|
+
* pages, including the `hasMore` signal and when a cursor is emitted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Budgets } from '../contract/config';
|
|
9
|
+
import type { PageRequest, ResultPage, SortKey } from '../contract/query';
|
|
10
|
+
import type { StorableRecordData, StoredRecord } from '../contract/record';
|
|
11
|
+
import { decodeCursor, encodeCursor, isAfterCursor } from './cursor';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PAGE_SIZE = 50;
|
|
14
|
+
|
|
15
|
+
export function resolvePageSize(page: PageRequest | undefined): number {
|
|
16
|
+
return page?.size ?? DEFAULT_PAGE_SIZE;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fetch one extra row to answer `hasMore` without a second count query. */
|
|
20
|
+
export function fetchLimitFor(size: number): number {
|
|
21
|
+
return Math.min(size, Budgets.MAX_PAGE_SIZE) + 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function applyCursor<T extends StoredRecord>(
|
|
25
|
+
records: T[],
|
|
26
|
+
sort: SortKey[],
|
|
27
|
+
cursor: string | undefined,
|
|
28
|
+
): T[] {
|
|
29
|
+
if (!cursor) {
|
|
30
|
+
return records;
|
|
31
|
+
}
|
|
32
|
+
const anchor = decodeCursor(cursor, sort);
|
|
33
|
+
return records.filter(record => isAfterCursor(record, sort, anchor));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildPage<T extends StorableRecordData>(
|
|
37
|
+
records: StoredRecord<T>[],
|
|
38
|
+
sort: SortKey[],
|
|
39
|
+
size: number,
|
|
40
|
+
): ResultPage<T> {
|
|
41
|
+
const hasMore = records.length > size;
|
|
42
|
+
const page = hasMore ? records.slice(0, size) : records;
|
|
43
|
+
const last = page[page.length - 1];
|
|
44
|
+
return {
|
|
45
|
+
records: page,
|
|
46
|
+
// No cursor on the final page: handing one back invites a consumer to
|
|
47
|
+
// keep paging past the end and treat an empty page as an error.
|
|
48
|
+
cursor: hasMore && last ? encodeCursor(last, sort) : undefined,
|
|
49
|
+
hasMore,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/query/sort.ts
CHANGED
|
@@ -1,56 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sort ordering.
|
|
3
|
-
*
|
|
4
|
-
* The record id is always appended as the final sort key (FR-031). Without a
|
|
5
|
-
* tiebreaker, records with equal sort values come back in whatever order the
|
|
6
|
-
* engine happens to produce, which makes keyset pagination lose or repeat
|
|
7
|
-
* rows — the exact failure SC-007 forbids.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { SortKey } from '../contract/query';
|
|
11
|
-
import type { StoredRecord } from '../contract/record';
|
|
12
|
-
import { asFilterValue, compareValues, lookupField } from './compare';
|
|
13
|
-
|
|
14
|
-
export const ID_SORT_KEY: SortKey = { field: 'id', direction: 'asc' };
|
|
15
|
-
|
|
16
|
-
/** Append `id ASC` unless the caller already sorted by it. */
|
|
17
|
-
export function withStableTiebreaker(sort: SortKey[] | undefined): SortKey[] {
|
|
18
|
-
const keys = sort ? [...sort] : [];
|
|
19
|
-
if (!keys.some(key => key.field === 'id')) {
|
|
20
|
-
keys.push(ID_SORT_KEY);
|
|
21
|
-
}
|
|
22
|
-
return keys;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Sort values come from the record id or from a declared field. */
|
|
26
|
-
export function sortValueOf(record: StoredRecord, field: string): ReturnType<typeof asFilterValue> {
|
|
27
|
-
if (field === 'id') {
|
|
28
|
-
return record.id;
|
|
29
|
-
}
|
|
30
|
-
const lookup = lookupField(record.data, field);
|
|
31
|
-
if (!lookup.present) {
|
|
32
|
-
// Absent sorts alongside null, matching how SQLite treats a NULL
|
|
33
|
-
// column, so the two adapters agree without special-casing.
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
return asFilterValue(lookup.value) ?? null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function compareRecords(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Sort ordering.
|
|
3
|
+
*
|
|
4
|
+
* The record id is always appended as the final sort key (FR-031). Without a
|
|
5
|
+
* tiebreaker, records with equal sort values come back in whatever order the
|
|
6
|
+
* engine happens to produce, which makes keyset pagination lose or repeat
|
|
7
|
+
* rows — the exact failure SC-007 forbids.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SortKey } from '../contract/query';
|
|
11
|
+
import type { StoredRecord } from '../contract/record';
|
|
12
|
+
import { asFilterValue, compareValues, lookupField } from './compare';
|
|
13
|
+
|
|
14
|
+
export const ID_SORT_KEY: SortKey = { field: 'id', direction: 'asc' };
|
|
15
|
+
|
|
16
|
+
/** Append `id ASC` unless the caller already sorted by it. */
|
|
17
|
+
export function withStableTiebreaker(sort: SortKey[] | undefined): SortKey[] {
|
|
18
|
+
const keys = sort ? [...sort] : [];
|
|
19
|
+
if (!keys.some(key => key.field === 'id')) {
|
|
20
|
+
keys.push(ID_SORT_KEY);
|
|
21
|
+
}
|
|
22
|
+
return keys;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Sort values come from the record id or from a declared field. */
|
|
26
|
+
export function sortValueOf(record: StoredRecord, field: string): ReturnType<typeof asFilterValue> {
|
|
27
|
+
if (field === 'id') {
|
|
28
|
+
return record.id;
|
|
29
|
+
}
|
|
30
|
+
const lookup = lookupField(record.data, field);
|
|
31
|
+
if (!lookup.present) {
|
|
32
|
+
// Absent sorts alongside null, matching how SQLite treats a NULL
|
|
33
|
+
// column, so the two adapters agree without special-casing.
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return asFilterValue(lookup.value) ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function compareRecords(a: StoredRecord, b: StoredRecord, sort: SortKey[]): number {
|
|
40
|
+
for (const key of sort) {
|
|
41
|
+
const direction = key.direction === 'desc' ? -1 : 1;
|
|
42
|
+
const result = compareValues(
|
|
43
|
+
sortValueOf(a, key.field) ?? null,
|
|
44
|
+
sortValueOf(b, key.field) ?? null,
|
|
45
|
+
);
|
|
46
|
+
if (result !== 0) {
|
|
47
|
+
return result * direction;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sortRecords<T extends StoredRecord>(records: T[], sort: SortKey[]): T[] {
|
|
54
|
+
return [...records].sort((a, b) => compareRecords(a, b, sort));
|
|
55
|
+
}
|