@syncular/typegen 0.6.0 → 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.
@@ -1,5 +1,6 @@
1
1
  import { analyzeStatement, inferParamTypeEvidence, scanTableRefs, stripCommentsAndStrings, } from './query.js';
2
2
  import { isSyqlTrivia, lexSyqlSqlSource, SyqlFrontendError, } from './syql-lexer.js';
3
+ import { syqlRangeEndBind, syqlRangeStartBind } from './syql-semantics.js';
3
4
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
5
  const NONDETERMINISTIC_FUNCTIONS = new Set([
5
6
  'random',
@@ -246,44 +247,56 @@ class Validator {
246
247
  const activeSql = this.#render(logical.template, 'active', []);
247
248
  const activeStructure = this.#inspect(activeSql, location);
248
249
  this.#validateStatementShape(activeSql, activeStructure, logical.declaration, location);
249
- this.#validateDeterminism(activeSql, logical.declaration.sql.span);
250
- this.#validatePortableProfile(activeSql, logical.declaration.sql.span);
250
+ this.#validateDeterminism(activeSql, logical.declaration.statement.span);
251
+ this.#validatePortableProfile(activeSql, logical.declaration.statement.span);
251
252
  const refs = scanTableRefs(activeSql, this.#ir);
252
253
  const bindSymbols = this.#bindSymbols(logical.declaration);
253
- const resolvedDirectives = this.#resolveDirectives(logical, refs, bindSymbols);
254
- const bindTypes = this.#resolveBindTypes(logical, activeSql, refs, bindSymbols, resolvedDirectives);
254
+ const bindTypes = this.#resolveBindTypes(logical, activeSql, refs, bindSymbols);
255
255
  const sort = this.#validateSort(logical.declaration, activeStructure, location);
256
256
  let referenceSql = this.#composeSort(activeSql, activeStructure, sort?.profiles.find((profile) => profile.name === sort.defaultProfile)
257
257
  ?.sql);
258
- if (logical.declaration.page !== undefined) {
259
- referenceSql = `${referenceSql.trimEnd()} limit ${logical.declaration.page.defaultSize}`;
258
+ if (logical.declaration.limit !== undefined) {
259
+ referenceSql = `${referenceSql.trimEnd()} limit ${logical.declaration.limit.defaultSize}`;
260
260
  }
261
+ const usedBindNames = new Set(lexSyqlSqlSource(location, activeSql)
262
+ .filter((token) => token.kind === 'bind')
263
+ .map((token) => token.text.slice(1)));
261
264
  const headers = [...bindTypes]
265
+ .filter(([name]) => usedBindNames.has(name))
262
266
  .map(([name, type]) => `-- param :${name} ${type.base}`)
263
267
  .join('\n');
264
268
  const statement = headers.length === 0 ? referenceSql : `${headers}\n${referenceSql}`;
265
269
  let analysis;
266
270
  try {
267
- analysis = analyzeStatement(logical.declaration.name, location, statement, this.#ir, this.#db, this.#naming);
271
+ const analysisNaming = this.#naming === undefined
272
+ ? undefined
273
+ : {
274
+ ...this.#naming,
275
+ internalParams: [
276
+ ...(this.#naming.internalParams ?? []),
277
+ ...[...usedBindNames].filter((name) => name.startsWith('__syql')),
278
+ ],
279
+ };
280
+ analysis = analyzeStatement(logical.declaration.name, location, statement, this.#ir, this.#db, analysisNaming);
268
281
  }
269
282
  catch (error) {
270
283
  const message = error instanceof Error ? error.message : String(error);
271
- this.#fail('SYQL6002_INVALID_SQL', logical.declaration.sql.span, `reference SQL rejected: ${message}`);
284
+ this.#fail('SYQL6002_INVALID_SQL', logical.declaration.statement.span, `reference SQL rejected: ${message}`);
272
285
  }
273
286
  for (const profile of sort?.profiles ?? []) {
274
287
  const candidate = this.#composeSort(activeSql, activeStructure, profile.sql);
275
- const paged = logical.declaration.page === undefined
288
+ const paged = logical.declaration.limit === undefined
276
289
  ? candidate
277
- : `${candidate.trimEnd()} limit ${logical.declaration.page.defaultSize}`;
290
+ : `${candidate.trimEnd()} limit ${logical.declaration.limit.defaultSize}`;
278
291
  try {
279
292
  this.#db.analyze(paged);
280
293
  }
281
294
  catch (error) {
282
295
  const message = error instanceof Error ? error.message : String(error);
283
- this.#fail('SYQL6006_INVALID_SORT', logical.declaration.sort?.span ?? logical.declaration.sql.span, `sort profile ${profile.name} rejected by SQLite: ${message}`);
296
+ this.#fail('SYQL6006_INVALID_SORT', logical.declaration.sort?.span ?? logical.declaration.statement.span, `sort profile ${profile.name} rejected by SQLite: ${message}`);
284
297
  }
285
298
  }
286
- const reactive = this.#buildReactive(refs, resolvedDirectives);
299
+ const reactive = this.#inferReactive(logical, markerSql, refs, bindSymbols);
287
300
  const identity = this.#proveIdentity(logical.declaration, activeSql, refs, analysis);
288
301
  this.#validateStableOrder(logical.declaration, activeSql, activeStructure, sort, identity, analysis, refs);
289
302
  const reactiveWithIdentity = {
@@ -298,13 +311,13 @@ class Validator {
298
311
  reactive: reactiveWithIdentity,
299
312
  ...(identity === undefined ? {} : { identity }),
300
313
  ...(sort === undefined ? {} : { sort }),
301
- ...(logical.declaration.page === undefined
314
+ ...(logical.declaration.limit === undefined
302
315
  ? {}
303
316
  : {
304
- page: {
305
- control: logical.declaration.page.control,
306
- defaultSize: logical.declaration.page.defaultSize,
307
- maxSize: logical.declaration.page.maxSize,
317
+ limit: {
318
+ control: logical.declaration.limit.control,
319
+ defaultSize: logical.declaration.limit.defaultSize,
320
+ maxSize: logical.declaration.limit.maxSize,
308
321
  },
309
322
  }),
310
323
  };
@@ -328,20 +341,10 @@ class Validator {
328
341
  if (node.kind === 'when') {
329
342
  return `(${this.#render(node.body, mode, markers)})`;
330
343
  }
331
- return this.#renderDirective(node);
344
+ throw new Error('unknown SYQL logical template node');
332
345
  })
333
346
  .join('');
334
347
  }
335
- #renderDirective(node) {
336
- const predicates = node.directive.bindings.map((binding) => {
337
- const column = `${binding.column.qualifier}.${binding.column.name}`;
338
- const values = binding.values.map((value) => `:${value.name}`);
339
- return binding.operator === 'equal'
340
- ? `${column} = ${values[0]}`
341
- : `${column} in (${values.join(', ')})`;
342
- });
343
- return `(${predicates.join(' and ')})`;
344
- }
345
348
  #inspect(sql, file) {
346
349
  const raw = significant(lexSyqlSqlSource(file, sql));
347
350
  const tokens = [];
@@ -435,42 +438,37 @@ class Validator {
435
438
  const boundaryBefore = previous === 'where' || previous === 'having' || previous === 'and';
436
439
  const boundaryAfter = next === undefined || next === 'and' || OUTER_CLAUSE_ENDERS.has(next);
437
440
  if (!boundaryBefore || !boundaryAfter) {
438
- this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, `${marker.node.kind === 'when' ? 'when' : `@${marker.node.kind}`} must be an entire outer conjunct`);
439
- }
440
- if (marker.node.kind === 'when') {
441
- if (clause !== 'where' && clause !== 'having') {
442
- this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, 'when is allowed only in the outer WHERE or HAVING clause');
443
- }
441
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, 'when must be an entire outer conjunct');
444
442
  }
445
- else if (clause !== 'where') {
446
- this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, `@${marker.node.kind} is allowed only in the outer WHERE clause`);
443
+ if (clause !== 'where' && clause !== 'having') {
444
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, 'when is allowed only in the outer WHERE or HAVING clause');
447
445
  }
448
446
  }
449
447
  for (const marker of markers) {
450
448
  if (!structure.outer.some((item) => item.token.text === marker.name)) {
451
- this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, `${marker.node.kind === 'when' ? 'when' : `@${marker.node.kind}`} cannot appear in a nested SQL expression or statement`);
449
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, 'when cannot appear in a nested SQL expression or statement');
452
450
  }
453
451
  }
454
452
  if (structure.hasCompound && markers.length > 0) {
455
- this.#fail('SYQL6001_INVALID_PLACEMENT', query.sql.span, 'embedded SYQL conjuncts are ambiguous in a compound outer statement');
453
+ this.#fail('SYQL6001_INVALID_PLACEMENT', query.statement.span, 'embedded SYQL conjuncts are ambiguous in a compound outer statement');
456
454
  }
457
455
  }
458
456
  #validateStatementShape(sql, structure, query, location) {
459
457
  if (query.sort !== undefined && structure.hasOuterOrder) {
460
458
  this.#fail('SYQL6006_INVALID_SORT', query.sort.span, 'sort section conflicts with an authored outer ORDER BY');
461
459
  }
462
- if (query.page !== undefined &&
460
+ if (query.limit !== undefined &&
463
461
  (structure.hasOuterLimit || structure.hasOuterOffset)) {
464
- this.#fail('SYQL6007_INVALID_PAGE', query.page.span, 'page declaration conflicts with an authored outer LIMIT or OFFSET');
462
+ this.#fail('SYQL6007_INVALID_LIMIT', query.limit.span, 'dynamic LIMIT conflicts with an authored outer LIMIT or OFFSET');
465
463
  }
466
464
  const first = structure.outer
467
465
  .find((item) => item.token.kind === 'identifier')
468
466
  ?.token.text.toLowerCase();
469
467
  if (first !== 'select' && first !== 'with') {
470
- this.#fail('SYQL6002_INVALID_SQL', query.sql.span, `${location} must contain one SELECT or WITH ... SELECT statement`);
468
+ this.#fail('SYQL6002_INVALID_SQL', query.statement.span, `${location} must contain one SELECT or WITH ... SELECT statement`);
471
469
  }
472
470
  if (sql.trim().length === 0) {
473
- this.#fail('SYQL6002_INVALID_SQL', query.sql.span, 'empty SQL statement');
471
+ this.#fail('SYQL6002_INVALID_SQL', query.statement.span, 'empty SQL statement');
474
472
  }
475
473
  }
476
474
  #validateDeterminism(sql, span) {
@@ -562,9 +560,22 @@ class Validator {
562
560
  #bindSymbols(query) {
563
561
  const symbols = new Map();
564
562
  for (const parameter of query.parameters) {
565
- if (parameter.kind === 'switch')
566
- continue;
567
- if (parameter.kind === 'group') {
563
+ if (parameter.kind === 'range') {
564
+ for (const name of [
565
+ syqlRangeStartBind(parameter.name),
566
+ syqlRangeEndBind(parameter.name),
567
+ ]) {
568
+ symbols.set(name, {
569
+ name,
570
+ parameter,
571
+ span: parameter.nameSpan,
572
+ ...(parameter.type === undefined
573
+ ? {}
574
+ : { authoredType: parameter.type }),
575
+ });
576
+ }
577
+ }
578
+ else if (parameter.kind === 'group') {
568
579
  for (const member of parameter.members) {
569
580
  symbols.set(member.name, {
570
581
  name: member.name,
@@ -587,26 +598,10 @@ class Validator {
587
598
  }
588
599
  return symbols;
589
600
  }
590
- #resolveBindTypes(logical, sql, refs, symbols, directives) {
601
+ #resolveBindTypes(logical, sql, refs, symbols) {
591
602
  const resolved = new Map(logical.bindTypes);
592
- const directiveTypes = new Map();
593
- for (const directive of directives) {
594
- for (const binding of directive.node.directive.bindings) {
595
- const column = directive.table.columns.find((item) => item.name === binding.column.name);
596
- if (column === undefined)
597
- continue;
598
- for (const bind of binding.values) {
599
- const list = directiveTypes.get(bind.name) ?? [];
600
- list.push(column.type);
601
- directiveTypes.set(bind.name, list);
602
- }
603
- }
604
- }
605
603
  for (const symbol of symbols.values()) {
606
- const evidence = [
607
- ...inferParamTypeEvidence(symbol.name, sql, refs, this.#ir),
608
- ...(directiveTypes.get(symbol.name) ?? []),
609
- ];
604
+ const evidence = inferParamTypeEvidence(symbol.name, sql, refs, this.#ir);
610
605
  const unique = [...new Set(evidence)];
611
606
  if (unique.length > 1) {
612
607
  this.#fail('SYQL6004_TYPE_CONFLICT', symbol.span, `bind :${symbol.name} has conflicting checked types: ${unique.join(', ')}`);
@@ -627,148 +622,206 @@ class Validator {
627
622
  this.#fail('SYQL6004_TYPE_CONFLICT', symbol.span, `cannot infer a revision-1 type for bind :${symbol.name}; add an annotation`);
628
623
  }
629
624
  }
630
- for (const directive of directives) {
631
- for (const binding of directive.node.directive.bindings) {
632
- const column = directive.table.columns.find((item) => item.name === binding.column.name);
633
- if (column === undefined)
634
- continue;
635
- for (const bind of binding.values) {
636
- const type = resolved.get(bind.name);
637
- if (type === undefined ||
638
- type.base !== column.type ||
639
- type.nullable !== column.nullable) {
640
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', bind.span, `scope bind :${bind.name} must exactly match ${column.type}${column.nullable ? ' | null' : ''}`);
641
- }
642
- }
643
- }
644
- }
645
625
  return resolved;
646
626
  }
647
- #resolveDirectives(logical, refs, symbols) {
648
- const nodes = [];
649
- const collect = (template) => {
650
- for (const node of template) {
651
- if (node.kind === 'scope' || node.kind === 'cover')
652
- nodes.push(node);
653
- else if (node.kind === 'predicate')
654
- collect(node.body);
627
+ #inferReactive(logical, markerSql, refs, symbols) {
628
+ const cleaned = stripCommentsAndStrings(markerSql);
629
+ const structure = this.#inspect(cleaned, logical.declaration.statement.span.file);
630
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
631
+ const requiredAt = (index) => {
632
+ let whereStart;
633
+ for (const item of structure.outer) {
634
+ if (item.token.span.start.offset >= index)
635
+ break;
636
+ const lower = tokenLower(item.token);
637
+ if (lower === 'where')
638
+ whereStart = item.token.span.end.offset;
639
+ else if (OUTER_CLAUSE_ENDERS.has(lower ?? ''))
640
+ whereStart = undefined;
641
+ }
642
+ if (whereStart === undefined)
643
+ return false;
644
+ const clauseEnd = structure.outer.find((item) => item.token.span.start.offset > index &&
645
+ OUTER_CLAUSE_ENDERS.has(tokenLower(item.token) ?? ''))?.token.span.start.offset ?? cleaned.length;
646
+ const matchStack = [];
647
+ for (let cursor = whereStart; cursor < index; cursor += 1) {
648
+ if (cleaned[cursor] === '(')
649
+ matchStack.push(cursor);
650
+ else if (cleaned[cursor] === ')')
651
+ matchStack.pop();
652
+ }
653
+ // Parenthesized boolean expressions are valid, but predicates inside a
654
+ // CTE or subquery are not outer proof obligations for sync coverage.
655
+ if (matchStack.some((open) => /\b(?:SELECT|WITH)\b/i.test(cleaned.slice(open + 1, index)))) {
656
+ return false;
657
+ }
658
+ // Negated equality does not constrain the result to that scope.
659
+ const prefix = cleaned.slice(whereStart, index);
660
+ const boundaries = [...prefix.matchAll(/\b(?:AND|OR)\b/gi)].map((match) => (match.index ?? -1) + match[0].length);
661
+ const lastBoundary = Math.max(prefix.lastIndexOf('('), ...boundaries);
662
+ if (/\bNOT\b/i.test(prefix.slice(lastBoundary + 1)))
663
+ return false;
664
+ if (matchStack.some((open) => /\bNOT\s*$/i.test(cleaned.slice(whereStart, open)))) {
665
+ return false;
666
+ }
667
+ const stack = [];
668
+ for (let cursor = whereStart; cursor < clauseEnd; cursor += 1) {
669
+ const char = cleaned[cursor];
670
+ if (char === '(')
671
+ stack.push(cursor);
672
+ else if (char === ')')
673
+ stack.pop();
674
+ if (/^OR\b/i.test(cleaned.slice(cursor)) &&
675
+ stack.length <= matchStack.length &&
676
+ stack.every((open, stackIndex) => matchStack[stackIndex] === open)) {
677
+ return false;
678
+ }
655
679
  }
680
+ return true;
656
681
  };
657
- collect(logical.template);
658
- return nodes.map((node) => {
659
- const first = node.directive.bindings[0];
660
- if (first === undefined) {
661
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', node.span, `@${node.kind} requires a binding`);
662
- }
663
- const qualifier = first.column.qualifier;
664
- const matchingRefs = refs.filter((ref) => ref.alias === qualifier);
665
- if (matchingRefs.length !== 1) {
666
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', first.column.span, `scope qualifier ${qualifier} must resolve to exactly one read table instance`);
667
- }
668
- const ref = matchingRefs[0];
682
+ const required = new Set([...symbols.values()].flatMap((symbol) => symbol.parameter.kind === 'value' &&
683
+ !symbol.parameter.optional &&
684
+ symbol.authoredType?.nullable !== true
685
+ ? [symbol.name]
686
+ : []));
687
+ const candidates = [];
688
+ for (const ref of refs) {
669
689
  const table = this.#ir.tables.find((item) => item.name === ref.table);
670
- if (table === undefined) {
671
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', first.column.span, `scope qualifier ${qualifier} does not resolve to a synced table`);
672
- }
673
- const seenColumns = new Set();
674
- const scopes = [];
675
- for (const binding of node.directive.bindings) {
676
- if (binding.column.qualifier !== qualifier) {
677
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding.column.span, `all @${node.kind} bindings must name the same table instance`);
678
- }
679
- if (seenColumns.has(binding.column.name)) {
680
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding.column.span, `scope column ${binding.column.name} is listed twice`);
681
- }
682
- seenColumns.add(binding.column.name);
683
- const scope = table.scopes.find((candidate) => candidate.column === binding.column.name);
684
- if (scope === undefined) {
685
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding.column.span, `${table.name}.${binding.column.name} is not a declared scope column`);
690
+ if (table === undefined)
691
+ continue;
692
+ const inferred = [];
693
+ for (const scope of table.scopes) {
694
+ const alias = escapeRegex(ref.alias);
695
+ const column = escapeRegex(scope.column);
696
+ const unqualifiedMatches = refs.filter((candidate) => {
697
+ const other = this.#ir.tables.find((item) => item.name === candidate.table);
698
+ return other?.columns.some((item) => item.name === scope.column);
699
+ }).length;
700
+ const subject = unqualifiedMatches === 1
701
+ ? `(?:${alias}\\.)?${column}`
702
+ : `${alias}\\.${column}`;
703
+ const found = [];
704
+ const equality = new RegExp(`${subject}\\s*(?:=|==|\\bIS\\b)\\s*:([A-Za-z_][A-Za-z0-9_]*)\\b|:([A-Za-z_][A-Za-z0-9_]*)\\b\\s*(?:=|==)\\s*${subject}`, 'gi');
705
+ for (const match of cleaned.matchAll(equality)) {
706
+ if (!requiredAt(match.index))
707
+ continue;
708
+ const name = match[1] ?? match[2];
709
+ if (name !== undefined && required.has(name)) {
710
+ found.push({ name, operator: 'equal' });
711
+ }
686
712
  }
687
- for (const bind of binding.values) {
688
- const symbol = symbols.get(bind.name);
689
- if (symbol === undefined ||
690
- symbol.parameter.kind !== 'value' ||
691
- symbol.parameter.optional) {
692
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', bind.span, `scope value :${bind.name} must be a required scalar input`);
713
+ const inList = new RegExp(`${subject}\\s+IN\\s*\\(([^)]*)\\)`, 'gi');
714
+ for (const match of cleaned.matchAll(inList)) {
715
+ if (!requiredAt(match.index))
716
+ continue;
717
+ const body = match[1] ?? '';
718
+ const bodyStart = match.index + match[0].length - body.length - 1;
719
+ const bodyTokens = significant(lexSyqlSqlSource(logical.declaration.statement.span.file, markerSql.slice(bodyStart, bodyStart + body.length)));
720
+ const validBindList = bodyTokens.every((token, index) => index % 2 === 0
721
+ ? token.kind === 'bind'
722
+ : token.kind === 'punctuation' && token.text === ',');
723
+ const params = bodyTokens
724
+ .filter((_, index) => index % 2 === 0)
725
+ .map((token) => token.text.slice(1));
726
+ if (validBindList &&
727
+ params.length > 0 &&
728
+ params.every((name) => required.has(name))) {
729
+ for (const name of params)
730
+ found.push({ name, operator: 'in' });
693
731
  }
694
732
  }
695
- scopes.push({
696
- table: table.name,
697
- variable: scope.variable,
698
- pattern: scope.pattern,
699
- params: binding.values.map((value) => value.name),
700
- });
701
- }
702
- let coverage;
703
- if (node.kind === 'cover') {
704
- const required = new Set(table.scopes.map((scope) => scope.column));
705
- if (seenColumns.size !== required.size ||
706
- [...required].some((column) => !seenColumns.has(column))) {
707
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', node.span, `@cover for ${table.name} must bind every declared scope exactly once`);
733
+ const params = [...new Set(found.map((item) => item.name))];
734
+ if (params.length > 0) {
735
+ inferred.push({
736
+ binding: {
737
+ table: table.name,
738
+ variable: scope.variable,
739
+ pattern: scope.pattern,
740
+ params,
741
+ },
742
+ operator: found.some((item) => item.operator === 'in')
743
+ ? 'in'
744
+ : 'equal',
745
+ });
708
746
  }
709
- const firstScope = scopes[0];
710
- for (let index = 1; index < node.directive.bindings.length; index += 1) {
711
- const binding = node.directive.bindings[index];
712
- if (binding?.operator !== 'equal' || binding.values.length !== 1) {
713
- this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding?.span ?? node.span, 'fixed @cover scopes after the first binding must use one equality bind');
714
- }
747
+ }
748
+ candidates.push({ ref, scopes: inferred });
749
+ }
750
+ const dependencies = [...new Set(refs.map((ref) => ref.table))]
751
+ .sort()
752
+ .map((table) => {
753
+ const instances = candidates.filter((item) => item.ref.table === table);
754
+ if (instances.some((item) => item.scopes.length === 0)) {
755
+ return { table, scopes: [] };
756
+ }
757
+ const scopes = instances
758
+ .flatMap((item) => item.scopes.map((scope) => scope.binding))
759
+ .reduce((out, scope) => {
760
+ const existing = out.find((item) => item.variable === scope.variable);
761
+ if (existing === undefined)
762
+ out.push(scope);
763
+ else {
764
+ out[out.indexOf(existing)] = {
765
+ ...existing,
766
+ params: [...new Set([...existing.params, ...scope.params])],
767
+ };
715
768
  }
716
- const byVariable = new Map(scopes.map((scope) => [scope.variable, scope]));
717
- coverage = {
718
- table: table.name,
719
- variable: firstScope.variable,
720
- units: firstScope.params,
721
- fixedScopes: table.scopes
722
- .filter((scope) => scope.variable !== firstScope.variable)
723
- .map((scope) => {
724
- const fixed = byVariable.get(scope.variable);
725
- return { variable: fixed.variable, params: fixed.params };
726
- }),
727
- };
769
+ return out;
770
+ }, []);
771
+ return { table, scopes };
772
+ });
773
+ if (!logical.declaration.sync)
774
+ return { dependencies, coverage: [] };
775
+ let eligible = candidates.filter((candidate) => {
776
+ const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
777
+ return (table !== undefined &&
778
+ candidates.filter((item) => item.ref.table === candidate.ref.table)
779
+ .length === 1 &&
780
+ table.scopes.length > 0 &&
781
+ candidate.scopes.length === table.scopes.length);
782
+ });
783
+ const syncBy = logical.declaration.syncBy;
784
+ if (syncBy !== undefined) {
785
+ eligible = eligible.filter((candidate) => candidate.ref.alias === syncBy.qualifier);
786
+ }
787
+ if (eligible.length !== 1) {
788
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', logical.declaration.syncBy?.span ?? logical.declaration.nameSpan, eligible.length === 0
789
+ ? 'sync query coverage cannot be proven from required equality/IN predicates over every declared scope'
790
+ : 'sync query resolves to multiple coverable table instances; split the query or select one with `by alias.scope`');
791
+ }
792
+ const candidate = eligible[0];
793
+ const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
794
+ if (table === undefined)
795
+ throw new Error('eligible table disappeared');
796
+ if (table.scopes.length > 1 && syncBy === undefined) {
797
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', logical.declaration.nameSpan, `sync query for multi-scope table ${table.name} must select its unit dimension with \`by ${candidate.ref.alias}.scope_column\``);
798
+ }
799
+ const dimensionColumn = syncBy?.column ?? table.scopes[0]?.column;
800
+ const dimension = table.scopes.find((scope) => scope.column === dimensionColumn);
801
+ if (dimension === undefined) {
802
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy?.span ?? logical.declaration.nameSpan, `${candidate.ref.alias}.${dimensionColumn ?? ''} is not a declared scope`);
803
+ }
804
+ const byVariable = new Map(candidate.scopes.map((scope) => [scope.binding.variable, scope]));
805
+ const unit = byVariable.get(dimension.variable);
806
+ const fixedScopes = table.scopes
807
+ .filter((scope) => scope.variable !== dimension.variable)
808
+ .map((scope) => {
809
+ const fixed = byVariable.get(scope.variable);
810
+ if (fixed.operator !== 'equal' || fixed.binding.params.length !== 1) {
811
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy?.span ?? logical.declaration.nameSpan, `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`);
728
812
  }
729
813
  return {
730
- node,
731
- ref,
732
- table,
733
- scopes,
734
- ...(coverage === undefined ? {} : { coverage }),
814
+ variable: fixed.binding.variable,
815
+ params: fixed.binding.params,
735
816
  };
736
817
  });
737
- }
738
- #buildReactive(refs, directives) {
739
- const dependencies = [];
740
- const coverage = [];
741
- for (const tableName of [...new Set(refs.map((ref) => ref.table))].sort()) {
742
- const instances = refs.filter((ref) => ref.table === tableName);
743
- const byAlias = new Map();
744
- for (const directive of directives.filter((item) => item.table.name === tableName)) {
745
- const list = byAlias.get(directive.ref.alias) ?? [];
746
- list.push(directive);
747
- byAlias.set(directive.ref.alias, list);
748
- }
749
- const fallback = instances.some((instance) => !byAlias.has(instance.alias));
750
- const scopes = fallback
751
- ? []
752
- : [...byAlias.values()]
753
- .flatMap((items) => items.flatMap((item) => item.scopes))
754
- .reduce((out, scope) => {
755
- const existing = out.find((item) => item.variable === scope.variable);
756
- if (existing === undefined)
757
- out.push(scope);
758
- else {
759
- const merged = [
760
- ...new Set([...existing.params, ...scope.params]),
761
- ];
762
- out[out.indexOf(existing)] = { ...existing, params: merged };
763
- }
764
- return out;
765
- }, []);
766
- dependencies.push({ table: tableName, scopes });
767
- if (!fallback) {
768
- coverage.push(...[...byAlias.values()].flatMap((items) => items.flatMap((item) => item.coverage === undefined ? [] : [item.coverage])));
769
- }
770
- }
771
- return { dependencies, coverage };
818
+ const coverage = {
819
+ table: table.name,
820
+ variable: unit.binding.variable,
821
+ units: unit.binding.params,
822
+ fixedScopes,
823
+ };
824
+ return { dependencies, coverage: [coverage] };
772
825
  }
773
826
  #validateSort(query, structure, location) {
774
827
  if (query.sort === undefined)
@@ -827,39 +880,13 @@ class Validator {
827
880
  const resultNames = new Set();
828
881
  for (const column of analysis.columns) {
829
882
  if (!IDENT_RE.test(column.name)) {
830
- this.#fail('SYQL6008_INVALID_IDENTITY', query.identity?.span ?? query.sql.span, `result name ${JSON.stringify(column.name)} must be an unquoted IDENT value`);
883
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.statement.span, `result name ${JSON.stringify(column.name)} must be an unquoted IDENT value`);
831
884
  }
832
885
  if (resultNames.has(column.name)) {
833
- this.#fail('SYQL6008_INVALID_IDENTITY', query.identity?.span ?? query.sql.span, `result name ${JSON.stringify(column.name)} is duplicated`);
886
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.statement.span, `result name ${JSON.stringify(column.name)} is duplicated`);
834
887
  }
835
888
  resultNames.add(column.name);
836
889
  }
837
- if (query.identity !== undefined) {
838
- if (nonSimple) {
839
- this.#fail('SYQL6008_INVALID_IDENTITY', query.identity.span, 'identity cannot be proven for this grouped, compound, nested, outer-join, aggregate, or self-join shape');
840
- }
841
- const selected = query.identity.fields.map((field) => {
842
- const matches = analysis.columns.filter((column) => column.name === field);
843
- const column = matches[0];
844
- if (matches.length !== 1 ||
845
- column === undefined ||
846
- column.fidelity !== 'exact' ||
847
- column.nullable ||
848
- column.origin === undefined) {
849
- this.#fail('SYQL6008_INVALID_IDENTITY', query.identity?.span ?? query.sql.span, `identity field ${field} must be one exact, non-null physical result column`);
850
- }
851
- return column;
852
- });
853
- for (const ref of refs) {
854
- const table = this.#ir.tables.find((item) => item.name === ref.table);
855
- if (table !== undefined &&
856
- !selected.some((column) => column.origin?.table === table.name &&
857
- column.origin.column === table.primaryKey)) {
858
- this.#fail('SYQL6008_INVALID_IDENTITY', query.identity.span, `identity must include projected primary key ${table.name}.${table.primaryKey}`);
859
- }
860
- }
861
- return selected.map((column) => column.langName);
862
- }
863
890
  if (nonSimple || refs.length === 0)
864
891
  return undefined;
865
892
  const inferred = [];
@@ -876,18 +903,18 @@ class Validator {
876
903
  return inferred;
877
904
  }
878
905
  #validateStableOrder(query, sql, structure, sort, identity, analysis, refs) {
879
- const bounded = query.page !== undefined ||
906
+ const bounded = query.limit !== undefined ||
880
907
  structure.hasOuterLimit ||
881
908
  structure.hasOuterOffset;
882
909
  if (!bounded)
883
910
  return;
884
911
  if (identity === undefined || identity.length === 0) {
885
- this.#fail('SYQL6006_INVALID_SORT', query.sort?.span ?? query.page?.span ?? query.sql.span, 'a bounded query requires a proven identity and total outer order');
912
+ this.#fail('SYQL6006_INVALID_SORT', query.sort?.span ?? query.limit?.span ?? query.statement.span, 'a bounded query requires a proven identity and total outer order');
886
913
  }
887
914
  const orders = sort?.profiles.map((profile) => ({
888
915
  name: profile.name,
889
916
  sql: profile.sql,
890
- span: query.sort?.span ?? query.sql.span,
917
+ span: query.sort?.span ?? query.statement.span,
891
918
  })) ??
892
919
  (structure.orderBodyStart !== undefined &&
893
920
  structure.orderEnd !== undefined
@@ -897,12 +924,12 @@ class Validator {
897
924
  sql: sql
898
925
  .slice(structure.orderBodyStart, structure.orderEnd)
899
926
  .trim(),
900
- span: query.sql.span,
927
+ span: query.statement.span,
901
928
  },
902
929
  ]
903
930
  : []);
904
931
  if (orders.length === 0) {
905
- this.#fail('SYQL6006_INVALID_SORT', query.page?.span ?? query.sql.span, 'a bounded query requires an outer ORDER BY or sort section');
932
+ this.#fail('SYQL6006_INVALID_SORT', query.limit?.span ?? query.statement.span, 'a bounded query requires an outer ORDER BY or sort section');
906
933
  }
907
934
  for (const order of orders) {
908
935
  const terms = this.#splitOrderTerms(order.sql, order.span);