@syncular/typegen 0.2.1 → 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 +2 -2
  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/naming.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The pinned naming-map algorithm (DESIGN-queries.md §5, §12): SQL stays
3
+ * snake_case; emitters render their language's convention. The IR carries
4
+ * SQL-truth names plus a derived, collision-checked map — this module is
5
+ * that derivation.
6
+ *
7
+ * `snake_case → camelCase`, pinned:
8
+ * 1. A leading run of `_` is preserved as a prefix, a trailing run as a
9
+ * suffix (they carry intent — privacy markers, disambiguation).
10
+ * 2. The middle splits on `_`; empty segments from doubled underscores drop.
11
+ * 3. First segment verbatim; each later segment upper-cases its first
12
+ * character, rest verbatim. No acronym awareness (`id_url` → `idUrl`).
13
+ * 4. Prefix/suffix re-attach. `created_at`→`createdAt`, `col_2`→`col2`,
14
+ * `_internal`→`_internal`, `__foo_bar`→`__fooBar`, `row_`→`row_`.
15
+ *
16
+ * Collisions and hazards are generate-time ERRORS, not warnings: two SQL
17
+ * names mapping to one language name, a mapped name hitting a target-language
18
+ * keyword, or a leading underscore on the Dart target (library-private).
19
+ */
20
+ import { TypegenError } from './errors.js';
21
+ /** A name the mapper touches: a plain (optionally underscore-framed)
22
+ * snake_case identifier. Expression-shaped result names (`count(*)`) pass
23
+ * through unmapped. */
24
+ const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
25
+ /** The pinned §12 snake→camel conversion. Non-identifier names (computed
26
+ * result columns like `count(*)`) are returned unchanged. */
27
+ export function snakeToCamel(name) {
28
+ if (!MAPPABLE_RE.test(name))
29
+ return name;
30
+ const lead = /^_*/.exec(name)?.[0] ?? '';
31
+ const bare = name.slice(lead.length);
32
+ const trail = /_*$/.exec(bare)?.[0] ?? '';
33
+ const middle = bare.slice(0, bare.length - trail.length);
34
+ const segments = middle.split('_').filter((s) => s.length > 0);
35
+ if (segments.length === 0)
36
+ return name;
37
+ const first = segments[0];
38
+ const rest = segments
39
+ .slice(1)
40
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1));
41
+ return lead + first + rest.join('') + trail;
42
+ }
43
+ /** Reserved words that cannot be a generated field/property name on each
44
+ * target. Deliberately the core keyword lists — mechanical and predictable
45
+ * beats exhaustive; an escape hatch (`AS` alias / "preserve") always exists. */
46
+ const TARGET_KEYWORDS = {
47
+ // TS interface properties/object keys may be quoted, so only names that
48
+ // would break generated code paths matter; keep the ES reserved core.
49
+ ts: new Set([
50
+ 'break',
51
+ 'case',
52
+ 'catch',
53
+ 'class',
54
+ 'const',
55
+ 'continue',
56
+ 'debugger',
57
+ 'default',
58
+ 'delete',
59
+ 'do',
60
+ 'else',
61
+ 'enum',
62
+ 'export',
63
+ 'extends',
64
+ 'false',
65
+ 'finally',
66
+ 'for',
67
+ 'function',
68
+ 'if',
69
+ 'import',
70
+ 'in',
71
+ 'instanceof',
72
+ 'new',
73
+ 'null',
74
+ 'return',
75
+ 'super',
76
+ 'switch',
77
+ 'this',
78
+ 'throw',
79
+ 'true',
80
+ 'try',
81
+ 'typeof',
82
+ 'var',
83
+ 'void',
84
+ 'while',
85
+ 'with',
86
+ ]),
87
+ swift: new Set([
88
+ 'associatedtype',
89
+ 'class',
90
+ 'deinit',
91
+ 'enum',
92
+ 'extension',
93
+ 'fileprivate',
94
+ 'func',
95
+ 'import',
96
+ 'init',
97
+ 'inout',
98
+ 'internal',
99
+ 'let',
100
+ 'open',
101
+ 'operator',
102
+ 'private',
103
+ 'protocol',
104
+ 'public',
105
+ 'rethrows',
106
+ 'static',
107
+ 'struct',
108
+ 'subscript',
109
+ 'typealias',
110
+ 'var',
111
+ 'break',
112
+ 'case',
113
+ 'continue',
114
+ 'default',
115
+ 'defer',
116
+ 'do',
117
+ 'else',
118
+ 'fallthrough',
119
+ 'for',
120
+ 'guard',
121
+ 'if',
122
+ 'in',
123
+ 'repeat',
124
+ 'return',
125
+ 'switch',
126
+ 'where',
127
+ 'while',
128
+ 'as',
129
+ 'catch',
130
+ 'false',
131
+ 'is',
132
+ 'nil',
133
+ 'super',
134
+ 'self',
135
+ 'throw',
136
+ 'throws',
137
+ 'true',
138
+ 'try',
139
+ ]),
140
+ kotlin: new Set([
141
+ 'as',
142
+ 'break',
143
+ 'class',
144
+ 'continue',
145
+ 'do',
146
+ 'else',
147
+ 'false',
148
+ 'for',
149
+ 'fun',
150
+ 'if',
151
+ 'in',
152
+ 'interface',
153
+ 'is',
154
+ 'null',
155
+ 'object',
156
+ 'package',
157
+ 'return',
158
+ 'super',
159
+ 'this',
160
+ 'throw',
161
+ 'true',
162
+ 'try',
163
+ 'typealias',
164
+ 'typeof',
165
+ 'val',
166
+ 'var',
167
+ 'when',
168
+ 'while',
169
+ ]),
170
+ dart: new Set([
171
+ 'assert',
172
+ 'break',
173
+ 'case',
174
+ 'catch',
175
+ 'class',
176
+ 'const',
177
+ 'continue',
178
+ 'default',
179
+ 'do',
180
+ 'else',
181
+ 'enum',
182
+ 'extends',
183
+ 'false',
184
+ 'final',
185
+ 'finally',
186
+ 'for',
187
+ 'if',
188
+ 'in',
189
+ 'is',
190
+ 'new',
191
+ 'null',
192
+ 'rethrow',
193
+ 'return',
194
+ 'super',
195
+ 'switch',
196
+ 'this',
197
+ 'throw',
198
+ 'true',
199
+ 'try',
200
+ 'var',
201
+ 'void',
202
+ 'while',
203
+ 'with',
204
+ ]),
205
+ };
206
+ /**
207
+ * Map a set of SQL names to language names under `mode`, enforcing the §12
208
+ * hard errors within the scope (one table's columns / one query's projection
209
+ * / one query's params). `context` and `scope` name the error location
210
+ * (e.g. `queries/list.sql`, `table todos`); `targets` are the emitters this
211
+ * run generates — keyword hazards are only real on targets that exist.
212
+ */
213
+ export function buildNamingMap(sqlNames, mode, context, scope, targets) {
214
+ if (mode === 'preserve') {
215
+ return sqlNames.map((sqlName) => ({ sqlName, langName: sqlName }));
216
+ }
217
+ const bySqlName = new Map();
218
+ const byLangName = new Map();
219
+ for (const sqlName of sqlNames) {
220
+ const langName = snakeToCamel(sqlName);
221
+ const clash = byLangName.get(langName);
222
+ if (clash !== undefined && clash !== sqlName) {
223
+ throw new TypegenError(context, `${scope}: ${JSON.stringify(clash)} and ${JSON.stringify(sqlName)} both map to ${JSON.stringify(langName)} under camelCase naming — rename one, alias it in SQL (AS), or set "naming": "preserve" in syncular.json`);
224
+ }
225
+ for (const target of targets) {
226
+ if (TARGET_KEYWORDS[target].has(langName)) {
227
+ throw new TypegenError(context, `${scope}: ${JSON.stringify(sqlName)} maps to ${JSON.stringify(langName)}, a reserved word on the ${target} target — rename it, alias it in SQL (AS), or set "naming": "preserve" in syncular.json`);
228
+ }
229
+ if (target === 'dart' && langName.startsWith('_')) {
230
+ throw new TypegenError(context, `${scope}: ${JSON.stringify(sqlName)} maps to ${JSON.stringify(langName)} — a leading underscore is library-private on the dart target. Alias it in SQL (AS) or set "naming": "preserve" in syncular.json`);
231
+ }
232
+ }
233
+ bySqlName.set(sqlName, langName);
234
+ byLangName.set(langName, sqlName);
235
+ }
236
+ return sqlNames.map((sqlName) => ({
237
+ sqlName,
238
+ langName: bySqlName.get(sqlName),
239
+ }));
240
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * QueryIR serialization (DESIGN-queries.md §1): the frontend-agnostic,
3
+ * deterministic JSON form of analyzed queries. This is the golden-fixture
4
+ * format — equivalent inputs in any frontend (`.sql` today, `.syql` later)
5
+ * must produce byte-identical IR JSON, the same trick the wire protocol
6
+ * uses for the two cores. Fixed key order, 2-space indent, trailing
7
+ * newline.
8
+ *
9
+ * Names are SQL-truth (`name`) plus the collision-checked language name
10
+ * (`langName`, §5); `sql` is the LOWERED statement (what actually runs).
11
+ */
12
+ import type { AnalyzedQuery } from './query.js';
13
+ /** Serialize analyzed queries as the deterministic QueryIR JSON document. */
14
+ export declare function serializeQueryIr(queries: readonly AnalyzedQuery[]): string;
@@ -0,0 +1,69 @@
1
+ /** Serialize analyzed queries as the deterministic QueryIR JSON document. */
2
+ export function serializeQueryIr(queries) {
3
+ const doc = {
4
+ queryIrVersion: 1,
5
+ queries: queries.map((query) => ({
6
+ name: query.name,
7
+ file: query.file,
8
+ sourceSql: query.sourceSql,
9
+ sql: query.sql,
10
+ positionalSql: query.positionalSql,
11
+ params: query.params.map((param) => ({
12
+ name: param.name,
13
+ langName: param.langName,
14
+ type: param.type,
15
+ // §4 metadata — emitted only when set, so `.sql`-tier IR bytes are
16
+ // unchanged from before the DSL existed.
17
+ ...(param.optional === true ? { optional: true } : {}),
18
+ ...(param.group !== undefined ? { group: param.group } : {}),
19
+ ...(param.flag === true ? { flag: true } : {}),
20
+ })),
21
+ columns: query.columns.map((column) => ({
22
+ name: column.name,
23
+ langName: column.langName,
24
+ type: column.type,
25
+ nullable: column.nullable,
26
+ fidelity: column.fidelity,
27
+ })),
28
+ tables: query.tables,
29
+ // §6 knob metadata — emitted only when declared.
30
+ ...(query.orderBy !== undefined
31
+ ? {
32
+ orderBy: {
33
+ allowed: query.orderBy.allowed.map((c) => ({
34
+ name: c.name,
35
+ langName: c.langName,
36
+ })),
37
+ defaultColumn: query.orderBy.defaultColumn,
38
+ defaultDir: query.orderBy.defaultDir,
39
+ },
40
+ }
41
+ : {}),
42
+ ...(query.limit !== undefined ? { limit: query.limit } : {}),
43
+ ...(query.positionalSqlBase !== undefined
44
+ ? { positionalSqlBase: query.positionalSqlBase }
45
+ : {}),
46
+ // §7 variant backend — emitted only when the query opts in.
47
+ ...(query.variantGroups !== undefined
48
+ ? {
49
+ variantGroups: query.variantGroups.map((g) => ({
50
+ key: g.key,
51
+ params: g.params,
52
+ flag: g.flag,
53
+ })),
54
+ }
55
+ : {}),
56
+ ...(query.variants !== undefined
57
+ ? {
58
+ variants: query.variants.map((v) => ({
59
+ when: v.when,
60
+ sql: v.sql,
61
+ positionalSql: v.positionalSql,
62
+ params: v.params,
63
+ })),
64
+ }
65
+ : {}),
66
+ })),
67
+ };
68
+ return `${JSON.stringify(doc, null, 2)}\n`;
69
+ }
package/dist/query.d.ts CHANGED
@@ -1,36 +1,121 @@
1
1
  import type { IrColumnType, IrDocument } from './ir.js';
2
+ import { type NamingMode, type NamingTarget } from './naming.js';
2
3
  /** A param type is one of the §2.4 types (the columns params compare to). */
3
4
  export type QueryParamType = IrColumnType;
4
5
  export interface QueryParam {
6
+ /** SQL-truth `:name` (as authored). */
5
7
  readonly name: string;
8
+ /** Language-facing name (§5 naming map; equals `name` under "preserve"). */
9
+ readonly langName: string;
6
10
  readonly type: QueryParamType;
7
11
  /** How the type was resolved — for the docs/tests, not emitted. */
8
12
  readonly source: 'inferred' | 'comment';
13
+ /** §4 (DESIGN-queries.md): an optional param — its auto-guarded conjunct
14
+ * applies only when it is provided. Always false for the `.sql` tier. */
15
+ readonly optional?: boolean;
16
+ /** §4: the `from+to` pairing — params sharing a group apply together. */
17
+ readonly group?: string;
18
+ /** §3: a `: flag` boolean guard param (never in a predicate as written;
19
+ * the lowering binds it). Implies optional. */
20
+ readonly flag?: boolean;
21
+ }
22
+ /** §6 orderBy knob: a generate-time allowlist (identifiers cannot bind). */
23
+ export interface QueryOrderBy {
24
+ /** Allowed columns: authored SQL name + the language-facing key the
25
+ * generated signature accepts. Each is SQLite-checked at generate time. */
26
+ readonly allowed: readonly {
27
+ name: string;
28
+ langName: string;
29
+ }[];
30
+ /** Default column (authored SQL name). */
31
+ readonly defaultColumn: string;
32
+ readonly defaultDir: 'asc' | 'desc';
33
+ }
34
+ /** §6 limit knob: a bound value with a codegen clamp + default. */
35
+ export interface QueryLimit {
36
+ readonly max?: number;
37
+ readonly default?: number;
9
38
  }
10
39
  export interface QueryColumn {
40
+ /** SQL-truth result name (pre-lowering, as authored). */
11
41
  readonly name: string;
42
+ /** Language-facing name — the RUNTIME result key after §5 projection
43
+ * lowering (equals `name` under "preserve"). */
44
+ readonly langName: string;
12
45
  readonly type: IrColumnType;
13
46
  readonly nullable: boolean;
14
47
  /** `exact` = resolved to an IR column (plain ref); `fallback` = a computed
15
48
  * expression typed by the documented honest fallback. */
16
49
  readonly fidelity: 'exact' | 'fallback';
17
50
  }
51
+ /** §7/§8 backend selection: neutralization (default), always-variants, or
52
+ * the small-N heuristic (`auto`: enumerate at ≤ 2 optional groups). */
53
+ export type QueryBackend = 'neutralize' | 'variants' | 'auto';
54
+ /** Naming/backend options threaded from the manifest into query analysis. */
55
+ export interface QueryNamingOptions {
56
+ readonly naming: NamingMode;
57
+ /** Emitter targets this run generates — keyword hazards are only real on
58
+ * targets that exist. */
59
+ readonly targets: readonly NamingTarget[];
60
+ /** `.syql` conditional-lowering backend (default `neutralize`); the
61
+ * per-query `variants` knob always forces enumeration. */
62
+ readonly backend?: QueryBackend;
63
+ }
18
64
  export interface AnalyzedQuery {
19
65
  /** camelCase function name (path-derived, or a `-- name:` override). */
20
66
  readonly name: string;
21
67
  /** Source location for errors, e.g. `billing/list.sql` (or `list.sql#2`
22
68
  * for the 2nd statement of a multi-statement file). */
23
69
  readonly file: string;
24
- /** The SQL as written (named `:params`), trimmed. */
70
+ /** The SQL as authored (named `:params`), trimmed — pre-lowering. */
71
+ readonly sourceSql: string;
72
+ /** The LOWERED SQL (named `:params`): §5 projection aliasing applied, so
73
+ * runtime result keys are the language-facing names. Equals `sourceSql`
74
+ * when nothing needed rewriting. */
25
75
  readonly sql: string;
26
- /** The SQL with `:name` rewritten to positional `?` (wrapper surface). */
76
+ /** The lowered SQL with `:name` rewritten to positional `?`. */
27
77
  readonly positionalSql: string;
28
78
  /** Params in first-occurrence (positional) order. */
29
79
  readonly params: readonly QueryParam[];
30
80
  /** Result columns in SELECT order. */
31
81
  readonly columns: readonly QueryColumn[];
32
- /** IR tables this query reads (the useSyncQuery `{tables}` set), sorted. */
82
+ /** IR tables this query reads (the useRawSql `{tables}` set), sorted. */
33
83
  readonly tables: readonly string[];
84
+ /** §6 orderBy knob (`.syql` tier). When present, `sql`/`positionalSql`
85
+ * carry the DEFAULT order-by tail and `positionalSqlBase` is the static
86
+ * prefix emitters compose the selected column onto. */
87
+ readonly orderBy?: QueryOrderBy;
88
+ /** §6 limit knob (`.syql` tier): the trailing `LIMIT ?` bind's clamp. */
89
+ readonly limit?: QueryLimit;
90
+ /** positional SQL WITHOUT the dynamic ORDER BY/LIMIT tail; present only
91
+ * when `orderBy` is (emitters build `base + ORDER BY <baked> + limit
92
+ * tail`). */
93
+ readonly positionalSqlBase?: string;
94
+ /** The positional limit tail (e.g. ` limit min(coalesce(?, 50), 100)` —
95
+ * the default+clamp live IN the SQL, so runtimes bind `limit ?? null`).
96
+ * Present only when BOTH knobs are declared (composers need it verbatim). */
97
+ readonly positionalLimitTail?: string;
98
+ /** §7 variant-enumeration backend (opt-in `variants` knob): one checked
99
+ * statement per combination of provided optional groups. Semantically
100
+ * identical to the neutralization `sql` by construction; the TS emitter
101
+ * dispatches on provided-ness for perfect index use. `when` lists the
102
+ * provided group keys; `params` are the DISTINCT param names this variant
103
+ * binds, positional order. */
104
+ readonly variants?: readonly {
105
+ readonly when: readonly string[];
106
+ readonly sql: string;
107
+ readonly positionalSql: string;
108
+ readonly params: readonly string[];
109
+ }[];
110
+ /** The optional-group keys, in dispatch-bit order (bit i of the variant
111
+ * index = group i provided). Present iff `variants` is. */
112
+ readonly variantGroups?: readonly {
113
+ readonly key: string;
114
+ /** Params whose provision defines the group (all must be non-null; a
115
+ * flag group is "on" when its single param is TRUE). */
116
+ readonly params: readonly string[];
117
+ readonly flag: boolean;
118
+ }[];
34
119
  }
35
120
  /**
36
121
  * Path-relative default name. `billing/invoices/list.sql` → `billingInvoicesList`;
@@ -56,6 +141,17 @@ export interface SplitStatement {
56
141
  export declare function splitStatements(content: string): SplitStatement[];
57
142
  /** The `-- name:` override in a statement's leading comment block, or null. */
58
143
  export declare function parseStatementName(statementText: string, location: string): string | null;
144
+ /** Rewrite `:name` → positional placeholders AND strip comments + collapse
145
+ * whitespace, producing a clean single-line SQL string suitable for
146
+ * embedding in a generated string literal. String literals are preserved
147
+ * verbatim (their inner whitespace is not collapsed).
148
+ *
149
+ * When every param occurs exactly once, plain `?` is emitted. When ANY
150
+ * param repeats (the §7 neutralization guards mention a param in the guard
151
+ * AND the predicate), the WHOLE statement uses SQLite's numbered `?N`
152
+ * form — one bound value per DISTINCT param, reuse resolved by SQLite —
153
+ * so the generated bind array stays one-entry-per-param. */
154
+ export declare function toPositionalSql(sql: string): string;
59
155
  export interface QueryDb {
60
156
  /** Prepare + return the result columns / declared types / param count.
61
157
  * Throws (with SQLite's message) when the SQL is invalid against the DDL. */
@@ -77,7 +173,7 @@ export declare function synthesizeDdl(ir: IrDocument): string;
77
173
  * top-level `;`). This is the per-statement core; {@link analyzeQueryFile}
78
174
  * splits a file and drives it.
79
175
  */
80
- export declare function analyzeStatement(name: string, location: string, statementText: string, ir: IrDocument, db: QueryDb): AnalyzedQuery;
176
+ export declare function analyzeStatement(name: string, location: string, statementText: string, ir: IrDocument, db: QueryDb, naming?: QueryNamingOptions): AnalyzedQuery;
81
177
  /**
82
178
  * Analyze a whole query FILE: split into statements, resolve each statement's
83
179
  * name (path-derived default, or a `-- name:` override), enforce the
@@ -89,8 +185,8 @@ export declare function analyzeStatement(name: string, location: string, stateme
89
185
  * - A file with MULTIPLE statements requires `-- name:` on EVERY statement; a
90
186
  * missing one errors, naming the file + the statement's position/first line.
91
187
  */
92
- export declare function analyzeQueryFile(relPath: string, content: string, ir: IrDocument, db: QueryDb): AnalyzedQuery[];
188
+ export declare function analyzeQueryFile(relPath: string, content: string, ir: IrDocument, db: QueryDb, naming?: QueryNamingOptions): AnalyzedQuery[];
93
189
  /** Back-compat single-statement analyzer: resolve the name from the file path
94
190
  * (or a `-- name:` override) and analyze. Rejects a `;`-separated file loudly
95
191
  * — use {@link analyzeQueryFile} for the multi-statement contract. */
96
- export declare function analyzeQuery(file: string, raw: string, ir: IrDocument, db: QueryDb): AnalyzedQuery;
192
+ export declare function analyzeQuery(file: string, raw: string, ir: IrDocument, db: QueryDb, naming?: QueryNamingOptions): AnalyzedQuery;