@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/platform/location.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/query/budgets.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/query/compare.ts
CHANGED
|
@@ -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
|
+
}
|