@syncular/typegen 0.5.1 → 0.6.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 (47) hide show
  1. package/README.md +75 -33
  2. package/dist/cli.js +43 -17
  3. package/dist/emit-queries-dart.js +167 -55
  4. package/dist/emit-queries-kotlin.js +166 -55
  5. package/dist/emit-queries-swift.js +182 -57
  6. package/dist/emit-queries.js +289 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +323 -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 +359 -185
  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 +115 -63
  19. package/dist/query.js +60 -11
  20. package/dist/syql-lexer.d.ts +2 -0
  21. package/dist/syql-lexer.js +9 -2
  22. package/dist/syql-lowering.d.ts +22 -0
  23. package/dist/syql-lowering.js +411 -0
  24. package/dist/syql-semantics.d.ts +76 -0
  25. package/dist/syql-semantics.js +551 -0
  26. package/dist/syql-validator.d.ts +35 -0
  27. package/dist/syql-validator.js +966 -0
  28. package/package.json +3 -3
  29. package/src/cli.ts +51 -21
  30. package/src/emit-queries-dart.ts +195 -69
  31. package/src/emit-queries-kotlin.ts +197 -69
  32. package/src/emit-queries-swift.ts +224 -68
  33. package/src/emit-queries.ts +413 -165
  34. package/src/fmt.ts +377 -303
  35. package/src/generate.ts +43 -9
  36. package/src/index.ts +3 -1
  37. package/src/lsp.ts +425 -215
  38. package/src/manifest.ts +2 -4
  39. package/src/query-ir.ts +52 -42
  40. package/src/query.ts +199 -79
  41. package/src/syql-lexer.ts +12 -1
  42. package/src/syql-lowering.ts +638 -0
  43. package/src/syql-semantics.ts +859 -0
  44. package/src/syql-validator.ts +1492 -0
  45. package/dist/syql.d.ts +0 -102
  46. package/dist/syql.js +0 -1141
  47. package/src/syql.ts +0 -1470
@@ -4,18 +4,13 @@
4
4
  *
5
5
  * - `QRow` — the projection row interface (its OWN type per query — the
6
6
  * drift-kill: the row shape is exactly what the SELECT returns),
7
- * - `QParams` — the typed params object (when the query has params or
8
- * knobs); §4 optional params are optional keys (`status?: string | null`),
9
- * §6 knobs add `orderBy?/dir?/limit?`,
7
+ * - `QParams` — the typed public input object when the query has inputs,
10
8
  * - `qTables` — the readonly table-dependency set (feeds useRawSql `{tables}`
11
9
  * for EXACT invalidation),
12
10
  * - `q(client, params?): Promise<QRow[]>` — runs the query over the wrapper's
13
11
  * positional `query(sql, params[])` surface, reordering the named params
14
- * object into the positional array the wire expects (optionals bind NULL —
15
- * the §7 neutralization guards make an absent param a no-op),
16
- * - for an orderBy knob: a baked column map + a compose function — user
17
- * input only ever SELECTS from the generate-time-checked allowlist (I2);
18
- * it never becomes SQL text.
12
+ * object into the positional array the wire expects. Revision-1 SYQL
13
+ * selection is driven exclusively by its target-neutral physical plan.
19
14
  *
20
15
  * A tiny structural `QueryClient` interface (just `query(sql, params?)`) keeps
21
16
  * the module import-free — it structurally accepts `SyncClientLike` /
@@ -23,7 +18,12 @@
23
18
  * `--check` gates freshness byte-exactly, like every other emitter.
24
19
  */
25
20
  import type { IrColumnType } from './ir';
26
- import type { AnalyzedQuery, QueryParam } from './query';
21
+ import type {
22
+ AnalyzedQuery,
23
+ QueryParam,
24
+ QuerySyqlPlanBind,
25
+ QuerySyqlPublicInput,
26
+ } from './query';
27
27
 
28
28
  const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
29
29
  string: 'string',
@@ -51,30 +51,377 @@ function propertyKey(name: string): string {
51
51
 
52
52
  /** A named-query param value is the SqlValue subset its type maps to. */
53
53
  const PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = TS_TYPE;
54
+ const SYQL_PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = {
55
+ ...TS_TYPE,
56
+ integer: 'bigint',
57
+ };
58
+
59
+ function bindExpr(param: QueryParam): string {
60
+ return `params.${propertyKey(param.langName)}`;
61
+ }
62
+
63
+ function reactiveParam(query: AnalyzedQuery, name: string): string {
64
+ if (query.syql !== undefined) {
65
+ const input = query.syql.inputs.find(
66
+ (candidate) => candidate.kind === 'value' && candidate.name === name,
67
+ );
68
+ if (input?.kind !== 'value')
69
+ throw new Error(`unknown reactive input ${name}`);
70
+ return `String(params.${propertyKey(input.langName)})`;
71
+ }
72
+ const param = query.params.find((candidate) => candidate.name === name);
73
+ if (param === undefined) throw new Error(`unknown reactive param ${name}`);
74
+ return `String(params.${propertyKey(param.langName)})`;
75
+ }
76
+
77
+ function syqlInput(query: AnalyzedQuery, name: string): QuerySyqlPublicInput {
78
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
79
+ if (input === undefined) throw new Error(`unknown SYQL input ${name}`);
80
+ return input;
81
+ }
82
+
83
+ function syqlTsValueType(type: IrColumnType, nullable: boolean): string {
84
+ return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
85
+ }
54
86
 
55
- function isOptional(param: QueryParam): boolean {
56
- return param.optional === true || param.flag === true;
87
+ function syqlTsTypeCheck(
88
+ expression: string,
89
+ type: IrColumnType,
90
+ nullable: boolean,
91
+ ): string {
92
+ let check: string;
93
+ switch (type) {
94
+ case 'integer':
95
+ check = `typeof ${expression} === 'bigint'`;
96
+ break;
97
+ case 'float':
98
+ check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
99
+ break;
100
+ case 'boolean':
101
+ check = `typeof ${expression} === 'boolean'`;
102
+ break;
103
+ case 'bytes':
104
+ case 'crdt':
105
+ check = `${expression} instanceof Uint8Array`;
106
+ break;
107
+ default:
108
+ check = `typeof ${expression} === 'string'`;
109
+ }
110
+ return nullable ? `${expression} === null || (${check})` : check;
57
111
  }
58
112
 
59
- /** The positional bind expression for one param. `access` is `params` or
60
- * `params?` depending on whether the params object itself may be absent. */
61
- function bindExpr(
113
+ function syqlControlActive(
62
114
  query: AnalyzedQuery,
63
- param: QueryParam,
64
- access: string,
115
+ control: string,
116
+ params: string,
65
117
  ): string {
66
- if (query.limit !== undefined && param.name === 'limit') {
67
- // The default + clamp live IN the SQL (`min(coalesce(?, d), m)`).
68
- return `${access}.limit ?? null`;
118
+ const input = syqlInput(query, control);
119
+ const access = `${params}.${propertyKey(input.langName)}`;
120
+ if (input.kind === 'switch') return `${access} === true`;
121
+ if (input.kind === 'value' || input.kind === 'group') {
122
+ return `${access} !== undefined`;
69
123
  }
70
- const key = propertyKey(param.langName);
71
- return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
124
+ throw new Error(`${control} is not an activation control`);
72
125
  }
73
126
 
74
- function reactiveParam(query: AnalyzedQuery, name: string): string {
75
- const param = query.params.find((candidate) => candidate.name === name);
76
- if (param === undefined) throw new Error(`unknown reactive param ${name}`);
77
- return `String(params.${propertyKey(param.langName)})`;
127
+ function syqlBindExpr(
128
+ query: AnalyzedQuery,
129
+ bind: QuerySyqlPlanBind,
130
+ params: string,
131
+ ): string {
132
+ if (bind.kind === 'condition-active') {
133
+ return bind.controls
134
+ .map((control) => syqlControlActive(query, control, params))
135
+ .join(' && ');
136
+ }
137
+ const input = syqlInput(query, bind.input);
138
+ const access = `${params}.${propertyKey(input.langName)}`;
139
+ if (bind.kind === 'page') {
140
+ if (input.kind !== 'page') throw new Error('page bind/input mismatch');
141
+ return `${access} ?? ${input.defaultSize}`;
142
+ }
143
+ if (bind.kind === 'group-member') {
144
+ if (input.kind !== 'group') throw new Error('group bind/input mismatch');
145
+ const member = input.members.find(
146
+ (candidate) => candidate.name === bind.member,
147
+ );
148
+ if (member === undefined)
149
+ throw new Error(`unknown group member ${bind.member}`);
150
+ return `${access}?.${propertyKey(member.langName)} ?? null`;
151
+ }
152
+ if (input.kind !== 'value') throw new Error('value bind/input mismatch');
153
+ if (input.required) return access;
154
+ return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
155
+ }
156
+
157
+ function emitSyqlValidation(query: AnalyzedQuery, Params: string): string[] {
158
+ const inputs = query.syql?.inputs ?? [];
159
+ const lines: string[] = [];
160
+ lines.push(
161
+ `function ${query.name}Validate(raw?: ${Params}): ${Params} {`,
162
+ ` const params = raw ?? ({} as ${Params});`,
163
+ ` const allowed = new Set([${inputs.map((input) => quote(input.langName)).join(', ')}]);`,
164
+ ' for (const key of Object.keys(params)) {',
165
+ ` if (!allowed.has(key)) throw new SyqlInputError('SYQL_RUNTIME_UNKNOWN_INPUT', ${quote(query.name)} + ': unknown input ' + key);`,
166
+ ' }',
167
+ );
168
+ for (const input of inputs) {
169
+ const key = propertyKey(input.langName);
170
+ const access = `params.${key}`;
171
+ if (input.kind === 'value') {
172
+ if (input.required) {
173
+ lines.push(
174
+ ` if (!Object.prototype.hasOwnProperty.call(params, ${quote(input.langName)})) throw new SyqlInputError('SYQL_RUNTIME_MISSING_REQUIRED_INPUT', ${quote(`${query.name}: missing required input ${input.name}`)});`,
175
+ ` if (!(${syqlTsTypeCheck(access, input.type, input.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid input ${input.name}`)});`,
176
+ );
177
+ } else if (input.nullable) {
178
+ lines.push(
179
+ ` if (${access} !== undefined && (typeof ${access} !== 'object' || ${access} === null || ${access}.present !== true || !(${syqlTsTypeCheck(`${access}.value`, input.type, true)}))) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`,
180
+ );
181
+ } else {
182
+ lines.push(
183
+ ` if (${access} !== undefined && !(${syqlTsTypeCheck(access, input.type, false)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`,
184
+ );
185
+ }
186
+ } else if (input.kind === 'group') {
187
+ lines.push(
188
+ ` if (${access} !== undefined) {`,
189
+ ` if (typeof ${access} !== 'object' || ${access} === null) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid group ${input.name}`)});`,
190
+ ` const allowedMembers = new Set([${input.members.map((member) => quote(member.langName)).join(', ')}]);`,
191
+ ` if (Object.keys(${access}).some((key) => !allowedMembers.has(key))) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: unknown member in group ${input.name}`)});`,
192
+ );
193
+ for (const member of input.members) {
194
+ const memberAccess = `${access}.${propertyKey(member.langName)}`;
195
+ lines.push(
196
+ ` if (!Object.prototype.hasOwnProperty.call(${access}, ${quote(member.langName)}) || !(${syqlTsTypeCheck(memberAccess, member.type, member.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid or partial group ${input.name}`)});`,
197
+ );
198
+ }
199
+ lines.push(' }');
200
+ } else if (input.kind === 'switch') {
201
+ lines.push(
202
+ ` if (${access} !== undefined && typeof ${access} !== 'boolean') throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid switch ${input.name}`)});`,
203
+ );
204
+ } else if (input.kind === 'sort') {
205
+ lines.push(
206
+ ` if (${access} !== undefined && ![${input.profiles.map((profile) => quote(profile.langName)).join(', ')}].includes(${access})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_SORT', ${quote(`${query.name}: invalid sort profile`)});`,
207
+ );
208
+ } else {
209
+ lines.push(
210
+ ` if (${access} !== undefined && (!Number.isSafeInteger(${access}) || ${access} < 1 || ${access} > ${input.maxSize})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_PAGE', ${quote(`${query.name}: page size must be an integer from 1 through ${input.maxSize}`)});`,
211
+ );
212
+ }
213
+ }
214
+ lines.push(' return params;', '}');
215
+ return lines;
216
+ }
217
+
218
+ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
219
+ const metadata = query.syql;
220
+ if (metadata === undefined) throw new Error('missing SYQL metadata');
221
+ const Row = `${pascalCase(query.name)}Row`;
222
+ const Params = `${pascalCase(query.name)}Params`;
223
+ const inputs = metadata.inputs;
224
+ const hasParams = inputs.length > 0;
225
+ const requiresParams = inputs.some(
226
+ (input) => input.kind === 'value' && input.required,
227
+ );
228
+ const lines: string[] = [];
229
+ lines.push(
230
+ `/** One row of the ${quote(query.name)} query (its projection). */`,
231
+ );
232
+ lines.push(`export interface ${Row} {`);
233
+ for (const column of query.columns) {
234
+ lines.push(
235
+ ` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
236
+ );
237
+ }
238
+ lines.push('}', '');
239
+
240
+ for (const input of inputs) {
241
+ if (input.kind === 'group') {
242
+ lines.push(
243
+ `export interface ${pascalCase(query.name)}${pascalCase(input.langName)} {`,
244
+ );
245
+ for (const member of input.members) {
246
+ lines.push(
247
+ ` ${propertyKey(member.langName)}: ${syqlTsValueType(member.type, member.nullable)};`,
248
+ );
249
+ }
250
+ lines.push('}', '');
251
+ }
252
+ }
253
+ if (hasParams) {
254
+ lines.push(
255
+ `/** Public revision-1 SYQL inputs for ${quote(query.name)}. */`,
256
+ );
257
+ lines.push(`export interface ${Params} {`);
258
+ for (const input of inputs) {
259
+ const key = propertyKey(input.langName);
260
+ if (input.kind === 'value') {
261
+ const optional = !input.required;
262
+ const type = syqlTsValueType(input.type, input.nullable);
263
+ lines.push(
264
+ ` ${key}${optional ? '?' : ''}: ${optional && input.nullable ? `SyqlPresent<${type}>` : type};`,
265
+ );
266
+ } else if (input.kind === 'group') {
267
+ lines.push(
268
+ ` ${key}?: ${pascalCase(query.name)}${pascalCase(input.langName)};`,
269
+ );
270
+ } else if (input.kind === 'switch') {
271
+ lines.push(` ${key}?: boolean;`);
272
+ } else if (input.kind === 'sort') {
273
+ lines.push(
274
+ ` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`,
275
+ );
276
+ } else {
277
+ lines.push(` ${key}?: number;`);
278
+ }
279
+ }
280
+ lines.push('}', '');
281
+ lines.push(...emitSyqlValidation(query, Params), '');
282
+ }
283
+
284
+ lines.push(
285
+ `/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
286
+ `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
287
+ '',
288
+ );
289
+ const paramDecl = !hasParams
290
+ ? ''
291
+ : requiresParams
292
+ ? `raw: ${Params}`
293
+ : `raw?: ${Params}`;
294
+ const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
295
+ const statementType = hasParams
296
+ ? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
297
+ : '{ sql: string; bind: () => QueryValue[] }';
298
+ lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
299
+ for (const statement of metadata.plan.statements) {
300
+ const binds = statement.binds
301
+ .map((bind) => syqlBindExpr(query, bind, 'params'))
302
+ .join(', ');
303
+ lines.push(
304
+ ` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`,
305
+ );
306
+ }
307
+ lines.push('];');
308
+ const sort = inputs.find((input) => input.kind === 'sort');
309
+ if (sort?.kind === 'sort') {
310
+ lines.push(
311
+ `const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`,
312
+ );
313
+ }
314
+ lines.push(
315
+ `function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`,
316
+ );
317
+ if (validated !== undefined) lines.push(` const params = ${validated};`);
318
+ let maskExpression = '0';
319
+ if (metadata.plan.backend === 'variants') {
320
+ lines.push(' let mask = 0;');
321
+ metadata.plan.activationControls.forEach((control, index) => {
322
+ lines.push(
323
+ ` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`,
324
+ );
325
+ });
326
+ maskExpression = 'mask';
327
+ }
328
+ let sortExpression = '0';
329
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
330
+ if (sort?.kind === 'sort') {
331
+ sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
332
+ }
333
+ const indexExpression =
334
+ metadata.plan.backend === 'variants'
335
+ ? `${maskExpression} * ${profileCount} + ${sortExpression}`
336
+ : sortExpression;
337
+ lines.push(
338
+ ` const statement = ${query.name}Statements[${indexExpression}];`,
339
+ ` if (statement === undefined) throw new Error('invalid generated SYQL statement index');`,
340
+ ` return { sql: statement.sql, bind: statement.bind(${hasParams ? 'params' : ''}) };`,
341
+ '}',
342
+ '',
343
+ );
344
+
345
+ const runnerParam = !hasParams
346
+ ? ''
347
+ : requiresParams
348
+ ? `, params: ${Params}`
349
+ : `, params?: ${Params}`;
350
+ lines.push(
351
+ `/** Run the ${quote(query.name)} named query (SELECT-only). */`,
352
+ `export async function ${query.name}(client: QueryClient${runnerParam}): Promise<${Row}[]> {`,
353
+ ` const selected = ${query.name}Select(${hasParams ? 'params' : ''});`,
354
+ ' const rows = await client.query(selected.sql, selected.bind);',
355
+ ` return rows as unknown as ${Row}[];`,
356
+ '}',
357
+ '',
358
+ );
359
+
360
+ const paramsType = hasParams ? Params : 'undefined';
361
+ const defaultStatement = metadata.plan.statements.find(
362
+ (statement) =>
363
+ (statement.activationMask === undefined ||
364
+ statement.activationMask === 0) &&
365
+ (sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile),
366
+ );
367
+ lines.push(
368
+ `/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
369
+ `export const ${query.name}Query: NamedQuery<${Row}, ${paramsType}> = {`,
370
+ ` id: ${quote(`${hash}/${query.name}`)},`,
371
+ ` hasParams: ${hasParams},`,
372
+ ` sql: ${quote(defaultStatement?.positionalSql ?? query.positionalSql)},`,
373
+ );
374
+ if (hasParams) {
375
+ lines.push(
376
+ ` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`,
377
+ );
378
+ }
379
+ lines.push(` tables: ${query.name}Tables,`);
380
+ const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
381
+ dependency.scopes.some((scope) => scope.params.length > 0),
382
+ );
383
+ lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
384
+ for (const dependency of query.reactive.dependencies) {
385
+ const keys = dependency.scopes.flatMap((scope) =>
386
+ scope.params.map((param) =>
387
+ scopeKeyExpression(query, scope.pattern, scope.variable, param),
388
+ ),
389
+ );
390
+ lines.push(
391
+ ` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`,
392
+ );
393
+ }
394
+ lines.push(' ],');
395
+ const coverageUsesParams = query.reactive.coverage.some(
396
+ (coverage) =>
397
+ coverage.units.length > 0 ||
398
+ coverage.fixedScopes.some((scope) => scope.params.length > 0),
399
+ );
400
+ lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
401
+ for (const coverage of query.reactive.coverage) {
402
+ const fixed = coverage.fixedScopes
403
+ .map(
404
+ (scope) =>
405
+ `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`,
406
+ )
407
+ .join(', ');
408
+ lines.push(
409
+ ` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : ''} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`,
410
+ );
411
+ }
412
+ lines.push(
413
+ ' ],',
414
+ hasParams
415
+ ? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
416
+ : ` bind: () => ${query.name}Select().bind,`,
417
+ );
418
+ if (query.reactive.rowKey !== undefined) {
419
+ lines.push(
420
+ ` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`,
421
+ );
422
+ }
423
+ lines.push('};');
424
+ return lines.join('\n');
78
425
  }
79
426
 
80
427
  function scopeKeyExpression(
@@ -92,12 +439,11 @@ function scopeKeyExpression(
92
439
  }
93
440
 
94
441
  function emitQuery(query: AnalyzedQuery, hash: string): string {
442
+ if (query.syql !== undefined) return emitSyqlQuery(query, hash);
95
443
  const Row = `${pascalCase(query.name)}Row`;
96
444
  const Params = `${pascalCase(query.name)}Params`;
97
445
  const lines: string[] = [];
98
446
 
99
- // Row interface — keyed by the language-facing names, which ARE the
100
- // runtime result keys (§5 projection lowering aliases them in SQL).
101
447
  lines.push(
102
448
  `/** One row of the ${quote(query.name)} query (its projection). */`,
103
449
  );
@@ -110,45 +456,19 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
110
456
  lines.push('}');
111
457
  lines.push('');
112
458
 
113
- const hasParams = query.params.length > 0 || query.orderBy !== undefined;
114
- const requiresParams = query.params.some(
115
- (p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'),
116
- );
117
-
118
- // Params interface (params and/or knob keys).
459
+ const hasParams = query.params.length > 0;
119
460
  if (hasParams) {
120
461
  lines.push(`/** Named parameters for ${quote(query.name)}. */`);
121
462
  lines.push(`export interface ${Params} {`);
122
463
  for (const param of query.params) {
123
- if (query.limit !== undefined && param.name === 'limit') continue;
124
- const opt = isOptional(param);
125
- lines.push(
126
- ` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`,
127
- );
128
- }
129
- if (query.orderBy !== undefined) {
130
- const keys = query.orderBy.allowed
131
- .map((c) => quote(c.langName))
132
- .join(' | ');
133
- lines.push(
134
- ` /** §6 orderBy knob — a generate-time-checked allowlist. */`,
135
- );
136
- lines.push(` orderBy?: ${keys};`);
137
- lines.push(` dir?: 'asc' | 'desc';`);
138
- }
139
- if (query.limit !== undefined) {
140
- const clamp =
141
- query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
142
464
  lines.push(
143
- ` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`,
465
+ ` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`,
144
466
  );
145
- lines.push(' limit?: number;');
146
467
  }
147
468
  lines.push('}');
148
469
  lines.push('');
149
470
  }
150
471
 
151
- // Tables dependency set (for useRawSql {tables} / useQuery).
152
472
  lines.push(
153
473
  `/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
154
474
  );
@@ -157,121 +477,28 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
157
477
  );
158
478
  lines.push('');
159
479
 
160
- // SQL constants: the static default statement, plus (orderBy knob) the
161
- // baked base + column map + compose function.
162
480
  const sqlConst = `${query.name}Sql`;
163
481
  lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
164
- const composeFn = `${query.name}ComposeSql`;
165
- if (query.orderBy !== undefined) {
166
- const base = `${query.name}SqlBase`;
167
- const colsConst = `${query.name}OrderColumns`;
168
- const defaultLang =
169
- query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
170
- ?.langName ?? query.orderBy.defaultColumn;
171
- lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
172
- lines.push(
173
- `const ${colsConst} = { ${query.orderBy.allowed
174
- .map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
175
- .join(', ')} } as const;`,
176
- );
177
- lines.push(`function ${composeFn}(params?: ${Params}): string {`);
178
- lines.push(
179
- ` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`,
180
- );
181
- lines.push(
182
- ` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`,
183
- );
184
- lines.push(
185
- ` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`,
186
- );
187
- lines.push('}');
188
- }
189
-
190
- // Positional bind list.
191
- const access = requiresParams ? 'params' : 'params?';
192
482
  const positional = hasParams
193
- ? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
483
+ ? `[${query.params.map(bindExpr).join(', ')}]`
194
484
  : '[]';
195
-
196
- // §7 variant backend: one statement per provided-combination, selected by
197
- // a bitmask over the optional groups (bit i = group i provided/true). The
198
- // neutralization statement above stays the canonical/default form.
199
- const selectFn = `${query.name}SelectVariant`;
200
- const paramArgDecl = requiresParams
201
- ? `params: ${Params}`
202
- : `params?: ${Params}`;
203
- if (query.variants !== undefined && query.variantGroups !== undefined) {
204
- const variantsConst = `${query.name}Variants`;
205
- lines.push(
206
- `const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`,
207
- );
208
- for (const variant of query.variants) {
209
- const binds = variant.params
210
- .map((name) => {
211
- const param = query.params.find((p) => p.name === name);
212
- if (param === undefined) throw new Error(`unknown param ${name}`);
213
- return bindExpr(query, param, access);
214
- })
215
- .join(', ');
216
- lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
217
- lines.push(
218
- ` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`,
219
- );
220
- }
221
- lines.push('];');
222
- lines.push(
223
- `function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`,
224
- );
225
- lines.push(' let mask = 0;');
226
- query.variantGroups.forEach((group, index) => {
227
- const langOf = (name: string): string =>
228
- query.params.find((p) => p.name === name)?.langName ?? name;
229
- const condition = group.flag
230
- ? `params?.${propertyKey(langOf(group.params[0] as string))} === true`
231
- : group.params
232
- .map(
233
- (name) =>
234
- `(params?.${propertyKey(langOf(name))} ?? null) !== null`,
235
- )
236
- .join(' && ');
237
- lines.push(` if (${condition}) mask |= ${1 << index};`);
238
- });
239
- lines.push(
240
- ` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`,
241
- );
242
- lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
243
- lines.push('}');
244
- }
245
485
  lines.push('');
246
486
 
247
- // The runner.
248
- const paramArg = !hasParams ? '' : paramArgDecl;
249
487
  lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
250
488
  lines.push(
251
- `export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`,
489
+ `export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`,
252
490
  );
253
- if (query.variants !== undefined) {
254
- lines.push(` const variant = ${selectFn}(params);`);
255
- lines.push(' const rows = await client.query(variant.sql, variant.bind);');
491
+ if (hasParams) {
492
+ lines.push(
493
+ ` const rows = await client.query(${sqlConst}, ${positional});`,
494
+ );
256
495
  } else {
257
- const sqlExpr =
258
- query.orderBy !== undefined ? `${composeFn}(params)` : sqlConst;
259
- if (hasParams) {
260
- lines.push(
261
- ` const rows = await client.query(${sqlExpr}, ${positional});`,
262
- );
263
- } else {
264
- lines.push(` const rows = await client.query(${sqlExpr});`);
265
- }
496
+ lines.push(` const rows = await client.query(${sqlConst});`);
266
497
  }
267
498
  lines.push(` return rows as unknown as ${Row}[];`);
268
499
  lines.push('}');
269
500
  lines.push('');
270
501
 
271
- // A descriptor for react's `useQuery` — the SQL (static, composed from
272
- // the baked allowlist, or variant-selected), the exact table dependency
273
- // set, and a `bind(params)` → positional array (always paired with the
274
- // sqlFor selection). Typed by the query's own Row/Params.
275
502
  lines.push(
276
503
  `/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
277
504
  );
@@ -282,11 +509,6 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
282
509
  lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
283
510
  lines.push(` hasParams: ${hasParams},`);
284
511
  lines.push(` sql: ${sqlConst},`);
285
- if (query.variants !== undefined) {
286
- lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
287
- } else if (query.orderBy !== undefined) {
288
- lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
289
- }
290
512
  lines.push(` tables: ${query.name}Tables,`);
291
513
  const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
292
514
  dependency.scopes.some((scope) => scope.params.length > 0),
@@ -322,9 +544,7 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
322
544
  );
323
545
  }
324
546
  lines.push(' ],');
325
- if (query.variants !== undefined) {
326
- lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
327
- } else if (hasParams) {
547
+ if (hasParams) {
328
548
  lines.push(` bind: (params: ${Params}) => ${positional},`);
329
549
  } else {
330
550
  lines.push(' bind: () => [],');
@@ -351,6 +571,35 @@ export function emitQueriesModule(
351
571
  `// irHash: ${hash}`,
352
572
  ].join('\n'),
353
573
  );
574
+ if (queries.some((query) => query.syql !== undefined)) {
575
+ parts.push(
576
+ [
577
+ 'export type SyqlRuntimeErrorCode =',
578
+ " | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
579
+ " | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
580
+ " | 'SYQL_RUNTIME_INVALID_INPUT'",
581
+ " | 'SYQL_RUNTIME_INVALID_GROUP'",
582
+ " | 'SYQL_RUNTIME_INVALID_SORT'",
583
+ " | 'SYQL_RUNTIME_INVALID_PAGE';",
584
+ '',
585
+ 'export class SyqlInputError extends Error {',
586
+ " readonly name = 'SyqlInputError';",
587
+ ' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
588
+ ' super(message);',
589
+ ' }',
590
+ '}',
591
+ '',
592
+ 'export interface SyqlPresent<T> {',
593
+ ' readonly present: true;',
594
+ ' readonly value: T;',
595
+ '}',
596
+ '',
597
+ 'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
598
+ ' return { present: true, value };',
599
+ '}',
600
+ ].join('\n'),
601
+ );
602
+ }
354
603
  parts.push(
355
604
  [
356
605
  "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
@@ -376,8 +625,7 @@ export function emitQueriesModule(
376
625
  ' * `bind(params)` → positional args. Consumed by',
377
626
  " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
378
627
  ' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
379
- ' * (present only with an orderBy knob) composes the statement from a',
380
- ' * generate-time-checked column allowlist. */',
628
+ ' * selects a checked revision-1 SYQL physical statement when needed. */',
381
629
  'export interface NamedQuery<Row, Params = undefined> {',
382
630
  ' readonly id: string;',
383
631
  ' readonly hasParams: boolean;',