@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -102
  2. package/README.md +728 -728
  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 -252
  8. package/src/adapter/index.ts +64 -68
  9. package/src/adapter/lifecycle.ts +517 -520
  10. package/src/adapter/transaction.ts +71 -71
  11. package/src/artifacts/availability.ts +67 -67
  12. package/src/artifacts/cascade.ts +114 -114
  13. package/src/artifacts/paths.ts +29 -29
  14. package/src/artifacts/reconcile.ts +63 -63
  15. package/src/artifacts/store.ts +334 -345
  16. package/src/contract/adapter.ts +76 -78
  17. package/src/contract/attachment.ts +75 -78
  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 -42
  23. package/src/contract/query.ts +81 -81
  24. package/src/contract/record.ts +46 -51
  25. package/src/contract/schema.ts +36 -36
  26. package/src/contract/scope.ts +36 -36
  27. package/src/contract/transaction.ts +52 -54
  28. package/src/crypto/chunked-cipher.ts +153 -150
  29. package/src/crypto/derive.ts +64 -64
  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 -144
  44. package/src/platform/fs.ts +89 -89
  45. package/src/platform/location.ts +135 -135
  46. package/src/query/budgets.ts +54 -54
  47. package/src/query/compare.ts +91 -91
  48. package/src/query/cursor.ts +100 -100
  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 -242
  60. package/src/sqlite/catalog.ts +105 -108
  61. package/src/sqlite/connection.ts +227 -227
  62. package/src/sqlite/index-coverage.ts +54 -56
  63. package/src/sqlite/native.ts +83 -83
  64. package/src/sqlite/row-codec.ts +217 -221
  65. package/src/sqlite/sql-builder.ts +244 -244
  66. package/src/sqlite/transaction-queue.ts +87 -87
  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 -492
  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,135 +1,135 @@
1
- /**
2
- * The managed storage area, and the single guard every filesystem call routes
3
- * through.
4
- *
5
- * POL-011(b) flags media-and-files as a risk because this package deletes
6
- * files from disk on cascade. The mitigation is that there is exactly one
7
- * function that turns a caller's intent into a path, and it refuses anything
8
- * outside the root. One choke point is what makes SC-013a's filesystem
9
- * tracing pass reliably rather than usually.
10
- */
11
-
12
- import { DiagnosticCodes } from '../contract/errors';
13
- import { raise } from '../diagnostics/errors';
14
- import { fileSystem } from './fs';
15
-
16
- export const MANAGED_ROOT_NAME = 'chipmobilesdk-localdb';
17
- export const CATALOG_FILE = 'catalog.db';
18
- export const SCOPES_DIR = 'scopes';
19
- export const DATA_FILE = 'data.db';
20
- export const ARTIFACTS_DIR = 'artifacts';
21
- export const MARKER_FILE = '.localdb-marker';
22
-
23
- /** Normalize separators and collapse redundant segments. No `..` survives. */
24
- export function normalizePath(path: string): string {
25
- const unified = path.replace(/\\/g, '/');
26
- const segments: string[] = [];
27
- for (const segment of unified.split('/')) {
28
- if (segment === '' || segment === '.') {
29
- continue;
30
- }
31
- if (segment === '..') {
32
- segments.pop();
33
- continue;
34
- }
35
- segments.push(segment);
36
- }
37
- const prefix = unified.startsWith('/') ? '/' : '';
38
- return prefix + segments.join('/');
39
- }
40
-
41
- export type StorageLayout = {
42
- /** Absolute path to the managed root for one logical database. */
43
- root: string;
44
- catalogPath: string;
45
- scopePath(scopeKey: string): string;
46
- scopeDataPath(scopeKey: string): string;
47
- scopeArtifactsPath(scopeKey: string): string;
48
- artifactPath(scopeKey: string, attachmentId: string): string;
49
- markerPath: string;
50
- };
51
-
52
- const SCOPE_KEY = /^[0-9a-f]{64}$/;
53
- const ARTIFACT_ID = /^[A-Za-z0-9._-]{1,128}$/;
54
- // `.` and `..` are made entirely of permitted characters and are the two that
55
- // matter: `artifacts/..` resolves to the scope directory itself, still inside
56
- // the managed root, so the boundary guard would wave it through and a cascade
57
- // would unlink the whole scope. A leading dot is refused with them, since no
58
- // attachment id needs one and hidden files are the package's own.
59
- const DOT_PREFIXED = /^\./;
60
-
61
- /**
62
- * An attachment id must be a safe single path segment.
63
- *
64
- * Shared by both adapters rather than left to the one that touches a
65
- * filesystem. The in-memory adapter has no paths to escape, but a consumer
66
- * whose tests accept `../../etc/passwd` and whose device build refuses it has
67
- * a test suite that stops predicting the device — which is the one thing the
68
- * adapter exists to avoid (FR-096).
69
- */
70
- export function assertValidAttachmentId(attachmentId: string): void {
71
- if (!ARTIFACT_ID.test(attachmentId) || DOT_PREFIXED.test(attachmentId)) {
72
- raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
73
- message: 'attachment id is not a valid path segment',
74
- });
75
- }
76
- }
77
-
78
- export function createLayout(documentDirectory: string, databaseName: string): StorageLayout {
79
- const root = normalizePath(`${documentDirectory}/${MANAGED_ROOT_NAME}/${databaseName}`);
80
-
81
- const scopePath = (scopeKey: string): string => {
82
- // The scope key is our own SHA-256 output. Validating its shape here
83
- // means a caller cannot smuggle a traversal through it even if some
84
- // future path reaches this function with unvalidated input.
85
- if (!SCOPE_KEY.test(scopeKey)) {
86
- raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
87
- message: 'scope key is not a valid digest',
88
- });
89
- }
90
- return `${root}/${SCOPES_DIR}/${scopeKey}`;
91
- };
92
-
93
- return {
94
- root,
95
- catalogPath: `${root}/${CATALOG_FILE}`,
96
- markerPath: `${root}/${MARKER_FILE}`,
97
- scopePath,
98
- scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
99
- scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
100
- artifactPath: (scopeKey, attachmentId) => {
101
- assertValidAttachmentId(attachmentId);
102
- return `${scopePath(scopeKey)}/${ARTIFACTS_DIR}/${attachmentId}`;
103
- },
104
- };
105
- }
106
-
107
- /**
108
- * The layout for one logical database, rooted at the app's private documents
109
- * directory.
110
- *
111
- * Resolved lazily through the filesystem peer rather than captured at module
112
- * load, so importing this package does not require the peer to be installed —
113
- * a consumer running only the in-memory adapter never reaches this function.
114
- */
115
- export function layoutFor(databaseName: string): StorageLayout {
116
- return createLayout(fileSystem().DocumentDirectoryPath, databaseName);
117
- }
118
-
119
- /**
120
- * The guard. Every read, write, and delete passes through this, and anything
121
- * resolving outside the managed root is refused before it reaches the
122
- * filesystem (FR-073, FR-073f).
123
- */
124
- export function assertWithinManagedArea(layout: StorageLayout, candidate: string): string {
125
- const normalized = normalizePath(candidate);
126
- const root = normalizePath(layout.root);
127
- if (normalized !== root && !normalized.startsWith(`${root}/`)) {
128
- raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
129
- // The offending path is NOT reported: it may contain a scope
130
- // component or a user-chosen filename.
131
- message: 'path resolves outside the managed storage area',
132
- });
133
- }
134
- return normalized;
135
- }
1
+ /**
2
+ * The managed storage area, and the single guard every filesystem call routes
3
+ * through.
4
+ *
5
+ * POL-011(b) flags media-and-files as a risk because this package deletes
6
+ * files from disk on cascade. The mitigation is that there is exactly one
7
+ * function that turns a caller's intent into a path, and it refuses anything
8
+ * outside the root. One choke point is what makes SC-013a's filesystem
9
+ * tracing pass reliably rather than usually.
10
+ */
11
+
12
+ import { DiagnosticCodes } from '../contract/errors';
13
+ import { raise } from '../diagnostics/errors';
14
+ import { fileSystem } from './fs';
15
+
16
+ export const MANAGED_ROOT_NAME = 'chipmobilesdk-localdb';
17
+ export const CATALOG_FILE = 'catalog.db';
18
+ export const SCOPES_DIR = 'scopes';
19
+ export const DATA_FILE = 'data.db';
20
+ export const ARTIFACTS_DIR = 'artifacts';
21
+ export const MARKER_FILE = '.localdb-marker';
22
+
23
+ /** Normalize separators and collapse redundant segments. No `..` survives. */
24
+ export function normalizePath(path: string): string {
25
+ const unified = path.replace(/\\/g, '/');
26
+ const segments: string[] = [];
27
+ for (const segment of unified.split('/')) {
28
+ if (segment === '' || segment === '.') {
29
+ continue;
30
+ }
31
+ if (segment === '..') {
32
+ segments.pop();
33
+ continue;
34
+ }
35
+ segments.push(segment);
36
+ }
37
+ const prefix = unified.startsWith('/') ? '/' : '';
38
+ return prefix + segments.join('/');
39
+ }
40
+
41
+ export type StorageLayout = {
42
+ /** Absolute path to the managed root for one logical database. */
43
+ root: string;
44
+ catalogPath: string;
45
+ scopePath(scopeKey: string): string;
46
+ scopeDataPath(scopeKey: string): string;
47
+ scopeArtifactsPath(scopeKey: string): string;
48
+ artifactPath(scopeKey: string, attachmentId: string): string;
49
+ markerPath: string;
50
+ };
51
+
52
+ const SCOPE_KEY = /^[0-9a-f]{64}$/;
53
+ const ARTIFACT_ID = /^[A-Za-z0-9._-]{1,128}$/;
54
+ // `.` and `..` are made entirely of permitted characters and are the two that
55
+ // matter: `artifacts/..` resolves to the scope directory itself, still inside
56
+ // the managed root, so the boundary guard would wave it through and a cascade
57
+ // would unlink the whole scope. A leading dot is refused with them, since no
58
+ // attachment id needs one and hidden files are the package's own.
59
+ const DOT_PREFIXED = /^\./;
60
+
61
+ /**
62
+ * An attachment id must be a safe single path segment.
63
+ *
64
+ * Shared by both adapters rather than left to the one that touches a
65
+ * filesystem. The in-memory adapter has no paths to escape, but a consumer
66
+ * whose tests accept `../../etc/passwd` and whose device build refuses it has
67
+ * a test suite that stops predicting the device — which is the one thing the
68
+ * adapter exists to avoid (FR-096).
69
+ */
70
+ export function assertValidAttachmentId(attachmentId: string): void {
71
+ if (!ARTIFACT_ID.test(attachmentId) || DOT_PREFIXED.test(attachmentId)) {
72
+ raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
73
+ message: 'attachment id is not a valid path segment',
74
+ });
75
+ }
76
+ }
77
+
78
+ export function createLayout(documentDirectory: string, databaseName: string): StorageLayout {
79
+ const root = normalizePath(`${documentDirectory}/${MANAGED_ROOT_NAME}/${databaseName}`);
80
+
81
+ const scopePath = (scopeKey: string): string => {
82
+ // The scope key is our own SHA-256 output. Validating its shape here
83
+ // means a caller cannot smuggle a traversal through it even if some
84
+ // future path reaches this function with unvalidated input.
85
+ if (!SCOPE_KEY.test(scopeKey)) {
86
+ raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
87
+ message: 'scope key is not a valid digest',
88
+ });
89
+ }
90
+ return `${root}/${SCOPES_DIR}/${scopeKey}`;
91
+ };
92
+
93
+ return {
94
+ root,
95
+ catalogPath: `${root}/${CATALOG_FILE}`,
96
+ markerPath: `${root}/${MARKER_FILE}`,
97
+ scopePath,
98
+ scopeDataPath: scopeKey => `${scopePath(scopeKey)}/${DATA_FILE}`,
99
+ scopeArtifactsPath: scopeKey => `${scopePath(scopeKey)}/${ARTIFACTS_DIR}`,
100
+ artifactPath: (scopeKey, attachmentId) => {
101
+ assertValidAttachmentId(attachmentId);
102
+ return `${scopePath(scopeKey)}/${ARTIFACTS_DIR}/${attachmentId}`;
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The layout for one logical database, rooted at the app's private documents
109
+ * directory.
110
+ *
111
+ * Resolved lazily through the filesystem peer rather than captured at module
112
+ * load, so importing this package does not require the peer to be installed —
113
+ * a consumer running only the in-memory adapter never reaches this function.
114
+ */
115
+ export function layoutFor(databaseName: string): StorageLayout {
116
+ return createLayout(fileSystem().DocumentDirectoryPath, databaseName);
117
+ }
118
+
119
+ /**
120
+ * The guard. Every read, write, and delete passes through this, and anything
121
+ * resolving outside the managed root is refused before it reaches the
122
+ * filesystem (FR-073, FR-073f).
123
+ */
124
+ export function assertWithinManagedArea(layout: StorageLayout, candidate: string): string {
125
+ const normalized = normalizePath(candidate);
126
+ const root = normalizePath(layout.root);
127
+ if (normalized !== root && !normalized.startsWith(`${root}/`)) {
128
+ raise(DiagnosticCodes.ATTACHMENT_OUT_OF_BOUNDS, {
129
+ // The offending path is NOT reported: it may contain a scope
130
+ // component or a user-chosen filename.
131
+ message: 'path resolves outside the managed storage area',
132
+ });
133
+ }
134
+ return normalized;
135
+ }
@@ -1,54 +1,54 @@
1
- /**
2
- * Budget enforcement.
3
- *
4
- * Per-record size, page size, and batch size are HARD limits reported through
5
- * typed errors (FR-088a). The rest are soft: exceeding them must not crash,
6
- * hang, or drop data silently, and the degraded behavior is documented.
7
- */
8
-
9
- import { Budgets } from '../contract/config';
10
- import { DiagnosticCodes } from '../contract/errors';
11
- import type { StorableRecordData } from '../contract/record';
12
- import { raise } from '../diagnostics/errors';
13
- import { utf8Bytes } from '../platform/bytes';
14
-
15
- /** UTF-8 byte length of the serialized payload — what actually hits disk. */
16
- export function serializedSize(data: StorableRecordData): number {
17
- const json = JSON.stringify(data ?? {});
18
- return utf8Bytes(json).length;
19
- }
20
-
21
- export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
22
- const size = serializedSize(data);
23
- if (size > Budgets.MAX_RECORD_BYTES) {
24
- // Truncating would be silent data loss; failing opaquely would leave
25
- // the consumer guessing. The size and the limit are both safe to
26
- // report — neither can carry a field value.
27
- raise(DiagnosticCodes.RECORD_TOO_LARGE, {
28
- message: 'record exceeds the per-record size budget',
29
- detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
30
- });
31
- }
32
- }
33
-
34
- export function assertBatchWithinBudget(count: number): void {
35
- if (count > Budgets.MAX_BATCH_OPERATIONS) {
36
- raise(DiagnosticCodes.BATCH_TOO_LARGE, {
37
- message: 'batch exceeds the operation budget',
38
- detail: { count, limit: Budgets.MAX_BATCH_OPERATIONS },
39
- });
40
- }
41
- }
42
-
43
- export function assertArtifactWithinBudget(id: string, size: number): void {
44
- if (size > Budgets.MAX_ARTIFACT_BYTES) {
45
- raise(DiagnosticCodes.ATTACHMENT_WRITE_FAILED, {
46
- message: 'artifact exceeds the size budget',
47
- detail: {
48
- attachmentId: id,
49
- sizeBytes: size,
50
- limitBytes: Budgets.MAX_ARTIFACT_BYTES,
51
- },
52
- });
53
- }
54
- }
1
+ /**
2
+ * Budget enforcement.
3
+ *
4
+ * Per-record size, page size, and batch size are HARD limits reported through
5
+ * typed errors (FR-088a). The rest are soft: exceeding them must not crash,
6
+ * hang, or drop data silently, and the degraded behavior is documented.
7
+ */
8
+
9
+ import { Budgets } from '../contract/config';
10
+ import { DiagnosticCodes } from '../contract/errors';
11
+ import type { StorableRecordData } from '../contract/record';
12
+ import { raise } from '../diagnostics/errors';
13
+ import { utf8Bytes } from '../platform/bytes';
14
+
15
+ /** UTF-8 byte length of the serialized payload — what actually hits disk. */
16
+ export function serializedSize(data: StorableRecordData): number {
17
+ const json = JSON.stringify(data ?? {});
18
+ return utf8Bytes(json).length;
19
+ }
20
+
21
+ export function assertRecordWithinBudget(collection: string, data: StorableRecordData): void {
22
+ const size = serializedSize(data);
23
+ if (size > Budgets.MAX_RECORD_BYTES) {
24
+ // Truncating would be silent data loss; failing opaquely would leave
25
+ // the consumer guessing. The size and the limit are both safe to
26
+ // report — neither can carry a field value.
27
+ raise(DiagnosticCodes.RECORD_TOO_LARGE, {
28
+ message: 'record exceeds the per-record size budget',
29
+ detail: { collection, sizeBytes: size, limitBytes: Budgets.MAX_RECORD_BYTES },
30
+ });
31
+ }
32
+ }
33
+
34
+ export function assertBatchWithinBudget(count: number): void {
35
+ if (count > Budgets.MAX_BATCH_OPERATIONS) {
36
+ raise(DiagnosticCodes.BATCH_TOO_LARGE, {
37
+ message: 'batch exceeds the operation budget',
38
+ detail: { count, limit: Budgets.MAX_BATCH_OPERATIONS },
39
+ });
40
+ }
41
+ }
42
+
43
+ export function assertArtifactWithinBudget(id: string, size: number): void {
44
+ if (size > Budgets.MAX_ARTIFACT_BYTES) {
45
+ raise(DiagnosticCodes.ATTACHMENT_WRITE_FAILED, {
46
+ message: 'artifact exceeds the size budget',
47
+ detail: {
48
+ attachmentId: id,
49
+ sizeBytes: size,
50
+ limitBytes: Budgets.MAX_ARTIFACT_BYTES,
51
+ },
52
+ });
53
+ }
54
+ }
@@ -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
+ }