@syncular/typegen 0.5.1 → 0.7.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 (56) hide show
  1. package/README.md +79 -32
  2. package/dist/cli.js +40 -17
  3. package/dist/emit-queries-dart.js +168 -55
  4. package/dist/emit-queries-kotlin.js +167 -55
  5. package/dist/emit-queries-swift.js +183 -57
  6. package/dist/emit-queries.js +286 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +348 -316
  9. package/dist/generate.d.ts +1 -1
  10. package/dist/generate.js +28 -9
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lsp.d.ts +1 -3
  14. package/dist/lsp.js +349 -187
  15. package/dist/manifest.d.ts +1 -3
  16. package/dist/manifest.js +1 -1
  17. package/dist/query-ir.js +53 -42
  18. package/dist/query.d.ts +110 -63
  19. package/dist/query.js +89 -11
  20. package/dist/syql-ast.d.ts +12 -13
  21. package/dist/syql-ast.js +26 -20
  22. package/dist/syql-lexer.d.ts +2 -0
  23. package/dist/syql-lexer.js +9 -2
  24. package/dist/syql-lowering.d.ts +22 -0
  25. package/dist/syql-lowering.js +434 -0
  26. package/dist/syql-parser.d.ts +19 -18
  27. package/dist/syql-parser.js +341 -93
  28. package/dist/syql-semantics.d.ts +73 -0
  29. package/dist/syql-semantics.js +607 -0
  30. package/dist/syql-template-parser.d.ts +5 -20
  31. package/dist/syql-template-parser.js +116 -109
  32. package/dist/syql-validator.d.ts +35 -0
  33. package/dist/syql-validator.js +993 -0
  34. package/package.json +3 -3
  35. package/src/cli.ts +49 -21
  36. package/src/emit-queries-dart.ts +195 -69
  37. package/src/emit-queries-kotlin.ts +197 -69
  38. package/src/emit-queries-swift.ts +224 -68
  39. package/src/emit-queries.ts +410 -165
  40. package/src/fmt.ts +405 -303
  41. package/src/generate.ts +43 -9
  42. package/src/index.ts +3 -1
  43. package/src/lsp.ts +414 -216
  44. package/src/manifest.ts +2 -4
  45. package/src/query-ir.ts +52 -42
  46. package/src/query.ts +229 -79
  47. package/src/syql-ast.ts +38 -34
  48. package/src/syql-lexer.ts +12 -1
  49. package/src/syql-lowering.ts +664 -0
  50. package/src/syql-parser.ts +472 -134
  51. package/src/syql-semantics.ts +930 -0
  52. package/src/syql-template-parser.ts +119 -163
  53. package/src/syql-validator.ts +1486 -0
  54. package/dist/syql.d.ts +0 -102
  55. package/dist/syql.js +0 -1141
  56. package/src/syql.ts +0 -1470
@@ -0,0 +1,1486 @@
1
+ /** Schema-aware revision-1 SYQL validation (§§5, 8–13). */
2
+ import type { IrColumnType, IrDocument } from './ir';
3
+ import {
4
+ type AnalyzedQuery,
5
+ analyzeStatement,
6
+ inferParamTypeEvidence,
7
+ type QueryCoverageBinding,
8
+ type QueryDb,
9
+ type QueryNamingOptions,
10
+ type QueryReactiveMetadata,
11
+ type QueryScopeBinding,
12
+ scanTableRefs,
13
+ stripCommentsAndStrings,
14
+ type TableRef,
15
+ } from './query';
16
+ import {
17
+ isSyqlTrivia,
18
+ lexSyqlSqlSource,
19
+ SyqlFrontendError,
20
+ type SyqlSourceSpan,
21
+ type SyqlToken,
22
+ } from './syql-lexer';
23
+ import type {
24
+ SyqlQueryDeclaration,
25
+ SyqlQueryParameter,
26
+ SyqlTemplate,
27
+ SyqlValueType,
28
+ } from './syql-parser';
29
+ import type {
30
+ SyqlLogicalQuery,
31
+ SyqlLogicalTemplateNode,
32
+ SyqlLogicalWhenNode,
33
+ SyqlSemanticProgram,
34
+ } from './syql-semantics';
35
+ import { syqlRangeEndBind, syqlRangeStartBind } from './syql-semantics';
36
+
37
+ export type SyqlValidationErrorCode =
38
+ | 'SYQL6001_INVALID_PLACEMENT'
39
+ | 'SYQL6002_INVALID_SQL'
40
+ | 'SYQL6003_NONDETERMINISTIC_SQL'
41
+ | 'SYQL6004_TYPE_CONFLICT'
42
+ | 'SYQL6005_INVALID_SYNC_QUERY'
43
+ | 'SYQL6006_INVALID_SORT'
44
+ | 'SYQL6007_INVALID_LIMIT'
45
+ | 'SYQL6008_INVALID_IDENTITY';
46
+
47
+ export interface SyqlValidatedSort {
48
+ readonly control: string;
49
+ readonly defaultProfile: string;
50
+ readonly profiles: readonly { readonly name: string; readonly sql: string }[];
51
+ }
52
+
53
+ export interface SyqlValidatedQuery {
54
+ readonly logical: SyqlLogicalQuery;
55
+ /** Reference realization: every conditional active, default controls. */
56
+ readonly referenceSql: string;
57
+ readonly analysis: AnalyzedQuery;
58
+ readonly bindTypes: ReadonlyMap<string, SyqlValueType>;
59
+ readonly reactive: QueryReactiveMetadata;
60
+ readonly identity?: readonly string[];
61
+ readonly sort?: SyqlValidatedSort;
62
+ readonly limit?: {
63
+ readonly control: string;
64
+ readonly defaultSize: number;
65
+ readonly maxSize: number;
66
+ };
67
+ }
68
+
69
+ export interface SyqlValidatedProgram {
70
+ readonly semantic: SyqlSemanticProgram;
71
+ readonly queries: readonly SyqlValidatedQuery[];
72
+ }
73
+
74
+ interface Marker {
75
+ readonly name: string;
76
+ readonly node: SyqlLogicalWhenNode;
77
+ }
78
+
79
+ interface StructuralToken {
80
+ readonly token: SyqlToken;
81
+ readonly depth: number;
82
+ }
83
+
84
+ interface SqlStructure {
85
+ readonly tokens: readonly StructuralToken[];
86
+ readonly outer: readonly StructuralToken[];
87
+ readonly hasOuterOrder: boolean;
88
+ readonly hasOuterLimit: boolean;
89
+ readonly hasOuterOffset: boolean;
90
+ readonly hasCompound: boolean;
91
+ readonly orderStart?: number;
92
+ readonly orderBodyStart?: number;
93
+ readonly orderEnd?: number;
94
+ readonly tailStart?: number;
95
+ }
96
+
97
+ interface BindSymbol {
98
+ readonly name: string;
99
+ readonly parameter: SyqlQueryParameter;
100
+ readonly span: SyqlSourceSpan;
101
+ readonly authoredType?: SyqlValueType;
102
+ }
103
+
104
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
105
+ const NONDETERMINISTIC_FUNCTIONS = new Set([
106
+ 'random',
107
+ 'randomblob',
108
+ 'changes',
109
+ 'total_changes',
110
+ 'last_insert_rowid',
111
+ 'sqlite_version',
112
+ 'sqlite_source_id',
113
+ ]);
114
+ const DATE_FUNCTIONS = new Set([
115
+ 'date',
116
+ 'time',
117
+ 'datetime',
118
+ 'julianday',
119
+ 'unixepoch',
120
+ 'strftime',
121
+ 'timediff',
122
+ ]);
123
+ const CURRENT_TIME_KEYWORDS = new Set([
124
+ 'current_date',
125
+ 'current_time',
126
+ 'current_timestamp',
127
+ ]);
128
+ const AGGREGATE_FUNCTIONS = new Set([
129
+ 'avg',
130
+ 'count',
131
+ 'group_concat',
132
+ 'max',
133
+ 'min',
134
+ 'sum',
135
+ 'total',
136
+ ]);
137
+ /**
138
+ * The function surface of the standard SQLite 3.46.0 amalgamation.
139
+ *
140
+ * Keep this explicit: preparing with Bun's host SQLite proves that SQL is
141
+ * valid on that (usually newer) engine, not that it is valid on the revision-1
142
+ * floor. Unknown, extension-provided, and post-floor functions are rejected
143
+ * before the host prepare step.
144
+ */
145
+ const SQLITE_346_PORTABLE_FUNCTIONS = new Set([
146
+ // Core scalar and aggregate functions.
147
+ 'abs',
148
+ 'avg',
149
+ 'char',
150
+ 'cast',
151
+ 'coalesce',
152
+ 'concat',
153
+ 'concat_ws',
154
+ 'count',
155
+ 'format',
156
+ 'glob',
157
+ 'group_concat',
158
+ 'hex',
159
+ 'ifnull',
160
+ 'iif',
161
+ 'instr',
162
+ 'length',
163
+ 'like',
164
+ 'likelihood',
165
+ 'likely',
166
+ 'lower',
167
+ 'ltrim',
168
+ 'max',
169
+ 'min',
170
+ 'nullif',
171
+ 'octet_length',
172
+ 'printf',
173
+ 'quote',
174
+ 'replace',
175
+ 'round',
176
+ 'rtrim',
177
+ 'sign',
178
+ 'string_agg',
179
+ 'substr',
180
+ 'substring',
181
+ 'sum',
182
+ 'total',
183
+ 'trim',
184
+ 'typeof',
185
+ 'unhex',
186
+ 'unicode',
187
+ 'unlikely',
188
+ 'upper',
189
+ 'zeroblob',
190
+ // Date/time functions. Determinism applies additional restrictions below.
191
+ ...DATE_FUNCTIONS,
192
+ // JSON1 and JSONB are core in the 3.46.0 amalgamation.
193
+ 'json',
194
+ 'json_array',
195
+ 'json_array_length',
196
+ 'json_error_position',
197
+ 'json_each',
198
+ 'json_extract',
199
+ 'json_group_array',
200
+ 'json_group_object',
201
+ 'json_insert',
202
+ 'json_object',
203
+ 'json_patch',
204
+ 'json_pretty',
205
+ 'json_quote',
206
+ 'json_remove',
207
+ 'json_replace',
208
+ 'json_set',
209
+ 'json_type',
210
+ 'json_tree',
211
+ 'json_valid',
212
+ 'jsonb',
213
+ 'jsonb_array',
214
+ 'jsonb_extract',
215
+ 'jsonb_group_array',
216
+ 'jsonb_group_object',
217
+ 'jsonb_insert',
218
+ 'jsonb_object',
219
+ 'jsonb_patch',
220
+ 'jsonb_remove',
221
+ 'jsonb_replace',
222
+ 'jsonb_set',
223
+ ]);
224
+ const SQL_PAREN_KEYWORDS = new Set([
225
+ 'and',
226
+ 'as',
227
+ 'else',
228
+ 'exists',
229
+ 'filter',
230
+ 'from',
231
+ 'group',
232
+ 'having',
233
+ 'in',
234
+ 'join',
235
+ 'limit',
236
+ 'not',
237
+ 'offset',
238
+ 'on',
239
+ 'or',
240
+ 'order',
241
+ 'over',
242
+ 'select',
243
+ 'then',
244
+ 'values',
245
+ 'when',
246
+ 'where',
247
+ 'with',
248
+ ]);
249
+ const SQLITE_346_PORTABLE_COLLATIONS = new Set(['binary', 'nocase', 'rtrim']);
250
+ const OUTER_CLAUSE_ENDERS = new Set([
251
+ 'group',
252
+ 'having',
253
+ 'order',
254
+ 'limit',
255
+ 'offset',
256
+ 'union',
257
+ 'intersect',
258
+ 'except',
259
+ ]);
260
+
261
+ function typeText(type: SyqlValueType | IrColumnType): string {
262
+ return typeof type === 'string'
263
+ ? type
264
+ : `${type.base}${type.nullable ? ' | null' : ''}`;
265
+ }
266
+
267
+ function valueType(
268
+ base: IrColumnType,
269
+ nullable: boolean,
270
+ span: SyqlSourceSpan,
271
+ ): SyqlValueType {
272
+ return { base, nullable, span };
273
+ }
274
+
275
+ function significant(tokens: readonly SyqlToken[]): readonly SyqlToken[] {
276
+ return tokens.filter((token) => !isSyqlTrivia(token) && token.kind !== 'eof');
277
+ }
278
+
279
+ function tokenLower(token: SyqlToken | undefined): string | undefined {
280
+ return token?.kind === 'identifier' ? token.text.toLowerCase() : token?.text;
281
+ }
282
+
283
+ function decodeSqlString(token: SyqlToken): string | undefined {
284
+ if (token.kind !== 'string' || token.text.length < 2) return undefined;
285
+ return token.text.slice(1, -1).replaceAll("''", "'");
286
+ }
287
+
288
+ function templateText(template: SyqlTemplate): string {
289
+ return template.tokens
290
+ .map((token) => token.text)
291
+ .join('')
292
+ .trim();
293
+ }
294
+
295
+ function functionArgumentCount(
296
+ tokens: readonly SyqlToken[],
297
+ openIndex: number,
298
+ ): number | undefined {
299
+ let depth = 0;
300
+ let commas = 0;
301
+ let hasArgument = false;
302
+ for (let index = openIndex + 1; index < tokens.length; index += 1) {
303
+ const token = tokens[index] as SyqlToken;
304
+ if (token.text === '(') {
305
+ depth += 1;
306
+ hasArgument = true;
307
+ } else if (token.text === ')') {
308
+ if (depth === 0) return hasArgument ? commas + 1 : 0;
309
+ depth -= 1;
310
+ hasArgument = true;
311
+ } else if (token.text === ',' && depth === 0) {
312
+ commas += 1;
313
+ } else {
314
+ hasArgument = true;
315
+ }
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ function matchingParenIndex(
321
+ tokens: readonly SyqlToken[],
322
+ openIndex: number,
323
+ ): number | undefined {
324
+ let depth = 0;
325
+ for (let index = openIndex + 1; index < tokens.length; index += 1) {
326
+ const token = tokens[index] as SyqlToken;
327
+ if (token.text === '(') depth += 1;
328
+ else if (token.text === ')') {
329
+ if (depth === 0) return index;
330
+ depth -= 1;
331
+ }
332
+ }
333
+ return undefined;
334
+ }
335
+
336
+ class Validator {
337
+ readonly #semantic: SyqlSemanticProgram;
338
+ readonly #ir: IrDocument;
339
+ readonly #db: QueryDb;
340
+ readonly #naming: QueryNamingOptions | undefined;
341
+
342
+ constructor(
343
+ semantic: SyqlSemanticProgram,
344
+ ir: IrDocument,
345
+ db: QueryDb,
346
+ naming: QueryNamingOptions | undefined,
347
+ ) {
348
+ this.#semantic = semantic;
349
+ this.#ir = ir;
350
+ this.#db = db;
351
+ this.#naming = naming;
352
+ }
353
+
354
+ validate(): SyqlValidatedProgram {
355
+ return {
356
+ semantic: this.#semantic,
357
+ queries: this.#semantic.queries.map((query) =>
358
+ this.#validateQuery(query),
359
+ ),
360
+ };
361
+ }
362
+
363
+ #validateQuery(logical: SyqlLogicalQuery): SyqlValidatedQuery {
364
+ const location = `${logical.module.file} (query ${logical.declaration.name})`;
365
+ const markers: Marker[] = [];
366
+ const markerSql = this.#render(logical.template, 'markers', markers);
367
+ const markerStructure = this.#inspect(markerSql, location);
368
+ this.#validatePlacement(markerStructure, markers, logical.declaration);
369
+
370
+ const activeSql = this.#render(logical.template, 'active', []);
371
+ const activeStructure = this.#inspect(activeSql, location);
372
+ this.#validateStatementShape(
373
+ activeSql,
374
+ activeStructure,
375
+ logical.declaration,
376
+ location,
377
+ );
378
+ this.#validateDeterminism(activeSql, logical.declaration.statement.span);
379
+ this.#validatePortableProfile(
380
+ activeSql,
381
+ logical.declaration.statement.span,
382
+ );
383
+
384
+ const refs = scanTableRefs(activeSql, this.#ir);
385
+ const bindSymbols = this.#bindSymbols(logical.declaration);
386
+ const bindTypes = this.#resolveBindTypes(
387
+ logical,
388
+ activeSql,
389
+ refs,
390
+ bindSymbols,
391
+ );
392
+
393
+ const sort = this.#validateSort(
394
+ logical.declaration,
395
+ activeStructure,
396
+ location,
397
+ );
398
+ let referenceSql = this.#composeSort(
399
+ activeSql,
400
+ activeStructure,
401
+ sort?.profiles.find((profile) => profile.name === sort.defaultProfile)
402
+ ?.sql,
403
+ );
404
+ if (logical.declaration.limit !== undefined) {
405
+ referenceSql = `${referenceSql.trimEnd()} limit ${logical.declaration.limit.defaultSize}`;
406
+ }
407
+
408
+ const usedBindNames = new Set(
409
+ lexSyqlSqlSource(location, activeSql)
410
+ .filter((token) => token.kind === 'bind')
411
+ .map((token) => token.text.slice(1)),
412
+ );
413
+ const headers = [...bindTypes]
414
+ .filter(([name]) => usedBindNames.has(name))
415
+ .map(([name, type]) => `-- param :${name} ${type.base}`)
416
+ .join('\n');
417
+ const statement =
418
+ headers.length === 0 ? referenceSql : `${headers}\n${referenceSql}`;
419
+ let analysis: AnalyzedQuery;
420
+ try {
421
+ const analysisNaming =
422
+ this.#naming === undefined
423
+ ? undefined
424
+ : {
425
+ ...this.#naming,
426
+ internalParams: [
427
+ ...(this.#naming.internalParams ?? []),
428
+ ...[...usedBindNames].filter((name) =>
429
+ name.startsWith('__syql'),
430
+ ),
431
+ ],
432
+ };
433
+ analysis = analyzeStatement(
434
+ logical.declaration.name,
435
+ location,
436
+ statement,
437
+ this.#ir,
438
+ this.#db,
439
+ analysisNaming,
440
+ );
441
+ } catch (error) {
442
+ const message = error instanceof Error ? error.message : String(error);
443
+ this.#fail(
444
+ 'SYQL6002_INVALID_SQL',
445
+ logical.declaration.statement.span,
446
+ `reference SQL rejected: ${message}`,
447
+ );
448
+ }
449
+
450
+ for (const profile of sort?.profiles ?? []) {
451
+ const candidate = this.#composeSort(
452
+ activeSql,
453
+ activeStructure,
454
+ profile.sql,
455
+ );
456
+ const paged =
457
+ logical.declaration.limit === undefined
458
+ ? candidate
459
+ : `${candidate.trimEnd()} limit ${logical.declaration.limit.defaultSize}`;
460
+ try {
461
+ this.#db.analyze(paged);
462
+ } catch (error) {
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ this.#fail(
465
+ 'SYQL6006_INVALID_SORT',
466
+ logical.declaration.sort?.span ?? logical.declaration.statement.span,
467
+ `sort profile ${profile.name} rejected by SQLite: ${message}`,
468
+ );
469
+ }
470
+ }
471
+
472
+ const reactive = this.#inferReactive(logical, markerSql, refs, bindSymbols);
473
+ const identity = this.#proveIdentity(
474
+ logical.declaration,
475
+ activeSql,
476
+ refs,
477
+ analysis,
478
+ );
479
+ this.#validateStableOrder(
480
+ logical.declaration,
481
+ activeSql,
482
+ activeStructure,
483
+ sort,
484
+ identity,
485
+ analysis,
486
+ refs,
487
+ );
488
+ const reactiveWithIdentity: QueryReactiveMetadata = {
489
+ ...reactive,
490
+ ...(identity === undefined ? {} : { rowKey: identity }),
491
+ };
492
+
493
+ return {
494
+ logical,
495
+ referenceSql,
496
+ analysis: { ...analysis, reactive: reactiveWithIdentity },
497
+ bindTypes,
498
+ reactive: reactiveWithIdentity,
499
+ ...(identity === undefined ? {} : { identity }),
500
+ ...(sort === undefined ? {} : { sort }),
501
+ ...(logical.declaration.limit === undefined
502
+ ? {}
503
+ : {
504
+ limit: {
505
+ control: logical.declaration.limit.control,
506
+ defaultSize: logical.declaration.limit.defaultSize,
507
+ maxSize: logical.declaration.limit.maxSize,
508
+ },
509
+ }),
510
+ };
511
+ }
512
+
513
+ #render(
514
+ nodes: readonly SyqlLogicalTemplateNode[],
515
+ mode: 'active' | 'markers',
516
+ markers: Marker[],
517
+ ): string {
518
+ return nodes
519
+ .map((node) => {
520
+ if (node.kind === 'sql') {
521
+ return node.parts
522
+ .map((part) => (part.kind === 'text' ? part.text : `:${part.name}`))
523
+ .join('');
524
+ }
525
+ if (node.kind === 'predicate') {
526
+ return `(${this.#render(node.body, mode, markers)})`;
527
+ }
528
+ if (mode === 'markers') {
529
+ const name = `__syql_node_${markers.length}`;
530
+ markers.push({ name, node });
531
+ return name;
532
+ }
533
+ if (node.kind === 'when') {
534
+ return `(${this.#render(node.body, mode, markers)})`;
535
+ }
536
+ throw new Error('unknown SYQL logical template node');
537
+ })
538
+ .join('');
539
+ }
540
+
541
+ #inspect(sql: string, file: string): SqlStructure {
542
+ const raw = significant(lexSyqlSqlSource(file, sql));
543
+ const tokens: StructuralToken[] = [];
544
+ let depth = 0;
545
+ for (const token of raw) {
546
+ if (token.kind === 'punctuation' && token.text === ')') depth -= 1;
547
+ tokens.push({ token, depth });
548
+ if (token.kind === 'punctuation' && token.text === '(') depth += 1;
549
+ if (depth < 0) {
550
+ this.#fail(
551
+ 'SYQL6002_INVALID_SQL',
552
+ token.span,
553
+ 'unbalanced SQL parenthesis',
554
+ );
555
+ }
556
+ }
557
+ if (depth !== 0) {
558
+ this.#fail(
559
+ 'SYQL6002_INVALID_SQL',
560
+ raw[raw.length - 1]?.span ?? {
561
+ file,
562
+ start: { offset: 0, line: 1, column: 1 },
563
+ end: { offset: 0, line: 1, column: 1 },
564
+ },
565
+ 'unbalanced SQL parenthesis',
566
+ );
567
+ }
568
+ const outer = tokens.filter((item) => item.depth === 0);
569
+ let orderStart: number | undefined;
570
+ let orderBodyStart: number | undefined;
571
+ let orderEnd: number | undefined;
572
+ let tailStart: number | undefined;
573
+ let hasOuterLimit = false;
574
+ let hasOuterOffset = false;
575
+ let hasCompound = false;
576
+ for (let index = 0; index < outer.length; index += 1) {
577
+ const item = outer[index] as StructuralToken;
578
+ const lower = tokenLower(item.token);
579
+ const next = outer[index + 1]?.token;
580
+ if (lower === 'order' && tokenLower(next) === 'by') {
581
+ orderStart = item.token.span.start.offset;
582
+ orderBodyStart = next?.span.end.offset;
583
+ } else if (lower === 'limit') {
584
+ hasOuterLimit = true;
585
+ tailStart = Math.min(
586
+ tailStart ?? Number.MAX_SAFE_INTEGER,
587
+ item.token.span.start.offset,
588
+ );
589
+ } else if (lower === 'offset') {
590
+ hasOuterOffset = true;
591
+ tailStart = Math.min(
592
+ tailStart ?? Number.MAX_SAFE_INTEGER,
593
+ item.token.span.start.offset,
594
+ );
595
+ } else if (
596
+ lower === 'union' ||
597
+ lower === 'intersect' ||
598
+ lower === 'except'
599
+ ) {
600
+ hasCompound = true;
601
+ }
602
+ if (
603
+ orderBodyStart !== undefined &&
604
+ orderEnd === undefined &&
605
+ (lower === 'limit' ||
606
+ lower === 'offset' ||
607
+ lower === 'union' ||
608
+ lower === 'intersect' ||
609
+ lower === 'except')
610
+ ) {
611
+ orderEnd = item.token.span.start.offset;
612
+ }
613
+ }
614
+ if (orderBodyStart !== undefined && orderEnd === undefined)
615
+ orderEnd = sql.length;
616
+ return {
617
+ tokens,
618
+ outer,
619
+ hasOuterOrder: orderStart !== undefined,
620
+ hasOuterLimit,
621
+ hasOuterOffset,
622
+ hasCompound,
623
+ ...(orderStart === undefined ? {} : { orderStart }),
624
+ ...(orderBodyStart === undefined ? {} : { orderBodyStart }),
625
+ ...(orderEnd === undefined ? {} : { orderEnd }),
626
+ ...(tailStart === undefined ? {} : { tailStart }),
627
+ };
628
+ }
629
+
630
+ #validatePlacement(
631
+ structure: SqlStructure,
632
+ markers: readonly Marker[],
633
+ query: SyqlQueryDeclaration,
634
+ ): void {
635
+ const byName = new Map(markers.map((marker) => [marker.name, marker]));
636
+ let clause: string | undefined;
637
+ for (let index = 0; index < structure.outer.length; index += 1) {
638
+ const item = structure.outer[index] as StructuralToken;
639
+ const lower = tokenLower(item.token);
640
+ if (lower === 'where' || lower === 'having') clause = lower;
641
+ else if (OUTER_CLAUSE_ENDERS.has(lower ?? '')) clause = undefined;
642
+ const marker = byName.get(item.token.text);
643
+ if (marker === undefined) continue;
644
+ const previous = tokenLower(structure.outer[index - 1]?.token);
645
+ const next = tokenLower(structure.outer[index + 1]?.token);
646
+ const boundaryBefore =
647
+ previous === 'where' || previous === 'having' || previous === 'and';
648
+ const boundaryAfter =
649
+ next === undefined || next === 'and' || OUTER_CLAUSE_ENDERS.has(next);
650
+ if (!boundaryBefore || !boundaryAfter) {
651
+ this.#fail(
652
+ 'SYQL6001_INVALID_PLACEMENT',
653
+ marker.node.span,
654
+ 'when must be an entire outer conjunct',
655
+ );
656
+ }
657
+ if (clause !== 'where' && clause !== 'having') {
658
+ this.#fail(
659
+ 'SYQL6001_INVALID_PLACEMENT',
660
+ marker.node.span,
661
+ 'when is allowed only in the outer WHERE or HAVING clause',
662
+ );
663
+ }
664
+ }
665
+ for (const marker of markers) {
666
+ if (!structure.outer.some((item) => item.token.text === marker.name)) {
667
+ this.#fail(
668
+ 'SYQL6001_INVALID_PLACEMENT',
669
+ marker.node.span,
670
+ 'when cannot appear in a nested SQL expression or statement',
671
+ );
672
+ }
673
+ }
674
+ if (structure.hasCompound && markers.length > 0) {
675
+ this.#fail(
676
+ 'SYQL6001_INVALID_PLACEMENT',
677
+ query.statement.span,
678
+ 'embedded SYQL conjuncts are ambiguous in a compound outer statement',
679
+ );
680
+ }
681
+ }
682
+
683
+ #validateStatementShape(
684
+ sql: string,
685
+ structure: SqlStructure,
686
+ query: SyqlQueryDeclaration,
687
+ location: string,
688
+ ): void {
689
+ if (query.sort !== undefined && structure.hasOuterOrder) {
690
+ this.#fail(
691
+ 'SYQL6006_INVALID_SORT',
692
+ query.sort.span,
693
+ 'sort section conflicts with an authored outer ORDER BY',
694
+ );
695
+ }
696
+ if (
697
+ query.limit !== undefined &&
698
+ (structure.hasOuterLimit || structure.hasOuterOffset)
699
+ ) {
700
+ this.#fail(
701
+ 'SYQL6007_INVALID_LIMIT',
702
+ query.limit.span,
703
+ 'dynamic LIMIT conflicts with an authored outer LIMIT or OFFSET',
704
+ );
705
+ }
706
+ const first = structure.outer
707
+ .find((item) => item.token.kind === 'identifier')
708
+ ?.token.text.toLowerCase();
709
+ if (first !== 'select' && first !== 'with') {
710
+ this.#fail(
711
+ 'SYQL6002_INVALID_SQL',
712
+ query.statement.span,
713
+ `${location} must contain one SELECT or WITH ... SELECT statement`,
714
+ );
715
+ }
716
+ if (sql.trim().length === 0) {
717
+ this.#fail(
718
+ 'SYQL6002_INVALID_SQL',
719
+ query.statement.span,
720
+ 'empty SQL statement',
721
+ );
722
+ }
723
+ }
724
+
725
+ #validateDeterminism(sql: string, span: SyqlSourceSpan): void {
726
+ const tokens = significant(lexSyqlSqlSource(span.file, sql));
727
+ const functionStack: Array<string | undefined> = [];
728
+ let depth = 0;
729
+ for (let index = 0; index < tokens.length; index += 1) {
730
+ const token = tokens[index] as SyqlToken;
731
+ const lower = tokenLower(token);
732
+ const next = tokens[index + 1];
733
+ if (
734
+ depth > 0 &&
735
+ token.kind === 'identifier' &&
736
+ (lower === 'limit' || lower === 'offset')
737
+ ) {
738
+ this.#fail(
739
+ 'SYQL6003_NONDETERMINISTIC_SQL',
740
+ token.span,
741
+ `nested ${token.text.toUpperCase()} is rejected until its local row identity and total order can be proven`,
742
+ );
743
+ }
744
+ if (token.kind === 'identifier' && lower === 'over') {
745
+ this.#fail(
746
+ 'SYQL6003_NONDETERMINISTIC_SQL',
747
+ token.span,
748
+ 'window expressions are rejected until their partition identity and total order can be proven',
749
+ );
750
+ }
751
+ if (CURRENT_TIME_KEYWORDS.has(lower ?? '')) {
752
+ this.#fail(
753
+ 'SYQL6003_NONDETERMINISTIC_SQL',
754
+ span,
755
+ `${token.text} depends on wall-clock time`,
756
+ );
757
+ }
758
+ if (
759
+ token.kind === 'identifier' &&
760
+ next?.text === '(' &&
761
+ NONDETERMINISTIC_FUNCTIONS.has(lower ?? '')
762
+ ) {
763
+ this.#fail(
764
+ 'SYQL6003_NONDETERMINISTIC_SQL',
765
+ span,
766
+ `${token.text}() is not deterministic from snapshot and inputs`,
767
+ );
768
+ }
769
+ if (token.text === '(') {
770
+ const previous = tokens[index - 1];
771
+ functionStack.push(
772
+ previous?.kind === 'identifier'
773
+ ? previous.text.toLowerCase()
774
+ : undefined,
775
+ );
776
+ depth += 1;
777
+ } else if (token.text === ')') {
778
+ functionStack.pop();
779
+ depth -= 1;
780
+ } else if (
781
+ decodeSqlString(token)?.toLowerCase() === 'now' &&
782
+ functionStack.some(
783
+ (name) => name !== undefined && DATE_FUNCTIONS.has(name),
784
+ )
785
+ ) {
786
+ this.#fail(
787
+ 'SYQL6003_NONDETERMINISTIC_SQL',
788
+ span,
789
+ "SQLite date/time modifier 'now' is not deterministic",
790
+ );
791
+ }
792
+ }
793
+ }
794
+
795
+ #validatePortableProfile(sql: string, span: SyqlSourceSpan): void {
796
+ const tokens = significant(lexSyqlSqlSource(span.file, sql));
797
+ for (let index = 0; index < tokens.length; index += 1) {
798
+ const token = tokens[index] as SyqlToken;
799
+ const lower = tokenLower(token);
800
+ if (lower === 'collate') {
801
+ const collation = tokens[index + 1];
802
+ const name = tokenLower(collation);
803
+ if (
804
+ collation === undefined ||
805
+ !SQLITE_346_PORTABLE_COLLATIONS.has(name ?? '')
806
+ ) {
807
+ this.#fail(
808
+ 'SYQL6002_INVALID_SQL',
809
+ collation?.span ?? token.span,
810
+ `collation ${collation?.text ?? '<missing>'} is outside the portable SQLite 3.46.0 profile`,
811
+ );
812
+ }
813
+ }
814
+ if (
815
+ token.kind !== 'identifier' ||
816
+ tokens[index + 1]?.text !== '(' ||
817
+ SQL_PAREN_KEYWORDS.has(lower ?? '')
818
+ ) {
819
+ continue;
820
+ }
821
+
822
+ const closeIndex = matchingParenIndex(tokens, index + 1);
823
+ // A WITH declaration such as `items(id) AS (...)` is not a function.
824
+ if (
825
+ closeIndex !== undefined &&
826
+ tokenLower(tokens[closeIndex + 1]) === 'as' &&
827
+ tokens[closeIndex + 2]?.text === '('
828
+ ) {
829
+ continue;
830
+ }
831
+ if (!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '')) {
832
+ this.#fail(
833
+ 'SYQL6002_INVALID_SQL',
834
+ token.span,
835
+ `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile`,
836
+ );
837
+ }
838
+
839
+ const argumentCount = functionArgumentCount(tokens, index + 1);
840
+ if (lower === 'iif' && argumentCount !== 3) {
841
+ this.#fail(
842
+ 'SYQL6002_INVALID_SQL',
843
+ token.span,
844
+ 'SQLite 3.46.0 requires exactly three arguments to iif()',
845
+ );
846
+ }
847
+ if (
848
+ (lower === 'date' ||
849
+ lower === 'time' ||
850
+ lower === 'datetime' ||
851
+ lower === 'julianday' ||
852
+ lower === 'unixepoch') &&
853
+ argumentCount === 0
854
+ ) {
855
+ this.#fail(
856
+ 'SYQL6003_NONDETERMINISTIC_SQL',
857
+ token.span,
858
+ `${token.text}() without a time-value implicitly reads the wall clock`,
859
+ );
860
+ }
861
+ if (lower === 'strftime' && (argumentCount ?? 0) < 2) {
862
+ this.#fail(
863
+ 'SYQL6003_NONDETERMINISTIC_SQL',
864
+ token.span,
865
+ 'strftime() without an explicit time-value implicitly reads the wall clock',
866
+ );
867
+ }
868
+ }
869
+ }
870
+
871
+ #bindSymbols(query: SyqlQueryDeclaration): ReadonlyMap<string, BindSymbol> {
872
+ const symbols = new Map<string, BindSymbol>();
873
+ for (const parameter of query.parameters) {
874
+ if (parameter.kind === 'range') {
875
+ for (const name of [
876
+ syqlRangeStartBind(parameter.name),
877
+ syqlRangeEndBind(parameter.name),
878
+ ]) {
879
+ symbols.set(name, {
880
+ name,
881
+ parameter,
882
+ span: parameter.nameSpan,
883
+ ...(parameter.type === undefined
884
+ ? {}
885
+ : { authoredType: parameter.type }),
886
+ });
887
+ }
888
+ } else if (parameter.kind === 'group') {
889
+ for (const member of parameter.members) {
890
+ symbols.set(member.name, {
891
+ name: member.name,
892
+ parameter,
893
+ span: member.nameSpan,
894
+ ...(member.type === undefined ? {} : { authoredType: member.type }),
895
+ });
896
+ }
897
+ } else {
898
+ symbols.set(parameter.name, {
899
+ name: parameter.name,
900
+ parameter,
901
+ span: parameter.nameSpan,
902
+ ...(parameter.type === undefined
903
+ ? {}
904
+ : { authoredType: parameter.type }),
905
+ });
906
+ }
907
+ }
908
+ return symbols;
909
+ }
910
+
911
+ #resolveBindTypes(
912
+ logical: SyqlLogicalQuery,
913
+ sql: string,
914
+ refs: readonly TableRef[],
915
+ symbols: ReadonlyMap<string, BindSymbol>,
916
+ ): ReadonlyMap<string, SyqlValueType> {
917
+ const resolved = new Map(logical.bindTypes);
918
+
919
+ for (const symbol of symbols.values()) {
920
+ const evidence = inferParamTypeEvidence(symbol.name, sql, refs, this.#ir);
921
+ const unique = [...new Set(evidence)];
922
+ if (unique.length > 1) {
923
+ this.#fail(
924
+ 'SYQL6004_TYPE_CONFLICT',
925
+ symbol.span,
926
+ `bind :${symbol.name} has conflicting checked types: ${unique.join(', ')}`,
927
+ );
928
+ }
929
+ const existing = resolved.get(symbol.name) ?? symbol.authoredType;
930
+ const inferred = unique[0];
931
+ if (
932
+ existing !== undefined &&
933
+ inferred !== undefined &&
934
+ existing.base !== inferred
935
+ ) {
936
+ this.#fail(
937
+ 'SYQL6004_TYPE_CONFLICT',
938
+ symbol.span,
939
+ `bind :${symbol.name} is declared ${typeText(existing)} but SQL requires ${inferred}`,
940
+ );
941
+ }
942
+ if (existing !== undefined) resolved.set(symbol.name, existing);
943
+ else if (inferred !== undefined) {
944
+ resolved.set(symbol.name, valueType(inferred, false, symbol.span));
945
+ } else {
946
+ this.#fail(
947
+ 'SYQL6004_TYPE_CONFLICT',
948
+ symbol.span,
949
+ `cannot infer a revision-1 type for bind :${symbol.name}; add an annotation`,
950
+ );
951
+ }
952
+ }
953
+ return resolved;
954
+ }
955
+
956
+ #inferReactive(
957
+ logical: SyqlLogicalQuery,
958
+ markerSql: string,
959
+ refs: readonly TableRef[],
960
+ symbols: ReadonlyMap<string, BindSymbol>,
961
+ ): QueryReactiveMetadata {
962
+ type InferredScope = {
963
+ readonly binding: QueryScopeBinding;
964
+ readonly operator: 'equal' | 'in';
965
+ };
966
+ type Candidate = {
967
+ readonly ref: TableRef;
968
+ readonly scopes: readonly InferredScope[];
969
+ };
970
+ const cleaned = stripCommentsAndStrings(markerSql);
971
+ const structure = this.#inspect(
972
+ cleaned,
973
+ logical.declaration.statement.span.file,
974
+ );
975
+ const escapeRegex = (value: string): string =>
976
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
977
+ const requiredAt = (index: number): boolean => {
978
+ let whereStart: number | undefined;
979
+ for (const item of structure.outer) {
980
+ if (item.token.span.start.offset >= index) break;
981
+ const lower = tokenLower(item.token);
982
+ if (lower === 'where') whereStart = item.token.span.end.offset;
983
+ else if (OUTER_CLAUSE_ENDERS.has(lower ?? '')) whereStart = undefined;
984
+ }
985
+ if (whereStart === undefined) return false;
986
+
987
+ const clauseEnd =
988
+ structure.outer.find(
989
+ (item) =>
990
+ item.token.span.start.offset > index &&
991
+ OUTER_CLAUSE_ENDERS.has(tokenLower(item.token) ?? ''),
992
+ )?.token.span.start.offset ?? cleaned.length;
993
+ const matchStack: number[] = [];
994
+ for (let cursor = whereStart; cursor < index; cursor += 1) {
995
+ if (cleaned[cursor] === '(') matchStack.push(cursor);
996
+ else if (cleaned[cursor] === ')') matchStack.pop();
997
+ }
998
+
999
+ // Parenthesized boolean expressions are valid, but predicates inside a
1000
+ // CTE or subquery are not outer proof obligations for sync coverage.
1001
+ if (
1002
+ matchStack.some((open) =>
1003
+ /\b(?:SELECT|WITH)\b/i.test(cleaned.slice(open + 1, index)),
1004
+ )
1005
+ ) {
1006
+ return false;
1007
+ }
1008
+
1009
+ // Negated equality does not constrain the result to that scope.
1010
+ const prefix = cleaned.slice(whereStart, index);
1011
+ const boundaries = [...prefix.matchAll(/\b(?:AND|OR)\b/gi)].map(
1012
+ (match) => (match.index ?? -1) + match[0].length,
1013
+ );
1014
+ const lastBoundary = Math.max(prefix.lastIndexOf('('), ...boundaries);
1015
+ if (/\bNOT\b/i.test(prefix.slice(lastBoundary + 1))) return false;
1016
+ if (
1017
+ matchStack.some((open) =>
1018
+ /\bNOT\s*$/i.test(cleaned.slice(whereStart, open)),
1019
+ )
1020
+ ) {
1021
+ return false;
1022
+ }
1023
+
1024
+ const stack: number[] = [];
1025
+ for (let cursor = whereStart; cursor < clauseEnd; cursor += 1) {
1026
+ const char = cleaned[cursor];
1027
+ if (char === '(') stack.push(cursor);
1028
+ else if (char === ')') stack.pop();
1029
+ if (
1030
+ /^OR\b/i.test(cleaned.slice(cursor)) &&
1031
+ stack.length <= matchStack.length &&
1032
+ stack.every((open, stackIndex) => matchStack[stackIndex] === open)
1033
+ ) {
1034
+ return false;
1035
+ }
1036
+ }
1037
+ return true;
1038
+ };
1039
+ const required = new Set(
1040
+ [...symbols.values()].flatMap((symbol) =>
1041
+ symbol.parameter.kind === 'value' &&
1042
+ !symbol.parameter.optional &&
1043
+ symbol.authoredType?.nullable !== true
1044
+ ? [symbol.name]
1045
+ : [],
1046
+ ),
1047
+ );
1048
+ const candidates: Candidate[] = [];
1049
+ for (const ref of refs) {
1050
+ const table = this.#ir.tables.find((item) => item.name === ref.table);
1051
+ if (table === undefined) continue;
1052
+ const inferred: InferredScope[] = [];
1053
+ for (const scope of table.scopes) {
1054
+ const alias = escapeRegex(ref.alias);
1055
+ const column = escapeRegex(scope.column);
1056
+ const unqualifiedMatches = refs.filter((candidate) => {
1057
+ const other = this.#ir.tables.find(
1058
+ (item) => item.name === candidate.table,
1059
+ );
1060
+ return other?.columns.some((item) => item.name === scope.column);
1061
+ }).length;
1062
+ const subject =
1063
+ unqualifiedMatches === 1
1064
+ ? `(?:${alias}\\.)?${column}`
1065
+ : `${alias}\\.${column}`;
1066
+ const found: Array<{ name: string; operator: 'equal' | 'in' }> = [];
1067
+ const equality = new RegExp(
1068
+ `${subject}\\s*(?:=|==|\\bIS\\b)\\s*:([A-Za-z_][A-Za-z0-9_]*)\\b|:([A-Za-z_][A-Za-z0-9_]*)\\b\\s*(?:=|==)\\s*${subject}`,
1069
+ 'gi',
1070
+ );
1071
+ for (const match of cleaned.matchAll(equality)) {
1072
+ if (!requiredAt(match.index)) continue;
1073
+ const name = match[1] ?? match[2];
1074
+ if (name !== undefined && required.has(name)) {
1075
+ found.push({ name, operator: 'equal' });
1076
+ }
1077
+ }
1078
+ const inList = new RegExp(`${subject}\\s+IN\\s*\\(([^)]*)\\)`, 'gi');
1079
+ for (const match of cleaned.matchAll(inList)) {
1080
+ if (!requiredAt(match.index)) continue;
1081
+ const body = match[1] ?? '';
1082
+ const bodyStart = match.index + match[0].length - body.length - 1;
1083
+ const bodyTokens = significant(
1084
+ lexSyqlSqlSource(
1085
+ logical.declaration.statement.span.file,
1086
+ markerSql.slice(bodyStart, bodyStart + body.length),
1087
+ ),
1088
+ );
1089
+ const validBindList = bodyTokens.every((token, index) =>
1090
+ index % 2 === 0
1091
+ ? token.kind === 'bind'
1092
+ : token.kind === 'punctuation' && token.text === ',',
1093
+ );
1094
+ const params = bodyTokens
1095
+ .filter((_, index) => index % 2 === 0)
1096
+ .map((token) => token.text.slice(1));
1097
+ if (
1098
+ validBindList &&
1099
+ params.length > 0 &&
1100
+ params.every((name) => required.has(name))
1101
+ ) {
1102
+ for (const name of params) found.push({ name, operator: 'in' });
1103
+ }
1104
+ }
1105
+ const params = [...new Set(found.map((item) => item.name))];
1106
+ if (params.length > 0) {
1107
+ inferred.push({
1108
+ binding: {
1109
+ table: table.name,
1110
+ variable: scope.variable,
1111
+ pattern: scope.pattern,
1112
+ params,
1113
+ },
1114
+ operator: found.some((item) => item.operator === 'in')
1115
+ ? 'in'
1116
+ : 'equal',
1117
+ });
1118
+ }
1119
+ }
1120
+ candidates.push({ ref, scopes: inferred });
1121
+ }
1122
+
1123
+ const dependencies = [...new Set(refs.map((ref) => ref.table))]
1124
+ .sort()
1125
+ .map((table) => {
1126
+ const instances = candidates.filter((item) => item.ref.table === table);
1127
+ if (instances.some((item) => item.scopes.length === 0)) {
1128
+ return { table, scopes: [] };
1129
+ }
1130
+ const scopes = instances
1131
+ .flatMap((item) => item.scopes.map((scope) => scope.binding))
1132
+ .reduce<QueryScopeBinding[]>((out, scope) => {
1133
+ const existing = out.find(
1134
+ (item) => item.variable === scope.variable,
1135
+ );
1136
+ if (existing === undefined) out.push(scope);
1137
+ else {
1138
+ out[out.indexOf(existing)] = {
1139
+ ...existing,
1140
+ params: [...new Set([...existing.params, ...scope.params])],
1141
+ };
1142
+ }
1143
+ return out;
1144
+ }, []);
1145
+ return { table, scopes };
1146
+ });
1147
+
1148
+ if (!logical.declaration.sync) return { dependencies, coverage: [] };
1149
+ let eligible = candidates.filter((candidate) => {
1150
+ const table = this.#ir.tables.find(
1151
+ (item) => item.name === candidate.ref.table,
1152
+ );
1153
+ return (
1154
+ table !== undefined &&
1155
+ candidates.filter((item) => item.ref.table === candidate.ref.table)
1156
+ .length === 1 &&
1157
+ table.scopes.length > 0 &&
1158
+ candidate.scopes.length === table.scopes.length
1159
+ );
1160
+ });
1161
+ const syncBy = logical.declaration.syncBy;
1162
+ if (syncBy !== undefined) {
1163
+ eligible = eligible.filter(
1164
+ (candidate) => candidate.ref.alias === syncBy.qualifier,
1165
+ );
1166
+ }
1167
+ if (eligible.length !== 1) {
1168
+ this.#fail(
1169
+ 'SYQL6005_INVALID_SYNC_QUERY',
1170
+ logical.declaration.syncBy?.span ?? logical.declaration.nameSpan,
1171
+ eligible.length === 0
1172
+ ? 'sync query coverage cannot be proven from required equality/IN predicates over every declared scope'
1173
+ : 'sync query resolves to multiple coverable table instances; split the query or select one with `by alias.scope`',
1174
+ );
1175
+ }
1176
+ const candidate = eligible[0] as Candidate;
1177
+ const table = this.#ir.tables.find(
1178
+ (item) => item.name === candidate.ref.table,
1179
+ );
1180
+ if (table === undefined) throw new Error('eligible table disappeared');
1181
+ if (table.scopes.length > 1 && syncBy === undefined) {
1182
+ this.#fail(
1183
+ 'SYQL6005_INVALID_SYNC_QUERY',
1184
+ logical.declaration.nameSpan,
1185
+ `sync query for multi-scope table ${table.name} must select its unit dimension with \`by ${candidate.ref.alias}.scope_column\``,
1186
+ );
1187
+ }
1188
+ const dimensionColumn = syncBy?.column ?? table.scopes[0]?.column;
1189
+ const dimension = table.scopes.find(
1190
+ (scope) => scope.column === dimensionColumn,
1191
+ );
1192
+ if (dimension === undefined) {
1193
+ this.#fail(
1194
+ 'SYQL6005_INVALID_SYNC_QUERY',
1195
+ syncBy?.span ?? logical.declaration.nameSpan,
1196
+ `${candidate.ref.alias}.${dimensionColumn ?? ''} is not a declared scope`,
1197
+ );
1198
+ }
1199
+ const byVariable = new Map(
1200
+ candidate.scopes.map((scope) => [scope.binding.variable, scope]),
1201
+ );
1202
+ const unit = byVariable.get(dimension.variable) as InferredScope;
1203
+ const fixedScopes = table.scopes
1204
+ .filter((scope) => scope.variable !== dimension.variable)
1205
+ .map((scope) => {
1206
+ const fixed = byVariable.get(scope.variable) as InferredScope;
1207
+ if (fixed.operator !== 'equal' || fixed.binding.params.length !== 1) {
1208
+ this.#fail(
1209
+ 'SYQL6005_INVALID_SYNC_QUERY',
1210
+ syncBy?.span ?? logical.declaration.nameSpan,
1211
+ `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`,
1212
+ );
1213
+ }
1214
+ return {
1215
+ variable: fixed.binding.variable,
1216
+ params: fixed.binding.params,
1217
+ };
1218
+ });
1219
+ const coverage: QueryCoverageBinding = {
1220
+ table: table.name,
1221
+ variable: unit.binding.variable,
1222
+ units: unit.binding.params,
1223
+ fixedScopes,
1224
+ };
1225
+ return { dependencies, coverage: [coverage] };
1226
+ }
1227
+
1228
+ #validateSort(
1229
+ query: SyqlQueryDeclaration,
1230
+ structure: SqlStructure,
1231
+ location: string,
1232
+ ): SyqlValidatedSort | undefined {
1233
+ if (query.sort === undefined) return undefined;
1234
+ if (structure.hasOuterOrder) {
1235
+ this.#fail(
1236
+ 'SYQL6006_INVALID_SORT',
1237
+ query.sort.span,
1238
+ 'sort section conflicts with authored outer ORDER BY',
1239
+ );
1240
+ }
1241
+ const profiles = query.sort.profiles.map((profile) => {
1242
+ const sql = templateText(profile.order);
1243
+ const tokens = significant(lexSyqlSqlSource(location, sql));
1244
+ for (let index = 0; index < tokens.length; index += 1) {
1245
+ const token = tokens[index] as SyqlToken;
1246
+ const lower = tokenLower(token);
1247
+ if (
1248
+ lower === 'select' ||
1249
+ lower === 'limit' ||
1250
+ lower === 'offset' ||
1251
+ lower === 'window' ||
1252
+ lower === 'over' ||
1253
+ lower === 'order' ||
1254
+ lower === 'group' ||
1255
+ lower === 'having'
1256
+ ) {
1257
+ this.#fail(
1258
+ 'SYQL6006_INVALID_SORT',
1259
+ profile.order.span,
1260
+ `sort profile ${profile.name} contains forbidden ${token.text}`,
1261
+ );
1262
+ }
1263
+ if (
1264
+ token.kind === 'identifier' &&
1265
+ tokens[index + 1]?.text === '(' &&
1266
+ AGGREGATE_FUNCTIONS.has(lower ?? '')
1267
+ ) {
1268
+ this.#fail(
1269
+ 'SYQL6006_INVALID_SORT',
1270
+ profile.order.span,
1271
+ `sort profile ${profile.name} contains aggregate ${token.text}()`,
1272
+ );
1273
+ }
1274
+ }
1275
+ this.#validateDeterminism(sql, profile.order.span);
1276
+ this.#validatePortableProfile(sql, profile.order.span);
1277
+ return { name: profile.name, sql };
1278
+ });
1279
+ return {
1280
+ control: query.sort.control,
1281
+ defaultProfile: query.sort.defaultProfile,
1282
+ profiles,
1283
+ };
1284
+ }
1285
+
1286
+ #composeSort(
1287
+ sql: string,
1288
+ structure: SqlStructure,
1289
+ order: string | undefined,
1290
+ ): string {
1291
+ if (order === undefined) return sql.trim();
1292
+ const insertion = structure.tailStart ?? sql.length;
1293
+ return `${sql.slice(0, insertion).trimEnd()} order by ${order} ${sql
1294
+ .slice(insertion)
1295
+ .trimStart()}`.trim();
1296
+ }
1297
+
1298
+ #proveIdentity(
1299
+ query: SyqlQueryDeclaration,
1300
+ sql: string,
1301
+ refs: readonly TableRef[],
1302
+ analysis: AnalyzedQuery,
1303
+ ): readonly string[] | undefined {
1304
+ const cleaned = stripCommentsAndStrings(sql);
1305
+ const nonSimple =
1306
+ /\b(?:DISTINCT|GROUP\s+BY|UNION|INTERSECT|EXCEPT|LEFT\s+JOIN|RIGHT\s+JOIN|FULL\s+JOIN)\b/i.test(
1307
+ cleaned,
1308
+ ) ||
1309
+ /\b(?:count|sum|total|avg|min|max|group_concat)\s*\(/i.test(cleaned) ||
1310
+ (cleaned.match(/\bSELECT\b/gi)?.length ?? 0) !== 1 ||
1311
+ new Set(refs.map((ref) => `${ref.table}\0${ref.alias}`)).size !==
1312
+ refs.length ||
1313
+ new Set(refs.map((ref) => ref.table)).size !== refs.length;
1314
+
1315
+ const resultNames = new Set<string>();
1316
+ for (const column of analysis.columns) {
1317
+ if (!IDENT_RE.test(column.name)) {
1318
+ this.#fail(
1319
+ 'SYQL6008_INVALID_IDENTITY',
1320
+ query.statement.span,
1321
+ `result name ${JSON.stringify(column.name)} must be an unquoted IDENT value`,
1322
+ );
1323
+ }
1324
+ if (resultNames.has(column.name)) {
1325
+ this.#fail(
1326
+ 'SYQL6008_INVALID_IDENTITY',
1327
+ query.statement.span,
1328
+ `result name ${JSON.stringify(column.name)} is duplicated`,
1329
+ );
1330
+ }
1331
+ resultNames.add(column.name);
1332
+ }
1333
+
1334
+ if (nonSimple || refs.length === 0) return undefined;
1335
+ const inferred: string[] = [];
1336
+ for (const ref of refs) {
1337
+ const table = this.#ir.tables.find((item) => item.name === ref.table);
1338
+ const column = analysis.columns.find(
1339
+ (candidate) =>
1340
+ table !== undefined &&
1341
+ candidate.origin?.table === table.name &&
1342
+ candidate.origin.column === table.primaryKey &&
1343
+ !candidate.nullable,
1344
+ );
1345
+ if (column === undefined) return undefined;
1346
+ inferred.push(column.langName);
1347
+ }
1348
+ return inferred;
1349
+ }
1350
+
1351
+ #validateStableOrder(
1352
+ query: SyqlQueryDeclaration,
1353
+ sql: string,
1354
+ structure: SqlStructure,
1355
+ sort: SyqlValidatedSort | undefined,
1356
+ identity: readonly string[] | undefined,
1357
+ analysis: AnalyzedQuery,
1358
+ refs: readonly TableRef[],
1359
+ ): void {
1360
+ const bounded =
1361
+ query.limit !== undefined ||
1362
+ structure.hasOuterLimit ||
1363
+ structure.hasOuterOffset;
1364
+ if (!bounded) return;
1365
+ if (identity === undefined || identity.length === 0) {
1366
+ this.#fail(
1367
+ 'SYQL6006_INVALID_SORT',
1368
+ query.sort?.span ?? query.limit?.span ?? query.statement.span,
1369
+ 'a bounded query requires a proven identity and total outer order',
1370
+ );
1371
+ }
1372
+ const orders =
1373
+ sort?.profiles.map((profile) => ({
1374
+ name: profile.name,
1375
+ sql: profile.sql,
1376
+ span: query.sort?.span ?? query.statement.span,
1377
+ })) ??
1378
+ (structure.orderBodyStart !== undefined &&
1379
+ structure.orderEnd !== undefined
1380
+ ? [
1381
+ {
1382
+ name: 'authored',
1383
+ sql: sql
1384
+ .slice(structure.orderBodyStart, structure.orderEnd)
1385
+ .trim(),
1386
+ span: query.statement.span,
1387
+ },
1388
+ ]
1389
+ : []);
1390
+ if (orders.length === 0) {
1391
+ this.#fail(
1392
+ 'SYQL6006_INVALID_SORT',
1393
+ query.limit?.span ?? query.statement.span,
1394
+ 'a bounded query requires an outer ORDER BY or sort section',
1395
+ );
1396
+ }
1397
+ for (const order of orders) {
1398
+ const terms = this.#splitOrderTerms(order.sql, order.span);
1399
+ if (terms.length < identity.length) {
1400
+ this.#fail(
1401
+ 'SYQL6006_INVALID_SORT',
1402
+ order.span,
1403
+ `order ${order.name} does not end in the complete identity tie-breaker`,
1404
+ );
1405
+ }
1406
+ const suffix = terms.slice(terms.length - identity.length);
1407
+ identity.forEach((field, index) => {
1408
+ const result = analysis.columns.find(
1409
+ (column) => column.langName === field,
1410
+ );
1411
+ const origin = result?.origin;
1412
+ const match =
1413
+ /^(?:([A-Za-z_][A-Za-z0-9_]*)\.)?([A-Za-z_][A-Za-z0-9_]*)\s+(?:asc|desc)$/i.exec(
1414
+ suffix[index] ?? '',
1415
+ );
1416
+ if (
1417
+ origin === undefined ||
1418
+ match === null ||
1419
+ match[2] !== origin.column
1420
+ ) {
1421
+ this.#fail(
1422
+ 'SYQL6006_INVALID_SORT',
1423
+ order.span,
1424
+ `order ${order.name} must end with plain ${origin?.column ?? field} ASC/DESC identity term`,
1425
+ );
1426
+ }
1427
+ const qualifier = match[1];
1428
+ if (qualifier !== undefined) {
1429
+ const ref = refs.find((candidate) => candidate.alias === qualifier);
1430
+ if (ref?.table !== origin.table) {
1431
+ this.#fail(
1432
+ 'SYQL6006_INVALID_SORT',
1433
+ order.span,
1434
+ `identity order qualifier ${qualifier} does not resolve to ${origin.table}`,
1435
+ );
1436
+ }
1437
+ }
1438
+ });
1439
+ }
1440
+ }
1441
+
1442
+ #splitOrderTerms(sql: string, span: SyqlSourceSpan): readonly string[] {
1443
+ const tokens = lexSyqlSqlSource(span.file, sql).filter(
1444
+ (token) => token.kind !== 'eof',
1445
+ );
1446
+ const terms: string[] = [];
1447
+ let depth = 0;
1448
+ let current = '';
1449
+ for (const token of tokens) {
1450
+ if (token.kind === 'punctuation' && token.text === ')') depth -= 1;
1451
+ if (token.kind === 'punctuation' && token.text === ',' && depth === 0) {
1452
+ if (current.trim().length === 0) {
1453
+ this.#fail('SYQL6006_INVALID_SORT', span, 'empty ORDER BY term');
1454
+ }
1455
+ terms.push(current.trim());
1456
+ current = '';
1457
+ continue;
1458
+ }
1459
+ current += token.text;
1460
+ if (token.kind === 'punctuation' && token.text === '(') depth += 1;
1461
+ }
1462
+ if (current.trim().length > 0) terms.push(current.trim());
1463
+ if (terms.length === 0) {
1464
+ this.#fail('SYQL6006_INVALID_SORT', span, 'empty ORDER BY list');
1465
+ }
1466
+ return terms;
1467
+ }
1468
+
1469
+ #fail(
1470
+ code: SyqlValidationErrorCode,
1471
+ span: SyqlSourceSpan,
1472
+ message: string,
1473
+ ): never {
1474
+ throw new SyqlFrontendError(code, span, message);
1475
+ }
1476
+ }
1477
+
1478
+ /** Validate a semantic SYQL program against schema IR and SQLite. */
1479
+ export function validateSyqlProgram(
1480
+ semantic: SyqlSemanticProgram,
1481
+ ir: IrDocument,
1482
+ db: QueryDb,
1483
+ naming?: QueryNamingOptions,
1484
+ ): SyqlValidatedProgram {
1485
+ return new Validator(semantic, ir, db, naming).validate();
1486
+ }