terrascale 0.3.0

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 (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +143 -0
  3. package/package.json +159 -0
  4. package/sdk-current-contract.json +27 -0
  5. package/sdk-route-manifest.json +67 -0
  6. package/src/admin.js +9 -0
  7. package/src/better-auth.js +14 -0
  8. package/src/config.js +121 -0
  9. package/src/database-codec.js +845 -0
  10. package/src/database-types.js +237 -0
  11. package/src/database-view.js +422 -0
  12. package/src/database.js +420 -0
  13. package/src/discovery.js +374 -0
  14. package/src/http.js +887 -0
  15. package/src/index.js +79 -0
  16. package/src/local/authentication.js +47 -0
  17. package/src/local/better-auth.js +517 -0
  18. package/src/local/cli.js +51 -0
  19. package/src/local/context.js +23 -0
  20. package/src/local/environment.js +109 -0
  21. package/src/local/index.js +204 -0
  22. package/src/local/router.js +999 -0
  23. package/src/local/server.js +664 -0
  24. package/src/local/store.js +530 -0
  25. package/src/local/test-environment.js +74 -0
  26. package/src/management-contracts.js +72 -0
  27. package/src/management.js +12 -0
  28. package/src/native-origin.js +75 -0
  29. package/src/postgres.js +494 -0
  30. package/src/react/core.js +743 -0
  31. package/src/react/index.js +99 -0
  32. package/src/result.js +251 -0
  33. package/src/schema.js +366 -0
  34. package/src/sql.js +996 -0
  35. package/src/svelte/index.js +129 -0
  36. package/src/tanstack/index.js +511 -0
  37. package/src/ts-auth-discovery.js +190 -0
  38. package/src/ts-auth.js +3497 -0
  39. package/types/admin.d.ts +6 -0
  40. package/types/better-auth.d.ts +8 -0
  41. package/types/config.d.ts +58 -0
  42. package/types/database-codec.d.ts +111 -0
  43. package/types/database-types.d.ts +213 -0
  44. package/types/database-view.d.ts +183 -0
  45. package/types/database.d.ts +98 -0
  46. package/types/discovery.d.ts +114 -0
  47. package/types/http.d.ts +46 -0
  48. package/types/index.d.ts +52 -0
  49. package/types/local/authentication.d.ts +11 -0
  50. package/types/local/better-auth.d.ts +33 -0
  51. package/types/local/cli.d.ts +2 -0
  52. package/types/local/context.d.ts +14 -0
  53. package/types/local/environment.d.ts +23 -0
  54. package/types/local/index.d.ts +94 -0
  55. package/types/local/router.d.ts +66 -0
  56. package/types/local/server.d.ts +54 -0
  57. package/types/local/store.d.ts +106 -0
  58. package/types/local/test-environment.d.ts +25 -0
  59. package/types/management-contracts.d.ts +44 -0
  60. package/types/management.d.ts +6 -0
  61. package/types/native-origin.d.ts +23 -0
  62. package/types/postgres.d.ts +123 -0
  63. package/types/react/core.d.ts +366 -0
  64. package/types/react/index.d.ts +54 -0
  65. package/types/result.d.ts +161 -0
  66. package/types/schema.d.ts +145 -0
  67. package/types/sql.d.ts +288 -0
  68. package/types/svelte/index.d.ts +81 -0
  69. package/types/tanstack/index.d.ts +165 -0
  70. package/types/ts-auth-discovery.d.ts +11 -0
  71. package/types/ts-auth.d.ts +1826 -0
package/src/sql.js ADDED
@@ -0,0 +1,996 @@
1
+ /**
2
+ * Pure parameterized SQL construction. Listener capabilities are checked by the PostgreSQL transport.
3
+ * This module performs no execution and grants no server SQL capabilities.
4
+ *
5
+ * The typed builder accepts schema field descriptors, not SQL fragments. All
6
+ * values (including LIMIT/OFFSET values) are kept separately from the SQL
7
+ * statement and are sent as positional parameters.
8
+ */
9
+
10
+ import { FieldTypes } from "./schema.js";
11
+ /** @import { CollectionSchema, FieldDef, FieldType } from "./schema.js" */
12
+
13
+ /**
14
+ * @typedef {Pick<FieldDef, "name" | "type"> &
15
+ * Partial<Omit<FieldDef, "name" | "type">>} SqlFieldDef
16
+ */
17
+ /**
18
+ * @typedef {{
19
+ * readonly name: string;
20
+ * readonly fields: readonly SqlFieldDef[];
21
+ * } & Partial<Omit<CollectionSchema, "name" | "fields">>} SqlCollectionSchema
22
+ */
23
+ /**
24
+ * @template {SqlCollectionSchema} S
25
+ * @typedef {S["fields"][number]} SchemaField
26
+ */
27
+ /**
28
+ * @template {SqlCollectionSchema} S
29
+ * @typedef {SchemaField<S>} QueryField
30
+ */
31
+
32
+ export const Direction = Object.freeze(
33
+ /** @type {const} */ ({
34
+ Asc: "ASC",
35
+ Desc: "DESC",
36
+ }),
37
+ );
38
+
39
+ /** @typedef {(typeof Direction)[keyof typeof Direction]} Direction */
40
+ /** @typedef {boolean | bigint | number | string | Uint8Array | null} SqlScalar */
41
+ /** @typedef {SqlScalar} SqlParameter */
42
+
43
+ export class SqlValidationError extends Error {
44
+ /**
45
+ * @override
46
+ * @readonly
47
+ */
48
+ name = /** @type {const} */ ("SqlValidationError");
49
+ /** @readonly */
50
+ code = /** @type {const} */ ("invalid_sql");
51
+
52
+ /**
53
+ * @param {string} message
54
+ */
55
+ constructor(message) {
56
+ super(`terrascale SQL: ${message}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @typedef {{
62
+ * readonly statement: string;
63
+ * readonly parameters: readonly SqlParameter[];
64
+ * }} QueryDescriptor
65
+ */
66
+
67
+ /**
68
+ * @param {readonly SqlParameter[]} parameters
69
+ * @returns {readonly SqlParameter[]}
70
+ */
71
+ function freezeParameters(parameters) {
72
+ return Object.freeze(
73
+ parameters.map((parameter) => cloneParameter(parameter)),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * @param {SqlParameter} parameter
79
+ * @returns {SqlParameter}
80
+ */
81
+ function cloneParameter(parameter) {
82
+ return parameter instanceof Uint8Array ? parameter.slice() : parameter;
83
+ }
84
+
85
+ /** A validated, parameterized read-only SQL statement. */
86
+ export class Query {
87
+ /**
88
+ * @readonly
89
+ * @type {string}
90
+ */
91
+ statement;
92
+ /** @type {readonly SqlParameter[]} */
93
+ #parameters;
94
+ /**
95
+ * @returns {readonly SqlParameter[]}
96
+ */
97
+ get parameters() {
98
+ return freezeParameters(this.#parameters);
99
+ }
100
+
101
+ /**
102
+ * @private
103
+ * @param {string} sql
104
+ * @param {readonly SqlParameter[]} parameters
105
+ */
106
+ constructor(sql, parameters) {
107
+ validateSql(sql, parameters);
108
+ this.statement = sql;
109
+ this.#parameters = freezeParameters(parameters);
110
+ Object.freeze(this);
111
+ }
112
+
113
+ /**
114
+ * @param {string} sql
115
+ * @param {readonly SqlParameter[]} [parameters]
116
+ * @returns {Query}
117
+ */
118
+ static raw(sql, parameters = []) {
119
+ return new Query(sql, parameters);
120
+ }
121
+
122
+ /**
123
+ * @param {readonly string[]} [columns]
124
+ * @returns {SelectBuilder}
125
+ */
126
+ static select(columns = ["*"]) {
127
+ return SelectBuilder.fromColumns(columns);
128
+ }
129
+
130
+ /**
131
+ * @template {SqlCollectionSchema} S
132
+ * @param {S} schema
133
+ * @returns {SelectBuilder<S>}
134
+ */
135
+ static selectSchema(schema) {
136
+ return SelectBuilder.fromSchema(schema);
137
+ }
138
+
139
+ /**
140
+ * @returns {QueryDescriptor}
141
+ */
142
+ toDescriptor() {
143
+ return Object.freeze({
144
+ statement: this.statement,
145
+ parameters: this.parameters,
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Schema-bound SELECT builder.
152
+ *
153
+ * @template {SqlCollectionSchema} [S=SqlCollectionSchema]
154
+ */
155
+ export class SelectBuilder {
156
+ /** @type {readonly string[]} */
157
+ #columns;
158
+ /** @type {string | undefined} */
159
+ #from;
160
+ /** @type {S | undefined} */
161
+ #schema;
162
+ /** @type {readonly string[]} */
163
+ #predicates;
164
+ /** @type {readonly (readonly [string, Direction])[]} */
165
+ #order;
166
+ /** @type {number | undefined} */
167
+ #limit;
168
+ /** @type {number | undefined} */
169
+ #offset;
170
+ /** @type {readonly SqlParameter[]} */
171
+ #parameters;
172
+ /** @type {boolean} */
173
+ #schemaBound;
174
+
175
+ /**
176
+ * @private
177
+ * @param {{
178
+ * columns: readonly string[];
179
+ * from?: string | undefined;
180
+ * schema?: S | undefined;
181
+ * predicates?: readonly string[];
182
+ * order?: readonly (readonly [string, Direction])[];
183
+ * limit?: number | undefined;
184
+ * offset?: number | undefined;
185
+ * parameters?: readonly SqlParameter[];
186
+ * schemaBound?: boolean;
187
+ * }} input
188
+ */
189
+ constructor(input) {
190
+ this.#columns = Object.freeze([...input.columns]);
191
+ this.#from = input.from;
192
+ this.#schema = input.schema;
193
+ this.#predicates = Object.freeze([...(input.predicates ?? [])]);
194
+ this.#order = input.order ?? [];
195
+ this.#limit = input.limit;
196
+ this.#offset = input.offset;
197
+ this.#parameters = freezeParameters(input.parameters ?? []);
198
+ this.#schemaBound = input.schemaBound ?? false;
199
+ }
200
+
201
+ /**
202
+ * @param {Partial<{
203
+ * columns: readonly string[];
204
+ * from: string | undefined;
205
+ * predicates: readonly string[];
206
+ * order: readonly (readonly [string, Direction])[];
207
+ * limit: number | undefined;
208
+ * offset: number | undefined;
209
+ * parameters: readonly SqlParameter[];
210
+ * }>} changes
211
+ * @returns {SelectBuilder<S>}
212
+ */
213
+ #copy(changes) {
214
+ return /** @type {SelectBuilder<S>} */ (
215
+ new SelectBuilder({
216
+ columns: changes.columns ?? this.#columns,
217
+ from: changes.from === undefined ? this.#from : changes.from,
218
+ schema: this.#schema,
219
+ predicates: changes.predicates ?? this.#predicates,
220
+ order: changes.order ?? this.#order,
221
+ limit: changes.limit === undefined ? this.#limit : changes.limit,
222
+ offset: changes.offset === undefined ? this.#offset : changes.offset,
223
+ parameters: changes.parameters ?? this.#parameters,
224
+ schemaBound: this.#schemaBound,
225
+ })
226
+ );
227
+ }
228
+
229
+ /**
230
+ * @param {readonly string[]} columns
231
+ * @returns {SelectBuilder}
232
+ */
233
+ static fromColumns(columns) {
234
+ return new SelectBuilder({ columns: [...columns] });
235
+ }
236
+
237
+ /**
238
+ * @template {SqlCollectionSchema} T
239
+ * @param {T} schema
240
+ * @returns {SelectBuilder<T>}
241
+ */
242
+ static fromSchema(schema) {
243
+ validateIdentifier(schema.name, "collection");
244
+ if (!Array.isArray(schema.fields))
245
+ throw new SqlValidationError("schema fields must be an array");
246
+ /** @type {Set<string>} */
247
+ const names = new Set();
248
+ for (const field of schema.fields) {
249
+ validateField(field, "schema field");
250
+ if (names.has(field.name))
251
+ throw new SqlValidationError(`duplicate schema field '${field.name}'`);
252
+ names.add(field.name);
253
+ }
254
+ return /** @type {SelectBuilder<T>} */ (
255
+ new SelectBuilder({
256
+ columns: ["*"],
257
+ from: schema.name,
258
+ schema: /** @type {T} */ (
259
+ Object.freeze({
260
+ ...schema,
261
+ fields: Object.freeze(
262
+ schema.fields.map((field) => Object.freeze({ ...field })),
263
+ ),
264
+ })
265
+ ),
266
+ schemaBound: true,
267
+ })
268
+ );
269
+ }
270
+
271
+ /**
272
+ * @param {QueryField<S>} field
273
+ * @returns {SelectBuilder<S>}
274
+ */
275
+ column(field) {
276
+ const name = this.#checkedField(field, "column");
277
+ return this.#copy({
278
+ columns: [
279
+ ...(this.#columns.length === 1 && this.#columns[0] === "*"
280
+ ? []
281
+ : this.#columns),
282
+ name,
283
+ ],
284
+ });
285
+ }
286
+
287
+ /**
288
+ * @param {readonly QueryField<S>[]} fields
289
+ * @returns {SelectBuilder<S>}
290
+ */
291
+ columns(fields) {
292
+ /** @type {SelectBuilder<S>} */
293
+ let builder = this;
294
+ for (const field of fields) builder = builder.column(field);
295
+ return builder;
296
+ }
297
+
298
+ /**
299
+ * @param {string} collection
300
+ * @returns {SelectBuilder<S>}
301
+ */
302
+ from(collection) {
303
+ const valid = validateIdentifier(collection, "collection");
304
+ if (this.#schemaBound && valid !== this.#from) {
305
+ throw new SqlValidationError(
306
+ "a schema-bound query cannot change its collection",
307
+ );
308
+ }
309
+ return this.#copy({ from: valid });
310
+ }
311
+
312
+ /**
313
+ * @template {SqlParameter} T
314
+ * @param {QueryField<S>} field
315
+ * @param {T} value
316
+ * @returns {SelectBuilder<S>}
317
+ */
318
+ whereEq(field, value) {
319
+ return this.#comparison(field, "=", value);
320
+ }
321
+
322
+ /**
323
+ * @template {SqlParameter} T
324
+ * @param {QueryField<S>} field
325
+ * @param {T} value
326
+ * @returns {SelectBuilder<S>}
327
+ */
328
+ whereNe(field, value) {
329
+ return this.#comparison(field, "<>", value);
330
+ }
331
+
332
+ /**
333
+ * @template {SqlParameter} T
334
+ * @param {QueryField<S>} field
335
+ * @param {T} value
336
+ * @returns {SelectBuilder<S>}
337
+ */
338
+ whereGt(field, value) {
339
+ return this.#comparison(field, ">", value);
340
+ }
341
+
342
+ /**
343
+ * @template {SqlParameter} T
344
+ * @param {QueryField<S>} field
345
+ * @param {T} value
346
+ * @returns {SelectBuilder<S>}
347
+ */
348
+ whereGte(field, value) {
349
+ return this.#comparison(field, ">=", value);
350
+ }
351
+
352
+ /**
353
+ * @template {SqlParameter} T
354
+ * @param {QueryField<S>} field
355
+ * @param {T} value
356
+ * @returns {SelectBuilder<S>}
357
+ */
358
+ whereLt(field, value) {
359
+ return this.#comparison(field, "<", value);
360
+ }
361
+
362
+ /**
363
+ * @template {SqlParameter} T
364
+ * @param {QueryField<S>} field
365
+ * @param {T} value
366
+ * @returns {SelectBuilder<S>}
367
+ */
368
+ whereLte(field, value) {
369
+ return this.#comparison(field, "<=", value);
370
+ }
371
+
372
+ /**
373
+ * @template {SqlParameter} T
374
+ * @param {QueryField<S>} field
375
+ * @param {T} value
376
+ * @returns {SelectBuilder<S>}
377
+ */
378
+ whereLike(field, value) {
379
+ return this.#comparison(field, "LIKE", value);
380
+ }
381
+
382
+ /**
383
+ * @template {SqlParameter} T
384
+ * @param {QueryField<S>} field
385
+ * @param {readonly T[]} values
386
+ * @returns {SelectBuilder<S>}
387
+ */
388
+ whereIn(field, values) {
389
+ if (values.length === 0)
390
+ throw new SqlValidationError("IN requires at least one value");
391
+ const name = this.#checkedField(field, "predicate");
392
+ const placeholders = Array.from(
393
+ { length: values.length },
394
+ (_, index) => `$${this.#parameters.length + index + 1}`,
395
+ );
396
+ return this.#copy({
397
+ predicates: [
398
+ ...this.#predicates,
399
+ `${name} IN (${placeholders.join(", ")})`,
400
+ ],
401
+ parameters: [...this.#parameters, ...values],
402
+ });
403
+ }
404
+
405
+ /**
406
+ * @param {SchemaField<S>} field
407
+ * @param {Direction} [direction]
408
+ * @returns {SelectBuilder<S>}
409
+ */
410
+ orderByField(field, direction = Direction.Asc) {
411
+ return this.orderBy(this.#checkedField(field, "order_by"), direction);
412
+ }
413
+
414
+ /**
415
+ * @param {string} column
416
+ * @param {Direction} [direction]
417
+ * @returns {SelectBuilder<S>}
418
+ */
419
+ orderBy(column, direction = Direction.Asc) {
420
+ if (direction !== Direction.Asc && direction !== Direction.Desc) {
421
+ throw new SqlValidationError("ORDER BY direction must be ASC or DESC");
422
+ }
423
+ const valid = this.#schemaBound
424
+ ? this.#checkedFieldName(column, "order_by")
425
+ : validateIdentifier(column, "order_by");
426
+ return this.#copy({ order: [...this.#order, [valid, direction]] });
427
+ }
428
+
429
+ /**
430
+ * @param {number} value
431
+ * @returns {SelectBuilder<S>}
432
+ */
433
+ limit(value) {
434
+ return this.#paginationValue(value, "limit", { limit: value });
435
+ }
436
+
437
+ /**
438
+ * @param {number} value
439
+ * @returns {SelectBuilder<S>}
440
+ */
441
+ offset(value) {
442
+ return this.#paginationValue(value, "offset", { offset: value });
443
+ }
444
+
445
+ /**
446
+ * @param {{
447
+ * readonly limit: number;
448
+ * readonly offset?: number | undefined;
449
+ * }} options
450
+ * @returns {SelectBuilder<S>}
451
+ */
452
+ paginate(options) {
453
+ const withLimit = this.limit(options.limit);
454
+ return options.offset === undefined
455
+ ? withLimit
456
+ : withLimit.offset(options.offset);
457
+ }
458
+
459
+ /**
460
+ * @param {number} page
461
+ * @param {number} pageSize
462
+ * @returns {SelectBuilder<S>}
463
+ */
464
+ page(page, pageSize) {
465
+ if (!Number.isSafeInteger(page) || page < 1)
466
+ throw new SqlValidationError("page must be a positive integer");
467
+ const result = this.limit(pageSize);
468
+ return result.offset((page - 1) * pageSize);
469
+ }
470
+
471
+ /**
472
+ * @returns {Query}
473
+ */
474
+ build() {
475
+ if (this.#from === undefined)
476
+ throw new SqlValidationError("a query requires .from(collection)");
477
+ if (this.#columns.length === 0)
478
+ throw new SqlValidationError(
479
+ "a query requires at least one selected column",
480
+ );
481
+ const columns = this.#columns.map(validateProjection);
482
+ const parts = [
483
+ `SELECT ${columns.join(", ")} FROM ${validateIdentifier(this.#from, "collection")}`,
484
+ ];
485
+ if (this.#predicates.length > 0)
486
+ parts.push(`WHERE ${this.#predicates.join(" AND ")}`);
487
+ if (this.#order.length > 0) {
488
+ parts.push(
489
+ `ORDER BY ${this.#order.map(([column, direction]) => `${column} ${direction}`).join(", ")}`,
490
+ );
491
+ }
492
+ const parameters = [...this.#parameters];
493
+ if (this.#limit !== undefined) {
494
+ parts.push(`LIMIT $${parameters.length + 1}`);
495
+ parameters.push(this.#limit);
496
+ }
497
+ if (this.#offset !== undefined) {
498
+ parts.push(`OFFSET $${parameters.length + 1}`);
499
+ parameters.push(this.#offset);
500
+ }
501
+ return Query.raw(parts.join(" "), parameters);
502
+ }
503
+
504
+ /**
505
+ * @returns {QueryDescriptor}
506
+ */
507
+ toDescriptor() {
508
+ return this.build().toDescriptor();
509
+ }
510
+
511
+ /**
512
+ * @template {SqlParameter} T
513
+ * @param {SchemaField<S>} field
514
+ * @param {"=" | "<>" | "!=" | ">" | ">=" | "<" | "<=" | "MATCH" | "LIKE"} operator
515
+ * @param {T} value
516
+ * @returns {SelectBuilder<S>}
517
+ */
518
+ #comparison(field, operator, value) {
519
+ return this.#comparisonExpression(
520
+ this.#checkedField(field, "predicate"),
521
+ operator,
522
+ value,
523
+ );
524
+ }
525
+
526
+ /**
527
+ * @template {SqlParameter} T
528
+ * @param {string} expression
529
+ * @param {"=" | "<>" | "!=" | ">" | ">=" | "<" | "<=" | "MATCH" | "LIKE"} operator
530
+ * @param {T} value
531
+ * @returns {SelectBuilder<S>}
532
+ */
533
+ #comparisonExpression(expression, operator, value) {
534
+ if (!COMPARISON_OPERATORS.has(operator))
535
+ throw new SqlValidationError("unsupported SQL comparison operator");
536
+ const placeholder = `$${this.#parameters.length + 1}`;
537
+ return this.#copy({
538
+ predicates: [
539
+ ...this.#predicates,
540
+ `${expression} ${operator} ${placeholder}`,
541
+ ],
542
+ parameters: [...this.#parameters, value],
543
+ });
544
+ }
545
+
546
+ /**
547
+ * @param {number} value
548
+ * @param {string} label
549
+ * @param {{ readonly limit?: number; readonly offset?: number }} pagination
550
+ * @returns {SelectBuilder<S>}
551
+ */
552
+ #paginationValue(value, label, pagination) {
553
+ if (!Number.isSafeInteger(value) || value < 0)
554
+ throw new SqlValidationError(
555
+ `${label} must be a non-negative safe integer`,
556
+ );
557
+ return this.#copy({
558
+ limit: pagination.limit === undefined ? this.#limit : pagination.limit,
559
+ offset:
560
+ pagination.offset === undefined ? this.#offset : pagination.offset,
561
+ });
562
+ }
563
+
564
+ /**
565
+ * @param {QueryField<S>} field
566
+ * @param {string} context
567
+ * @returns {string}
568
+ */
569
+ #checkedField(field, context) {
570
+ return this.#checkedFieldDef(field, context).name;
571
+ }
572
+
573
+ /**
574
+ * @param {QueryField<S>} field
575
+ * @param {string} context
576
+ * @returns {SqlFieldDef}
577
+ */
578
+ #checkedFieldDef(field, context) {
579
+ if (!field || typeof field !== "object")
580
+ throw new SqlValidationError(`${context} field is invalid`);
581
+ const name = validateIdentifier(field.name, context);
582
+ if (!this.#schemaBound || this.#schema === undefined)
583
+ throw new SqlValidationError(
584
+ "use a schema-bound SELECT before passing field descriptors",
585
+ );
586
+ const declared = this.#schema.fields.find(
587
+ (candidate) => candidate.name === name,
588
+ );
589
+ if (declared === undefined || fieldType(declared) !== fieldType(field)) {
590
+ throw new SqlValidationError(
591
+ `${context} field '${name}' is not declared by this collection`,
592
+ );
593
+ }
594
+ return declared;
595
+ }
596
+
597
+ /**
598
+ * @param {string} name
599
+ * @param {string} context
600
+ * @returns {string}
601
+ */
602
+ #checkedFieldName(name, context) {
603
+ validateIdentifier(name, context);
604
+ if (
605
+ !this.#schemaBound ||
606
+ this.#schema === undefined ||
607
+ !this.#schema.fields.some((field) => field.name === name)
608
+ ) {
609
+ throw new SqlValidationError(
610
+ `${context} field '${name}' is not declared by this collection`,
611
+ );
612
+ }
613
+ return name;
614
+ }
615
+ }
616
+
617
+ /**
618
+ * @template {SqlCollectionSchema} S
619
+ * @overload
620
+ * @param {S} schema
621
+ * @returns {SelectBuilder<S>}
622
+ */
623
+ /**
624
+ * @overload
625
+ * @param {readonly string[]} [columns]
626
+ * @returns {SelectBuilder}
627
+ */
628
+ /**
629
+ * @param {SqlCollectionSchema | readonly string[]} [input]
630
+ * @returns {SelectBuilder}
631
+ */
632
+ export function select(input = ["*"]) {
633
+ if (Array.isArray(input)) return SelectBuilder.fromColumns(input);
634
+ return SelectBuilder.fromSchema(/** @type {CollectionSchema} */ (input));
635
+ }
636
+
637
+ /**
638
+ * @template {SqlCollectionSchema} S
639
+ * @param {S} schema
640
+ * @returns {SelectBuilder<S>}
641
+ */
642
+ export function selectSchema(schema) {
643
+ return SelectBuilder.fromSchema(schema);
644
+ }
645
+
646
+ /**
647
+ * @param {string} sql
648
+ * @param {readonly SqlParameter[]} [parameters]
649
+ * @returns {Query}
650
+ */
651
+ export function raw(sql, parameters = []) {
652
+ return Query.raw(sql, parameters);
653
+ }
654
+
655
+ /** @type {Set<string>} */
656
+ const COMPARISON_OPERATORS = new Set([
657
+ "=",
658
+ "<>",
659
+ "!=",
660
+ ">",
661
+ ">=",
662
+ "<",
663
+ "<=",
664
+ "MATCH",
665
+ "LIKE",
666
+ ]);
667
+
668
+ /**
669
+ * @param {SqlFieldDef} field
670
+ * @returns {FieldType | undefined}
671
+ */
672
+ function fieldType(field) {
673
+ return normalizeFieldType(field.type);
674
+ }
675
+
676
+ /**
677
+ * @param {FieldType | string} value
678
+ * @returns {FieldType | undefined}
679
+ */
680
+ function normalizeFieldType(value) {
681
+ return Object.values(FieldTypes).includes(/** @type {FieldType} */ (value))
682
+ ? /** @type {FieldType} */ (value)
683
+ : undefined;
684
+ }
685
+
686
+ /**
687
+ * @param {SqlFieldDef} field
688
+ * @param {string} context
689
+ * @returns {void}
690
+ */
691
+ function validateField(field, context) {
692
+ validateIdentifier(field.name, context);
693
+ if (fieldType(field) === undefined)
694
+ throw new SqlValidationError(
695
+ `${context} '${field.name}' has an unknown type`,
696
+ );
697
+ }
698
+
699
+ /**
700
+ * @param {string} value
701
+ * @returns {boolean}
702
+ */
703
+ function isIdentifier(value) {
704
+ return /^[A-Za-z_][A-Za-z0-9_]*$/u.test(value);
705
+ }
706
+
707
+ /**
708
+ * @param {string} value
709
+ * @param {string} context
710
+ * @returns {string}
711
+ */
712
+ function validateIdentifier(value, context) {
713
+ if (!isIdentifier(value))
714
+ throw new SqlValidationError(
715
+ `${context} must match [A-Za-z_][A-Za-z0-9_]*`,
716
+ );
717
+ return value;
718
+ }
719
+
720
+ /**
721
+ * @param {string} value
722
+ * @returns {string}
723
+ */
724
+ function validateProjection(value) {
725
+ if (value.trim() === "*") return "*";
726
+ return validateIdentifier(value, "column");
727
+ }
728
+
729
+ const FORBIDDEN_KEYWORDS = new Set([
730
+ "attach",
731
+ "alter",
732
+ "analyze",
733
+ "begin",
734
+ "commit",
735
+ "create",
736
+ "delete",
737
+ "detach",
738
+ "drop",
739
+ "end",
740
+ "insert",
741
+ "pragma",
742
+ "reindex",
743
+ "replace",
744
+ "rollback",
745
+ "savepoint",
746
+ "transaction",
747
+ "truncate",
748
+ "update",
749
+ "vacuum",
750
+ "upsert",
751
+ ]);
752
+ const FORBIDDEN_FUNCTIONS = new Set([
753
+ "random",
754
+ "randomblob",
755
+ "changes",
756
+ "total_changes",
757
+ "last_insert_rowid",
758
+ "load_extension",
759
+ "readfile",
760
+ "writefile",
761
+ "datetime",
762
+ "date",
763
+ "time",
764
+ "strftime",
765
+ "julianday",
766
+ "unixepoch",
767
+ ]);
768
+
769
+ /**
770
+ * Validate the client-side read-only SQL allow-list and positional bindings.
771
+ *
772
+ * @param {string} sql
773
+ * @param {readonly SqlParameter[]} [parameters]
774
+ * @returns {void}
775
+ */
776
+ export function validateSql(sql, parameters = []) {
777
+ if (typeof sql !== "string" || sql.trim() === "")
778
+ throw new SqlValidationError("SQL must not be empty");
779
+ if (new TextEncoder().encode(sql).length > 1_048_576)
780
+ throw new SqlValidationError("SQL exceeds the 1 MiB query text limit");
781
+ for (const parameter of parameters) validateParameter(parameter);
782
+ const tokens = tokenizeSql(sql);
783
+ const first = tokens[0]?.toLowerCase();
784
+ let bodyStart = 1;
785
+ if (first === "select") {
786
+ // normal SELECT
787
+ } else if (first === "with") {
788
+ if (tokens[1]?.toLowerCase() === "recursive")
789
+ throw new SqlValidationError("WITH RECURSIVE is not allowed");
790
+ } else if (first === "explain") {
791
+ if (tokens[1]?.toLowerCase() === "query") {
792
+ if (tokens[2]?.toLowerCase() !== "plan")
793
+ throw new SqlValidationError(
794
+ "EXPLAIN may only use EXPLAIN QUERY PLAN or EXPLAIN SELECT",
795
+ );
796
+ bodyStart = 3;
797
+ }
798
+ if (
799
+ tokens[bodyStart]?.toLowerCase() !== "select" &&
800
+ tokens[bodyStart]?.toLowerCase() !== "with"
801
+ ) {
802
+ throw new SqlValidationError(
803
+ "only EXPLAIN SELECT and EXPLAIN QUERY PLAN SELECT are allowed",
804
+ );
805
+ }
806
+ if (
807
+ tokens[bodyStart]?.toLowerCase() === "with" &&
808
+ tokens[bodyStart + 1]?.toLowerCase() === "recursive"
809
+ ) {
810
+ throw new SqlValidationError("WITH RECURSIVE is not allowed");
811
+ }
812
+ } else {
813
+ throw new SqlValidationError(
814
+ "only SELECT, WITH, and EXPLAIN statements are allowed",
815
+ );
816
+ }
817
+ for (let index = bodyStart; index < tokens.length; index += 1) {
818
+ const token = /** @type {string} */ (tokens[index]).toLowerCase();
819
+ if (FORBIDDEN_KEYWORDS.has(token))
820
+ throw new SqlValidationError(
821
+ "SQL contains a DDL, DML, or transaction keyword",
822
+ );
823
+ if (["current_date", "current_time", "current_timestamp"].includes(token))
824
+ throw new SqlValidationError(
825
+ "non-deterministic SQL functions are not allowed",
826
+ );
827
+ if (FORBIDDEN_FUNCTIONS.has(token) && tokens[index + 1] === "(")
828
+ throw new SqlValidationError("unsafe SQL functions are not allowed");
829
+ if (token.startsWith("sqlite_") || token.startsWith("pragma_"))
830
+ throw new SqlValidationError(
831
+ "internal SQLite names and pragma functions are not allowed",
832
+ );
833
+ }
834
+ validateBindings(tokens, parameters.length);
835
+ }
836
+
837
+ export const validate_sql = validateSql;
838
+
839
+ /**
840
+ * @param {unknown} value
841
+ * @returns {void}
842
+ */
843
+ function validateParameter(value) {
844
+ if (
845
+ value === null ||
846
+ typeof value === "string" ||
847
+ typeof value === "boolean" ||
848
+ value instanceof Uint8Array
849
+ )
850
+ return;
851
+ if (typeof value === "bigint" && value >= -(1n << 63n) && value < 1n << 63n)
852
+ return;
853
+ if (typeof value === "number" && Number.isSafeInteger(value)) return;
854
+ throw new SqlValidationError(
855
+ "SQL parameters require exact i64 integers, strings, booleans, bytes or null; listener support is separate.",
856
+ );
857
+ }
858
+
859
+ /**
860
+ * @param {string} sql
861
+ * @returns {string[]}
862
+ */
863
+ function tokenizeSql(sql) {
864
+ /** @type {string[]} */
865
+ const tokens = [];
866
+ const bytes = new TextEncoder().encode(sql);
867
+ let index = 0;
868
+ while (index < bytes.length) {
869
+ const byte = /** @type {number} */ (bytes[index]);
870
+ if (byte === 32 || byte === 9 || byte === 10 || byte === 13) {
871
+ index += 1;
872
+ continue;
873
+ }
874
+ if (byte === 59) {
875
+ if (
876
+ bytes
877
+ .slice(index + 1)
878
+ .some(
879
+ (next) => next !== 32 && next !== 9 && next !== 10 && next !== 13,
880
+ )
881
+ )
882
+ throw new SqlValidationError("multiple SQL statements are not allowed");
883
+ index += 1;
884
+ continue;
885
+ }
886
+ if (
887
+ (byte === 45 && bytes[index + 1] === 45) ||
888
+ (byte === 47 && bytes[index + 1] === 42)
889
+ )
890
+ throw new SqlValidationError("SQL comments are not allowed");
891
+ if (byte === 39 || byte === 34 || byte === 96 || byte === 91) {
892
+ const end = byte === 91 ? 93 : byte;
893
+ index += 1;
894
+ let closed = false;
895
+ while (index < bytes.length) {
896
+ if (bytes[index] === end) {
897
+ if (end !== 93 && bytes[index + 1] === end) {
898
+ index += 2;
899
+ continue;
900
+ }
901
+ index += 1;
902
+ closed = true;
903
+ break;
904
+ }
905
+ index += 1;
906
+ }
907
+ if (!closed)
908
+ throw new SqlValidationError(
909
+ "SQL contains an unterminated quoted value",
910
+ );
911
+ tokens.push("<quoted>");
912
+ continue;
913
+ }
914
+ if (byte === 36) {
915
+ const start = index;
916
+ index += 1;
917
+ while (
918
+ index < bytes.length &&
919
+ /** @type {number} */ (bytes[index]) >= 48 &&
920
+ /** @type {number} */ (bytes[index]) <= 57
921
+ )
922
+ index += 1;
923
+ if (index === start + 1)
924
+ throw new SqlValidationError("only numbered $n parameters are allowed");
925
+ tokens.push(new TextDecoder().decode(bytes.slice(start, index)));
926
+ continue;
927
+ }
928
+ if (byte === 63) {
929
+ const start = index;
930
+ index += 1;
931
+ while (
932
+ index < bytes.length &&
933
+ /** @type {number} */ (bytes[index]) >= 48 &&
934
+ /** @type {number} */ (bytes[index]) <= 57
935
+ )
936
+ index += 1;
937
+ tokens.push(new TextDecoder().decode(bytes.slice(start, index)));
938
+ continue;
939
+ }
940
+ if (
941
+ (byte >= 48 && byte <= 57) ||
942
+ (byte >= 65 && byte <= 90) ||
943
+ (byte >= 97 && byte <= 122) ||
944
+ byte === 95
945
+ ) {
946
+ const start = index;
947
+ index += 1;
948
+ while (
949
+ index < bytes.length &&
950
+ ((/** @type {number} */ (bytes[index]) >= 48 &&
951
+ /** @type {number} */ (bytes[index]) <= 57) ||
952
+ (/** @type {number} */ (bytes[index]) >= 65 &&
953
+ /** @type {number} */ (bytes[index]) <= 90) ||
954
+ (/** @type {number} */ (bytes[index]) >= 97 &&
955
+ /** @type {number} */ (bytes[index]) <= 122) ||
956
+ bytes[index] === 95)
957
+ )
958
+ index += 1;
959
+ tokens.push(new TextDecoder().decode(bytes.slice(start, index)));
960
+ continue;
961
+ }
962
+ tokens.push(new TextDecoder().decode(bytes.slice(index, index + 1)));
963
+ index += 1;
964
+ }
965
+ return tokens;
966
+ }
967
+
968
+ /**
969
+ * @param {readonly string[]} tokens
970
+ * @param {number} parameterCount
971
+ * @returns {void}
972
+ */
973
+ function validateBindings(tokens, parameterCount) {
974
+ let nextUnnamed = 1;
975
+ let highest = 0;
976
+ /** @type {Set<number>} */
977
+ const seen = new Set();
978
+ for (const token of tokens) {
979
+ /** @type {number | undefined} */
980
+ let number;
981
+ if (/^\$[0-9]+$/u.test(token)) number = Number(token.slice(1));
982
+ else if (token === "?") {
983
+ number = nextUnnamed;
984
+ nextUnnamed += 1;
985
+ } else if (/^\?[0-9]+$/u.test(token)) number = Number(token.slice(1));
986
+ if (number === undefined) continue;
987
+ if (!Number.isSafeInteger(number) || number < 1)
988
+ throw new SqlValidationError("parameter numbering starts at 1");
989
+ seen.add(number);
990
+ highest = Math.max(highest, number);
991
+ }
992
+ if (highest !== parameterCount || seen.size !== parameterCount)
993
+ throw new SqlValidationError(
994
+ `SQL has ${highest} positional parameter slots but ${parameterCount} values were supplied`,
995
+ );
996
+ }