@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -52
  2. package/README.md +663 -661
  3. package/package.json +59 -62
  4. package/skills/sdk-rn-local-db/SKILL.md +0 -1
  5. package/src/adapter/budgets.ts +67 -67
  6. package/src/adapter/collection.ts +303 -298
  7. package/src/adapter/deletion.ts +252 -238
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -444
  10. package/src/adapter/transaction.ts +71 -65
  11. package/src/artifacts/availability.ts +67 -69
  12. package/src/artifacts/cascade.ts +114 -78
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -323
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -72
  18. package/src/contract/backup.ts +28 -28
  19. package/src/contract/config.ts +54 -54
  20. package/src/contract/encryption.ts +34 -34
  21. package/src/contract/errors.ts +76 -76
  22. package/src/contract/migration.ts +42 -40
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -42
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -46
  28. package/src/crypto/chunked-cipher.ts +153 -144
  29. package/src/crypto/derive.ts +64 -63
  30. package/src/crypto/key-provider.ts +78 -78
  31. package/src/crypto/provider.ts +81 -81
  32. package/src/diagnostics/dev-log.ts +112 -108
  33. package/src/diagnostics/errors.ts +62 -66
  34. package/src/diagnostics/faults.ts +58 -64
  35. package/src/diagnostics/map-native.ts +108 -106
  36. package/src/diagnostics/redact.ts +137 -141
  37. package/src/index.ts +80 -110
  38. package/src/migration/executor.ts +132 -136
  39. package/src/migration/ledger.ts +95 -97
  40. package/src/migration/validate-chain.ts +65 -65
  41. package/src/platform/backup-android-verify.ts +88 -88
  42. package/src/platform/backup-ios.ts +48 -48
  43. package/src/platform/bytes.ts +144 -0
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -57
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -99
  49. package/src/query/filter.ts +82 -82
  50. package/src/query/paginate.ts +51 -51
  51. package/src/query/sort.ts +55 -56
  52. package/src/query/validate.ts +171 -171
  53. package/src/schema/declare.ts +42 -45
  54. package/src/schema/validate-config.ts +158 -158
  55. package/src/scope/hash.ts +131 -134
  56. package/src/scope/normalize.ts +83 -90
  57. package/src/scope/validate.ts +83 -83
  58. package/src/sqlite/adapter.ts +26 -26
  59. package/src/sqlite/batch.ts +242 -232
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -205
  62. package/src/sqlite/index-coverage.ts +54 -53
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -201
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -0
  67. package/src/sqlite/transactions.ts +33 -33
  68. package/src/sqlite/values.ts +24 -24
  69. package/src/testing/conformance/attachments.ts +138 -136
  70. package/src/testing/conformance/encryption.ts +150 -150
  71. package/src/testing/conformance/index.ts +161 -161
  72. package/src/testing/conformance/lifecycle.ts +99 -99
  73. package/src/testing/conformance/migrations.ts +177 -177
  74. package/src/testing/conformance/queries.ts +204 -167
  75. package/src/testing/conformance/records.ts +169 -169
  76. package/src/testing/conformance/scopes.ts +86 -88
  77. package/src/testing/conformance/transactions.ts +149 -149
  78. package/src/testing/faults.ts +21 -21
  79. package/src/testing/index.ts +17 -21
  80. package/src/testing/memory-adapter/collection.ts +214 -219
  81. package/src/testing/memory-adapter/index.ts +510 -388
  82. package/src/testing/memory-adapter/migrations.ts +117 -119
  83. package/src/testing/memory-adapter/reset.ts +50 -50
  84. package/src/testing/memory-adapter/store.ts +128 -134
@@ -1,91 +1,91 @@
1
- /**
2
- * Comparison and field access semantics.
3
- *
4
- * Defined once here, used by the in-memory adapter directly and by the SQL
5
- * builder for validation and cursor construction. Two implementations of
6
- * these rules would drift, and the drift would show up as a conformance
7
- * divergence that hides a real bug (research.md R8).
8
- */
9
-
10
- import type { FilterValue } from '../contract/query';
11
- import type { StorableRecordData, StorableValue } from '../contract/record';
12
-
13
- /**
14
- * A field is one of three things, and the difference is the whole point of
15
- * FR-019: present with a value, present and null, or absent.
16
- */
17
- export type FieldLookup =
18
- | { present: true; value: Exclude<StorableValue, null> }
19
- | { present: true; value: null }
20
- | { present: false };
21
-
22
- export function lookupField(data: StorableRecordData, field: string): FieldLookup {
23
- if (!Object.prototype.hasOwnProperty.call(data, field)) {
24
- return { present: false };
25
- }
26
- const value = data[field];
27
- if (value === null) {
28
- return { present: true, value: null };
29
- }
30
- return { present: true, value: value as Exclude<StorableValue, null> };
31
- }
32
-
33
- /**
34
- * Ordering for values of the same declared type.
35
- *
36
- * Null sorts before every non-null value, matching SQLite's default so the
37
- * two adapters agree without the SQL builder emitting NULLS FIRST/LAST
38
- * clauses that older SQLite versions do not support.
39
- */
40
- export function compareValues(a: FilterValue, b: FilterValue): number {
41
- if (a === null && b === null) {
42
- return 0;
43
- }
44
- if (a === null) {
45
- return -1;
46
- }
47
- if (b === null) {
48
- return 1;
49
- }
50
- if (typeof a === 'boolean' || typeof b === 'boolean') {
51
- return Number(a) - Number(b);
52
- }
53
- if (typeof a === 'number' && typeof b === 'number') {
54
- if (Number.isNaN(a) || Number.isNaN(b)) {
55
- return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? -1 : 1;
56
- }
57
- return a < b ? -1 : a > b ? 1 : 0;
58
- }
59
- const sa = String(a);
60
- const sb = String(b);
61
- // Byte-order rather than locale collation: a locale-aware comparison would
62
- // make the stored order depend on device settings, so the same data would
63
- // page differently on two phones.
64
- return sa < sb ? -1 : sa > sb ? 1 : 0;
65
- }
66
-
67
- /** Only primitives can be compared; a nested object is never a filter target. */
68
- export function asFilterValue(value: StorableValue): FilterValue | undefined {
69
- if (
70
- value === null ||
71
- typeof value === 'string' ||
72
- typeof value === 'number' ||
73
- typeof value === 'boolean'
74
- ) {
75
- return value;
76
- }
77
- return undefined;
78
- }
79
-
80
- /**
81
- * Byte order, not locale collation.
82
- *
83
- * A locale-aware sort would make the reported order depend on device
84
- * settings, so two phones holding identical data would report it differently.
85
- */
86
- export function byteOrder(a: string, b: string): number {
87
- if (a < b) {
88
- return -1;
89
- }
90
- return a > b ? 1 : 0;
91
- }
1
+ /**
2
+ * Comparison and field access semantics.
3
+ *
4
+ * Defined once here, used by the in-memory adapter directly and by the SQL
5
+ * builder for validation and cursor construction. Two implementations of
6
+ * these rules would drift, and the drift would show up as a conformance
7
+ * divergence that hides a real bug (research.md R8).
8
+ */
9
+
10
+ import type { FilterValue } from '../contract/query';
11
+ import type { StorableRecordData, StorableValue } from '../contract/record';
12
+
13
+ /**
14
+ * A field is one of three things, and the difference is the whole point of
15
+ * FR-019: present with a value, present and null, or absent.
16
+ */
17
+ export type FieldLookup =
18
+ | { present: true; value: Exclude<StorableValue, null> }
19
+ | { present: true; value: null }
20
+ | { present: false };
21
+
22
+ export function lookupField(data: StorableRecordData, field: string): FieldLookup {
23
+ if (!Object.prototype.hasOwnProperty.call(data, field)) {
24
+ return { present: false };
25
+ }
26
+ const value = data[field];
27
+ if (value === null) {
28
+ return { present: true, value: null };
29
+ }
30
+ return { present: true, value: value as Exclude<StorableValue, null> };
31
+ }
32
+
33
+ /**
34
+ * Ordering for values of the same declared type.
35
+ *
36
+ * Null sorts before every non-null value, matching SQLite's default so the
37
+ * two adapters agree without the SQL builder emitting NULLS FIRST/LAST
38
+ * clauses that older SQLite versions do not support.
39
+ */
40
+ export function compareValues(a: FilterValue, b: FilterValue): number {
41
+ if (a === null && b === null) {
42
+ return 0;
43
+ }
44
+ if (a === null) {
45
+ return -1;
46
+ }
47
+ if (b === null) {
48
+ return 1;
49
+ }
50
+ if (typeof a === 'boolean' || typeof b === 'boolean') {
51
+ return Number(a) - Number(b);
52
+ }
53
+ if (typeof a === 'number' && typeof b === 'number') {
54
+ if (Number.isNaN(a) || Number.isNaN(b)) {
55
+ return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? -1 : 1;
56
+ }
57
+ return a < b ? -1 : a > b ? 1 : 0;
58
+ }
59
+ const sa = String(a);
60
+ const sb = String(b);
61
+ // Byte-order rather than locale collation: a locale-aware comparison would
62
+ // make the stored order depend on device settings, so the same data would
63
+ // page differently on two phones.
64
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
65
+ }
66
+
67
+ /** Only primitives can be compared; a nested object is never a filter target. */
68
+ export function asFilterValue(value: StorableValue): FilterValue | undefined {
69
+ if (
70
+ value === null ||
71
+ typeof value === 'string' ||
72
+ typeof value === 'number' ||
73
+ typeof value === 'boolean'
74
+ ) {
75
+ return value;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Byte order, not locale collation.
82
+ *
83
+ * A locale-aware sort would make the reported order depend on device
84
+ * settings, so two phones holding identical data would report it differently.
85
+ */
86
+ export function byteOrder(a: string, b: string): number {
87
+ if (a < b) {
88
+ return -1;
89
+ }
90
+ return a > b ? 1 : 0;
91
+ }
@@ -1,99 +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
-
18
- export type CursorPayload = {
19
- /** Sort key fields and directions, so a cursor cannot be reused across shapes. */
20
- k: string;
21
- /** The last row's values, in sort-key order. */
22
- v: FilterValue[];
23
- };
24
-
25
- function shapeOf(sort: SortKey[]): string {
26
- return sort.map(key => `${key.field}:${key.direction === 'desc' ? 'd' : 'a'}`).join(',');
27
- }
28
-
29
- function toBase64(value: string): string {
30
- if (typeof globalThis.btoa === 'function') {
31
- return globalThis.btoa(unescape(encodeURIComponent(value)));
32
- }
33
- return Buffer.from(value, 'utf8').toString('base64');
34
- }
35
-
36
- function fromBase64(value: string): string {
37
- if (typeof globalThis.atob === 'function') {
38
- return decodeURIComponent(escape(globalThis.atob(value)));
39
- }
40
- return Buffer.from(value, 'base64').toString('utf8');
41
- }
42
-
43
- export function encodeCursor(record: StoredRecord, sort: SortKey[]): string {
44
- const payload: CursorPayload = {
45
- k: shapeOf(sort),
46
- v: sort.map(key => sortValueOf(record, key.field) ?? null),
47
- };
48
- return toBase64(JSON.stringify(payload));
49
- }
50
-
51
- export function decodeCursor(cursor: string, sort: SortKey[]): FilterValue[] {
52
- let payload: CursorPayload;
53
- try {
54
- payload = JSON.parse(fromBase64(cursor)) as CursorPayload;
55
- } catch {
56
- raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor is not decodable' });
57
- }
58
- if (!payload || typeof payload.k !== 'string' || !Array.isArray(payload.v)) {
59
- raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor payload is malformed' });
60
- }
61
- // A cursor from a differently sorted query would produce a silently wrong
62
- // page rather than an error, so the shape is part of the cursor.
63
- if (payload.k !== shapeOf(sort)) {
64
- raise(DiagnosticCodes.CURSOR_INVALID, {
65
- message: 'cursor does not match this query shape',
66
- });
67
- }
68
- if (payload.v.length !== sort.length) {
69
- raise(DiagnosticCodes.CURSOR_INVALID, { message: 'cursor arity does not match sort' });
70
- }
71
- return payload.v;
72
- }
73
-
74
- /**
75
- * Is this record strictly after the cursor position, under the sort order?
76
- *
77
- * Lexicographic across the sort keys: equal on the first key means the second
78
- * decides, and so on down to the id tiebreaker that guarantees a total order.
79
- */
80
- export function isAfterCursor(
81
- record: StoredRecord,
82
- sort: SortKey[],
83
- anchor: FilterValue[],
84
- ): boolean {
85
- for (let i = 0; i < sort.length; i += 1) {
86
- const key = sort[i]!;
87
- const direction = key.direction === 'desc' ? -1 : 1;
88
- const value = sortValueOf(record, key.field) ?? null;
89
- const result = compareValues(value, anchor[i] ?? null) * direction;
90
- if (result > 0) {
91
- return true;
92
- }
93
- if (result < 0) {
94
- return false;
95
- }
96
- }
97
- // Equal on every key including id — this is the anchor row itself.
98
- return false;
99
- }
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,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
+ }
@@ -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
+ }