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