@happyvertical/smrt-core 0.42.3 → 0.42.5
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/AGENTS.md +20 -0
- package/dist/data-query.d.ts +45 -0
- package/dist/data-query.d.ts.map +1 -0
- package/dist/data-query.js +822 -0
- package/dist/data-query.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +5 -5
- package/package.json +4 -4
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
//#region src/data-query.ts
|
|
4
|
+
/**
|
|
5
|
+
* Canonical bounded data-query normalization (#2444).
|
|
6
|
+
*
|
|
7
|
+
* The shared package supplies only portable types. This server/runtime module
|
|
8
|
+
* makes the contract executable at every trust boundary: it accepts only an
|
|
9
|
+
* adapter-declared field/operator allowlist, canonicalizes equivalent
|
|
10
|
+
* requests, creates a collision-resistant match fingerprint, and validates
|
|
11
|
+
* that adapters return bounded, policy-filtered rows. It deliberately does
|
|
12
|
+
* not execute SQL or resolve tenant/principal state; authenticated domain
|
|
13
|
+
* adapters supply the schema and perform those responsibilities separately.
|
|
14
|
+
*/
|
|
15
|
+
/** Default and hard ceilings for normalized data-query input and output. */
|
|
16
|
+
var DEFAULT_DATA_QUERY_PAGE_LIMIT = 50;
|
|
17
|
+
var MAX_DATA_QUERY_PAGE_LIMIT = 1e3;
|
|
18
|
+
var DEFAULT_DATA_QUERY_RESULT_BYTES = 1e6;
|
|
19
|
+
var MAX_DATA_QUERY_RESULT_BYTES = 1e7;
|
|
20
|
+
var MAX_DATA_QUERY_REQUEST_BYTES = 1e5;
|
|
21
|
+
var MAX_DATA_QUERY_OFFSET = 1e6;
|
|
22
|
+
var MAX_DATA_QUERY_FILTER_DEPTH = 8;
|
|
23
|
+
var MAX_DATA_QUERY_FILTERS = 50;
|
|
24
|
+
var MAX_DATA_QUERY_IN_VALUES = 100;
|
|
25
|
+
var MAX_DATA_QUERY_FACETS = 20;
|
|
26
|
+
var MAX_DATA_QUERY_WARNINGS = 100;
|
|
27
|
+
var MAX_DATA_QUERY_CURSOR_LENGTH = 2048;
|
|
28
|
+
var MAX_DATA_QUERY_JSON_CONTAINER_ITEMS = 1e3;
|
|
29
|
+
var MAX_DATA_QUERY_JSON_STRING_LENGTH = 65536;
|
|
30
|
+
var FILTER_OPERATORS = /* @__PURE__ */ new Set([
|
|
31
|
+
"eq",
|
|
32
|
+
"ne",
|
|
33
|
+
"gt",
|
|
34
|
+
"gte",
|
|
35
|
+
"lt",
|
|
36
|
+
"lte",
|
|
37
|
+
"in",
|
|
38
|
+
"notIn",
|
|
39
|
+
"like"
|
|
40
|
+
]);
|
|
41
|
+
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
|
|
42
|
+
"__proto__",
|
|
43
|
+
"constructor",
|
|
44
|
+
"prototype"
|
|
45
|
+
]);
|
|
46
|
+
/** A typed 400-class failure for malformed or policy-disallowed data queries. */
|
|
47
|
+
var DataQueryValidationError = class extends ValidationError {
|
|
48
|
+
status = 400;
|
|
49
|
+
publicMessage;
|
|
50
|
+
constructor(message, code = "INVALID_DATA_QUERY", details) {
|
|
51
|
+
super(message, code, details);
|
|
52
|
+
this.name = "DataQueryValidationError";
|
|
53
|
+
this.publicMessage = message;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var encoder = new TextEncoder();
|
|
57
|
+
var RFC_3339_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
|
|
58
|
+
function fail(message, code = "INVALID_DATA_QUERY", details) {
|
|
59
|
+
throw new DataQueryValidationError(message, code, details);
|
|
60
|
+
}
|
|
61
|
+
function plainObject(value, label) {
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail(`${label} must be an object`);
|
|
63
|
+
const prototype = Object.getPrototypeOf(value);
|
|
64
|
+
if (prototype !== Object.prototype && prototype !== null) return fail(`${label} must be a plain object`);
|
|
65
|
+
for (const key of Object.keys(value)) if (FORBIDDEN_KEYS.has(key)) return fail(`${label} contains a forbidden key`, "FORBIDDEN_DATA_QUERY");
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function exactKeys(object, allowed, label) {
|
|
69
|
+
const allowedKeys = new Set(allowed);
|
|
70
|
+
for (const key of Object.keys(object)) if (!allowedKeys.has(key)) fail(`${label} contains unsupported key "${key}"`, "FORBIDDEN_DATA_QUERY");
|
|
71
|
+
}
|
|
72
|
+
function stringValue(value, label, maxLength = 256) {
|
|
73
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return fail(`${label} must be a non-empty string up to ${maxLength} characters`);
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function nonNegativeInteger(value, label) {
|
|
77
|
+
if (!Number.isSafeInteger(value) || value < 0) return fail(`${label} must be a non-negative safe integer`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function positiveInteger(value, label) {
|
|
81
|
+
const normalized = nonNegativeInteger(value, label);
|
|
82
|
+
if (normalized === 0) return fail(`${label} must be a positive safe integer`);
|
|
83
|
+
return normalized;
|
|
84
|
+
}
|
|
85
|
+
function normalizedInstant(value, label) {
|
|
86
|
+
const input = stringValue(value, label, 128);
|
|
87
|
+
const match = RFC_3339_INSTANT.exec(input);
|
|
88
|
+
if (!match) return fail(`${label} must be an RFC 3339 instant`);
|
|
89
|
+
const [year, month, day] = match.slice(1, 4).map(Number);
|
|
90
|
+
const calendar = /* @__PURE__ */ new Date(0);
|
|
91
|
+
calendar.setUTCFullYear(year, month - 1, day);
|
|
92
|
+
calendar.setUTCHours(0, 0, 0, 0);
|
|
93
|
+
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return fail(`${label} must be an RFC 3339 instant`);
|
|
94
|
+
const milliseconds = Date.parse(input);
|
|
95
|
+
if (!Number.isFinite(milliseconds)) return fail(`${label} must be an RFC 3339 instant`);
|
|
96
|
+
return new Date(milliseconds).toISOString();
|
|
97
|
+
}
|
|
98
|
+
function dataQueryScalar(value, label) {
|
|
99
|
+
if (value === null || typeof value === "boolean") return value;
|
|
100
|
+
if (typeof value === "string") {
|
|
101
|
+
if (value.length > 4096) return fail(`${label} cannot exceed 4096 characters`);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
105
|
+
return fail(`${label} must be a JSON scalar`);
|
|
106
|
+
}
|
|
107
|
+
function consumeJsonSegment(budget, text, label) {
|
|
108
|
+
if (!budget) return;
|
|
109
|
+
if (text.length > budget.remaining) fail(`${label} exceeds the maximum byte limit`, budget.failureCode);
|
|
110
|
+
const bytes = encoder.encode(text).byteLength;
|
|
111
|
+
if (bytes > budget.remaining) fail(`${label} exceeds the maximum byte limit`, budget.failureCode);
|
|
112
|
+
budget.remaining -= bytes;
|
|
113
|
+
}
|
|
114
|
+
function canonicalJson(value, label = "Data query JSON", budget, depth = 0, ancestors = /* @__PURE__ */ new Set()) {
|
|
115
|
+
if (depth > 16) return fail(`${label} exceeds the JSON depth limit`);
|
|
116
|
+
if (value === null || typeof value === "boolean") {
|
|
117
|
+
consumeJsonSegment(budget, JSON.stringify(value), label);
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
if (value.length > 65536) return fail(`${label} exceeds the JSON string limit`);
|
|
122
|
+
consumeJsonSegment(budget, JSON.stringify(value), label);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
if (typeof value === "number") {
|
|
126
|
+
if (!Number.isFinite(value)) return fail(`${label} cannot contain a non-finite number`);
|
|
127
|
+
const normalized = value === 0 ? 0 : value;
|
|
128
|
+
consumeJsonSegment(budget, JSON.stringify(normalized), label);
|
|
129
|
+
return normalized;
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
if (value.length > 1e3) return fail(`${label} exceeds the JSON container-item limit`);
|
|
133
|
+
if (ancestors.has(value)) return fail(`${label} cannot contain a cycle`);
|
|
134
|
+
ancestors.add(value);
|
|
135
|
+
try {
|
|
136
|
+
const result = [];
|
|
137
|
+
consumeJsonSegment(budget, "[", label);
|
|
138
|
+
for (const [index, entry] of value.entries()) {
|
|
139
|
+
if (index > 0) consumeJsonSegment(budget, ",", label);
|
|
140
|
+
result.push(canonicalJson(entry, `${label}[${index}]`, budget, depth + 1, ancestors));
|
|
141
|
+
}
|
|
142
|
+
consumeJsonSegment(budget, "]", label);
|
|
143
|
+
return result;
|
|
144
|
+
} finally {
|
|
145
|
+
ancestors.delete(value);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const object = plainObject(value, label);
|
|
149
|
+
const keys = Object.keys(object).sort(compareCanonicalStrings);
|
|
150
|
+
if (keys.length > 1e3) return fail(`${label} exceeds the JSON container-item limit`);
|
|
151
|
+
if (ancestors.has(object)) return fail(`${label} cannot contain a cycle`);
|
|
152
|
+
ancestors.add(object);
|
|
153
|
+
try {
|
|
154
|
+
const result = Object.create(null);
|
|
155
|
+
consumeJsonSegment(budget, "{", label);
|
|
156
|
+
for (const [index, key] of keys.entries()) {
|
|
157
|
+
if (key.length > 65536) return fail(`${label}.${key} exceeds the JSON string limit`);
|
|
158
|
+
if (index > 0) consumeJsonSegment(budget, ",", label);
|
|
159
|
+
consumeJsonSegment(budget, JSON.stringify(key), `${label}.${key}`);
|
|
160
|
+
consumeJsonSegment(budget, ":", label);
|
|
161
|
+
Object.defineProperty(result, key, {
|
|
162
|
+
value: canonicalJson(object[key], `${label}.${key}`, budget, depth + 1, ancestors),
|
|
163
|
+
enumerable: true,
|
|
164
|
+
configurable: true,
|
|
165
|
+
writable: true
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
consumeJsonSegment(budget, "}", label);
|
|
169
|
+
return result;
|
|
170
|
+
} finally {
|
|
171
|
+
ancestors.delete(object);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function signature(value) {
|
|
175
|
+
return JSON.stringify(canonicalJson(value));
|
|
176
|
+
}
|
|
177
|
+
/** Reject an over-limit raw request before canonicalization can collapse it. */
|
|
178
|
+
function assertBoundedRawRequest(value) {
|
|
179
|
+
const budget = {
|
|
180
|
+
remaining: MAX_DATA_QUERY_REQUEST_BYTES,
|
|
181
|
+
failureCode: "DATA_QUERY_REQUEST_TOO_LARGE"
|
|
182
|
+
};
|
|
183
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
184
|
+
const measure = (candidate, label, depth = 0) => {
|
|
185
|
+
if (depth > 16) fail(`${label} exceeds the JSON depth limit`);
|
|
186
|
+
if (candidate === null || typeof candidate === "boolean") {
|
|
187
|
+
consumeJsonSegment(budget, JSON.stringify(candidate), label);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (typeof candidate === "string") {
|
|
191
|
+
consumeJsonSegment(budget, JSON.stringify(candidate), label);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (typeof candidate === "number") {
|
|
195
|
+
if (!Number.isFinite(candidate)) fail(`${label} cannot contain a non-finite number`);
|
|
196
|
+
consumeJsonSegment(budget, JSON.stringify(candidate === 0 ? 0 : candidate), label);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (Array.isArray(candidate)) {
|
|
200
|
+
if (ancestors.has(candidate)) fail(`${label} cannot contain a cycle`);
|
|
201
|
+
ancestors.add(candidate);
|
|
202
|
+
try {
|
|
203
|
+
consumeJsonSegment(budget, "[", label);
|
|
204
|
+
for (const [index, entry] of candidate.entries()) {
|
|
205
|
+
if (index > 0) consumeJsonSegment(budget, ",", label);
|
|
206
|
+
measure(entry, `${label}[${index}]`, depth + 1);
|
|
207
|
+
}
|
|
208
|
+
consumeJsonSegment(budget, "]", label);
|
|
209
|
+
} finally {
|
|
210
|
+
ancestors.delete(candidate);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const object = plainObject(candidate, label);
|
|
215
|
+
if (ancestors.has(object)) fail(`${label} cannot contain a cycle`);
|
|
216
|
+
ancestors.add(object);
|
|
217
|
+
try {
|
|
218
|
+
consumeJsonSegment(budget, "{", label);
|
|
219
|
+
for (const [index, key] of Object.keys(object).entries()) {
|
|
220
|
+
if (index > 0) consumeJsonSegment(budget, ",", label);
|
|
221
|
+
consumeJsonSegment(budget, JSON.stringify(key), `${label}.${key}`);
|
|
222
|
+
consumeJsonSegment(budget, ":", label);
|
|
223
|
+
measure(object[key], `${label}.${key}`, depth + 1);
|
|
224
|
+
}
|
|
225
|
+
consumeJsonSegment(budget, "}", label);
|
|
226
|
+
} finally {
|
|
227
|
+
ancestors.delete(object);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
measure(value, "Data query request");
|
|
231
|
+
}
|
|
232
|
+
/** Locale-independent ordering for canonical envelopes and fingerprints. */
|
|
233
|
+
function compareCanonicalStrings(left, right) {
|
|
234
|
+
if (left === right) return 0;
|
|
235
|
+
return left < right ? -1 : 1;
|
|
236
|
+
}
|
|
237
|
+
function normalizeFieldDescriptor(value) {
|
|
238
|
+
const object = plainObject(value, "Data query schema field");
|
|
239
|
+
exactKeys(object, [
|
|
240
|
+
"id",
|
|
241
|
+
"type",
|
|
242
|
+
"projectable",
|
|
243
|
+
"sortable",
|
|
244
|
+
"facetable",
|
|
245
|
+
"filterOperators"
|
|
246
|
+
], "Data query schema field");
|
|
247
|
+
const id = stringValue(object.id, "Data query field id");
|
|
248
|
+
const type = stringValue(object.type, `Data query field ${id} type`);
|
|
249
|
+
if (![
|
|
250
|
+
"string",
|
|
251
|
+
"number",
|
|
252
|
+
"boolean",
|
|
253
|
+
"datetime",
|
|
254
|
+
"json"
|
|
255
|
+
].includes(type)) return fail(`Unsupported data query field type: ${type}`);
|
|
256
|
+
for (const flag of [
|
|
257
|
+
"projectable",
|
|
258
|
+
"sortable",
|
|
259
|
+
"facetable"
|
|
260
|
+
]) if (object[flag] !== void 0 && typeof object[flag] !== "boolean") fail(`Data query field ${id} ${flag} must be boolean`);
|
|
261
|
+
let filterOperators;
|
|
262
|
+
if (object.filterOperators !== void 0) {
|
|
263
|
+
if (!Array.isArray(object.filterOperators)) return fail(`Data query field ${id} filterOperators must be an array`);
|
|
264
|
+
filterOperators = [...new Set(object.filterOperators.map((operator) => {
|
|
265
|
+
const normalized = stringValue(operator, `Data query field ${id} filter operator`);
|
|
266
|
+
if (!FILTER_OPERATORS.has(normalized)) fail(`Unsupported data query filter operator: ${normalized}`);
|
|
267
|
+
return normalized;
|
|
268
|
+
}))].sort();
|
|
269
|
+
}
|
|
270
|
+
const projectable = typeof object.projectable === "boolean" ? object.projectable : void 0;
|
|
271
|
+
const sortable = typeof object.sortable === "boolean" ? object.sortable : void 0;
|
|
272
|
+
const facetable = typeof object.facetable === "boolean" ? object.facetable : void 0;
|
|
273
|
+
return {
|
|
274
|
+
id,
|
|
275
|
+
type,
|
|
276
|
+
...projectable === void 0 ? {} : { projectable },
|
|
277
|
+
...sortable === void 0 ? {} : { sortable },
|
|
278
|
+
...facetable === void 0 ? {} : { facetable },
|
|
279
|
+
...filterOperators ? { filterOperators } : {}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** Validate and canonicalize trusted adapter query policy before use. */
|
|
283
|
+
function normalizeDataQuerySchema(value) {
|
|
284
|
+
const object = plainObject(value, "Data query schema");
|
|
285
|
+
exactKeys(object, [
|
|
286
|
+
"version",
|
|
287
|
+
"identityField",
|
|
288
|
+
"fields",
|
|
289
|
+
"defaultPageLimit",
|
|
290
|
+
"maxPageLimit",
|
|
291
|
+
"maxResultBytes",
|
|
292
|
+
"defaultSort",
|
|
293
|
+
"supports"
|
|
294
|
+
], "Data query schema");
|
|
295
|
+
if (object.version !== 1) return fail("Unsupported data query schema version");
|
|
296
|
+
if (!Array.isArray(object.fields) || object.fields.length === 0) return fail("Data query schema fields must be a non-empty array");
|
|
297
|
+
const fields = object.fields.map(normalizeFieldDescriptor).sort((left, right) => compareCanonicalStrings(left.id, right.id));
|
|
298
|
+
if (new Set(fields.map((field) => field.id)).size !== fields.length) return fail("Data query schema field ids must be unique");
|
|
299
|
+
const identityField = stringValue(object.identityField, "Data query identity field");
|
|
300
|
+
const identity = fields.find((field) => field.id === identityField);
|
|
301
|
+
if (!identity) return fail("Data query identity field must be declared");
|
|
302
|
+
if (identity.projectable === false) return fail("Data query identity field must be projectable");
|
|
303
|
+
if (![
|
|
304
|
+
"string",
|
|
305
|
+
"number",
|
|
306
|
+
"datetime"
|
|
307
|
+
].includes(identity.type)) return fail("Data query identity field must use a string, number, or datetime type");
|
|
308
|
+
const maxPageLimit = object.maxPageLimit === void 0 ? MAX_DATA_QUERY_PAGE_LIMIT : positiveInteger(object.maxPageLimit, "Data query maximum page limit");
|
|
309
|
+
if (maxPageLimit > 1e3) return fail(`Data query maximum page limit cannot exceed ${MAX_DATA_QUERY_PAGE_LIMIT}`);
|
|
310
|
+
const defaultPageLimit = object.defaultPageLimit === void 0 ? Math.min(50, maxPageLimit) : positiveInteger(object.defaultPageLimit, "Data query default page limit");
|
|
311
|
+
if (defaultPageLimit > maxPageLimit) return fail("Data query default page limit cannot exceed the maximum page limit");
|
|
312
|
+
const maxResultBytes = object.maxResultBytes === void 0 ? DEFAULT_DATA_QUERY_RESULT_BYTES : positiveInteger(object.maxResultBytes, "Data query maximum result bytes");
|
|
313
|
+
if (maxResultBytes > 1e7) return fail(`Data query maximum result bytes cannot exceed ${MAX_DATA_QUERY_RESULT_BYTES}`);
|
|
314
|
+
let supports;
|
|
315
|
+
if (object.supports !== void 0) {
|
|
316
|
+
const supportObject = plainObject(object.supports, "Data query schema supports");
|
|
317
|
+
exactKeys(supportObject, [
|
|
318
|
+
"cursorPagination",
|
|
319
|
+
"consistency",
|
|
320
|
+
"facets"
|
|
321
|
+
], "Data query schema supports");
|
|
322
|
+
for (const key of [
|
|
323
|
+
"cursorPagination",
|
|
324
|
+
"consistency",
|
|
325
|
+
"facets"
|
|
326
|
+
]) if (supportObject[key] !== void 0 && typeof supportObject[key] !== "boolean") fail(`Data query schema supports.${key} must be boolean`);
|
|
327
|
+
const cursorPagination = typeof supportObject.cursorPagination === "boolean" ? supportObject.cursorPagination : void 0;
|
|
328
|
+
const consistency = typeof supportObject.consistency === "boolean" ? supportObject.consistency : void 0;
|
|
329
|
+
const facets = typeof supportObject.facets === "boolean" ? supportObject.facets : void 0;
|
|
330
|
+
supports = {
|
|
331
|
+
...cursorPagination === void 0 ? {} : { cursorPagination },
|
|
332
|
+
...consistency === void 0 ? {} : { consistency },
|
|
333
|
+
...facets === void 0 ? {} : { facets }
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
|
337
|
+
const defaultSort = normalizeSort(object.defaultSort, fieldMap, identityField, "Data query default sort", false);
|
|
338
|
+
return {
|
|
339
|
+
version: 1,
|
|
340
|
+
identityField,
|
|
341
|
+
fields,
|
|
342
|
+
defaultPageLimit,
|
|
343
|
+
maxPageLimit,
|
|
344
|
+
maxResultBytes,
|
|
345
|
+
...defaultSort.length > 0 ? { defaultSort } : {},
|
|
346
|
+
...supports ? { supports } : {}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function scalarForField(value, descriptor, label) {
|
|
350
|
+
const scalar = dataQueryScalar(value, label);
|
|
351
|
+
if (scalar === null) return scalar;
|
|
352
|
+
if (descriptor.type === "number" && typeof scalar !== "number") return fail(`${label} must be a number for ${descriptor.id}`);
|
|
353
|
+
if (descriptor.type === "boolean" && typeof scalar !== "boolean") return fail(`${label} must be a boolean for ${descriptor.id}`);
|
|
354
|
+
if (descriptor.type === "string" && typeof scalar !== "string") return fail(`${label} must be a string for ${descriptor.id}`);
|
|
355
|
+
if (descriptor.type === "datetime") {
|
|
356
|
+
if (typeof scalar !== "string") return fail(`${label} must be an RFC 3339 instant for ${descriptor.id}`);
|
|
357
|
+
return normalizedInstant(scalar, label);
|
|
358
|
+
}
|
|
359
|
+
return scalar;
|
|
360
|
+
}
|
|
361
|
+
function normalizeFilter(value, fields, depth = 0, budget = { nodes: 0 }) {
|
|
362
|
+
if (depth > 8) return fail(`Data query filter cannot exceed depth 8`);
|
|
363
|
+
budget.nodes += 1;
|
|
364
|
+
if (budget.nodes > 50) return fail(`Data query filter cannot exceed 50 expressions`);
|
|
365
|
+
const object = plainObject(value, "Data query filter");
|
|
366
|
+
const kind = stringValue(object.kind, "Data query filter kind");
|
|
367
|
+
if (kind === "condition") {
|
|
368
|
+
exactKeys(object, [
|
|
369
|
+
"kind",
|
|
370
|
+
"field",
|
|
371
|
+
"operator",
|
|
372
|
+
"value"
|
|
373
|
+
], "Data query condition");
|
|
374
|
+
const field = stringValue(object.field, "Data query condition field");
|
|
375
|
+
const descriptor = fields.get(field);
|
|
376
|
+
if (!descriptor) return fail(`Data query field is not declared: ${field}`, "DATA_QUERY_FIELD_NOT_ALLOWED");
|
|
377
|
+
const operator = stringValue(object.operator, "Data query condition operator");
|
|
378
|
+
if (!FILTER_OPERATORS.has(operator) || !descriptor.filterOperators?.includes(operator)) return fail(`Data query operator ${operator} is not allowed for ${field}`, "DATA_QUERY_OPERATOR_NOT_ALLOWED");
|
|
379
|
+
if (operator === "in" || operator === "notIn") {
|
|
380
|
+
if (!Array.isArray(object.value) || object.value.length === 0) return fail(`Data query ${operator} value must be a non-empty array`);
|
|
381
|
+
if (object.value.length > 100) return fail(`Data query ${operator} value cannot exceed 100 items`);
|
|
382
|
+
const values = object.value.map((entry, index) => scalarForField(entry, descriptor, `Data query ${operator} value ${index}`));
|
|
383
|
+
return {
|
|
384
|
+
kind: "condition",
|
|
385
|
+
field,
|
|
386
|
+
operator,
|
|
387
|
+
value: [...new Map(values.map((entry) => [signature(entry), entry])).values()].sort((left, right) => compareCanonicalStrings(signature(left), signature(right)))
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (Array.isArray(object.value)) return fail(`Data query ${operator} value must be a scalar`);
|
|
391
|
+
const scalar = scalarForField(object.value, descriptor, `Data query ${operator} value`);
|
|
392
|
+
if (operator === "like" && descriptor.type !== "string") return fail("Data query like is only available for string fields");
|
|
393
|
+
if (operator === "like" && typeof scalar !== "string") return fail("Data query like value must be a string");
|
|
394
|
+
if ([
|
|
395
|
+
"gt",
|
|
396
|
+
"gte",
|
|
397
|
+
"lt",
|
|
398
|
+
"lte"
|
|
399
|
+
].includes(operator) && ![
|
|
400
|
+
"number",
|
|
401
|
+
"string",
|
|
402
|
+
"datetime"
|
|
403
|
+
].includes(descriptor.type)) return fail(`Data query ${operator} is not available for ${descriptor.type} fields`);
|
|
404
|
+
if ([
|
|
405
|
+
"gt",
|
|
406
|
+
"gte",
|
|
407
|
+
"lt",
|
|
408
|
+
"lte",
|
|
409
|
+
"like"
|
|
410
|
+
].includes(operator) && scalar === null) return fail(`Data query ${operator} value cannot be null`);
|
|
411
|
+
return {
|
|
412
|
+
kind: "condition",
|
|
413
|
+
field,
|
|
414
|
+
operator,
|
|
415
|
+
value: scalar
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (kind === "all" || kind === "any") {
|
|
419
|
+
exactKeys(object, ["kind", "filters"], `Data query ${kind} filter`);
|
|
420
|
+
if (!Array.isArray(object.filters) || object.filters.length === 0) return fail(`Data query ${kind} filter must contain at least one child`);
|
|
421
|
+
if (object.filters.length > 50) return fail(`Data query ${kind} filter cannot exceed 50 children`);
|
|
422
|
+
return {
|
|
423
|
+
kind,
|
|
424
|
+
filters: object.filters.map((filter) => normalizeFilter(filter, fields, depth + 1, budget)).sort((left, right) => compareCanonicalStrings(signature(left), signature(right)))
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (kind === "not") {
|
|
428
|
+
exactKeys(object, ["kind", "filter"], "Data query not filter");
|
|
429
|
+
return {
|
|
430
|
+
kind: "not",
|
|
431
|
+
filter: normalizeFilter(object.filter, fields, depth + 1, budget)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return fail(`Unsupported data query filter kind: ${kind}`);
|
|
435
|
+
}
|
|
436
|
+
function normalizeSort(value, fields, identityField, label, appendIdentity) {
|
|
437
|
+
if (value === void 0) return appendIdentity ? [{
|
|
438
|
+
field: identityField,
|
|
439
|
+
direction: "asc"
|
|
440
|
+
}] : [];
|
|
441
|
+
if (!Array.isArray(value)) return fail(`${label} must be an array`);
|
|
442
|
+
if (value.length > 50) return fail(`${label} cannot exceed 50 terms`);
|
|
443
|
+
const sort = value.map((entry) => {
|
|
444
|
+
const object = plainObject(entry, label);
|
|
445
|
+
exactKeys(object, ["field", "direction"], label);
|
|
446
|
+
const field = stringValue(object.field, `${label} field`);
|
|
447
|
+
if (!fields.get(field)?.sortable && field !== identityField) fail(`Data query sort field is not allowed: ${field}`, "DATA_QUERY_SORT_NOT_ALLOWED");
|
|
448
|
+
const direction = stringValue(object.direction, `${label} direction`);
|
|
449
|
+
if (direction !== "asc" && direction !== "desc") fail(`Data query sort direction must be asc or desc`);
|
|
450
|
+
return {
|
|
451
|
+
field,
|
|
452
|
+
direction
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
if (new Set(sort.map((term) => term.field)).size !== sort.length) return fail(`${label} field ids must be unique`);
|
|
456
|
+
if (appendIdentity && !sort.some((term) => term.field === identityField)) sort.push({
|
|
457
|
+
field: identityField,
|
|
458
|
+
direction: "asc"
|
|
459
|
+
});
|
|
460
|
+
return sort;
|
|
461
|
+
}
|
|
462
|
+
function normalizePage(value, schema) {
|
|
463
|
+
if (value === void 0) return {
|
|
464
|
+
kind: "offset",
|
|
465
|
+
offset: 0,
|
|
466
|
+
limit: schema.defaultPageLimit ?? 50
|
|
467
|
+
};
|
|
468
|
+
const object = plainObject(value, "Data query page");
|
|
469
|
+
const kind = stringValue(object.kind, "Data query page kind");
|
|
470
|
+
if (kind === "offset") {
|
|
471
|
+
exactKeys(object, [
|
|
472
|
+
"kind",
|
|
473
|
+
"offset",
|
|
474
|
+
"limit"
|
|
475
|
+
], "Data query offset page");
|
|
476
|
+
const offset = nonNegativeInteger(object.offset, "Data query offset");
|
|
477
|
+
if (offset > 1e6) return fail(`Data query offset cannot exceed ${MAX_DATA_QUERY_OFFSET}`);
|
|
478
|
+
return {
|
|
479
|
+
kind,
|
|
480
|
+
offset,
|
|
481
|
+
limit: Math.min(positiveInteger(object.limit, "Data query page limit"), schema.maxPageLimit ?? 1e3)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
if (kind === "cursor") {
|
|
485
|
+
if (!schema.supports?.cursorPagination) return fail("Data query cursor pagination is not supported", "DATA_QUERY_UNSUPPORTED");
|
|
486
|
+
exactKeys(object, [
|
|
487
|
+
"kind",
|
|
488
|
+
"after",
|
|
489
|
+
"limit"
|
|
490
|
+
], "Data query cursor page");
|
|
491
|
+
const after = object.after === void 0 ? void 0 : stringValue(object.after, "Data query cursor", MAX_DATA_QUERY_CURSOR_LENGTH);
|
|
492
|
+
return {
|
|
493
|
+
kind,
|
|
494
|
+
...after === void 0 ? {} : { after },
|
|
495
|
+
limit: Math.min(positiveInteger(object.limit, "Data query page limit"), schema.maxPageLimit ?? 1e3)
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return fail(`Unsupported data query page kind: ${kind}`);
|
|
499
|
+
}
|
|
500
|
+
function normalizeConsistency(value, schema) {
|
|
501
|
+
if (value === void 0) return void 0;
|
|
502
|
+
if (!schema.supports?.consistency) return fail("Data query consistency options are not supported", "DATA_QUERY_UNSUPPORTED");
|
|
503
|
+
const object = plainObject(value, "Data query consistency");
|
|
504
|
+
exactKeys(object, ["mode", "asOf"], "Data query consistency");
|
|
505
|
+
if (object.mode !== "eventual" && object.mode !== "snapshot") return fail("Unsupported data query consistency mode");
|
|
506
|
+
const asOf = object.asOf === void 0 ? void 0 : normalizedInstant(object.asOf, "Data query consistency asOf");
|
|
507
|
+
return {
|
|
508
|
+
mode: object.mode,
|
|
509
|
+
...asOf === void 0 ? {} : { asOf }
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function normalizeFacets(value, schema, fields) {
|
|
513
|
+
if (!schema.supports?.facets) return fail("Data query facets are not supported", "DATA_QUERY_UNSUPPORTED");
|
|
514
|
+
if (!Array.isArray(value) || value.length === 0) return fail("Data query facets must be a non-empty array");
|
|
515
|
+
if (value.length > 20) return fail(`Data query facets cannot exceed 20`);
|
|
516
|
+
const facets = value.map((entry) => {
|
|
517
|
+
const object = plainObject(entry, "Data query facet request");
|
|
518
|
+
exactKeys(object, ["field", "limit"], "Data query facet request");
|
|
519
|
+
const field = stringValue(object.field, "Data query facet field");
|
|
520
|
+
if (!fields.get(field)?.facetable) fail(`Data query facet field is not allowed: ${field}`, "DATA_QUERY_FACET_NOT_ALLOWED");
|
|
521
|
+
return {
|
|
522
|
+
field,
|
|
523
|
+
limit: Math.min(positiveInteger(object.limit, "Data query facet limit"), schema.maxPageLimit ?? 1e3)
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
if (new Set(facets.map((facet) => facet.field)).size !== facets.length) return fail("Data query facet fields must be unique");
|
|
527
|
+
return facets.sort((left, right) => compareCanonicalStrings(left.field, right.field));
|
|
528
|
+
}
|
|
529
|
+
function boundRequestSize(request) {
|
|
530
|
+
if (new TextEncoder().encode(JSON.stringify(request)).byteLength > 1e5) return fail("Data query request exceeds its maximum byte limit", "DATA_QUERY_REQUEST_TOO_LARGE");
|
|
531
|
+
return request;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Normalize an untrusted request against its trusted adapter schema. Equivalent
|
|
535
|
+
* projection/filter/facet orderings produce byte-identical output; sort order
|
|
536
|
+
* remains intact because it is semantically meaningful. The identity field is
|
|
537
|
+
* always projected even when a caller omits it.
|
|
538
|
+
*/
|
|
539
|
+
function normalizeDataQueryRequest(value, inputSchema) {
|
|
540
|
+
const schema = normalizeDataQuerySchema(inputSchema);
|
|
541
|
+
const fields = new Map(schema.fields.map((field) => [field.id, field]));
|
|
542
|
+
const object = plainObject(value, "Data query request");
|
|
543
|
+
assertBoundedRawRequest(value);
|
|
544
|
+
exactKeys(object, [
|
|
545
|
+
"version",
|
|
546
|
+
"requestId",
|
|
547
|
+
"mode",
|
|
548
|
+
"projection",
|
|
549
|
+
"filter",
|
|
550
|
+
"sort",
|
|
551
|
+
"page",
|
|
552
|
+
"consistency",
|
|
553
|
+
"facets"
|
|
554
|
+
], "Data query request");
|
|
555
|
+
if (object.version !== 1) return fail("Unsupported data query request version");
|
|
556
|
+
const requestId = stringValue(object.requestId, "Data query request id", 128);
|
|
557
|
+
const mode = stringValue(object.mode, "Data query mode");
|
|
558
|
+
if (![
|
|
559
|
+
"rows",
|
|
560
|
+
"count",
|
|
561
|
+
"facets"
|
|
562
|
+
].includes(mode)) return fail(`Unsupported data query mode: ${mode}`);
|
|
563
|
+
const filter = object.filter === void 0 ? void 0 : normalizeFilter(object.filter, fields);
|
|
564
|
+
const consistency = normalizeConsistency(object.consistency, schema);
|
|
565
|
+
if (mode === "rows") {
|
|
566
|
+
if (object.facets !== void 0) return fail("Rows queries cannot request facets");
|
|
567
|
+
if (object.projection !== void 0 && !Array.isArray(object.projection)) return fail("Data query projection must be an array");
|
|
568
|
+
if (Array.isArray(object.projection) && object.projection.length > 50) return fail(`Data query projection cannot exceed 50 fields`);
|
|
569
|
+
const requestedProjection = (object.projection ?? []).map((field) => stringValue(field, "Data query projection field"));
|
|
570
|
+
for (const field of requestedProjection) if (!fields.get(field)?.projectable && field !== schema.identityField) fail(`Data query projection field is not allowed: ${field}`, "DATA_QUERY_PROJECTION_NOT_ALLOWED");
|
|
571
|
+
const projection = [.../* @__PURE__ */ new Set([...requestedProjection, schema.identityField])].sort();
|
|
572
|
+
const sort = normalizeSort(object.sort === void 0 ? schema.defaultSort : object.sort, fields, schema.identityField, "Data query sort", true);
|
|
573
|
+
return boundRequestSize({
|
|
574
|
+
version: 1,
|
|
575
|
+
requestId,
|
|
576
|
+
mode,
|
|
577
|
+
projection,
|
|
578
|
+
...filter === void 0 ? {} : { filter },
|
|
579
|
+
sort,
|
|
580
|
+
page: normalizePage(object.page, schema),
|
|
581
|
+
...consistency === void 0 ? {} : { consistency }
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (object.projection !== void 0 || object.sort !== void 0 || object.page !== void 0) return fail(`${mode} data queries cannot carry projection, sort, or page`);
|
|
585
|
+
if (mode === "facets") return boundRequestSize({
|
|
586
|
+
version: 1,
|
|
587
|
+
requestId,
|
|
588
|
+
mode,
|
|
589
|
+
...filter === void 0 ? {} : { filter },
|
|
590
|
+
...consistency === void 0 ? {} : { consistency },
|
|
591
|
+
facets: normalizeFacets(object.facets, schema, fields)
|
|
592
|
+
});
|
|
593
|
+
if (object.facets !== void 0) return fail("Count data queries cannot request facets");
|
|
594
|
+
return boundRequestSize({
|
|
595
|
+
version: 1,
|
|
596
|
+
requestId,
|
|
597
|
+
mode: "count",
|
|
598
|
+
...filter === void 0 ? {} : { filter },
|
|
599
|
+
...consistency === void 0 ? {} : { consistency }
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/** Canonical match/query form used for stable fingerprints (no request id/page). */
|
|
603
|
+
function canonicalizeDataQuery(value, schema) {
|
|
604
|
+
const { requestId: _requestId, page: _page, ...semanticQuery } = normalizeDataQueryRequest(value, schema);
|
|
605
|
+
return signature(semanticQuery);
|
|
606
|
+
}
|
|
607
|
+
/** Collision-resistant canonical fingerprint for result correlation and selection scope. */
|
|
608
|
+
function createDataQueryFingerprint(value, schema) {
|
|
609
|
+
return `dq1_${createHash("sha256").update(canonicalizeDataQuery(value, schema)).digest("base64url")}`;
|
|
610
|
+
}
|
|
611
|
+
function normalizeResultFieldValue(value, descriptor, label, budget) {
|
|
612
|
+
if (descriptor.type === "json") return canonicalJson(value, label, budget);
|
|
613
|
+
const normalized = scalarForField(value, descriptor, label);
|
|
614
|
+
consumeJsonSegment(budget, JSON.stringify(normalized), label);
|
|
615
|
+
return normalized;
|
|
616
|
+
}
|
|
617
|
+
function normalizeRow(value, projection, identityField, fields, budget) {
|
|
618
|
+
const object = plainObject(value, "Data query row");
|
|
619
|
+
const allowed = new Set(projection);
|
|
620
|
+
const normalized = Object.create(null);
|
|
621
|
+
consumeJsonSegment(budget, "{", "Data query row");
|
|
622
|
+
for (const [index, key] of Object.keys(object).entries()) {
|
|
623
|
+
if (!allowed.has(key)) fail(`Data query row returned a non-projected field: ${key}`, "DATA_QUERY_RESULT_NOT_ALLOWED");
|
|
624
|
+
const descriptor = fields.get(key);
|
|
625
|
+
if (!descriptor) return fail(`Data query row returned an undeclared field: ${key}`, "DATA_QUERY_RESULT_NOT_ALLOWED");
|
|
626
|
+
if (index > 0) consumeJsonSegment(budget, ",", "Data query row");
|
|
627
|
+
consumeJsonSegment(budget, JSON.stringify(key), `Data query row ${key}`);
|
|
628
|
+
consumeJsonSegment(budget, ":", `Data query row ${key}`);
|
|
629
|
+
normalized[key] = normalizeResultFieldValue(object[key], descriptor, `Data query row ${key}`, budget);
|
|
630
|
+
}
|
|
631
|
+
consumeJsonSegment(budget, "}", "Data query row");
|
|
632
|
+
const identity = normalized[identityField];
|
|
633
|
+
if (typeof identity !== "string" && typeof identity !== "number" || identity === "") fail("Data query row must return a string or number identity field", "DATA_QUERY_RESULT_INVALID");
|
|
634
|
+
return normalized;
|
|
635
|
+
}
|
|
636
|
+
function normalizeTotal(value) {
|
|
637
|
+
const object = plainObject(value, "Data query total");
|
|
638
|
+
const kind = stringValue(object.kind, "Data query total kind");
|
|
639
|
+
if (kind === "unavailable") {
|
|
640
|
+
exactKeys(object, ["kind", "reason"], "Data query unavailable total");
|
|
641
|
+
const reason = object.reason === void 0 ? void 0 : stringValue(object.reason, "Data query total reason");
|
|
642
|
+
return {
|
|
643
|
+
kind,
|
|
644
|
+
...reason === void 0 ? {} : { reason }
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
if (kind !== "exact" && kind !== "estimated") return fail(`Unsupported data query total kind: ${kind}`);
|
|
648
|
+
exactKeys(object, [
|
|
649
|
+
"kind",
|
|
650
|
+
"value",
|
|
651
|
+
"asOf"
|
|
652
|
+
], "Data query total");
|
|
653
|
+
const asOf = object.asOf === void 0 ? void 0 : normalizedInstant(object.asOf, "Data query total asOf");
|
|
654
|
+
return {
|
|
655
|
+
kind,
|
|
656
|
+
value: nonNegativeInteger(object.value, "Data query total value"),
|
|
657
|
+
...asOf === void 0 ? {} : { asOf }
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
function normalizeFreshness(value) {
|
|
661
|
+
const object = plainObject(value, "Data query freshness");
|
|
662
|
+
exactKeys(object, ["state", "asOf"], "Data query freshness");
|
|
663
|
+
const state = stringValue(object.state, "Data query freshness state");
|
|
664
|
+
if (![
|
|
665
|
+
"fresh",
|
|
666
|
+
"stale",
|
|
667
|
+
"unknown"
|
|
668
|
+
].includes(state)) return fail(`Unsupported data query freshness state: ${state}`);
|
|
669
|
+
const asOf = object.asOf === void 0 ? void 0 : normalizedInstant(object.asOf, "Data query freshness asOf");
|
|
670
|
+
return {
|
|
671
|
+
state,
|
|
672
|
+
...asOf === void 0 ? {} : { asOf }
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function normalizeFacetsResult(value, requested, fields, budget) {
|
|
676
|
+
if (!Array.isArray(value)) return fail("Data query result facets must be an array");
|
|
677
|
+
if (value.length > requested.length) return fail("Data query result returned too many facets");
|
|
678
|
+
const limits = new Map(requested.map((facet) => [facet.field, facet.limit]));
|
|
679
|
+
consumeJsonSegment(budget, "[", "Data query result facets");
|
|
680
|
+
const results = value.map((entry, index) => {
|
|
681
|
+
if (index > 0) consumeJsonSegment(budget, ",", "Data query result facets");
|
|
682
|
+
const object = plainObject(entry, "Data query facet result");
|
|
683
|
+
exactKeys(object, [
|
|
684
|
+
"field",
|
|
685
|
+
"values",
|
|
686
|
+
"truncated"
|
|
687
|
+
], "Data query facet result");
|
|
688
|
+
const field = stringValue(object.field, "Data query facet result field");
|
|
689
|
+
const limit = limits.get(field);
|
|
690
|
+
if (limit === void 0) return fail(`Data query returned an unrequested facet: ${field}`);
|
|
691
|
+
if (!Array.isArray(object.values) || object.values.length > limit) return fail(`Data query facet ${field} exceeds its requested limit`);
|
|
692
|
+
if (typeof object.truncated !== "boolean") return fail("Data query facet truncated must be boolean");
|
|
693
|
+
const descriptor = fields.get(field);
|
|
694
|
+
if (!descriptor) return fail(`Data query facet field is not declared: ${field}`);
|
|
695
|
+
consumeJsonSegment(budget, "{\"field\":", `Data query facet ${field}`);
|
|
696
|
+
consumeJsonSegment(budget, JSON.stringify(field), `Data query facet ${field}`);
|
|
697
|
+
consumeJsonSegment(budget, ",\"values\":[", `Data query facet ${field}`);
|
|
698
|
+
const values = object.values.map((candidate, valueIndex) => {
|
|
699
|
+
if (valueIndex > 0) consumeJsonSegment(budget, ",", `Data query facet ${field}`);
|
|
700
|
+
const facet = plainObject(candidate, "Data query facet value");
|
|
701
|
+
exactKeys(facet, ["value", "count"], "Data query facet value");
|
|
702
|
+
const normalizedValue = scalarForField(facet.value, descriptor, `Data query facet ${field} value`);
|
|
703
|
+
const count = nonNegativeInteger(facet.count, "Data query facet count");
|
|
704
|
+
consumeJsonSegment(budget, `{"value":${JSON.stringify(normalizedValue)},"count":${JSON.stringify(count)}}`, `Data query facet ${field} value`);
|
|
705
|
+
return {
|
|
706
|
+
value: normalizedValue,
|
|
707
|
+
count
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
consumeJsonSegment(budget, `],"truncated":${object.truncated}}`, `Data query facet ${field}`);
|
|
711
|
+
return {
|
|
712
|
+
field,
|
|
713
|
+
values,
|
|
714
|
+
truncated: object.truncated
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
consumeJsonSegment(budget, "]", "Data query result facets");
|
|
718
|
+
if (new Set(results.map((facet) => facet.field)).size !== results.length) return fail("Data query result facet fields must be unique");
|
|
719
|
+
return results.sort((left, right) => compareCanonicalStrings(left.field, right.field));
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Validate an adapter result against the normalized request and its schema.
|
|
723
|
+
* This is the final projection boundary: undeclared fields, malformed totals,
|
|
724
|
+
* forged fingerprints, oversized rows, and authority-shaped prototype payloads
|
|
725
|
+
* cannot cross into REST, MCP, WebMCP, or browser consumers unnoticed.
|
|
726
|
+
*/
|
|
727
|
+
function normalizeDataQueryResult(value, inputRequest, inputSchema) {
|
|
728
|
+
const schema = normalizeDataQuerySchema(inputSchema);
|
|
729
|
+
const request = normalizeDataQueryRequest(inputRequest, schema);
|
|
730
|
+
const fields = new Map(schema.fields.map((field) => [field.id, field]));
|
|
731
|
+
const object = plainObject(value, "Data query result");
|
|
732
|
+
exactKeys(object, [
|
|
733
|
+
"version",
|
|
734
|
+
"requestId",
|
|
735
|
+
"queryFingerprint",
|
|
736
|
+
"identityField",
|
|
737
|
+
"rows",
|
|
738
|
+
"page",
|
|
739
|
+
"total",
|
|
740
|
+
"facets",
|
|
741
|
+
"freshness",
|
|
742
|
+
"warnings",
|
|
743
|
+
"truncated"
|
|
744
|
+
], "Data query result");
|
|
745
|
+
if (object.version !== 1) return fail("Unsupported data query result version");
|
|
746
|
+
if (stringValue(object.requestId, "Data query result request id", 128) !== request.requestId) return fail("Data query result request id does not match its request", "DATA_QUERY_RESULT_INVALID");
|
|
747
|
+
const fingerprint = createDataQueryFingerprint(request, schema);
|
|
748
|
+
if (stringValue(object.queryFingerprint, "Data query result fingerprint", 128) !== fingerprint) return fail("Data query result fingerprint does not match its request", "DATA_QUERY_RESULT_INVALID");
|
|
749
|
+
if (stringValue(object.identityField, "Data query result identity field") !== schema.identityField) return fail("Data query result identity field does not match its schema", "DATA_QUERY_RESULT_INVALID");
|
|
750
|
+
if (!Array.isArray(object.rows)) return fail("Data query result rows must be an array");
|
|
751
|
+
const projection = request.mode === "rows" ? request.projection ?? [schema.identityField] : [];
|
|
752
|
+
const page = request.mode === "rows" ? request.page : void 0;
|
|
753
|
+
if (request.mode !== "rows" && object.rows.length > 0) return fail(`${request.mode} data query results cannot return rows`);
|
|
754
|
+
if (page && object.rows.length > page.limit) return fail("Data query result rows exceed its requested page limit");
|
|
755
|
+
const budget = {
|
|
756
|
+
remaining: schema.maxResultBytes ?? 1e6,
|
|
757
|
+
failureCode: "DATA_QUERY_RESULT_TOO_LARGE"
|
|
758
|
+
};
|
|
759
|
+
consumeJsonSegment(budget, "[", "Data query result rows");
|
|
760
|
+
const rows = object.rows.map((row, index) => {
|
|
761
|
+
if (index > 0) consumeJsonSegment(budget, ",", "Data query result rows");
|
|
762
|
+
return normalizeRow(row, projection, schema.identityField, fields, budget);
|
|
763
|
+
});
|
|
764
|
+
consumeJsonSegment(budget, "]", "Data query result rows");
|
|
765
|
+
let normalizedPage;
|
|
766
|
+
if (page) {
|
|
767
|
+
const pageObject = plainObject(object.page, "Data query result page");
|
|
768
|
+
exactKeys(pageObject, [
|
|
769
|
+
"kind",
|
|
770
|
+
"limit",
|
|
771
|
+
"offset",
|
|
772
|
+
"nextCursor",
|
|
773
|
+
"hasMore"
|
|
774
|
+
], "Data query result page");
|
|
775
|
+
if (pageObject.kind !== page.kind || nonNegativeInteger(pageObject.limit, "Data query result page limit") !== page.limit) return fail("Data query result page does not match its request");
|
|
776
|
+
if (typeof pageObject.hasMore !== "boolean") return fail("Data query result page hasMore must be boolean");
|
|
777
|
+
if (page.kind === "offset") {
|
|
778
|
+
if (nonNegativeInteger(pageObject.offset, "Data query result offset") !== page.offset) return fail("Data query result offset does not match its request");
|
|
779
|
+
if (pageObject.nextCursor !== void 0) return fail("Offset data query results cannot return a cursor");
|
|
780
|
+
normalizedPage = {
|
|
781
|
+
kind: "offset",
|
|
782
|
+
limit: page.limit,
|
|
783
|
+
offset: page.offset,
|
|
784
|
+
hasMore: pageObject.hasMore
|
|
785
|
+
};
|
|
786
|
+
} else {
|
|
787
|
+
if (pageObject.offset !== void 0) return fail("Cursor data query results cannot return an offset");
|
|
788
|
+
const nextCursor = pageObject.nextCursor === void 0 ? void 0 : stringValue(pageObject.nextCursor, "Data query result next cursor", MAX_DATA_QUERY_CURSOR_LENGTH);
|
|
789
|
+
if (pageObject.hasMore !== Boolean(nextCursor)) return fail("Data query cursor result hasMore must match nextCursor");
|
|
790
|
+
normalizedPage = {
|
|
791
|
+
kind: "cursor",
|
|
792
|
+
limit: page.limit,
|
|
793
|
+
hasMore: pageObject.hasMore,
|
|
794
|
+
...nextCursor === void 0 ? {} : { nextCursor }
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
} else if (object.page !== void 0) return fail(`${request.mode} data query results cannot return a page`);
|
|
798
|
+
const total = normalizeTotal(object.total);
|
|
799
|
+
const freshness = normalizeFreshness(object.freshness);
|
|
800
|
+
if (!Array.isArray(object.warnings) || object.warnings.length > 100 || object.warnings.some((warning) => typeof warning !== "string" || warning.length === 0 || warning.length > 512)) return fail("Data query result warnings must be at most 100 short strings");
|
|
801
|
+
if (typeof object.truncated !== "boolean") return fail("Data query result truncated must be boolean");
|
|
802
|
+
const facets = request.mode === "facets" ? normalizeFacetsResult(object.facets, request.facets ?? [], fields, budget) : object.facets === void 0 ? void 0 : fail(`${request.mode} data query results cannot return facets`);
|
|
803
|
+
const normalized = {
|
|
804
|
+
version: 1,
|
|
805
|
+
requestId: request.requestId,
|
|
806
|
+
queryFingerprint: fingerprint,
|
|
807
|
+
identityField: schema.identityField,
|
|
808
|
+
rows,
|
|
809
|
+
...normalizedPage === void 0 ? {} : { page: normalizedPage },
|
|
810
|
+
total,
|
|
811
|
+
...facets === void 0 ? {} : { facets },
|
|
812
|
+
freshness,
|
|
813
|
+
warnings: [...new Set(object.warnings)].sort(),
|
|
814
|
+
truncated: object.truncated
|
|
815
|
+
};
|
|
816
|
+
if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > (schema.maxResultBytes ?? 1e6)) return fail("Data query result exceeds its maximum byte limit", "DATA_QUERY_RESULT_TOO_LARGE");
|
|
817
|
+
return normalized;
|
|
818
|
+
}
|
|
819
|
+
//#endregion
|
|
820
|
+
export { DEFAULT_DATA_QUERY_PAGE_LIMIT, DEFAULT_DATA_QUERY_RESULT_BYTES, DataQueryValidationError, MAX_DATA_QUERY_CURSOR_LENGTH, MAX_DATA_QUERY_FACETS, MAX_DATA_QUERY_FILTERS, MAX_DATA_QUERY_FILTER_DEPTH, MAX_DATA_QUERY_IN_VALUES, MAX_DATA_QUERY_JSON_CONTAINER_ITEMS, MAX_DATA_QUERY_JSON_STRING_LENGTH, MAX_DATA_QUERY_OFFSET, MAX_DATA_QUERY_PAGE_LIMIT, MAX_DATA_QUERY_REQUEST_BYTES, MAX_DATA_QUERY_RESULT_BYTES, MAX_DATA_QUERY_WARNINGS, canonicalizeDataQuery, createDataQueryFingerprint, normalizeDataQueryRequest, normalizeDataQueryResult, normalizeDataQuerySchema };
|
|
821
|
+
|
|
822
|
+
//# sourceMappingURL=data-query.js.map
|