@syncular/typegen 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +79 -32
  2. package/dist/cli.js +40 -17
  3. package/dist/emit-queries-dart.js +168 -55
  4. package/dist/emit-queries-kotlin.js +167 -55
  5. package/dist/emit-queries-swift.js +183 -57
  6. package/dist/emit-queries.js +286 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +348 -316
  9. package/dist/generate.d.ts +1 -1
  10. package/dist/generate.js +28 -9
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lsp.d.ts +1 -3
  14. package/dist/lsp.js +349 -187
  15. package/dist/manifest.d.ts +1 -3
  16. package/dist/manifest.js +1 -1
  17. package/dist/query-ir.js +53 -42
  18. package/dist/query.d.ts +110 -63
  19. package/dist/query.js +89 -11
  20. package/dist/syql-ast.d.ts +12 -13
  21. package/dist/syql-ast.js +26 -20
  22. package/dist/syql-lexer.d.ts +2 -0
  23. package/dist/syql-lexer.js +9 -2
  24. package/dist/syql-lowering.d.ts +22 -0
  25. package/dist/syql-lowering.js +434 -0
  26. package/dist/syql-parser.d.ts +19 -18
  27. package/dist/syql-parser.js +341 -93
  28. package/dist/syql-semantics.d.ts +73 -0
  29. package/dist/syql-semantics.js +607 -0
  30. package/dist/syql-template-parser.d.ts +5 -20
  31. package/dist/syql-template-parser.js +116 -109
  32. package/dist/syql-validator.d.ts +35 -0
  33. package/dist/syql-validator.js +993 -0
  34. package/package.json +3 -3
  35. package/src/cli.ts +49 -21
  36. package/src/emit-queries-dart.ts +195 -69
  37. package/src/emit-queries-kotlin.ts +197 -69
  38. package/src/emit-queries-swift.ts +224 -68
  39. package/src/emit-queries.ts +410 -165
  40. package/src/fmt.ts +405 -303
  41. package/src/generate.ts +43 -9
  42. package/src/index.ts +3 -1
  43. package/src/lsp.ts +414 -216
  44. package/src/manifest.ts +2 -4
  45. package/src/query-ir.ts +52 -42
  46. package/src/query.ts +229 -79
  47. package/src/syql-ast.ts +38 -34
  48. package/src/syql-lexer.ts +12 -1
  49. package/src/syql-lowering.ts +664 -0
  50. package/src/syql-parser.ts +472 -134
  51. package/src/syql-semantics.ts +930 -0
  52. package/src/syql-template-parser.ts +119 -163
  53. package/src/syql-validator.ts +1486 -0
  54. package/dist/syql.d.ts +0 -102
  55. package/dist/syql.js +0 -1141
  56. package/src/syql.ts +0 -1470
package/src/manifest.ts CHANGED
@@ -128,9 +128,7 @@ export interface Manifest {
128
128
  * generated row types/params (queries lower with `AS` aliases); "preserve"
129
129
  * keeps SQL-truth names everywhere. */
130
130
  readonly naming: NamingMode;
131
- /** §7 `.syql` backend: "neutralize" (default), "variants" (enumerate
132
- * whenever eligible), or "auto" (enumerate at ≤ 2 optional groups). The
133
- * per-query `variants` knob always forces enumeration. */
131
+ /** §7 `.syql` backend: "auto" (default), "neutralize", or "variants". */
134
132
  readonly queryBackend: ManifestQueryBackend;
135
133
  readonly output: ManifestOutput;
136
134
  readonly schemaVersions: readonly ManifestSchemaVersion[];
@@ -389,7 +387,7 @@ export function parseManifest(raw: unknown): Manifest {
389
387
  }
390
388
  naming = obj.naming;
391
389
  }
392
- let queryBackend: ManifestQueryBackend = 'neutralize';
390
+ let queryBackend: ManifestQueryBackend = 'auto';
393
391
  if (obj.queryBackend !== undefined) {
394
392
  if (
395
393
  obj.queryBackend !== 'neutralize' &&
package/src/query-ir.ts CHANGED
@@ -14,7 +14,7 @@ import type { AnalyzedQuery } from './query';
14
14
  /** Serialize analyzed queries as the deterministic QueryIR JSON document. */
15
15
  export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
16
16
  const doc = {
17
- queryIrVersion: 2,
17
+ queryIrVersion: 3,
18
18
  queries: queries.map((query) => ({
19
19
  name: query.name,
20
20
  file: query.file,
@@ -25,11 +25,6 @@ export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
25
25
  name: param.name,
26
26
  langName: param.langName,
27
27
  type: param.type,
28
- // §4 metadata — emitted only when set, so `.sql`-tier IR bytes are
29
- // unchanged from before the DSL existed.
30
- ...(param.optional === true ? { optional: true } : {}),
31
- ...(param.group !== undefined ? { group: param.group } : {}),
32
- ...(param.flag === true ? { flag: true } : {}),
33
28
  })),
34
29
  columns: query.columns.map((column) => ({
35
30
  name: column.name,
@@ -62,43 +57,58 @@ export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
62
57
  ? { rowKey: query.reactive.rowKey }
63
58
  : {}),
64
59
  },
65
- // §6 knob metadata — emitted only when declared.
66
- ...(query.orderBy !== undefined
67
- ? {
68
- orderBy: {
69
- allowed: query.orderBy.allowed.map((c) => ({
70
- name: c.name,
71
- langName: c.langName,
72
- })),
73
- defaultColumn: query.orderBy.defaultColumn,
74
- defaultDir: query.orderBy.defaultDir,
60
+ ...(query.syql === undefined
61
+ ? {}
62
+ : {
63
+ syql: {
64
+ revision: query.syql.revision,
65
+ inputs: query.syql.inputs.map((input) => {
66
+ if (input.kind === 'value') return { ...input };
67
+ if (input.kind === 'group') {
68
+ return {
69
+ kind: input.kind,
70
+ name: input.name,
71
+ langName: input.langName,
72
+ members: input.members.map((member) => ({ ...member })),
73
+ };
74
+ }
75
+ if (input.kind === 'sort') {
76
+ return {
77
+ kind: input.kind,
78
+ name: input.name,
79
+ langName: input.langName,
80
+ defaultProfile: input.defaultProfile,
81
+ profiles: input.profiles.map((profile) => ({ ...profile })),
82
+ };
83
+ }
84
+ return { ...input };
85
+ }),
86
+ plan: {
87
+ backend: query.syql.plan.backend,
88
+ activationControls: query.syql.plan.activationControls,
89
+ conditions: query.syql.plan.conditions.map((condition) => ({
90
+ controls: condition.controls,
91
+ ...(condition.bind === undefined
92
+ ? {}
93
+ : { bind: condition.bind }),
94
+ })),
95
+ statements: query.syql.plan.statements.map((statement) => ({
96
+ ...(statement.sortProfile === undefined
97
+ ? {}
98
+ : { sortProfile: statement.sortProfile }),
99
+ ...(statement.activationMask === undefined
100
+ ? {}
101
+ : { activationMask: statement.activationMask }),
102
+ sql: statement.sql,
103
+ positionalSql: statement.positionalSql,
104
+ binds: statement.binds.map((bind) => ({ ...bind })),
105
+ })),
106
+ },
107
+ ...(query.syql.identity === undefined
108
+ ? {}
109
+ : { identity: query.syql.identity }),
75
110
  },
76
- }
77
- : {}),
78
- ...(query.limit !== undefined ? { limit: query.limit } : {}),
79
- ...(query.positionalSqlBase !== undefined
80
- ? { positionalSqlBase: query.positionalSqlBase }
81
- : {}),
82
- // §7 variant backend — emitted only when the query opts in.
83
- ...(query.variantGroups !== undefined
84
- ? {
85
- variantGroups: query.variantGroups.map((g) => ({
86
- key: g.key,
87
- params: g.params,
88
- flag: g.flag,
89
- })),
90
- }
91
- : {}),
92
- ...(query.variants !== undefined
93
- ? {
94
- variants: query.variants.map((v) => ({
95
- when: v.when,
96
- sql: v.sql,
97
- positionalSql: v.positionalSql,
98
- params: v.params,
99
- })),
100
- }
101
- : {}),
111
+ }),
102
112
  })),
103
113
  };
104
114
  return `${JSON.stringify(doc, null, 2)}\n`;
package/src/query.ts CHANGED
@@ -87,6 +87,9 @@ const DECLTYPE_MAP: Readonly<Record<string, IrColumnType>> = {
87
87
  CRDT: 'crdt',
88
88
  };
89
89
 
90
+ /** Local client protocol column; query-only and never part of schema IR. */
91
+ const SYNC_VERSION_COLUMN = '_sync_version';
92
+
90
93
  /** A param type is one of the §2.4 types (the columns params compare to). */
91
94
  export type QueryParamType = IrColumnType;
92
95
 
@@ -98,30 +101,6 @@ export interface QueryParam {
98
101
  readonly type: QueryParamType;
99
102
  /** How the type was resolved — for the docs/tests, not emitted. */
100
103
  readonly source: 'inferred' | 'comment';
101
- /** §4 (DESIGN-queries.md): an optional param — its auto-guarded conjunct
102
- * applies only when it is provided. Always false for the `.sql` tier. */
103
- readonly optional?: boolean;
104
- /** §4: the `from+to` pairing — params sharing a group apply together. */
105
- readonly group?: string;
106
- /** §3: a `: flag` boolean guard param (never in a predicate as written;
107
- * the lowering binds it). Implies optional. */
108
- readonly flag?: boolean;
109
- }
110
-
111
- /** §6 orderBy knob: a generate-time allowlist (identifiers cannot bind). */
112
- export interface QueryOrderBy {
113
- /** Allowed columns: authored SQL name + the language-facing key the
114
- * generated signature accepts. Each is SQLite-checked at generate time. */
115
- readonly allowed: readonly { name: string; langName: string }[];
116
- /** Default column (authored SQL name). */
117
- readonly defaultColumn: string;
118
- readonly defaultDir: 'asc' | 'desc';
119
- }
120
-
121
- /** §6 limit knob: a bound value with a codegen clamp + default. */
122
- export interface QueryLimit {
123
- readonly max?: number;
124
- readonly default?: number;
125
104
  }
126
105
 
127
106
  export interface QueryColumn {
@@ -166,7 +145,7 @@ export interface QueryReactiveMetadata {
166
145
  readonly rowKey?: readonly string[];
167
146
  }
168
147
 
169
- /** §7/§8 backend selection: neutralization (default), always-variants, or
148
+ /** §7/§8 backend selection: neutralization, always-variants, or
170
149
  * the small-N heuristic (`auto`: enumerate at ≤ 2 optional groups). */
171
150
  export type QueryBackend = 'neutralize' | 'variants' | 'auto';
172
151
 
@@ -176,13 +155,115 @@ export interface QueryNamingOptions {
176
155
  /** Emitter targets this run generates — keyword hazards are only real on
177
156
  * targets that exist. */
178
157
  readonly targets: readonly NamingTarget[];
179
- /** `.syql` conditional-lowering backend (default `neutralize`); the
180
- * per-query `variants` knob always forces enumeration. */
158
+ /** `.syql` conditional-lowering policy (default `auto`). */
181
159
  readonly backend?: QueryBackend;
160
+ /** Compiler-reserved physical binds. They retain their exact names and do
161
+ * not participate in public target-language keyword/private-name checks. */
162
+ readonly internalParams?: readonly string[];
182
163
  }
183
164
 
184
165
  const DEFAULT_NAMING: QueryNamingOptions = { naming: 'camel', targets: ['ts'] };
185
166
 
167
+ /** SYQL public inputs. These are deliberately separate from SQL binds: groups
168
+ * and compiler-generated parameters have distinct public/runtime shapes. */
169
+ export type QuerySyqlPublicInput =
170
+ | {
171
+ readonly kind: 'value';
172
+ readonly name: string;
173
+ readonly langName: string;
174
+ readonly type: QueryParamType;
175
+ readonly nullable: boolean;
176
+ readonly required: boolean;
177
+ readonly default?: false;
178
+ }
179
+ | {
180
+ readonly kind: 'group';
181
+ readonly name: string;
182
+ readonly langName: string;
183
+ readonly members: readonly {
184
+ readonly name: string;
185
+ readonly langName: string;
186
+ readonly type: QueryParamType;
187
+ readonly nullable: boolean;
188
+ }[];
189
+ }
190
+ | {
191
+ readonly kind: 'sort';
192
+ readonly name: string;
193
+ readonly langName: string;
194
+ readonly defaultProfile: string;
195
+ readonly profiles: readonly {
196
+ readonly name: string;
197
+ readonly langName: string;
198
+ }[];
199
+ }
200
+ | {
201
+ readonly kind: 'limit';
202
+ readonly name: string;
203
+ readonly langName: string;
204
+ readonly defaultSize: number;
205
+ readonly maxSize: number;
206
+ };
207
+
208
+ /** One distinct SQLite bind in a physical revision-1 statement. */
209
+ export type QuerySyqlPlanBind =
210
+ | {
211
+ readonly kind: 'value';
212
+ readonly name: string;
213
+ readonly type: QueryParamType;
214
+ readonly input: string;
215
+ }
216
+ | {
217
+ readonly kind: 'group-member';
218
+ readonly name: string;
219
+ readonly type: QueryParamType;
220
+ readonly input: string;
221
+ readonly member: string;
222
+ }
223
+ | {
224
+ readonly kind: 'condition-active';
225
+ readonly name: string;
226
+ readonly type: 'boolean';
227
+ readonly condition: number;
228
+ readonly controls: readonly string[];
229
+ }
230
+ | {
231
+ readonly kind: 'limit';
232
+ readonly name: string;
233
+ readonly type: 'integer';
234
+ readonly input: string;
235
+ };
236
+
237
+ /** One SQLite-checked statement selected by sort profile and, for the
238
+ * enumerated backend, the activation bitmask. */
239
+ export interface QuerySyqlStatement {
240
+ readonly sortProfile?: string;
241
+ readonly activationMask?: number;
242
+ readonly sql: string;
243
+ readonly positionalSql: string;
244
+ readonly binds: readonly QuerySyqlPlanBind[];
245
+ }
246
+
247
+ /** The target-neutral physical plan every emitter must implement exactly. */
248
+ export interface QuerySyqlExecutionPlan {
249
+ readonly backend: Exclude<QueryBackend, 'auto'>;
250
+ /** One bit per conditional activation control, in declaration order. */
251
+ readonly activationControls: readonly string[];
252
+ readonly conditions: readonly {
253
+ readonly controls: readonly string[];
254
+ /** Present only for neutralized plans. */
255
+ readonly bind?: string;
256
+ }[];
257
+ readonly statements: readonly QuerySyqlStatement[];
258
+ }
259
+
260
+ export interface QuerySyqlMetadata {
261
+ readonly revision: 1;
262
+ readonly inputs: readonly QuerySyqlPublicInput[];
263
+ readonly plan: QuerySyqlExecutionPlan;
264
+ readonly identity?: readonly string[];
265
+ }
266
+
186
267
  export interface AnalyzedQuery {
187
268
  /** camelCase function name (path-derived, or a `-- name:` override). */
188
269
  readonly name: string;
@@ -204,41 +285,9 @@ export interface AnalyzedQuery {
204
285
  /** IR tables this query reads (the useRawSql `{tables}` set), sorted. */
205
286
  readonly tables: readonly string[];
206
287
  readonly reactive: QueryReactiveMetadata;
207
- /** §6 orderBy knob (`.syql` tier). When present, `sql`/`positionalSql`
208
- * carry the DEFAULT order-by tail and `positionalSqlBase` is the static
209
- * prefix emitters compose the selected column onto. */
210
- readonly orderBy?: QueryOrderBy;
211
- /** §6 limit knob (`.syql` tier): the trailing `LIMIT ?` bind's clamp. */
212
- readonly limit?: QueryLimit;
213
- /** positional SQL WITHOUT the dynamic ORDER BY/LIMIT tail; present only
214
- * when `orderBy` is (emitters build `base + ORDER BY <baked> + limit
215
- * tail`). */
216
- readonly positionalSqlBase?: string;
217
- /** The positional limit tail (e.g. ` limit min(coalesce(?, 50), 100)` —
218
- * the default+clamp live IN the SQL, so runtimes bind `limit ?? null`).
219
- * Present only when BOTH knobs are declared (composers need it verbatim). */
220
- readonly positionalLimitTail?: string;
221
- /** §7 variant-enumeration backend (opt-in `variants` knob): one checked
222
- * statement per combination of provided optional groups. Semantically
223
- * identical to the neutralization `sql` by construction; the TS emitter
224
- * dispatches on provided-ness for perfect index use. `when` lists the
225
- * provided group keys; `params` are the DISTINCT param names this variant
226
- * binds, positional order. */
227
- readonly variants?: readonly {
228
- readonly when: readonly string[];
229
- readonly sql: string;
230
- readonly positionalSql: string;
231
- readonly params: readonly string[];
232
- }[];
233
- /** The optional-group keys, in dispatch-bit order (bit i of the variant
234
- * index = group i provided). Present iff `variants` is. */
235
- readonly variantGroups?: readonly {
236
- readonly key: string;
237
- /** Params whose provision defines the group (all must be non-null; a
238
- * flag group is "on" when its single param is TRUE). */
239
- readonly params: readonly string[];
240
- readonly flag: boolean;
241
- }[];
288
+ /** Revision-1 frontend/public-input and physical execution contract. When
289
+ * present, emitters use this instead of treating `params` as the API. */
290
+ readonly syql?: QuerySyqlMetadata;
242
291
  }
243
292
 
244
293
  // -- path → name --------------------------------------------------------------
@@ -502,7 +551,7 @@ function parseCommentParams(file: string, raw: string): CommentParam[] {
502
551
 
503
552
  /** Strip `--` and block comments so identifier scans don't hit commented SQL,
504
553
  * and string literals so `':x'` inside a string isn't read as a param. */
505
- function stripCommentsAndStrings(sql: string): string {
554
+ export function stripCommentsAndStrings(sql: string): string {
506
555
  let out = '';
507
556
  let i = 0;
508
557
  while (i < sql.length) {
@@ -638,7 +687,7 @@ export function toPositionalSql(sql: string): string {
638
687
 
639
688
  // -- FROM/JOIN table resolution ----------------------------------------------
640
689
 
641
- interface TableRef {
690
+ export interface TableRef {
642
691
  readonly table: string;
643
692
  /** Alias (or the table name when un-aliased). */
644
693
  readonly alias: string;
@@ -668,7 +717,7 @@ const RESERVED_ALIAS = new Set([
668
717
  'having',
669
718
  ]);
670
719
 
671
- function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
720
+ export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
672
721
  const cleaned = stripCommentsAndStrings(sql);
673
722
  const known = new Map(ir.tables.map((t) => [t.name, t] as const));
674
723
  const refs: TableRef[] = [];
@@ -767,6 +816,20 @@ function resolveSource(
767
816
  return null;
768
817
  }
769
818
 
819
+ /** Resolve the one supported query-only protocol column. SQLite still proves
820
+ * qualifier/ambiguity against the synthesized local tables. */
821
+ function isSyncVersionSource(
822
+ item: SelectItem,
823
+ refs: readonly TableRef[],
824
+ ): boolean {
825
+ const match = PLAIN_REF_RE.exec(item.expr);
826
+ if (match === null || match[2] !== SYNC_VERSION_COLUMN) return false;
827
+ const qualifier = match[1];
828
+ return qualifier === undefined
829
+ ? refs.length === 1
830
+ : refs.some((ref) => ref.alias === qualifier);
831
+ }
832
+
770
833
  // -- param type inference -----------------------------------------------------
771
834
  //
772
835
  // Infer a param's type from `col <op> :name` or `col IN (:name, …)` where `col`
@@ -855,6 +918,78 @@ function inferParamType(
855
918
  return null;
856
919
  }
857
920
 
921
+ /**
922
+ * Return every revision-1 column/LIKE type constraint for one bind. Unlike
923
+ * `inferParamType` (the legacy first-match helper), SYQL uses the complete set
924
+ * so conflicting checked uses cannot be silently accepted.
925
+ */
926
+ export function inferParamTypeEvidence(
927
+ paramName: string,
928
+ sql: string,
929
+ refs: readonly TableRef[],
930
+ ir: IrDocument,
931
+ ): readonly IrColumnType[] {
932
+ const cleaned = stripCommentsAndStrings(sql);
933
+ const byName = new Map(
934
+ ir.tables.map((table) => [table.name, table] as const),
935
+ );
936
+ const evidence: IrColumnType[] = [];
937
+ const add = (qualifier: string | undefined, column: string): void => {
938
+ if (qualifier !== undefined) {
939
+ const ref = refs.find((candidate) => candidate.alias === qualifier);
940
+ const table = ref === undefined ? undefined : byName.get(ref.table);
941
+ const type = table?.columns.find((item) => item.name === column)?.type;
942
+ if (type !== undefined) evidence.push(type);
943
+ return;
944
+ }
945
+ const matches = refs.flatMap((ref) => {
946
+ const table = byName.get(ref.table);
947
+ const type = table?.columns.find((item) => item.name === column)?.type;
948
+ return type === undefined ? [] : [type];
949
+ });
950
+ if (matches.length === 1) evidence.push(matches[0] as IrColumnType);
951
+ };
952
+
953
+ const comparison = new RegExp(
954
+ `(?:(${IDENT})\\.)?(${IDENT})\\s*(?:=|==|!=|<>|<=|>=|<|>|\\bLIKE\\b|\\bIS\\b)\\s*:${paramName}\\b`,
955
+ 'gi',
956
+ );
957
+ for (const match of cleaned.matchAll(comparison)) {
958
+ add(match[1], match[2] as string);
959
+ }
960
+ const reversed = new RegExp(
961
+ `:${paramName}\\b\\s*(?:=|==|!=|<>|<=|>=|<|>)\\s*(?:(${IDENT})\\.)?(${IDENT})`,
962
+ 'gi',
963
+ );
964
+ for (const match of cleaned.matchAll(reversed)) {
965
+ add(match[1], match[2] as string);
966
+ }
967
+ const inList = new RegExp(
968
+ `(?:(${IDENT})\\.)?(${IDENT})\\s+IN\\s*\\(([^)]*:${paramName}\\b[^)]*)\\)`,
969
+ 'gi',
970
+ );
971
+ for (const match of cleaned.matchAll(inList)) {
972
+ add(match[1], match[2] as string);
973
+ }
974
+ const betweenLeft = new RegExp(
975
+ `(?:(${IDENT})\\.)?(${IDENT})\\s+BETWEEN\\s+:${paramName}\\b`,
976
+ 'gi',
977
+ );
978
+ for (const match of cleaned.matchAll(betweenLeft)) {
979
+ add(match[1], match[2] as string);
980
+ }
981
+ const betweenRight = new RegExp(
982
+ `(?:(${IDENT})\\.)?(${IDENT})\\s+BETWEEN\\s+\\S+\\s+AND\\s+:${paramName}\\b`,
983
+ 'gi',
984
+ );
985
+ for (const match of cleaned.matchAll(betweenRight)) {
986
+ add(match[1], match[2] as string);
987
+ }
988
+ const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'gi');
989
+ if (like.test(cleaned)) evidence.push('string');
990
+ return evidence;
991
+ }
992
+
858
993
  // -- fallback column typing (computed expressions) ----------------------------
859
994
 
860
995
  const AGG_RE = /\b(count|sum|total|avg|min|max)\s*\(/i;
@@ -941,11 +1076,7 @@ function inferReactiveMetadata(
941
1076
  if (!requiredAt(match.index)) continue;
942
1077
  const name = match[1] ?? match[2];
943
1078
  const param = name === undefined ? undefined : byParam.get(name);
944
- if (
945
- param !== undefined &&
946
- param.optional !== true &&
947
- param.flag !== true
948
- ) {
1079
+ if (param !== undefined) {
949
1080
  found.push(param.name);
950
1081
  }
951
1082
  }
@@ -956,11 +1087,7 @@ function inferReactiveMetadata(
956
1087
  /:([A-Za-z_][A-Za-z0-9_]*)/g,
957
1088
  )) {
958
1089
  const param = byParam.get(paramMatch[1] as string);
959
- if (
960
- param !== undefined &&
961
- param.optional !== true &&
962
- param.flag !== true
963
- ) {
1090
+ if (param !== undefined) {
964
1091
  found.push(param.name);
965
1092
  }
966
1093
  }
@@ -1052,6 +1179,10 @@ export function synthesizeDdl(ir: IrDocument): string {
1052
1179
  const pk = c.name === table.primaryKey ? ' PRIMARY KEY' : '';
1053
1180
  return ` ${c.name} ${SQL_TYPE[c.type]}${pk}${notNull}`;
1054
1181
  });
1182
+ // The materialized local client table always carries this hidden protocol
1183
+ // token. It is absent from IR mutation types but may be explicitly aliased
1184
+ // by a named read query for optimistic concurrency.
1185
+ cols.push(` ${SYNC_VERSION_COLUMN} INTEGER NOT NULL DEFAULT -1`);
1055
1186
  lines.push(`CREATE TABLE ${table.name} (\n${cols.join(',\n')}\n);`);
1056
1187
  // Create the declared indexes too — harmless for prepare()/decltype, and
1057
1188
  // keeps the type-check DB's shape honest with what the client materializes.
@@ -1175,6 +1306,21 @@ export function analyzeStatement(
1175
1306
  const source = item !== undefined ? resolveSource(item, refs, ir) : null;
1176
1307
  const sqlName = sqlNames[index] ?? colName;
1177
1308
  const langName = langNames[index] ?? colName;
1309
+ if (item !== undefined && isSyncVersionSource(item, refs)) {
1310
+ if (sqlName === SYNC_VERSION_COLUMN) {
1311
+ throw new TypegenError(
1312
+ file,
1313
+ `${SYNC_VERSION_COLUMN} is stripped from app-facing query rows unless explicitly aliased (for example \`${SYNC_VERSION_COLUMN} AS server_version\`)`,
1314
+ );
1315
+ }
1316
+ return {
1317
+ name: sqlName,
1318
+ langName,
1319
+ type: 'integer',
1320
+ nullable: false,
1321
+ fidelity: 'exact',
1322
+ };
1323
+ }
1178
1324
  if (source !== null) {
1179
1325
  // Exact: IR column type + IR nullability. (decltype agrees; we prefer
1180
1326
  // the IR type so blob_ref/crdt/json semantic types survive.)
@@ -1214,16 +1360,20 @@ export function analyzeStatement(
1214
1360
  `internal: scanned ${paramNames.length} params (${paramNames.join(', ')}) but SQLite reports ${described.paramsCount}`,
1215
1361
  );
1216
1362
  }
1217
- const paramLangNames = buildNamingMap(
1218
- paramNames,
1363
+ const internalParams = new Set(naming.internalParams ?? []);
1364
+ const publicParamLangNames = buildNamingMap(
1365
+ paramNames.filter((name) => !internalParams.has(name)),
1219
1366
  naming.naming,
1220
1367
  file,
1221
1368
  'params',
1222
1369
  naming.targets,
1223
1370
  );
1371
+ const langNameByParam = new Map(
1372
+ publicParamLangNames.map((mapping) => [mapping.sqlName, mapping.langName]),
1373
+ );
1224
1374
  const commentByName = new Map(commentParams.map((p) => [p.name, p.type]));
1225
- const params: QueryParam[] = paramNames.map((paramName, index) => {
1226
- const langName = paramLangNames[index]?.langName ?? paramName;
1375
+ const params: QueryParam[] = paramNames.map((paramName) => {
1376
+ const langName = langNameByParam.get(paramName) ?? paramName;
1227
1377
  const commented = commentByName.get(paramName);
1228
1378
  if (commented !== undefined) {
1229
1379
  return { name: paramName, langName, type: commented, source: 'comment' };