@ganju/utils 0.0.15 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/constants.d.ts +23 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +95 -8
  4. package/dist/data.d.ts +9 -0
  5. package/dist/data.d.ts.map +1 -0
  6. package/dist/data.js +78 -0
  7. package/dist/dataApi.d.ts +511 -0
  8. package/dist/dataApi.d.ts.map +1 -0
  9. package/dist/dataApi.js +355 -0
  10. package/dist/dataCompiler.d.ts +68 -0
  11. package/dist/dataCompiler.d.ts.map +1 -0
  12. package/dist/dataCompiler.js +726 -0
  13. package/dist/dataDdl.d.ts +29 -0
  14. package/dist/dataDdl.d.ts.map +1 -0
  15. package/dist/dataDdl.js +354 -0
  16. package/dist/dataEngine.d.ts +143 -0
  17. package/dist/dataEngine.d.ts.map +1 -0
  18. package/dist/dataEngine.js +2022 -0
  19. package/dist/dataIdentifiers.d.ts +8 -0
  20. package/dist/dataIdentifiers.d.ts.map +1 -0
  21. package/dist/dataIdentifiers.js +55 -0
  22. package/dist/dataImport.d.ts +77 -0
  23. package/dist/dataImport.d.ts.map +1 -0
  24. package/dist/dataImport.js +450 -0
  25. package/dist/dataRpc.d.ts +131 -0
  26. package/dist/dataRpc.d.ts.map +1 -0
  27. package/dist/dataRpc.js +2 -0
  28. package/dist/dataTypes.d.ts +157 -0
  29. package/dist/dataTypes.d.ts.map +1 -0
  30. package/dist/dataTypes.js +160 -0
  31. package/dist/index.d.ts +308 -6
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/oauth.d.ts +14 -0
  35. package/dist/oauth.d.ts.map +1 -1
  36. package/dist/oauth.js +42 -2
  37. package/dist/resourceRerank.d.ts +3 -2
  38. package/dist/resourceRerank.d.ts.map +1 -1
  39. package/dist/resourceRerank.js +5 -2
  40. package/dist/schema.d.ts +285 -4
  41. package/dist/schema.d.ts.map +1 -1
  42. package/dist/schema.js +203 -0
  43. package/package.json +17 -2
  44. package/src/constants.ts +108 -8
  45. package/src/data.ts +74 -0
  46. package/src/dataApi.ts +428 -0
  47. package/src/dataCompiler.ts +948 -0
  48. package/src/dataDdl.ts +419 -0
  49. package/src/dataEngine.ts +2753 -0
  50. package/src/dataIdentifiers.ts +63 -0
  51. package/src/dataImport.ts +554 -0
  52. package/src/dataRpc.ts +121 -0
  53. package/src/dataTypes.ts +312 -0
  54. package/src/index.ts +4 -0
  55. package/src/oauth.ts +42 -1
  56. package/src/resourceRerank.ts +5 -2
  57. package/src/schema.ts +246 -0
@@ -0,0 +1,726 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSensitiveWrite = exports.tidyArgs = exports.chunkRows = exports.rowsPerInsert = exports.keysetCondition = exports.decodeCursor = exports.encodeCursor = exports.ftsMatch = exports.assertParamCount = exports.compileWhere = exports.resolvePath = exports.fromStored = exports.zoned = exports.zoneOffsetMinutes = exports.toStored = exports.isoSeconds = exports.relationJoin = exports.joinsParams = exports.joinsSql = exports.participantOf = exports.canRead = exports.isScoped = exports.tableFor = exports.visibleTables = exports.accessOf = exports.unknownColumn = exports.columnFor = exports.isSystemColumn = exports.FILTER_OPERATORS = void 0;
4
+ // Turns the structured query and write arguments into SQL with bound values.
5
+ // Identifiers are checked against the database's own schema and quoted;
6
+ // every value a caller sends is a parameter. Pure: the engine runs the SQL.
7
+ const dataIdentifiers_1 = require("./dataIdentifiers");
8
+ const dataDdl_1 = require("./dataDdl");
9
+ const base64_1 = require("./base64");
10
+ const dataTypes_1 = require("./dataTypes");
11
+ exports.FILTER_OPERATORS = [
12
+ 'eq',
13
+ 'ne',
14
+ 'gt',
15
+ 'gte',
16
+ 'lt',
17
+ 'lte',
18
+ 'in',
19
+ 'notIn',
20
+ 'contains',
21
+ 'startsWith',
22
+ 'isNull'
23
+ ];
24
+ const SYSTEM_COLUMN_TYPES = {
25
+ id: 'integer',
26
+ created_at: 'datetime',
27
+ updated_at: 'datetime'
28
+ };
29
+ const isSystemColumn = (column) => 'system' in column && column.system === true;
30
+ exports.isSystemColumn = isSystemColumn;
31
+ const columnFor = (table, name) => {
32
+ if (name in SYSTEM_COLUMN_TYPES) {
33
+ return { name, type: SYSTEM_COLUMN_TYPES[name], system: true };
34
+ }
35
+ return table.columns.find(column => column.name === name);
36
+ };
37
+ exports.columnFor = columnFor;
38
+ const unknownColumn = (table, name) => new dataTypes_1.DataError(`Unknown column "${name}" in ${table.name}. Columns: ${[
39
+ 'id',
40
+ ...table.columns.map(c => c.name),
41
+ 'created_at',
42
+ 'updated_at'
43
+ ].join(', ')}.`, 'invalid');
44
+ exports.unknownColumn = unknownColumn;
45
+ // Access
46
+ const accessOf = (caller, table) => caller.restricted ? table.access : 'full';
47
+ exports.accessOf = accessOf;
48
+ const visibleTables = (schema, caller) => schema.tables.filter(table => dataTypes_1.DATA_ACCESS_RANK[(0, exports.accessOf)(caller, table)] >= 1);
49
+ exports.visibleTables = visibleTables;
50
+ // The table a call names, if the caller may `need` it. A table the caller
51
+ // can't see is reported as unknown, so a hidden table doesn't exist for it.
52
+ const tableFor = (schema, caller, name, need = 'read') => {
53
+ const table = typeof name === 'string'
54
+ ? schema.tables.find(t => t.name === name)
55
+ : undefined;
56
+ if (!table || dataTypes_1.DATA_ACCESS_RANK[(0, exports.accessOf)(caller, table)] < 1) {
57
+ const names = (0, exports.visibleTables)(schema, caller).map(t => t.name);
58
+ throw new dataTypes_1.DataError(typeof name === 'string'
59
+ ? `Unknown table "${name}". Tables: ${names.join(', ') || '(none)'}.`
60
+ : `"table" is the name of a table. Tables: ${names.join(', ') || '(none)'}.`, 'not_found');
61
+ }
62
+ const access = (0, exports.accessOf)(caller, table);
63
+ if (dataTypes_1.DATA_ACCESS_RANK[access] < dataTypes_1.DATA_ACCESS_RANK[need]) {
64
+ const allowed = access === 'read'
65
+ ? 'read only'
66
+ : access === 'add'
67
+ ? 'read and add rows only'
68
+ : access;
69
+ throw new dataTypes_1.DataError(`Not allowed: the assistant's access to "${table.name}" is ${allowed}.`, 'forbidden');
70
+ }
71
+ return table;
72
+ };
73
+ exports.tableFor = tableFor;
74
+ const isScoped = (caller, table) => Boolean(caller.restricted && table.customerScoped);
75
+ exports.isScoped = isScoped;
76
+ const canRead = (caller, table) => dataTypes_1.DATA_ACCESS_RANK[(0, exports.accessOf)(caller, table)] >= 1;
77
+ exports.canRead = canRead;
78
+ // The customer a scoped call is for. A restricted caller without one would
79
+ // otherwise share a single bucket with every other caller like it.
80
+ const participantOf = (caller) => {
81
+ if (typeof caller.participantId !== 'string' || !caller.participantId) {
82
+ throw new dataTypes_1.DataError("This conversation has no customer, so customer-scoped tables can't be used.", 'forbidden');
83
+ }
84
+ return caller.participantId;
85
+ };
86
+ exports.participantOf = participantOf;
87
+ const joinsSql = (joins) => [...joins.values()].map(join => join.sql).join(' ');
88
+ exports.joinsSql = joinsSql;
89
+ const joinsParams = (joins) => [...joins.values()].flatMap(join => join.params);
90
+ exports.joinsParams = joinsParams;
91
+ // The related table as the caller may see it: a bot reaches only its own
92
+ // customer's rows of a scoped table, through a label, a filter or a sort just
93
+ // as through a query on that table.
94
+ const relationJoin = (caller, target, alias, on) => {
95
+ const scoped = (0, exports.isScoped)(caller, target);
96
+ // No customer: no row of a scoped table is theirs.
97
+ if (scoped && !caller.participantId) {
98
+ return { sql: `LEFT JOIN ${(0, dataIdentifiers_1.quoteIdentifier)(target.name)} ${alias} ON 0`, params: [] };
99
+ }
100
+ return {
101
+ sql: `LEFT JOIN ${(0, dataIdentifiers_1.quoteIdentifier)(target.name)} ${alias} ON ${alias}.id = ${on}${scoped ? ` AND ${alias}.participant_id = ?` : ''}`,
102
+ params: scoped ? [(0, exports.participantOf)(caller)] : []
103
+ };
104
+ };
105
+ exports.relationJoin = relationJoin;
106
+ // Values
107
+ const isoSeconds = (date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z');
108
+ exports.isoSeconds = isoSeconds;
109
+ const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?$/;
110
+ // "2026-10-21T15:00:00" with no offset, read as a wall-clock time in
111
+ // `timeZone` (UTC without one). `new Date()` would read it in the server's
112
+ // zone: UTC on Workers, the developer's own zone on a laptop.
113
+ const localDatetime = (value, timeZone) => {
114
+ const m = LOCAL_DATETIME.exec(value.trim());
115
+ if (!m)
116
+ return null;
117
+ const [year, month, day, hour, minute, second] = m
118
+ .slice(1, 7)
119
+ .map(part => Number(part ?? 0));
120
+ const wall = Date.UTC(year, month - 1, day, hour, minute, second);
121
+ const check = new Date(wall);
122
+ if (check.getUTCFullYear() !== year ||
123
+ check.getUTCMonth() !== month - 1 ||
124
+ check.getUTCDate() !== day ||
125
+ hour > 23 ||
126
+ minute > 59 ||
127
+ second > 59) {
128
+ return new Date(NaN);
129
+ }
130
+ // The offset at the guess, then again at the result, so a time next to a
131
+ // daylight-saving change lands on the right side of it.
132
+ let at = wall - (0, exports.zoneOffsetMinutes)(new Date(wall), timeZone) * 60_000;
133
+ at = wall - (0, exports.zoneOffsetMinutes)(new Date(at), timeZone) * 60_000;
134
+ return new Date(at);
135
+ };
136
+ const normaliseDatetime = (value, label, timeZone) => {
137
+ if (typeof value !== 'string' && typeof value !== 'number') {
138
+ throw new dataTypes_1.DataError(`${label}: expected an ISO 8601 date and time.`);
139
+ }
140
+ const date = (typeof value === 'string' ? localDatetime(value, timeZone) : null) ??
141
+ new Date(value);
142
+ if (Number.isNaN(date.getTime())) {
143
+ throw new dataTypes_1.DataError(`${label}: "${value}" is not a date and time; use ISO 8601, e.g. 2026-10-19T09:00:00-05:00.`);
144
+ }
145
+ return (0, exports.isoSeconds)(date);
146
+ };
147
+ const isRealDate = (value) => {
148
+ const date = new Date(`${value}T00:00:00Z`);
149
+ return (!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value);
150
+ };
151
+ const asNumber = (value) => {
152
+ const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
153
+ return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
154
+ };
155
+ const checkSize = (label, value) => {
156
+ if (value.length > dataTypes_1.DATA_MAX_VALUE_BYTES) {
157
+ throw new dataTypes_1.DataError(`${label} is too long (over ${dataTypes_1.DATA_MAX_VALUE_BYTES.toLocaleString('en-US')} characters).`, 'too_large');
158
+ }
159
+ return value;
160
+ };
161
+ // Turns a value from a caller into what the column stores, or explains why
162
+ // it can't. `timeZone` is the project's, for datetimes sent without an
163
+ // offset.
164
+ const toStored = (table, column, value, timeZone) => {
165
+ const label = `${table.name}.${column.name}`;
166
+ if (value === null || value === undefined) {
167
+ if (!(0, exports.isSystemColumn)(column) && column.required) {
168
+ throw new dataTypes_1.DataError(`${label} is required.`);
169
+ }
170
+ return null;
171
+ }
172
+ switch (column.type) {
173
+ case 'integer':
174
+ case 'relation': {
175
+ const n = typeof value === 'string' && /^-?\d+$/.test(value.trim())
176
+ ? Number(value)
177
+ : value;
178
+ if (typeof n !== 'number' || !Number.isSafeInteger(n)) {
179
+ const target = (0, exports.isSystemColumn)(column) ? null : column.table;
180
+ throw new dataTypes_1.DataError(column.type === 'relation'
181
+ ? `${label} takes the id (a whole number) of a row in ${target}.`
182
+ : `${label} must be a whole number.`);
183
+ }
184
+ return n;
185
+ }
186
+ case 'decimal': {
187
+ const n = asNumber(value);
188
+ if (n === undefined)
189
+ throw new dataTypes_1.DataError(`${label} must be a number.`);
190
+ return n;
191
+ }
192
+ case 'boolean':
193
+ if (value === true || value === 'true' || value === 1)
194
+ return 1;
195
+ if (value === false || value === 'false' || value === 0)
196
+ return 0;
197
+ throw new dataTypes_1.DataError(`${label} must be true or false.`);
198
+ case 'date':
199
+ if (typeof value !== 'string' ||
200
+ !/^\d{4}-\d{2}-\d{2}$/.test(value) ||
201
+ !isRealDate(value)) {
202
+ throw new dataTypes_1.DataError(`${label} must be a date, YYYY-MM-DD.`);
203
+ }
204
+ return value;
205
+ case 'datetime':
206
+ return normaliseDatetime(value, label, timeZone);
207
+ case 'enum': {
208
+ const values = (0, exports.isSystemColumn)(column) ? [] : (column.values ?? []);
209
+ const match = values.find(v => v.toLowerCase() === String(value).toLowerCase());
210
+ if (!match) {
211
+ throw new dataTypes_1.DataError(`${label} must be one of: ${values.join(', ')}.`);
212
+ }
213
+ return match;
214
+ }
215
+ case 'json':
216
+ return checkSize(label, JSON.stringify(value));
217
+ case 'email': {
218
+ const text = typeof value === 'string' ? value.trim() : '';
219
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(text)) {
220
+ throw new dataTypes_1.DataError(`${label} must be an email address.`);
221
+ }
222
+ return checkSize(label, text);
223
+ }
224
+ case 'url':
225
+ if (typeof value !== 'string' || !/^https?:\/\/\S+$/i.test(value.trim())) {
226
+ throw new dataTypes_1.DataError(`${label} must be a URL starting with http(s)://.`);
227
+ }
228
+ return checkSize(label, value.trim());
229
+ case 'phone': {
230
+ if (typeof value !== 'string' && typeof value !== 'number') {
231
+ throw new dataTypes_1.DataError(`${label} must be a phone number.`);
232
+ }
233
+ const phone = String(value).replace(/[^\d+]/g, '');
234
+ if (!/^\+?\d{4,20}$/.test(phone)) {
235
+ throw new dataTypes_1.DataError(`${label} must be a phone number.`);
236
+ }
237
+ return phone;
238
+ }
239
+ default:
240
+ if (typeof value === 'object') {
241
+ throw new dataTypes_1.DataError(`${label} must be text.`);
242
+ }
243
+ return checkSize(label, String(value));
244
+ }
245
+ };
246
+ exports.toStored = toStored;
247
+ const zoneOffsetMinutes = (date, timeZone) => {
248
+ if (!timeZone)
249
+ return 0;
250
+ const parts = {};
251
+ for (const part of new Intl.DateTimeFormat('en-US', {
252
+ timeZone,
253
+ hourCycle: 'h23',
254
+ year: 'numeric',
255
+ month: '2-digit',
256
+ day: '2-digit',
257
+ hour: '2-digit',
258
+ minute: '2-digit',
259
+ second: '2-digit'
260
+ }).formatToParts(date)) {
261
+ parts[part.type] = part.value;
262
+ }
263
+ const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
264
+ return Math.round((asUtc - date.getTime()) / 60_000);
265
+ };
266
+ exports.zoneOffsetMinutes = zoneOffsetMinutes;
267
+ // A stored UTC datetime in the caller's zone, with the offset. Given UTC,
268
+ // models told customers "8:00 p. m." for a 3 pm appointment.
269
+ const zoned = (iso, timeZone) => {
270
+ const date = new Date(iso);
271
+ if (!timeZone || Number.isNaN(date.getTime()))
272
+ return iso;
273
+ const offset = (0, exports.zoneOffsetMinutes)(date, timeZone);
274
+ const local = new Date(date.getTime() + offset * 60_000)
275
+ .toISOString()
276
+ .slice(0, 19);
277
+ const sign = offset < 0 ? '-' : '+';
278
+ const abs = Math.abs(offset);
279
+ return `${local}${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
280
+ };
281
+ exports.zoned = zoned;
282
+ const fromStored = (column, value, timeZone) => {
283
+ if (value === null || value === undefined)
284
+ return null;
285
+ if (column.type === 'datetime')
286
+ return (0, exports.zoned)(String(value), timeZone);
287
+ if (column.type === 'boolean')
288
+ return value === 1;
289
+ if (column.type === 'json') {
290
+ try {
291
+ return JSON.parse(String(value));
292
+ }
293
+ catch {
294
+ return value;
295
+ }
296
+ }
297
+ return value;
298
+ };
299
+ exports.fromStored = fromStored;
300
+ // "column" or "relation.column", adding the join a relation path needs.
301
+ const resolvePath = (ctx, path) => {
302
+ if (typeof path !== 'string') {
303
+ throw new dataTypes_1.DataError('Column names are strings.');
304
+ }
305
+ const [head, tail, ...rest] = path.split('.');
306
+ if (rest.length > 0) {
307
+ throw new dataTypes_1.DataError(`"${path}": only one level of relation is supported (relation.column).`);
308
+ }
309
+ const column = (0, exports.columnFor)(ctx.table, head);
310
+ if (!column)
311
+ throw (0, exports.unknownColumn)(ctx.table, head);
312
+ if (tail === undefined) {
313
+ return { sql: `t.${(0, dataIdentifiers_1.quoteIdentifier)(head)}`, column, table: ctx.table, own: true };
314
+ }
315
+ if ((0, exports.isSystemColumn)(column) || column.type !== 'relation' || !column.table) {
316
+ throw new dataTypes_1.DataError(`"${head}" is not a relation, so "${path}" can't be used.`);
317
+ }
318
+ const target = (0, exports.tableFor)(ctx.schema, ctx.caller, column.table);
319
+ const targetColumn = (0, exports.columnFor)(target, tail);
320
+ if (!targetColumn)
321
+ throw (0, exports.unknownColumn)(target, tail);
322
+ const alias = `j_${head}`;
323
+ ctx.joins.set(alias, (0, exports.relationJoin)(ctx.caller, target, alias, `t.${(0, dataIdentifiers_1.quoteIdentifier)(head)}`));
324
+ return {
325
+ sql: `${alias}.${(0, dataIdentifiers_1.quoteIdentifier)(tail)}`,
326
+ column: targetColumn,
327
+ table: target,
328
+ own: false
329
+ };
330
+ };
331
+ exports.resolvePath = resolvePath;
332
+ const filterValue = (resolved, value, timeZone) => {
333
+ const { column, table } = resolved;
334
+ const label = `${table.name}.${column.name}`;
335
+ if (column.type === 'datetime') {
336
+ return normaliseDatetime(value, label, timeZone);
337
+ }
338
+ if (column.type === 'boolean' || column.type === 'enum') {
339
+ if (column.type === 'enum') {
340
+ const values = (0, exports.isSystemColumn)(column) ? [] : (column.values ?? []);
341
+ const match = values.find(v => v.toLowerCase() === String(value).toLowerCase());
342
+ if (!match) {
343
+ throw new dataTypes_1.DataError(`${label} is one of: ${values.join(', ')}.`);
344
+ }
345
+ return match;
346
+ }
347
+ return (0, exports.toStored)(table, column, value);
348
+ }
349
+ if (dataDdl_1.NUMERIC_TYPES.has(column.type) || column.type === 'relation') {
350
+ const n = asNumber(value);
351
+ if (n === undefined) {
352
+ throw new dataTypes_1.DataError(`${label} compares with a number.`);
353
+ }
354
+ return n;
355
+ }
356
+ if (typeof value === 'object' && value !== null) {
357
+ throw new dataTypes_1.DataError(`${label}: expected a value or an operator object.`);
358
+ }
359
+ if (typeof value === 'boolean')
360
+ return String(value);
361
+ return value;
362
+ };
363
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
364
+ // The conditions of a `where`, as SQL joined with AND by the caller.
365
+ // `indexed` collects the columns of the queried table an AND-ed condition
366
+ // filters on, which is what lets a large table be aggregated.
367
+ const compileWhere = (ctx, where, indexed) => {
368
+ if (where === undefined || where === null)
369
+ return [];
370
+ if (!isPlainObject(where)) {
371
+ throw new dataTypes_1.DataError('"where" is an object: { column: value, column: { operator: value } }.');
372
+ }
373
+ const clauses = [];
374
+ for (const [key, condition] of Object.entries(where)) {
375
+ if (key === 'or' || key === 'and') {
376
+ if (!Array.isArray(condition) || condition.length === 0) {
377
+ throw new dataTypes_1.DataError(`"${key}" takes a list of where objects.`);
378
+ }
379
+ const parts = condition.map(sub => {
380
+ const inner = (0, exports.compileWhere)(ctx, sub, key === 'and' ? indexed : undefined);
381
+ // An empty entry would match every row: { "or": [{}] } is "1".
382
+ if (inner.length === 0) {
383
+ throw new dataTypes_1.DataError(`Each entry in "${key}" is a where object with at least one condition.`);
384
+ }
385
+ return `(${inner.join(' AND ')})`;
386
+ });
387
+ clauses.push(`(${parts.join(key === 'or' ? ' OR ' : ' AND ')})`);
388
+ continue;
389
+ }
390
+ const resolved = (0, exports.resolvePath)(ctx, key);
391
+ // { "customer": { "name": "Ana" } } reads as "customer.name": models
392
+ // write relation filters this way unprompted.
393
+ if (resolved.column.type === 'relation' &&
394
+ !key.includes('.') &&
395
+ isPlainObject(condition) &&
396
+ Object.keys(condition).some(k => !exports.FILTER_OPERATORS.includes(k))) {
397
+ clauses.push(...(0, exports.compileWhere)(ctx, Object.fromEntries(Object.entries(condition).map(([k, v]) => [`${key}.${k}`, v]))));
398
+ continue;
399
+ }
400
+ if (resolved.own && indexed) {
401
+ const column = resolved.column;
402
+ if (column.name === 'id' ||
403
+ (!(0, exports.isSystemColumn)(column) &&
404
+ (column.unique ||
405
+ column.filterable ||
406
+ column.type === 'relation' ||
407
+ column.type === 'file'))) {
408
+ indexed.add(column.name);
409
+ }
410
+ }
411
+ if (!isPlainObject(condition) || resolved.column.type === 'json') {
412
+ if (Array.isArray(condition)) {
413
+ throw new dataTypes_1.DataError(`${key}: to match several values use { "in": [...] }.`);
414
+ }
415
+ if (condition === null) {
416
+ clauses.push(`${resolved.sql} IS NULL`);
417
+ }
418
+ else {
419
+ clauses.push(`${resolved.sql} = ?`);
420
+ ctx.params.push(resolved.column.type === 'json'
421
+ ? JSON.stringify(condition)
422
+ : filterValue(resolved, condition, ctx.caller.timeZone));
423
+ }
424
+ continue;
425
+ }
426
+ for (const [operator, value] of Object.entries(condition)) {
427
+ if (!exports.FILTER_OPERATORS.includes(operator)) {
428
+ throw new dataTypes_1.DataError(`${key}: unknown operator "${operator}". Operators: ${exports.FILTER_OPERATORS.join(', ')}.`);
429
+ }
430
+ switch (operator) {
431
+ case 'eq':
432
+ case 'ne':
433
+ case 'gt':
434
+ case 'gte':
435
+ case 'lt':
436
+ case 'lte': {
437
+ if (value === null && (operator === 'eq' || operator === 'ne')) {
438
+ clauses.push(`${resolved.sql} IS ${operator === 'ne' ? 'NOT ' : ''}NULL`);
439
+ break;
440
+ }
441
+ const sign = {
442
+ eq: '=',
443
+ ne: '!=',
444
+ gt: '>',
445
+ gte: '>=',
446
+ lt: '<',
447
+ lte: '<='
448
+ }[operator];
449
+ // { "stock": { "lt": { "column": "reorder_level" } } }
450
+ if (isPlainObject(value) && typeof value.column === 'string') {
451
+ const other = (0, exports.resolvePath)(ctx, value.column);
452
+ clauses.push(`${resolved.sql} ${sign} ${other.sql}`);
453
+ break;
454
+ }
455
+ clauses.push(`${resolved.sql} ${sign} ?`);
456
+ ctx.params.push(filterValue(resolved, value, ctx.caller.timeZone));
457
+ break;
458
+ }
459
+ case 'in':
460
+ case 'notIn': {
461
+ if (!Array.isArray(value) ||
462
+ value.length === 0 ||
463
+ value.length > dataTypes_1.DATA_MAX_IN_VALUES) {
464
+ throw new dataTypes_1.DataError(`${key}.${operator} takes a list of 1 to ${dataTypes_1.DATA_MAX_IN_VALUES} values.`);
465
+ }
466
+ clauses.push(`${resolved.sql} ${operator === 'notIn' ? 'NOT ' : ''}IN (${value
467
+ .map(() => '?')
468
+ .join(', ')})`);
469
+ ctx.params.push(...value.map(v => filterValue(resolved, v, ctx.caller.timeZone)));
470
+ break;
471
+ }
472
+ case 'contains':
473
+ case 'startsWith': {
474
+ if (typeof value !== 'string') {
475
+ throw new dataTypes_1.DataError(`${key}.${operator} takes text.`);
476
+ }
477
+ const escaped = value.replace(/[\\%_]/g, c => `\\${c}`);
478
+ clauses.push(`${resolved.sql} LIKE ? ESCAPE '\\'`);
479
+ ctx.params.push(operator === 'contains' ? `%${escaped}%` : `${escaped}%`);
480
+ break;
481
+ }
482
+ case 'isNull':
483
+ clauses.push(`${resolved.sql} IS ${value ? '' : 'NOT '}NULL`);
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ return clauses;
489
+ };
490
+ exports.compileWhere = compileWhere;
491
+ const assertParamCount = (params) => {
492
+ if (params.length > dataTypes_1.DATA_MAX_PARAMS) {
493
+ throw new dataTypes_1.DataError(`This query uses ${params.length} values; the limit is ${dataTypes_1.DATA_MAX_PARAMS}. Use fewer "in" values or split it.`, 'too_large');
494
+ }
495
+ };
496
+ exports.assertParamCount = assertParamCount;
497
+ // Search
498
+ // FTS5 query for the words in `text`: each word as a quoted prefix, so
499
+ // nothing the caller types is FTS syntax. All words, or any with `any`.
500
+ const ftsMatch = (text, any) => {
501
+ const tokens = String(text ?? '')
502
+ .normalize('NFKD')
503
+ .replace(/[̀-ͯ]/g, '')
504
+ .toLowerCase()
505
+ .match(/[\p{L}\p{N}]+/gu);
506
+ if (!tokens || tokens.length === 0) {
507
+ throw new dataTypes_1.DataError('"search" needs at least one word.');
508
+ }
509
+ return tokens
510
+ .slice(0, 10)
511
+ .map(t => `"${t}"*`)
512
+ .join(any ? ' OR ' : ' ');
513
+ };
514
+ exports.ftsMatch = ftsMatch;
515
+ const encodeCursor = (cursor) => (0, base64_1.utf8ToBase64Url)(JSON.stringify(cursor));
516
+ exports.encodeCursor = encodeCursor;
517
+ const decodeCursor = (raw, signature) => {
518
+ try {
519
+ const cursor = JSON.parse((0, base64_1.base64UrlToUtf8)(String(raw)));
520
+ if (!cursor ||
521
+ cursor.k !== signature ||
522
+ !Array.isArray(cursor.v) ||
523
+ cursor.v.some(v => v !== null && typeof v !== 'string' && typeof v !== 'number')) {
524
+ throw new Error('mismatch');
525
+ }
526
+ return cursor;
527
+ }
528
+ catch {
529
+ throw new dataTypes_1.DataError('"cursor" is not one this query returned; send the same table and sort with the cursor from the previous page.');
530
+ }
531
+ };
532
+ exports.decodeCursor = decodeCursor;
533
+ // Rows strictly after `values` in the order `keys`, as one condition. NULLs
534
+ // sort first ascending and last descending, as in SQLite. The last key is
535
+ // unique (the id), so no row is skipped or repeated across pages, even when
536
+ // rows share a sort value.
537
+ const keysetCondition = (keys, values, params) => {
538
+ const branches = [];
539
+ for (let i = 0; i < keys.length; i++) {
540
+ const parts = [];
541
+ for (let j = 0; j < i; j++) {
542
+ if (values[j] === null) {
543
+ parts.push(`${keys[j].sql} IS NULL`);
544
+ }
545
+ else {
546
+ parts.push(`${keys[j].sql} = ?`);
547
+ params.push(values[j]);
548
+ }
549
+ }
550
+ const { sql, descending } = keys[i];
551
+ const value = values[i];
552
+ if (!descending) {
553
+ if (value === null) {
554
+ parts.push(`${sql} IS NOT NULL`);
555
+ }
556
+ else {
557
+ parts.push(`${sql} > ?`);
558
+ params.push(value);
559
+ }
560
+ }
561
+ else {
562
+ if (value === null)
563
+ continue;
564
+ parts.push(`(${sql} < ? OR ${sql} IS NULL)`);
565
+ params.push(value);
566
+ }
567
+ branches.push(`(${parts.join(' AND ')})`);
568
+ }
569
+ return branches.length ? `(${branches.join(' OR ')})` : '0';
570
+ };
571
+ exports.keysetCondition = keysetCondition;
572
+ // Inserts
573
+ // How many rows of `columnsPerRow` values fit in one statement.
574
+ const rowsPerInsert = (columnsPerRow) => Math.max(1, Math.floor(dataTypes_1.DATA_MAX_PARAMS / Math.max(1, columnsPerRow)));
575
+ exports.rowsPerInsert = rowsPerInsert;
576
+ // Splits rows into groups that each bind at most 100 parameters.
577
+ const chunkRows = (rows, columnsPerRow) => {
578
+ if (columnsPerRow > dataTypes_1.DATA_MAX_PARAMS) {
579
+ throw new dataTypes_1.DataError(`A row with ${columnsPerRow} values can't be written in one statement.`, 'too_large');
580
+ }
581
+ const size = (0, exports.rowsPerInsert)(columnsPerRow);
582
+ const out = [];
583
+ for (let i = 0; i < rows.length; i += size)
584
+ out.push(rows.slice(i, i + size));
585
+ return out;
586
+ };
587
+ exports.chunkRows = chunkRows;
588
+ // Arguments
589
+ // Models sometimes quote keys twice ("\"stock\"") or send "where" as a JSON
590
+ // string; both are read as meant. Keys inside a value a row stores (a json
591
+ // column) are left alone: only column names and our own operators are ours
592
+ // to tidy.
593
+ const tidyKey = (key) => key.replace(/^[\s"'`]+|[\s"'`]+$/g, '');
594
+ const tidy = (value) => {
595
+ if (Array.isArray(value))
596
+ return value.map(tidy);
597
+ if (isPlainObject(value)) {
598
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [tidyKey(k), tidy(v)]));
599
+ }
600
+ return value;
601
+ };
602
+ const VALUE_OPERATORS = ['increment', 'decrement', '$ref'];
603
+ // A value in "values", "rows" or "set": { "\"decrement\"": 2 } is ours,
604
+ // anything else is what the column stores, as sent.
605
+ const tidyValue = (value) => {
606
+ if (!isPlainObject(value))
607
+ return value;
608
+ const entries = Object.entries(value);
609
+ if (entries.length !== 1)
610
+ return value;
611
+ const key = tidyKey(entries[0][0]);
612
+ return VALUE_OPERATORS.includes(key) ? { [key]: entries[0][1] } : value;
613
+ };
614
+ const tidyColumns = (values) => isPlainObject(values)
615
+ ? Object.fromEntries(Object.entries(values).map(([k, v]) => [tidyKey(k), tidyValue(v)]))
616
+ : values;
617
+ const tidyOperation = (op) => {
618
+ if (!isPlainObject(op))
619
+ return op;
620
+ const out = {};
621
+ for (const [rawKey, value] of Object.entries(op)) {
622
+ const key = tidyKey(rawKey);
623
+ out[key] =
624
+ key === 'values' || key === 'set'
625
+ ? tidyColumns(value)
626
+ : key === 'rows' && Array.isArray(value)
627
+ ? value.map(tidyColumns)
628
+ : key === 'where'
629
+ ? tidy(value)
630
+ : value;
631
+ }
632
+ return out;
633
+ };
634
+ const tidyArgs = (args) => {
635
+ if (!isPlainObject(args ?? {})) {
636
+ throw new dataTypes_1.DataError('Arguments are an object.');
637
+ }
638
+ const out = {};
639
+ for (const [rawKey, value] of Object.entries((args ?? {}))) {
640
+ out[tidyKey(rawKey)] = value;
641
+ }
642
+ for (const key of [
643
+ 'where',
644
+ 'aggregate',
645
+ 'groupBy',
646
+ 'sort',
647
+ 'include',
648
+ 'operations'
649
+ ]) {
650
+ if (typeof out[key] === 'string') {
651
+ try {
652
+ out[key] = JSON.parse(out[key]);
653
+ }
654
+ catch {
655
+ // Left as is; the compiler explains the expected shape.
656
+ }
657
+ }
658
+ }
659
+ for (const [key, value] of Object.entries(out)) {
660
+ if (key === 'operations') {
661
+ out[key] = Array.isArray(value) ? value.map(tidyOperation) : value;
662
+ }
663
+ else if (key !== 'search' && key !== 'cursor') {
664
+ out[key] = tidy(value);
665
+ }
666
+ }
667
+ return out;
668
+ };
669
+ exports.tidyArgs = tidyArgs;
670
+ const singleId = (value) => {
671
+ const n = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value;
672
+ return typeof n === 'number' && Number.isInteger(n);
673
+ };
674
+ // Whether an update can touch one row at most: it names an `id`, or its
675
+ // "where" pins id to one value ({ "id": 1 }, { "id": { "eq": 1 } },
676
+ // { "id": { "in": [1] } }), or it names a row inserted earlier in the same
677
+ // write ({ "$ref": 0 }, always a single-row insert). Other keys sit beside it
678
+ // with AND, so they only narrow it further.
679
+ const targetsOneRow = (op) => {
680
+ if (typeof op.id === 'number')
681
+ return true;
682
+ if (isPlainObject(op.id) && '$ref' in op.id)
683
+ return true;
684
+ if (!isPlainObject(op.where) || !('id' in op.where))
685
+ return false;
686
+ const id = op.where.id;
687
+ if (singleId(id))
688
+ return true;
689
+ if (!isPlainObject(id))
690
+ return false;
691
+ return (singleId(id.eq) ||
692
+ (Array.isArray(id.in) && id.in.length === 1 && singleId(id.in[0])));
693
+ };
694
+ const isRelativeChange = (value) => isPlainObject(value) &&
695
+ Object.keys(value).length === 1 &&
696
+ ('increment' in value || 'decrement' in value);
697
+ // Whether a write is held for confirmation: it deletes, or sets a column to
698
+ // a value. Inserts are not, or every order would ask. A relative change (the
699
+ // stock an order uses) is not held when it reaches one row by id, or when the
700
+ // column has a minimum that stops it; otherwise "lower every price by
701
+ // 27,999" would run without asking. Without `schema`, only the id counts.
702
+ const isSensitiveWrite = (rawArgs, schema) => {
703
+ const args = (0, exports.tidyArgs)(rawArgs);
704
+ const operations = args.operations;
705
+ if (!Array.isArray(operations))
706
+ return true;
707
+ return operations.some(op => {
708
+ if (!isPlainObject(op))
709
+ return true;
710
+ if (op.op === 'insert')
711
+ return false;
712
+ if (op.op !== 'update')
713
+ return true;
714
+ const oneRow = targetsOneRow(op);
715
+ const table = schema?.tables.find(t => t.name === op.table);
716
+ return Object.entries(isPlainObject(op.set) ? op.set : {}).some(([name, value]) => {
717
+ if (!isRelativeChange(value))
718
+ return true;
719
+ if (oneRow)
720
+ return false;
721
+ const column = table?.columns.find(c => c.name === name);
722
+ return column?.min === null || column?.min === undefined;
723
+ });
724
+ });
725
+ };
726
+ exports.isSensitiveWrite = isSensitiveWrite;