@syncular/typegen 0.15.28 → 0.15.30

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.
package/README.md CHANGED
@@ -557,7 +557,7 @@ boundary):
557
557
 
558
558
  | Result column | Type source | Nullability | Fidelity |
559
559
  |---|---|---|---|
560
- | **plain column ref** (`title`, `t.title`, `title AS x`) | resolved to the IR column (decltype confirms) — exact IR type, incl. `json`/`blob_ref`/`crdt` | the IR column's `NOT NULL` | **exact** |
560
+ | **plain column ref** (`title`, `t.title`, `title AS x`) | resolved to the IR column (decltype confirms) — exact IR type, incl. `json`/`blob_ref`/`crdt` | the IR column's nullability, lifted to nullable whenever `LEFT`, `RIGHT`, or `FULL OUTER JOIN` can null-extend its table alias | **exact** |
561
561
  | **computed expression** (`count(*)`, `done + 1`, `:p AS l`) | decltype is null → documented fallback: aggregate/arithmetic → number, else string | always nullable (an expression's nullability is not knowable from decltype) | **fallback** |
562
562
 
563
563
  Every local synced table also has the protocol-owned `_sync_version` column.
@@ -572,7 +572,9 @@ query getTodo(listId, todoId) {
572
572
  }
573
573
  ```
574
574
 
575
- The generated `serverVersion` field is an exact, non-null `integer`. The
575
+ The generated `serverVersion` field is an exact, non-null `integer` for a base
576
+ or required join relation; it is nullable when projected from an optional side
577
+ of an outer join. The
576
578
  physical name is intentionally not a public result name: project it with an
577
579
  alias such as `server_version`. `_sync_version` is query-only, is excluded from
578
580
  schema and mutation types, and is not added by `select *`; this prevents client
@@ -585,9 +587,17 @@ server assigns one.
585
587
  run the statement against the empty DB), and `paramsCount`. It does **not**
586
588
  expose column origin (table/column), param **names**, or `NOT NULL` flags — so
587
589
  typegen resolves plain refs against the IR itself (parse the SELECT list +
588
- FROM/JOIN, match `name`/`alias.name`) to attach exact nullability, and parses
589
- `:name` placeholders from the SQL text for param names. Want an exact type for
590
- a computed column? Alias it to a plain ref, or accept the honest fallback.
590
+ FROM/JOIN, match `name`/`alias.name`) to attach schema nullability plus SQL
591
+ outer-join null-extension, and parses `:name` placeholders from the SQL text
592
+ for param names. Each join chain is evaluated left-to-right: `LEFT` lifts the
593
+ new right relation unit, `RIGHT` lifts every prior relation in that chain, and
594
+ `FULL` lifts both. A parenthesized relation group is one unit for its enclosing
595
+ join while its nested join order remains intact. `INNER`, `CROSS`, and ordinary
596
+ `JOIN` do not remove nullability already introduced earlier in the chain.
597
+ Unqualified coalesced `USING`/`NATURAL` columns are not assigned a guessed
598
+ physical origin and stay on the honest nullable fallback path. Want an exact
599
+ type for a computed column? Alias it to a plain ref, or accept the honest
600
+ fallback.
591
601
 
592
602
  **Parameters** use the `:name` convention. A param's type is **inferred** where
593
603
  it compares against a plain column ref — `WHERE list_id = :listId` (equality,
@@ -623,8 +633,8 @@ identifier and keeping those that name an IR table. This captures subquery /
623
633
  `WHERE EXISTS (…)` tables (they still appear under `FROM`/`JOIN`), and the
624
634
  prepare() still guarantees the SQL itself is correct.
625
635
 
626
- Inference is deliberately conservative around `OR`, joins, grouping, and
627
- computed identity. Ordinary scope predicates construct exact reactive facts;
636
+ Inference is deliberately conservative around `OR`, grouping, ambiguous joins,
637
+ and computed identity. Ordinary scope predicates construct exact reactive facts;
628
638
  `sync query` additionally requests checked coverage:
629
639
 
630
640
  ```syql
@@ -634,10 +644,21 @@ sync query compareLists(left, right) {
634
644
  }
635
645
  ```
636
646
 
637
- Coverage must resolve one table instance and bind every declared scope. Only
647
+ Coverage must resolve every read schema table, with one instance per table, and
648
+ bind every declared scope. A required scope bind can propagate across a
649
+ qualified scope-column equality in an unconditional outer `WHERE` clause or a
650
+ simple mandatory `ON` clause. The generated descriptor contains aggregate
651
+ coverage for every table, and readiness requires all of it. Self-joins and
652
+ `ON` clauses containing `OR`, `NOT`, or nested SQL cannot claim coverage. Only
638
653
  required, non-null, exactly typed binds are allowed. Result identity is inferred
639
- from schema keys and the projection. Without constructive proof the compiler
640
- falls back to table-wide dependency, no coverage, and/or unkeyed reconciliation.
654
+ from schema keys and the projection. Without constructive proof an ordinary
655
+ query falls back to table-wide dependency, no coverage, and/or unkeyed
656
+ reconciliation; a `sync query` fails generation rather than claiming partial
657
+ completeness.
658
+
659
+ Use explicit `JOIN ... ON` relations. SQLite comma joins are rejected because
660
+ the compiler will not emit reactive metadata unless every read relation is
661
+ represented by the same proof model.
641
662
 
642
663
  **Emitted shape, per language** (abbreviated):
643
664
 
@@ -299,7 +299,9 @@ function emitSelect(query) {
299
299
  const name = field(limit.langName);
300
300
  lines.push(` let effective_${name} = params.${name}.unwrap_or(${limit.defaultSize});`, ` if !(1..=${limit.maxSize}).contains(&effective_${name}) {`, ' return Err(QueryError::Input {', ' code: "SYQL_RUNTIME_INVALID_LIMIT",', ' query: ID,', ` message: ${quote(`${query.name}: limit must be an integer from 1 through ${limit.maxSize}`)}.to_owned(),`, ' });', ' }');
301
301
  }
302
- if (metadata.plan.backend === 'variants') {
302
+ const usesActivationMask = metadata.plan.backend === 'variants' &&
303
+ metadata.plan.activationControls.length > 0;
304
+ if (usesActivationMask) {
303
305
  lines.push(' let mut activation_mask = 0usize;');
304
306
  metadata.plan.activationControls.forEach((control, index) => {
305
307
  lines.push(` if ${syqlControlActive(query, control)} {`, ` activation_mask |= ${2 ** index};`, ' }');
@@ -315,7 +317,7 @@ function emitSelect(query) {
315
317
  }
316
318
  const sortIndex = sort?.kind === 'sort' ? 'sort_index' : '0usize';
317
319
  const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
318
- const index = metadata.plan.backend === 'variants'
320
+ const index = usesActivationMask
319
321
  ? profileCount === 1
320
322
  ? 'activation_mask'
321
323
  : `activation_mask * ${profileCount} + ${sortIndex}`
package/dist/query.d.ts CHANGED
@@ -221,6 +221,8 @@ export interface TableRef {
221
221
  readonly table: string;
222
222
  /** Alias (or the table name when un-aliased). */
223
223
  readonly alias: string;
224
+ /** True when an enclosing flat join chain can null-extend this relation. */
225
+ readonly nullable: boolean;
224
226
  }
225
227
  export declare function scanTableRefs(sql: string, ir: IrDocument): TableRef[];
226
228
  /**
package/dist/query.js CHANGED
@@ -28,9 +28,10 @@
28
28
  * origin column's exact declared type. We map it back through the SAME
29
29
  * TYPE_MAP the migration parser uses → the exact IR column type. For
30
30
  * NULLABILITY we resolve the ref against the IR ourselves (parse the SELECT
31
- * list + FROM/JOIN, match `name`/`alias.name` to an IR table column) — an
32
- * IR-exact non-nullable/nullable answer. This is the drift-kill: same type
33
- * AND nullability as the schema, guaranteed by SQLite's own reference check.
31
+ * list + FROM/JOIN, match `name`/`alias.name` to an IR table column), then
32
+ * lift it when an outer join can synthesize NULL for that relation. This is
33
+ * the drift-kill: schema nullability plus SQL join null-extension, with
34
+ * SQLite still proving every reference.
34
35
  * - **Computed expression** (`count(*)`, `done + 1`, `:label AS l`): decltype
35
36
  * is null. We fall back to a documented honest type from the raw column
36
37
  * name/shape: aggregate/arith → nullable number, anything else → nullable
@@ -450,6 +451,7 @@ const RESERVED_ALIAS = new Set([
450
451
  'left',
451
452
  'right',
452
453
  'outer',
454
+ 'natural',
453
455
  'join',
454
456
  'cross',
455
457
  'using',
@@ -457,23 +459,159 @@ const RESERVED_ALIAS = new Set([
457
459
  'having',
458
460
  ]);
459
461
  const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
460
- const TABLE_REF_RE = new RegExp(`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`, 'gi');
461
- export function scanTableRefs(sql, ir) {
462
+ const TABLE_REF_RE = new RegExp(`\\b(FROM|(?:NATURAL\\s+)?(?:(LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`, 'gi');
463
+ function parenthesisDepthAt(sql, index) {
464
+ let depth = 0;
465
+ for (let cursor = 0; cursor < index; cursor += 1) {
466
+ if (sql[cursor] === '(')
467
+ depth += 1;
468
+ else if (sql[cursor] === ')')
469
+ depth = Math.max(0, depth - 1);
470
+ }
471
+ return depth;
472
+ }
473
+ function matchingParenthesis(sql, open) {
474
+ let depth = 0;
475
+ for (let cursor = open; cursor < sql.length; cursor += 1) {
476
+ if (sql[cursor] === '(')
477
+ depth += 1;
478
+ else if (sql[cursor] === ')') {
479
+ depth -= 1;
480
+ if (depth === 0)
481
+ return cursor;
482
+ }
483
+ }
484
+ return sql.length;
485
+ }
486
+ function hasCommaJoinedSchemaTable(sql, ir) {
462
487
  const cleaned = stripCommentsAndStrings(sql);
463
488
  const known = new Set(ir.tables.flatMap((table) => [
464
- table.name,
465
- ...table.ftsIndexes.map((index) => index.name),
489
+ table.name.toLowerCase(),
490
+ ...table.ftsIndexes.map((index) => index.name.toLowerCase()),
491
+ ]));
492
+ const activeFromDepths = new Set();
493
+ const clauseEnders = new Set([
494
+ 'WHERE',
495
+ 'GROUP',
496
+ 'HAVING',
497
+ 'ORDER',
498
+ 'LIMIT',
499
+ 'WINDOW',
500
+ 'UNION',
501
+ 'EXCEPT',
502
+ 'INTERSECT',
503
+ 'RETURNING',
504
+ ]);
505
+ let depth = 0;
506
+ let cursor = 0;
507
+ const nextIdentifier = (start) => {
508
+ let index = start;
509
+ while (index < cleaned.length &&
510
+ (/\s/.test(cleaned[index]) || cleaned[index] === '(')) {
511
+ index += 1;
512
+ }
513
+ const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cleaned.slice(index));
514
+ return match?.[0];
515
+ };
516
+ while (cursor < cleaned.length) {
517
+ const char = cleaned[cursor];
518
+ if (char === '(') {
519
+ const next = nextIdentifier(cursor + 1);
520
+ if (activeFromDepths.has(depth) &&
521
+ next !== undefined &&
522
+ known.has(next.toLowerCase())) {
523
+ activeFromDepths.add(depth + 1);
524
+ }
525
+ depth += 1;
526
+ cursor += 1;
527
+ continue;
528
+ }
529
+ if (char === ')') {
530
+ activeFromDepths.delete(depth);
531
+ depth = Math.max(0, depth - 1);
532
+ cursor += 1;
533
+ continue;
534
+ }
535
+ if (char === ',' && activeFromDepths.has(depth)) {
536
+ const next = nextIdentifier(cursor + 1);
537
+ if (next !== undefined && known.has(next.toLowerCase()))
538
+ return true;
539
+ cursor += 1;
540
+ continue;
541
+ }
542
+ if (/[A-Za-z_]/.test(char)) {
543
+ let end = cursor + 1;
544
+ while (end < cleaned.length &&
545
+ /[A-Za-z0-9_]/.test(cleaned[end])) {
546
+ end += 1;
547
+ }
548
+ const word = cleaned.slice(cursor, end).toUpperCase();
549
+ if (word === 'FROM')
550
+ activeFromDepths.add(depth);
551
+ else if (clauseEnders.has(word))
552
+ activeFromDepths.delete(depth);
553
+ cursor = end;
554
+ continue;
555
+ }
556
+ cursor += 1;
557
+ }
558
+ return false;
559
+ }
560
+ export function scanTableRefs(sql, ir) {
561
+ const cleaned = stripCommentsAndStrings(sql);
562
+ const known = new Map(ir.tables.flatMap((table) => [
563
+ [table.name.toLowerCase(), table.name],
564
+ ...table.ftsIndexes.map((index) => [index.name.toLowerCase(), index.name]),
466
565
  ]));
566
+ const nullableGroups = [
567
+ ...cleaned.matchAll(/\b(?:LEFT|FULL)(?:\s+OUTER)?\s+JOIN\s*(\()/gi),
568
+ ].map((match) => {
569
+ const open = (match.index ?? 0) + match[0].lastIndexOf('(');
570
+ return { open, close: matchingParenthesis(cleaned, open) };
571
+ });
467
572
  const refs = [];
573
+ const relationStartByDepth = new Map();
468
574
  for (const m of cleaned.matchAll(TABLE_REF_RE)) {
469
- const table = m[1];
470
- if (!known.has(table))
471
- continue; // e.g. FROM (subquery) — table is `(`; skip
472
- let alias = m[2];
575
+ const operator = m[1].toUpperCase();
576
+ const outerKind = m[2]?.toUpperCase();
577
+ const rawTable = m[4];
578
+ const operatorDepth = parenthesisDepthAt(cleaned, m.index ?? 0);
579
+ const relativeTableIndex = m[0]
580
+ .toLowerCase()
581
+ .indexOf(rawTable.toLowerCase(), m[1].length);
582
+ const tableIndex = (m.index ?? 0) + Math.max(m[1].length, relativeTableIndex);
583
+ const tableDepth = parenthesisDepthAt(cleaned, tableIndex);
584
+ if (operator === 'FROM') {
585
+ relationStartByDepth.set(operatorDepth, refs.length);
586
+ relationStartByDepth.set(tableDepth, refs.length);
587
+ }
588
+ else if (tableDepth > operatorDepth &&
589
+ !relationStartByDepth.has(tableDepth)) {
590
+ relationStartByDepth.set(tableDepth, refs.length);
591
+ }
592
+ if (outerKind === 'RIGHT' || outerKind === 'FULL') {
593
+ const relationStart = relationStartByDepth.get(operatorDepth) ?? refs.length;
594
+ for (let index = relationStart; index < refs.length; index += 1) {
595
+ const prior = refs[index];
596
+ if (prior !== undefined && !prior.nullable) {
597
+ refs[index] = { ...prior, nullable: true };
598
+ }
599
+ }
600
+ }
601
+ const table = known.get(rawTable.toLowerCase());
602
+ if (table === undefined)
603
+ continue; // e.g. a derived SELECT/CTE name
604
+ let alias = m[5];
473
605
  if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
474
606
  alias = undefined;
475
607
  }
476
- refs.push({ table, alias: alias ?? table });
608
+ refs.push({
609
+ table,
610
+ alias: alias ?? table,
611
+ nullable: outerKind === 'LEFT' ||
612
+ outerKind === 'FULL' ||
613
+ nullableGroups.some((group) => tableIndex > group.open && tableIndex < group.close),
614
+ });
477
615
  }
478
616
  return refs;
479
617
  }
@@ -532,43 +670,66 @@ function resolveSource(item, refs, ir) {
532
670
  const columnName = m[2];
533
671
  const byName = new Map(ir.tables.map((t) => [t.name, t]));
534
672
  if (qualifier !== undefined) {
535
- const ref = refs.find((r) => r.alias === qualifier);
673
+ const ref = refs.find((candidate) => candidate.alias.toLowerCase() === qualifier.toLowerCase());
536
674
  if (ref === undefined)
537
675
  return null;
538
676
  const table = byName.get(ref.table);
539
- const col = table?.columns.find((c) => c.name === columnName);
540
- if (col !== undefined)
541
- return { table: ref.table, column: col };
677
+ const col = table?.columns.find((candidate) => candidate.name.toLowerCase() === columnName.toLowerCase());
678
+ if (col !== undefined) {
679
+ return {
680
+ table: ref.table,
681
+ column: col,
682
+ nullableByJoin: ref.nullable,
683
+ };
684
+ }
542
685
  if (columnName === FTS_SOURCE_ID_COLUMN &&
543
686
  declaredFtsProjection(ir, ref.table)) {
544
- return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
687
+ return {
688
+ table: ref.table,
689
+ column: FTS_SOURCE_ID_IR_COLUMN,
690
+ nullableByJoin: ref.nullable,
691
+ };
545
692
  }
546
693
  return null;
547
694
  }
548
695
  // Unqualified: search every FROM/JOIN table (SQLite already resolved
549
- // ambiguity; a single-table query is the common case).
696
+ // ambiguity; a single-table query is the common case). A USING/NATURAL
697
+ // coalesced name can have more than one physical origin, so keep it on the
698
+ // honest nullable fallback path instead of guessing one side.
699
+ const matches = [];
550
700
  for (const ref of refs) {
551
701
  const table = byName.get(ref.table);
552
- const col = table?.columns.find((c) => c.name === columnName);
553
- if (col !== undefined)
554
- return { table: ref.table, column: col };
702
+ const col = table?.columns.find((candidate) => candidate.name.toLowerCase() === columnName.toLowerCase());
703
+ if (col !== undefined) {
704
+ matches.push({
705
+ table: ref.table,
706
+ column: col,
707
+ nullableByJoin: ref.nullable,
708
+ });
709
+ }
555
710
  if (columnName === FTS_SOURCE_ID_COLUMN &&
556
711
  declaredFtsProjection(ir, ref.table)) {
557
- return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
712
+ matches.push({
713
+ table: ref.table,
714
+ column: FTS_SOURCE_ID_IR_COLUMN,
715
+ nullableByJoin: ref.nullable,
716
+ });
558
717
  }
559
718
  }
560
- return null;
719
+ return matches.length === 1 ? matches[0] : null;
561
720
  }
562
721
  /** Resolve the one supported query-only protocol column. SQLite still proves
563
722
  * qualifier/ambiguity against the synthesized local tables. */
564
- function isSyncVersionSource(item, refs) {
723
+ function syncVersionSourceNullability(item, refs) {
565
724
  const match = PLAIN_REF_RE.exec(item.expr);
566
725
  if (match === null || match[2] !== SYNC_VERSION_COLUMN)
567
- return false;
726
+ return null;
568
727
  const qualifier = match[1];
569
- return qualifier === undefined
570
- ? refs.length === 1
571
- : refs.some((ref) => ref.alias === qualifier);
728
+ if (qualifier === undefined) {
729
+ return refs.length === 1 ? (refs[0]?.nullable ?? null) : null;
730
+ }
731
+ const matches = refs.filter((ref) => ref.alias.toLowerCase() === qualifier.toLowerCase());
732
+ return matches.length === 1 ? (matches[0]?.nullable ?? null) : null;
572
733
  }
573
734
  // -- param type inference -----------------------------------------------------
574
735
  //
@@ -883,6 +1044,9 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
883
1044
  if (sourceSql.length === 0) {
884
1045
  throw new TypegenError(file, 'query file is empty');
885
1046
  }
1047
+ if (hasCommaJoinedSchemaTable(sourceSql, ir)) {
1048
+ throw new TypegenError(file, 'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause');
1049
+ }
886
1050
  // SELECT-only (the read tier). A `WITH` is allowed when its main statement
887
1051
  // is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
888
1052
  const firstKeyword = /^\s*([A-Za-z]+)/.exec(stripCommentsAndStrings(statementText).trimStart())?.[1];
@@ -944,7 +1108,8 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
944
1108
  const source = item !== undefined ? resolveSource(item, refs, ir) : null;
945
1109
  const sqlName = sqlNames[index] ?? colName;
946
1110
  const langName = langNames[index] ?? colName;
947
- if (item !== undefined && isSyncVersionSource(item, refs)) {
1111
+ const syncVersionNullable = item !== undefined ? syncVersionSourceNullability(item, refs) : null;
1112
+ if (syncVersionNullable !== null) {
948
1113
  if (sqlName === SYNC_VERSION_COLUMN) {
949
1114
  throw new TypegenError(file, `${SYNC_VERSION_COLUMN} is stripped from app-facing query rows unless explicitly aliased (for example \`${SYNC_VERSION_COLUMN} AS server_version\`)`);
950
1115
  }
@@ -952,7 +1117,7 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
952
1117
  name: sqlName,
953
1118
  langName,
954
1119
  type: 'integer',
955
- nullable: false,
1120
+ nullable: syncVersionNullable,
956
1121
  fidelity: 'exact',
957
1122
  };
958
1123
  }
@@ -968,7 +1133,7 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
968
1133
  name: sqlName,
969
1134
  langName,
970
1135
  type: sourceType,
971
- nullable: source.column.nullable,
1136
+ nullable: source.column.nullable || source.nullableByJoin,
972
1137
  fidelity: 'exact',
973
1138
  origin: { table: source.table, column: source.column.name },
974
1139
  };