@chipmobilesdk/rn-local-db 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -52
- package/README.md +663 -661
- package/package.json +59 -62
- package/skills/sdk-rn-local-db/SKILL.md +0 -1
- package/src/adapter/budgets.ts +67 -67
- package/src/adapter/collection.ts +303 -298
- package/src/adapter/deletion.ts +252 -238
- package/src/adapter/index.ts +64 -68
- package/src/adapter/lifecycle.ts +517 -444
- package/src/adapter/transaction.ts +71 -65
- package/src/artifacts/availability.ts +67 -69
- package/src/artifacts/cascade.ts +114 -78
- package/src/artifacts/paths.ts +29 -29
- package/src/artifacts/reconcile.ts +63 -63
- package/src/artifacts/store.ts +334 -323
- package/src/contract/adapter.ts +76 -78
- package/src/contract/attachment.ts +75 -72
- package/src/contract/backup.ts +28 -28
- package/src/contract/config.ts +54 -54
- package/src/contract/encryption.ts +34 -34
- package/src/contract/errors.ts +76 -76
- package/src/contract/migration.ts +42 -40
- package/src/contract/query.ts +81 -81
- package/src/contract/record.ts +46 -42
- package/src/contract/schema.ts +36 -36
- package/src/contract/scope.ts +36 -36
- package/src/contract/transaction.ts +52 -46
- package/src/crypto/chunked-cipher.ts +153 -144
- package/src/crypto/derive.ts +64 -63
- package/src/crypto/key-provider.ts +78 -78
- package/src/crypto/provider.ts +81 -81
- package/src/diagnostics/dev-log.ts +112 -108
- package/src/diagnostics/errors.ts +62 -66
- package/src/diagnostics/faults.ts +58 -64
- package/src/diagnostics/map-native.ts +108 -106
- package/src/diagnostics/redact.ts +137 -141
- package/src/index.ts +80 -110
- package/src/migration/executor.ts +132 -136
- package/src/migration/ledger.ts +95 -97
- package/src/migration/validate-chain.ts +65 -65
- package/src/platform/backup-android-verify.ts +88 -88
- package/src/platform/backup-ios.ts +48 -48
- package/src/platform/bytes.ts +144 -0
- package/src/platform/fs.ts +89 -89
- package/src/platform/location.ts +135 -135
- package/src/query/budgets.ts +54 -57
- package/src/query/compare.ts +91 -91
- package/src/query/cursor.ts +100 -99
- package/src/query/filter.ts +82 -82
- package/src/query/paginate.ts +51 -51
- package/src/query/sort.ts +55 -56
- package/src/query/validate.ts +171 -171
- package/src/schema/declare.ts +42 -45
- package/src/schema/validate-config.ts +158 -158
- package/src/scope/hash.ts +131 -134
- package/src/scope/normalize.ts +83 -90
- package/src/scope/validate.ts +83 -83
- package/src/sqlite/adapter.ts +26 -26
- package/src/sqlite/batch.ts +242 -232
- package/src/sqlite/catalog.ts +105 -108
- package/src/sqlite/connection.ts +227 -205
- package/src/sqlite/index-coverage.ts +54 -53
- package/src/sqlite/native.ts +83 -83
- package/src/sqlite/row-codec.ts +217 -201
- package/src/sqlite/sql-builder.ts +244 -244
- package/src/sqlite/transaction-queue.ts +87 -0
- package/src/sqlite/transactions.ts +33 -33
- package/src/sqlite/values.ts +24 -24
- package/src/testing/conformance/attachments.ts +138 -136
- package/src/testing/conformance/encryption.ts +150 -150
- package/src/testing/conformance/index.ts +161 -161
- package/src/testing/conformance/lifecycle.ts +99 -99
- package/src/testing/conformance/migrations.ts +177 -177
- package/src/testing/conformance/queries.ts +204 -167
- package/src/testing/conformance/records.ts +169 -169
- package/src/testing/conformance/scopes.ts +86 -88
- package/src/testing/conformance/transactions.ts +149 -149
- package/src/testing/faults.ts +21 -21
- package/src/testing/index.ts +17 -21
- package/src/testing/memory-adapter/collection.ts +214 -219
- package/src/testing/memory-adapter/index.ts +510 -388
- package/src/testing/memory-adapter/migrations.ts +117 -119
- package/src/testing/memory-adapter/reset.ts +50 -50
- package/src/testing/memory-adapter/store.ts +128 -134
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
|
+
}
|
package/src/query/validate.ts
CHANGED
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Query validation, run before any SQL is built.
|
|
3
|
-
*
|
|
4
|
-
* Two things fall out of validating first: FR-034 is satisfied, and no
|
|
5
|
-
* consumer-supplied string ever reaches SQL construction — field names are
|
|
6
|
-
* resolved through the schema to a known column, and values bind as
|
|
7
|
-
* parameters. Injection stops being a thing to remember.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Budgets } from '../contract/config';
|
|
11
|
-
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
-
import type { FilterNode, FilterValue, Query, SortKey } from '../contract/query';
|
|
13
|
-
import type { CollectionSchema, FieldType } from '../contract/schema';
|
|
14
|
-
import { raise } from '../diagnostics/errors';
|
|
15
|
-
import { sanitizeIdentifier } from '../diagnostics/redact';
|
|
16
|
-
import { fieldOf } from '../schema/declare';
|
|
17
|
-
|
|
18
|
-
function queryInvalid(message: string, collection: string): never {
|
|
19
|
-
// The field name is named; its *value* never is (FR-079).
|
|
20
|
-
raise(DiagnosticCodes.QUERY_INVALID, { message, detail: { collection } });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const ORDERED_OPERATORS = new Set(['lt', 'lte', 'gt', 'gte', 'between']);
|
|
24
|
-
|
|
25
|
-
function typeMatches(type: FieldType, value: FilterValue): boolean {
|
|
26
|
-
if (value === null) {
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
switch (type) {
|
|
30
|
-
case 'string':
|
|
31
|
-
return typeof value === 'string';
|
|
32
|
-
case 'number':
|
|
33
|
-
case 'timestamp':
|
|
34
|
-
return typeof value === 'number';
|
|
35
|
-
case 'boolean':
|
|
36
|
-
return typeof value === 'boolean';
|
|
37
|
-
default:
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function validateField(
|
|
43
|
-
collection
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
return declared;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function validateFilter(collection: CollectionSchema, node: FilterNode): void {
|
|
56
|
-
switch (node.op) {
|
|
57
|
-
case 'and':
|
|
58
|
-
case 'or': {
|
|
59
|
-
if (!Array.isArray(node.nodes) || node.nodes.length === 0) {
|
|
60
|
-
queryInvalid(`${node.op} requires at least one child`, collection.name);
|
|
61
|
-
}
|
|
62
|
-
for (const child of node.nodes) {
|
|
63
|
-
validateFilter(collection, child);
|
|
64
|
-
}
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
case 'not': {
|
|
68
|
-
if (!node.node) {
|
|
69
|
-
queryInvalid('not requires a child', collection.name);
|
|
70
|
-
}
|
|
71
|
-
validateFilter(collection, node.node);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
case 'isNull':
|
|
75
|
-
case 'isNotNull': {
|
|
76
|
-
validateField(collection, node.field);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
case 'exists': {
|
|
80
|
-
validateField(collection, node.field);
|
|
81
|
-
if (typeof node.value !== 'boolean') {
|
|
82
|
-
queryInvalid('exists requires a boolean', collection.name);
|
|
83
|
-
}
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
case 'in':
|
|
87
|
-
case 'nin': {
|
|
88
|
-
const declared = validateField(collection, node.field)!;
|
|
89
|
-
if (!Array.isArray(node.value) || node.value.length === 0) {
|
|
90
|
-
queryInvalid(`${node.op} requires a non-empty array`, collection.name);
|
|
91
|
-
}
|
|
92
|
-
for (const value of node.value) {
|
|
93
|
-
if (!typeMatches(declared.type, value)) {
|
|
94
|
-
queryInvalid(`${node.op} value does not match field type`, collection.name);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
case 'between': {
|
|
100
|
-
const declared = validateField(collection, node.field)!;
|
|
101
|
-
if (!Array.isArray(node.value) || node.value.length !== 2) {
|
|
102
|
-
queryInvalid('between requires exactly two bounds', collection.name);
|
|
103
|
-
}
|
|
104
|
-
for (const value of node.value) {
|
|
105
|
-
if (!typeMatches(declared.type, value)) {
|
|
106
|
-
queryInvalid('between bound does not match field type', collection.name);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
case 'eq':
|
|
112
|
-
case 'ne':
|
|
113
|
-
case 'lt':
|
|
114
|
-
case 'lte':
|
|
115
|
-
case 'gt':
|
|
116
|
-
case 'gte': {
|
|
117
|
-
const declared = validateField(collection, node.field)!;
|
|
118
|
-
if (declared.type === 'boolean' && ORDERED_OPERATORS.has(node.op)) {
|
|
119
|
-
queryInvalid('ordered comparison is not defined for a boolean', collection.name);
|
|
120
|
-
}
|
|
121
|
-
if (!typeMatches(declared.type, node.value)) {
|
|
122
|
-
queryInvalid('filter value does not match field type', collection.name);
|
|
123
|
-
}
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
default: {
|
|
127
|
-
queryInvalid(
|
|
128
|
-
`unsupported operator ${sanitizeIdentifier(String((node as { op: string }).op))}`,
|
|
129
|
-
collection.name,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export function validateSort(collection: CollectionSchema, sort: SortKey[]): void {
|
|
136
|
-
const seen = new Set<string>();
|
|
137
|
-
for (const key of sort) {
|
|
138
|
-
validateField(collection, key.field);
|
|
139
|
-
if (seen.has(key.field)) {
|
|
140
|
-
queryInvalid(`duplicate sort field ${sanitizeIdentifier(key.field)}`, collection.name);
|
|
141
|
-
}
|
|
142
|
-
seen.add(key.field);
|
|
143
|
-
if (key.direction && key.direction !== 'asc' && key.direction !== 'desc') {
|
|
144
|
-
queryInvalid('sort direction must be asc or desc', collection.name);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function validatePageSize(size: number): void {
|
|
150
|
-
if (!Number.isInteger(size) || size < 1 || size > Budgets.MAX_PAGE_SIZE) {
|
|
151
|
-
raise(DiagnosticCodes.PAGE_SIZE_INVALID, {
|
|
152
|
-
message: 'page size out of range',
|
|
153
|
-
detail: { limit: Budgets.MAX_PAGE_SIZE },
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function validateQuery(collection: CollectionSchema, query: Query | undefined): void {
|
|
159
|
-
if (!query) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (query.filter) {
|
|
163
|
-
validateFilter(collection, query.filter);
|
|
164
|
-
}
|
|
165
|
-
if (query.sort) {
|
|
166
|
-
validateSort(collection, query.sort);
|
|
167
|
-
}
|
|
168
|
-
if (query.page) {
|
|
169
|
-
validatePageSize(query.page.size);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Query validation, run before any SQL is built.
|
|
3
|
+
*
|
|
4
|
+
* Two things fall out of validating first: FR-034 is satisfied, and no
|
|
5
|
+
* consumer-supplied string ever reaches SQL construction — field names are
|
|
6
|
+
* resolved through the schema to a known column, and values bind as
|
|
7
|
+
* parameters. Injection stops being a thing to remember.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Budgets } from '../contract/config';
|
|
11
|
+
import { DiagnosticCodes } from '../contract/errors';
|
|
12
|
+
import type { FilterNode, FilterValue, Query, SortKey } from '../contract/query';
|
|
13
|
+
import type { CollectionSchema, FieldType } from '../contract/schema';
|
|
14
|
+
import { raise } from '../diagnostics/errors';
|
|
15
|
+
import { sanitizeIdentifier } from '../diagnostics/redact';
|
|
16
|
+
import { fieldOf } from '../schema/declare';
|
|
17
|
+
|
|
18
|
+
function queryInvalid(message: string, collection: string): never {
|
|
19
|
+
// The field name is named; its *value* never is (FR-079).
|
|
20
|
+
raise(DiagnosticCodes.QUERY_INVALID, { message, detail: { collection } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ORDERED_OPERATORS = new Set(['lt', 'lte', 'gt', 'gte', 'between']);
|
|
24
|
+
|
|
25
|
+
function typeMatches(type: FieldType, value: FilterValue): boolean {
|
|
26
|
+
if (value === null) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
switch (type) {
|
|
30
|
+
case 'string':
|
|
31
|
+
return typeof value === 'string';
|
|
32
|
+
case 'number':
|
|
33
|
+
case 'timestamp':
|
|
34
|
+
return typeof value === 'number';
|
|
35
|
+
case 'boolean':
|
|
36
|
+
return typeof value === 'boolean';
|
|
37
|
+
default:
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateField(collection: CollectionSchema, field: string): ReturnType<typeof fieldOf> {
|
|
43
|
+
const declared = fieldOf(collection, field);
|
|
44
|
+
if (!declared) {
|
|
45
|
+
// Returning everything or nothing here would be worse than failing:
|
|
46
|
+
// a typo would silently change what the user sees.
|
|
47
|
+
queryInvalid(
|
|
48
|
+
`filter references undeclared field ${sanitizeIdentifier(field)}`,
|
|
49
|
+
collection.name,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return declared;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateFilter(collection: CollectionSchema, node: FilterNode): void {
|
|
56
|
+
switch (node.op) {
|
|
57
|
+
case 'and':
|
|
58
|
+
case 'or': {
|
|
59
|
+
if (!Array.isArray(node.nodes) || node.nodes.length === 0) {
|
|
60
|
+
queryInvalid(`${node.op} requires at least one child`, collection.name);
|
|
61
|
+
}
|
|
62
|
+
for (const child of node.nodes) {
|
|
63
|
+
validateFilter(collection, child);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
case 'not': {
|
|
68
|
+
if (!node.node) {
|
|
69
|
+
queryInvalid('not requires a child', collection.name);
|
|
70
|
+
}
|
|
71
|
+
validateFilter(collection, node.node);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
case 'isNull':
|
|
75
|
+
case 'isNotNull': {
|
|
76
|
+
validateField(collection, node.field);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
case 'exists': {
|
|
80
|
+
validateField(collection, node.field);
|
|
81
|
+
if (typeof node.value !== 'boolean') {
|
|
82
|
+
queryInvalid('exists requires a boolean', collection.name);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
case 'in':
|
|
87
|
+
case 'nin': {
|
|
88
|
+
const declared = validateField(collection, node.field)!;
|
|
89
|
+
if (!Array.isArray(node.value) || node.value.length === 0) {
|
|
90
|
+
queryInvalid(`${node.op} requires a non-empty array`, collection.name);
|
|
91
|
+
}
|
|
92
|
+
for (const value of node.value) {
|
|
93
|
+
if (!typeMatches(declared.type, value)) {
|
|
94
|
+
queryInvalid(`${node.op} value does not match field type`, collection.name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
case 'between': {
|
|
100
|
+
const declared = validateField(collection, node.field)!;
|
|
101
|
+
if (!Array.isArray(node.value) || node.value.length !== 2) {
|
|
102
|
+
queryInvalid('between requires exactly two bounds', collection.name);
|
|
103
|
+
}
|
|
104
|
+
for (const value of node.value) {
|
|
105
|
+
if (!typeMatches(declared.type, value)) {
|
|
106
|
+
queryInvalid('between bound does not match field type', collection.name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case 'eq':
|
|
112
|
+
case 'ne':
|
|
113
|
+
case 'lt':
|
|
114
|
+
case 'lte':
|
|
115
|
+
case 'gt':
|
|
116
|
+
case 'gte': {
|
|
117
|
+
const declared = validateField(collection, node.field)!;
|
|
118
|
+
if (declared.type === 'boolean' && ORDERED_OPERATORS.has(node.op)) {
|
|
119
|
+
queryInvalid('ordered comparison is not defined for a boolean', collection.name);
|
|
120
|
+
}
|
|
121
|
+
if (!typeMatches(declared.type, node.value)) {
|
|
122
|
+
queryInvalid('filter value does not match field type', collection.name);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
default: {
|
|
127
|
+
queryInvalid(
|
|
128
|
+
`unsupported operator ${sanitizeIdentifier(String((node as { op: string }).op))}`,
|
|
129
|
+
collection.name,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function validateSort(collection: CollectionSchema, sort: SortKey[]): void {
|
|
136
|
+
const seen = new Set<string>();
|
|
137
|
+
for (const key of sort) {
|
|
138
|
+
validateField(collection, key.field);
|
|
139
|
+
if (seen.has(key.field)) {
|
|
140
|
+
queryInvalid(`duplicate sort field ${sanitizeIdentifier(key.field)}`, collection.name);
|
|
141
|
+
}
|
|
142
|
+
seen.add(key.field);
|
|
143
|
+
if (key.direction && key.direction !== 'asc' && key.direction !== 'desc') {
|
|
144
|
+
queryInvalid('sort direction must be asc or desc', collection.name);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function validatePageSize(size: number): void {
|
|
150
|
+
if (!Number.isInteger(size) || size < 1 || size > Budgets.MAX_PAGE_SIZE) {
|
|
151
|
+
raise(DiagnosticCodes.PAGE_SIZE_INVALID, {
|
|
152
|
+
message: 'page size out of range',
|
|
153
|
+
detail: { limit: Budgets.MAX_PAGE_SIZE },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function validateQuery(collection: CollectionSchema, query: Query | undefined): void {
|
|
159
|
+
if (!query) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (query.filter) {
|
|
163
|
+
validateFilter(collection, query.filter);
|
|
164
|
+
}
|
|
165
|
+
if (query.sort) {
|
|
166
|
+
validateSort(collection, query.sort);
|
|
167
|
+
}
|
|
168
|
+
if (query.page) {
|
|
169
|
+
validatePageSize(query.page.size);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/schema/declare.ts
CHANGED
|
@@ -1,45 +1,42 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Schema lookup helpers shared by both adapters.
|
|
3
|
-
*
|
|
4
|
-
* Declaring a field makes it queryable — it does not restrict what a record
|
|
5
|
-
* may contain. `isQueryable` is the single answer to "may a filter or sort
|
|
6
|
-
* reference this?", so both adapters reject the same field names (FR-034).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { CollectionSchema, FieldDeclaration } from '../contract/schema';
|
|
10
|
-
|
|
11
|
-
export type SchemaIndex = ReadonlyMap<string, CollectionSchema>;
|
|
12
|
-
|
|
13
|
-
export function indexSchemas(collections: CollectionSchema[]): SchemaIndex {
|
|
14
|
-
return new Map(collections.map(collection => [collection.name, collection]));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function fieldOf(
|
|
18
|
-
collection
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export function collectionTable(name: string): string {
|
|
44
|
-
return `c_${name}`;
|
|
45
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Schema lookup helpers shared by both adapters.
|
|
3
|
+
*
|
|
4
|
+
* Declaring a field makes it queryable — it does not restrict what a record
|
|
5
|
+
* may contain. `isQueryable` is the single answer to "may a filter or sort
|
|
6
|
+
* reference this?", so both adapters reject the same field names (FR-034).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CollectionSchema, FieldDeclaration } from '../contract/schema';
|
|
10
|
+
|
|
11
|
+
export type SchemaIndex = ReadonlyMap<string, CollectionSchema>;
|
|
12
|
+
|
|
13
|
+
export function indexSchemas(collections: CollectionSchema[]): SchemaIndex {
|
|
14
|
+
return new Map(collections.map(collection => [collection.name, collection]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fieldOf(collection: CollectionSchema, name: string): FieldDeclaration | undefined {
|
|
18
|
+
return collection.fields?.find(field => field.name === name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isQueryable(collection: CollectionSchema, name: string): boolean {
|
|
22
|
+
return fieldOf(collection, name) !== undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasSoftDelete(collection: CollectionSchema): boolean {
|
|
26
|
+
return collection.softDelete === true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasTimestamps(collection: CollectionSchema): boolean {
|
|
30
|
+
return collection.timestamps === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Column name for a projected queryable field. Prefixed to avoid colliding
|
|
34
|
+
* with the reserved columns (`id`, `payload`, `created_at`, ...). */
|
|
35
|
+
export function projectedColumn(field: string): string {
|
|
36
|
+
return `f_${field}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Table name for a collection, prefixed for the same reason. */
|
|
40
|
+
export function collectionTable(name: string): string {
|
|
41
|
+
return `c_${name}`;
|
|
42
|
+
}
|