@syncular/typegen 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +25 -10
  2. package/dist/cli.js +155 -4
  3. package/dist/emit-dart.js +3 -2
  4. package/dist/emit-kotlin.js +3 -2
  5. package/dist/emit-queries-dart.js +87 -15
  6. package/dist/emit-queries-kotlin.js +89 -15
  7. package/dist/emit-queries-swift.js +92 -14
  8. package/dist/emit-queries.js +131 -22
  9. package/dist/emit-swift.js +3 -2
  10. package/dist/emit.d.ts +2 -1
  11. package/dist/emit.js +13 -27
  12. package/dist/fmt.d.ts +3 -0
  13. package/dist/fmt.js +356 -0
  14. package/dist/generate.d.ts +16 -6
  15. package/dist/generate.js +34 -9
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +6 -0
  18. package/dist/lower.d.ts +54 -0
  19. package/dist/lower.js +283 -0
  20. package/dist/lsp.d.ts +20 -0
  21. package/dist/lsp.js +376 -0
  22. package/dist/manifest.d.ts +11 -0
  23. package/dist/manifest.js +20 -0
  24. package/dist/naming.d.ts +22 -0
  25. package/dist/naming.js +240 -0
  26. package/dist/query-ir.d.ts +14 -0
  27. package/dist/query-ir.js +69 -0
  28. package/dist/query.d.ts +102 -6
  29. package/dist/query.js +126 -34
  30. package/dist/syql.d.ts +83 -0
  31. package/dist/syql.js +945 -0
  32. package/package.json +4 -4
  33. package/src/cli.ts +155 -4
  34. package/src/emit-dart.ts +3 -2
  35. package/src/emit-kotlin.ts +3 -2
  36. package/src/emit-queries-dart.ts +109 -16
  37. package/src/emit-queries-kotlin.ts +111 -18
  38. package/src/emit-queries-swift.ts +106 -17
  39. package/src/emit-queries.ts +179 -28
  40. package/src/emit-swift.ts +3 -2
  41. package/src/emit.ts +18 -28
  42. package/src/fmt.ts +351 -0
  43. package/src/generate.ts +54 -11
  44. package/src/index.ts +6 -0
  45. package/src/lower.ts +331 -0
  46. package/src/lsp.ts +445 -0
  47. package/src/manifest.ts +38 -0
  48. package/src/naming.ts +271 -0
  49. package/src/query-ir.ts +82 -0
  50. package/src/query.ts +241 -34
  51. package/src/syql.ts +1171 -0
package/dist/query.js CHANGED
@@ -48,7 +48,7 @@
48
48
  * error listing the fix. bun:sqlite can't name params, so we parse `:name`
49
49
  * tokens from the SQL ourselves (first-occurrence order = positional order).
50
50
  *
51
- * ## The tables set (for useSyncQuery `{tables}` — exact invalidation)
51
+ * ## The tables set (for useRawSql `{tables}` — exact invalidation)
52
52
  *
53
53
  * bun:sqlite exposes no authorizer and no statement table-list; EXPLAIN
54
54
  * opcodes are fragile. The honest mechanism: the FROM/JOIN table set we
@@ -61,6 +61,8 @@
61
61
  * for the query shapes this tier supports.
62
62
  */
63
63
  import { TypegenError } from './errors.js';
64
+ import { lowerProjection, mainVerbAfterWith } from './lower.js';
65
+ import { buildNamingMap } from './naming.js';
64
66
  /** The SQL decltype keyword → §2.4 type map (mirrors sql.ts TYPE_MAP so a
65
67
  * plain column ref's decltype resolves to the exact IR type). */
66
68
  const DECLTYPE_MAP = {
@@ -82,6 +84,7 @@ const DECLTYPE_MAP = {
82
84
  BLOBREF: 'blob_ref',
83
85
  CRDT: 'crdt',
84
86
  };
87
+ const DEFAULT_NAMING = { naming: 'camel', targets: ['ts'] };
85
88
  // -- path → name --------------------------------------------------------------
86
89
  /** A single kebab-case path segment (folder) or filename stem. */
87
90
  const KEBAB_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -328,11 +331,17 @@ function scanParamNames(sql) {
328
331
  }
329
332
  return seen;
330
333
  }
331
- /** Rewrite `:name` → positional `?` (repeats included) AND strip comments +
332
- * collapse whitespace, producing a clean single-line SQL string suitable for
334
+ /** Rewrite `:name` → positional placeholders AND strip comments + collapse
335
+ * whitespace, producing a clean single-line SQL string suitable for
333
336
  * embedding in a generated string literal. String literals are preserved
334
- * verbatim (their inner whitespace is not collapsed). */
335
- function toPositionalSql(sql) {
337
+ * verbatim (their inner whitespace is not collapsed).
338
+ *
339
+ * When every param occurs exactly once, plain `?` is emitted. When ANY
340
+ * param repeats (the §7 neutralization guards mention a param in the guard
341
+ * AND the predicate), the WHOLE statement uses SQLite's numbered `?N`
342
+ * form — one bound value per DISTINCT param, reuse resolved by SQLite —
343
+ * so the generated bind array stays one-entry-per-param. */
344
+ export function toPositionalSql(sql) {
336
345
  const tokens = [];
337
346
  let i = 0;
338
347
  while (i < sql.length) {
@@ -379,7 +388,7 @@ function toPositionalSql(sql) {
379
388
  while (end < sql.length && /[A-Za-z0-9_]/.test(sql[end])) {
380
389
  end += 1;
381
390
  }
382
- tokens.push('?');
391
+ tokens.push({ param: sql.slice(i + 1, end) });
383
392
  i = end;
384
393
  }
385
394
  else if (/\s/.test(ch)) {
@@ -391,8 +400,25 @@ function toPositionalSql(sql) {
391
400
  i += 1;
392
401
  }
393
402
  }
403
+ const order = [];
404
+ let repeats = false;
405
+ for (const token of tokens) {
406
+ if (typeof token === 'string')
407
+ continue;
408
+ if (order.includes(token.param))
409
+ repeats = true;
410
+ else
411
+ order.push(token.param);
412
+ }
413
+ const rendered = tokens
414
+ .map((token) => {
415
+ if (typeof token === 'string')
416
+ return token;
417
+ return repeats ? `?${order.indexOf(token.param) + 1}` : '?';
418
+ })
419
+ .join('');
394
420
  // Collapse runs of the whitespace placeholders; trim.
395
- return tokens.join('').replace(/\s+/g, ' ').trim();
421
+ return rendered.replace(/\s+/g, ' ').trim();
396
422
  }
397
423
  const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
398
424
  // `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
@@ -539,6 +565,28 @@ function inferParamType(paramName, sql, refs, ir) {
539
565
  if (t !== null)
540
566
  return t;
541
567
  }
568
+ // `col BETWEEN :name AND …` / `col BETWEEN … AND :name` — both endpoints
569
+ // take the column's type (the §4 from+to group shape).
570
+ const btwLeft = new RegExp(`(?:(${IDENT})\\.)?(${IDENT})\\s+BETWEEN\\s+:${paramName}\\b`, 'i');
571
+ const btwLeftM = btwLeft.exec(cleaned);
572
+ if (btwLeftM !== null) {
573
+ const t = lookup(btwLeftM[1], btwLeftM[2]);
574
+ if (t !== null)
575
+ return t;
576
+ }
577
+ const btwRight = new RegExp(`(?:(${IDENT})\\.)?(${IDENT})\\s+BETWEEN\\s+\\S+\\s+AND\\s+:${paramName}\\b`, 'i');
578
+ const btwRightM = btwRight.exec(cleaned);
579
+ if (btwRightM !== null) {
580
+ const t = lookup(btwRightM[1], btwRightM[2]);
581
+ if (t !== null)
582
+ return t;
583
+ }
584
+ // `… LIKE <expr mentioning :name>` — a LIKE operand is TEXT, even inside
585
+ // a concatenation (`title like '%' || :q || '%'`, the search-fragment
586
+ // shape).
587
+ const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'i');
588
+ if (like.test(cleaned))
589
+ return 'string';
542
590
  return null;
543
591
  }
544
592
  // -- fallback column typing (computed expressions) ----------------------------
@@ -591,45 +639,79 @@ export function synthesizeDdl(ir) {
591
639
  * top-level `;`). This is the per-statement core; {@link analyzeQueryFile}
592
640
  * splits a file and drives it.
593
641
  */
594
- export function analyzeStatement(name, location, statementText, ir, db) {
642
+ export function analyzeStatement(name, location, statementText, ir, db, naming = DEFAULT_NAMING) {
595
643
  const file = location;
596
644
  const commentParams = parseCommentParams(file, statementText);
597
- const sql = statementText.trim();
598
- if (sql.length === 0) {
645
+ const sourceSql = statementText.trim();
646
+ if (sourceSql.length === 0) {
599
647
  throw new TypegenError(file, 'query file is empty');
600
648
  }
601
- // SELECT-only (the read tier). Reject the first keyword loudly otherwise.
649
+ // SELECT-only (the read tier). A `WITH` is allowed when its main statement
650
+ // is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
602
651
  const firstKeyword = /^\s*([A-Za-z]+)/.exec(stripCommentsAndStrings(statementText).trimStart())?.[1];
603
- if (firstKeyword === undefined || firstKeyword.toUpperCase() !== 'SELECT') {
604
- throw new TypegenError(file, `named queries are SELECT-only (the read tier); found ${JSON.stringify(firstKeyword ?? '')}. Writes go through mutate() (SPEC §7.1).`);
605
- }
606
- // Let SQLite validate + describe the query. Any bad reference throws here
607
- // with SQLite's own message (which names the offending table/column).
608
- let described;
609
- try {
610
- described = db.analyze(sql);
652
+ const upperKeyword = firstKeyword?.toUpperCase();
653
+ if (upperKeyword === 'WITH') {
654
+ const main = mainVerbAfterWith(stripCommentsAndStrings(statementText));
655
+ if (main !== 'SELECT') {
656
+ throw new TypegenError(file, `named queries are SELECT-only (the read tier); this WITH statement's main verb is ${JSON.stringify(main ?? '')}. Writes go through mutate() (SPEC §7.1).`);
657
+ }
611
658
  }
612
- catch (error) {
613
- const message = error instanceof Error ? error.message : String(error);
614
- throw new TypegenError(file, `SQL rejected by SQLite: ${message}`);
659
+ else if (upperKeyword !== 'SELECT') {
660
+ throw new TypegenError(file, `named queries are SELECT-only (the read tier); found ${JSON.stringify(firstKeyword ?? '')}. Writes go through mutate() (SPEC §7.1).`);
615
661
  }
616
- const refs = scanTableRefs(sql, ir);
662
+ // Let SQLite validate + describe the AUTHORED query first. Any bad
663
+ // reference throws here with SQLite's own message.
664
+ const analyze = (candidate) => {
665
+ try {
666
+ return db.analyze(candidate);
667
+ }
668
+ catch (error) {
669
+ const message = error instanceof Error ? error.message : String(error);
670
+ throw new TypegenError(file, `SQL rejected by SQLite: ${message}`);
671
+ }
672
+ };
673
+ let described = analyze(sourceSql);
674
+ const refs = scanTableRefs(sourceSql, ir);
617
675
  const tableSet = new Set(refs.map((r) => r.table));
618
676
  const tables = [...tableSet].sort();
619
677
  if (tables.length === 0) {
620
678
  throw new TypegenError(file, 'query reads no synced table — every named query must read at least one IR table (for exact invalidation)');
621
679
  }
622
- // Columns: pair bun's column names + decltypes with our source resolution.
680
+ // §5 lowering: rewrite the projection so runtime result keys are the
681
+ // language-facing names ("camel"); a no-op pass-through under "preserve".
682
+ let sql = sourceSql;
683
+ let sqlNames = described.columnNames;
684
+ let langNames = described.columnNames;
685
+ if (naming.naming === 'camel') {
686
+ const lowered = lowerProjection(sourceSql, refs, ir, file, (candidate) => analyze(candidate).columnNames, (names) => buildNamingMap(names, 'camel', file, 'projection', naming.targets).map((m) => m.langName));
687
+ sqlNames = lowered.sqlNames;
688
+ langNames = lowered.langNames;
689
+ if (lowered.changed) {
690
+ sql = lowered.sql;
691
+ described = analyze(sql);
692
+ // Defensive: the rewritten projection's runtime keys must BE the
693
+ // language names — this is the whole point of the lowering.
694
+ if (described.columnNames.length !== langNames.length ||
695
+ described.columnNames.some((n, i) => n !== langNames[i])) {
696
+ throw new TypegenError(file, `internal: lowered projection keys [${described.columnNames.join(', ')}] do not match the naming map [${langNames.join(', ')}]`);
697
+ }
698
+ }
699
+ }
700
+ // Columns: pair bun's column names + decltypes with our source resolution
701
+ // (against the LOWERED sql — aliased plain refs still resolve exactly).
623
702
  const items = splitSelectList(sql);
624
703
  const columns = described.columnNames.map((colName, index) => {
625
704
  const decl = described.declaredTypes[index];
626
705
  const item = items?.[index];
627
706
  const source = item !== undefined ? resolveSource(item, refs, ir) : null;
707
+ const sqlName = sqlNames[index] ?? colName;
708
+ const langName = langNames[index] ?? colName;
628
709
  if (source !== null) {
629
710
  // Exact: IR column type + IR nullability. (decltype agrees; we prefer
630
711
  // the IR type so blob_ref/crdt/json semantic types survive.)
631
712
  return {
632
- name: colName,
713
+ name: sqlName,
714
+ langName,
633
715
  type: source.column.type,
634
716
  nullable: source.column.nullable,
635
717
  fidelity: 'exact',
@@ -640,24 +722,33 @@ export function analyzeStatement(name, location, statementText, ir, db) {
640
722
  ? DECLTYPE_MAP[decl.toUpperCase()]
641
723
  : undefined;
642
724
  const type = mapped ?? (item !== undefined ? fallbackColumnType(item.expr) : 'string');
643
- return { name: colName, type, nullable: true, fidelity: 'fallback' };
725
+ return {
726
+ name: sqlName,
727
+ langName,
728
+ type,
729
+ nullable: true,
730
+ fidelity: 'fallback',
731
+ };
644
732
  });
645
733
  // Params: names from the text (positional order), types from inference or
646
- // the `-- param` comment; every param must resolve.
734
+ // the `-- param` comment; every param must resolve. Param langNames go
735
+ // through the same §12 map (authored camelCase params are identity).
647
736
  const paramNames = scanParamNames(sql);
648
737
  if (paramNames.length !== described.paramsCount) {
649
738
  // Defensive: our scan and SQLite disagree (shouldn't happen for the subset).
650
739
  throw new TypegenError(file, `internal: scanned ${paramNames.length} params (${paramNames.join(', ')}) but SQLite reports ${described.paramsCount}`);
651
740
  }
741
+ const paramLangNames = buildNamingMap(paramNames, naming.naming, file, 'params', naming.targets);
652
742
  const commentByName = new Map(commentParams.map((p) => [p.name, p.type]));
653
- const params = paramNames.map((paramName) => {
743
+ const params = paramNames.map((paramName, index) => {
744
+ const langName = paramLangNames[index]?.langName ?? paramName;
654
745
  const commented = commentByName.get(paramName);
655
746
  if (commented !== undefined) {
656
- return { name: paramName, type: commented, source: 'comment' };
747
+ return { name: paramName, langName, type: commented, source: 'comment' };
657
748
  }
658
749
  const inferred = inferParamType(paramName, sql, refs, ir);
659
750
  if (inferred !== null) {
660
- return { name: paramName, type: inferred, source: 'inferred' };
751
+ return { name: paramName, langName, type: inferred, source: 'inferred' };
661
752
  }
662
753
  throw new TypegenError(file, `cannot infer a type for param :${paramName} (it is not compared to a plain column). Add a header comment: \`-- param :${paramName} <type>\` (one of ${[...PARAM_TYPES].join(', ')}).`);
663
754
  });
@@ -670,6 +761,7 @@ export function analyzeStatement(name, location, statementText, ir, db) {
670
761
  return {
671
762
  name,
672
763
  file,
764
+ sourceSql,
673
765
  sql,
674
766
  positionalSql: toPositionalSql(sql),
675
767
  params,
@@ -688,7 +780,7 @@ export function analyzeStatement(name, location, statementText, ir, db) {
688
780
  * - A file with MULTIPLE statements requires `-- name:` on EVERY statement; a
689
781
  * missing one errors, naming the file + the statement's position/first line.
690
782
  */
691
- export function analyzeQueryFile(relPath, content, ir, db) {
783
+ export function analyzeQueryFile(relPath, content, ir, db, naming = DEFAULT_NAMING) {
692
784
  const statements = splitStatements(content);
693
785
  if (statements.length === 0) {
694
786
  throw new TypegenError(relPath, 'query file is empty');
@@ -703,17 +795,17 @@ export function analyzeQueryFile(relPath, content, ir, db) {
703
795
  throw new TypegenError(location, `${relPath} holds ${statements.length} statements, so each requires a \`-- name: <camelCase>\` marker; statement #${index + 1} (line ${stmt.startLine}: ${JSON.stringify(firstLine.slice(0, 60))}) has none`);
704
796
  }
705
797
  const name = override ?? defaultName;
706
- return analyzeStatement(name, location, stmt.text, ir, db);
798
+ return analyzeStatement(name, location, stmt.text, ir, db, naming);
707
799
  });
708
800
  }
709
801
  /** Back-compat single-statement analyzer: resolve the name from the file path
710
802
  * (or a `-- name:` override) and analyze. Rejects a `;`-separated file loudly
711
803
  * — use {@link analyzeQueryFile} for the multi-statement contract. */
712
- export function analyzeQuery(file, raw, ir, db) {
804
+ export function analyzeQuery(file, raw, ir, db, naming = DEFAULT_NAMING) {
713
805
  const statements = splitStatements(raw);
714
806
  if (statements.length > 1) {
715
807
  throw new TypegenError(file, 'a query file holds exactly one SELECT statement here (found a `;` separating statements) — use analyzeQueryFile for multi-statement files');
716
808
  }
717
- const results = analyzeQueryFile(file, raw, ir, db);
809
+ const results = analyzeQueryFile(file, raw, ir, db, naming);
718
810
  return results[0];
719
811
  }
package/dist/syql.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { IrDocument } from './ir.js';
2
+ import { type AnalyzedQuery, type QueryDb, type QueryNamingOptions } from './query.js';
3
+ export interface SyqlParamDecl {
4
+ readonly name: string;
5
+ readonly optional: boolean;
6
+ /** `from+to?` pairing: both params share the group key. */
7
+ readonly group?: string;
8
+ /** `name?: flag` — a boolean guard param (§3). */
9
+ readonly flag: boolean;
10
+ }
11
+ export interface SyqlOrderBy {
12
+ readonly allowed: readonly string[];
13
+ readonly defaultColumn: string;
14
+ readonly defaultDir: 'asc' | 'desc';
15
+ }
16
+ export interface SyqlLimit {
17
+ readonly max?: number;
18
+ readonly default?: number;
19
+ }
20
+ export interface SyqlFragmentDecl {
21
+ readonly name: string;
22
+ readonly params: readonly SyqlParamDecl[];
23
+ /** The predicate body (raw SQL expression text). */
24
+ readonly body: string;
25
+ /** Source offset of the `fragment` keyword (editor tooling). */
26
+ readonly offset: number;
27
+ }
28
+ export interface SyqlQueryDecl {
29
+ readonly name: string;
30
+ readonly params: readonly SyqlParamDecl[];
31
+ readonly orderBy?: SyqlOrderBy;
32
+ readonly limit?: SyqlLimit;
33
+ /** §7 opt-in: lower to 2^N enumerated statements (perfect index use)
34
+ * instead of one neutralized statement. API-invisible. */
35
+ readonly variants?: boolean;
36
+ /** The SQL-shaped body (may contain @fragment refs and if-guards). */
37
+ readonly body: string;
38
+ /** Source offset of the `query` keyword (editor tooling). */
39
+ readonly offset: number;
40
+ }
41
+ export interface SyqlFile {
42
+ readonly fragments: readonly SyqlFragmentDecl[];
43
+ readonly queries: readonly SyqlQueryDecl[];
44
+ }
45
+ /** Parse one `.syql` file into its declarations (no lowering yet). */
46
+ export declare function parseSyqlFile(file: string, content: string): SyqlFile;
47
+ interface ParamInfo {
48
+ optional: boolean;
49
+ group?: string;
50
+ flag: boolean;
51
+ declared: boolean;
52
+ }
53
+ /** One top-level WHERE conjunct after guard analysis: `governing` is the
54
+ * set of OPTIONAL param names gating it (empty = required conjunct). */
55
+ export interface LoweredConjunct {
56
+ readonly pred: string;
57
+ readonly governing: readonly string[];
58
+ }
59
+ export interface LoweredSyqlQuery {
60
+ /** The lowered single SQL statement (named params, no knob tails). */
61
+ readonly sql: string;
62
+ /** Params discovered/declared, in signature-then-first-use order. */
63
+ readonly paramInfo: ReadonlyMap<string, ParamInfo>;
64
+ /** Statement text before the `where` keyword (whole statement when there
65
+ * is no WHERE). */
66
+ readonly prefix: string;
67
+ /** Statement text after the WHERE clause (may be empty). */
68
+ readonly suffix: string;
69
+ /** The structured conjuncts (empty when there is no WHERE) — the §7
70
+ * variant backend re-assembles per provided-combination from these. */
71
+ readonly conjuncts: readonly LoweredConjunct[];
72
+ }
73
+ /**
74
+ * Lower one query body: splice fragments, auto-guard optional conjuncts,
75
+ * expand `if` guards, enforce B1. Returns plain SQL (knob tails are the
76
+ * caller's job).
77
+ */
78
+ export declare function lowerSyqlBody(decl: SyqlQueryDecl, fragments: ReadonlyMap<string, SyqlFragmentDecl>, location: string): LoweredSyqlQuery;
79
+ /** Analyze every query of one `.syql` file into `AnalyzedQuery` units —
80
+ * byte-compatible with the `.sql` frontend's output (§1: nothing below the
81
+ * IR knows the frontend). */
82
+ export declare function analyzeSyqlFile(relPath: string, content: string, ir: IrDocument, db: QueryDb, naming?: QueryNamingOptions): AnalyzedQuery[];
83
+ export {};