@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
@@ -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,374 @@ 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
+ const access = `params.${propertyKey(input.langName)}`;
71
+ return `String(${input.default === false ? `${access} ?? false` : access})`;
72
+ }
73
+ const param = query.params.find((candidate) => candidate.name === name);
74
+ if (param === undefined) throw new Error(`unknown reactive param ${name}`);
75
+ return `String(params.${propertyKey(param.langName)})`;
76
+ }
77
+
78
+ function syqlInput(query: AnalyzedQuery, name: string): QuerySyqlPublicInput {
79
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
80
+ if (input === undefined) throw new Error(`unknown SYQL input ${name}`);
81
+ return input;
82
+ }
83
+
84
+ function syqlTsValueType(type: IrColumnType, nullable: boolean): string {
85
+ return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
86
+ }
54
87
 
55
- function isOptional(param: QueryParam): boolean {
56
- return param.optional === true || param.flag === true;
88
+ function syqlTsTypeCheck(
89
+ expression: string,
90
+ type: IrColumnType,
91
+ nullable: boolean,
92
+ ): string {
93
+ let check: string;
94
+ switch (type) {
95
+ case 'integer':
96
+ check = `typeof ${expression} === 'bigint'`;
97
+ break;
98
+ case 'float':
99
+ check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
100
+ break;
101
+ case 'boolean':
102
+ check = `typeof ${expression} === 'boolean'`;
103
+ break;
104
+ case 'bytes':
105
+ case 'crdt':
106
+ check = `${expression} instanceof Uint8Array`;
107
+ break;
108
+ default:
109
+ check = `typeof ${expression} === 'string'`;
110
+ }
111
+ return nullable ? `${expression} === null || (${check})` : check;
57
112
  }
58
113
 
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(
114
+ function syqlControlActive(
62
115
  query: AnalyzedQuery,
63
- param: QueryParam,
64
- access: string,
116
+ control: string,
117
+ params: string,
65
118
  ): 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`;
119
+ const input = syqlInput(query, control);
120
+ const access = `${params}.${propertyKey(input.langName)}`;
121
+ if (input.kind === 'value' && input.default === false)
122
+ return `${access} === true`;
123
+ if (input.kind === 'value' || input.kind === 'group') {
124
+ return `${access} !== undefined`;
69
125
  }
70
- const key = propertyKey(param.langName);
71
- return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
126
+ throw new Error(`${control} is not an activation control`);
72
127
  }
73
128
 
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)})`;
129
+ function syqlBindExpr(
130
+ query: AnalyzedQuery,
131
+ bind: QuerySyqlPlanBind,
132
+ params: string,
133
+ ): string {
134
+ if (bind.kind === 'condition-active') {
135
+ return bind.controls
136
+ .map((control) => syqlControlActive(query, control, params))
137
+ .join(' && ');
138
+ }
139
+ const input = syqlInput(query, bind.input);
140
+ const access = `${params}.${propertyKey(input.langName)}`;
141
+ if (bind.kind === 'limit') {
142
+ if (input.kind !== 'limit') throw new Error('limit bind/input mismatch');
143
+ return `${access} ?? ${input.defaultSize}`;
144
+ }
145
+ if (bind.kind === 'group-member') {
146
+ if (input.kind !== 'group') throw new Error('group bind/input mismatch');
147
+ const member = input.members.find(
148
+ (candidate) => candidate.name === bind.member,
149
+ );
150
+ if (member === undefined)
151
+ throw new Error(`unknown group member ${bind.member}`);
152
+ return `${access}?.${propertyKey(member.langName)} ?? null`;
153
+ }
154
+ if (input.kind !== 'value') throw new Error('value bind/input mismatch');
155
+ if (input.default === false) return `${access} ?? false`;
156
+ if (input.required) return access;
157
+ return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
158
+ }
159
+
160
+ function emitSyqlValidation(query: AnalyzedQuery, Params: string): string[] {
161
+ const inputs = query.syql?.inputs ?? [];
162
+ const lines: string[] = [];
163
+ lines.push(
164
+ `function ${query.name}Validate(raw?: ${Params}): ${Params} {`,
165
+ ` const params = raw ?? ({} as ${Params});`,
166
+ ` const allowed = new Set([${inputs.map((input) => quote(input.langName)).join(', ')}]);`,
167
+ ' for (const key of Object.keys(params)) {',
168
+ ` if (!allowed.has(key)) throw new SyqlInputError('SYQL_RUNTIME_UNKNOWN_INPUT', ${quote(query.name)} + ': unknown input ' + key);`,
169
+ ' }',
170
+ );
171
+ for (const input of inputs) {
172
+ const key = propertyKey(input.langName);
173
+ const access = `params.${key}`;
174
+ if (input.kind === 'value') {
175
+ if (input.required) {
176
+ lines.push(
177
+ ` 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}`)});`,
178
+ ` if (!(${syqlTsTypeCheck(access, input.type, input.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid input ${input.name}`)});`,
179
+ );
180
+ } else if (input.nullable) {
181
+ lines.push(
182
+ ` 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}`)});`,
183
+ );
184
+ } else {
185
+ lines.push(
186
+ ` if (${access} !== undefined && !(${syqlTsTypeCheck(access, input.type, false)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`,
187
+ );
188
+ }
189
+ } else if (input.kind === 'group') {
190
+ lines.push(
191
+ ` if (${access} !== undefined) {`,
192
+ ` if (typeof ${access} !== 'object' || ${access} === null) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid group ${input.name}`)});`,
193
+ ` const allowedMembers = new Set([${input.members.map((member) => quote(member.langName)).join(', ')}]);`,
194
+ ` 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}`)});`,
195
+ );
196
+ for (const member of input.members) {
197
+ const memberAccess = `${access}.${propertyKey(member.langName)}`;
198
+ lines.push(
199
+ ` 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}`)});`,
200
+ );
201
+ }
202
+ lines.push(' }');
203
+ } else if (input.kind === 'sort') {
204
+ lines.push(
205
+ ` 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`)});`,
206
+ );
207
+ } else {
208
+ lines.push(
209
+ ` if (${access} !== undefined && (!Number.isSafeInteger(${access}) || ${access} < 1 || ${access} > ${input.maxSize})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_LIMIT', ${quote(`${query.name}: limit must be an integer from 1 through ${input.maxSize}`)});`,
210
+ );
211
+ }
212
+ }
213
+ lines.push(' return params;', '}');
214
+ return lines;
215
+ }
216
+
217
+ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
218
+ const metadata = query.syql;
219
+ if (metadata === undefined) throw new Error('missing SYQL metadata');
220
+ const Row = `${pascalCase(query.name)}Row`;
221
+ const Params = `${pascalCase(query.name)}Params`;
222
+ const inputs = metadata.inputs;
223
+ const hasParams = inputs.length > 0;
224
+ const requiresParams = inputs.some(
225
+ (input) => input.kind === 'value' && input.required,
226
+ );
227
+ const lines: string[] = [];
228
+ lines.push(
229
+ `/** One row of the ${quote(query.name)} query (its projection). */`,
230
+ );
231
+ lines.push(`export interface ${Row} {`);
232
+ for (const column of query.columns) {
233
+ lines.push(
234
+ ` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
235
+ );
236
+ }
237
+ lines.push('}', '');
238
+
239
+ for (const input of inputs) {
240
+ if (input.kind === 'group') {
241
+ lines.push(
242
+ `export interface ${pascalCase(query.name)}${pascalCase(input.langName)} {`,
243
+ );
244
+ for (const member of input.members) {
245
+ lines.push(
246
+ ` ${propertyKey(member.langName)}: ${syqlTsValueType(member.type, member.nullable)};`,
247
+ );
248
+ }
249
+ lines.push('}', '');
250
+ }
251
+ }
252
+ if (hasParams) {
253
+ lines.push(
254
+ `/** Public revision-1 SYQL inputs for ${quote(query.name)}. */`,
255
+ );
256
+ lines.push(`export interface ${Params} {`);
257
+ for (const input of inputs) {
258
+ const key = propertyKey(input.langName);
259
+ if (input.kind === 'value') {
260
+ const optional = !input.required;
261
+ const type = syqlTsValueType(input.type, input.nullable);
262
+ lines.push(
263
+ ` ${key}${optional ? '?' : ''}: ${optional && input.nullable ? `SyqlPresent<${type}>` : type};`,
264
+ );
265
+ } else if (input.kind === 'group') {
266
+ lines.push(
267
+ ` ${key}?: ${pascalCase(query.name)}${pascalCase(input.langName)};`,
268
+ );
269
+ } else if (input.kind === 'sort') {
270
+ lines.push(
271
+ ` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`,
272
+ );
273
+ } else {
274
+ lines.push(` ${key}?: number;`);
275
+ }
276
+ }
277
+ lines.push('}', '');
278
+ lines.push(...emitSyqlValidation(query, Params), '');
279
+ }
280
+
281
+ lines.push(
282
+ `/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
283
+ `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
284
+ '',
285
+ );
286
+ const paramDecl = !hasParams
287
+ ? ''
288
+ : requiresParams
289
+ ? `raw: ${Params}`
290
+ : `raw?: ${Params}`;
291
+ const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
292
+ const statementType = hasParams
293
+ ? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
294
+ : '{ sql: string; bind: () => QueryValue[] }';
295
+ lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
296
+ for (const statement of metadata.plan.statements) {
297
+ const binds = statement.binds
298
+ .map((bind) => syqlBindExpr(query, bind, 'params'))
299
+ .join(', ');
300
+ lines.push(
301
+ ` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`,
302
+ );
303
+ }
304
+ lines.push('];');
305
+ const sort = inputs.find((input) => input.kind === 'sort');
306
+ if (sort?.kind === 'sort') {
307
+ lines.push(
308
+ `const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`,
309
+ );
310
+ }
311
+ lines.push(
312
+ `function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`,
313
+ );
314
+ if (validated !== undefined) lines.push(` const params = ${validated};`);
315
+ let maskExpression = '0';
316
+ if (metadata.plan.backend === 'variants') {
317
+ lines.push(' let mask = 0;');
318
+ metadata.plan.activationControls.forEach((control, index) => {
319
+ lines.push(
320
+ ` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`,
321
+ );
322
+ });
323
+ maskExpression = 'mask';
324
+ }
325
+ let sortExpression = '0';
326
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
327
+ if (sort?.kind === 'sort') {
328
+ sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
329
+ }
330
+ const indexExpression =
331
+ metadata.plan.backend === 'variants'
332
+ ? `${maskExpression} * ${profileCount} + ${sortExpression}`
333
+ : sortExpression;
334
+ lines.push(
335
+ ` const statement = ${query.name}Statements[${indexExpression}];`,
336
+ ` if (statement === undefined) throw new Error('invalid generated SYQL statement index');`,
337
+ ` return { sql: statement.sql, bind: statement.bind(${hasParams ? 'params' : ''}) };`,
338
+ '}',
339
+ '',
340
+ );
341
+
342
+ const runnerParam = !hasParams
343
+ ? ''
344
+ : requiresParams
345
+ ? `, params: ${Params}`
346
+ : `, params?: ${Params}`;
347
+ lines.push(
348
+ `/** Run the ${quote(query.name)} named query (SELECT-only). */`,
349
+ `export async function ${query.name}(client: QueryClient${runnerParam}): Promise<${Row}[]> {`,
350
+ ` const selected = ${query.name}Select(${hasParams ? 'params' : ''});`,
351
+ ' const rows = await client.query(selected.sql, selected.bind);',
352
+ ` return rows as unknown as ${Row}[];`,
353
+ '}',
354
+ '',
355
+ );
356
+
357
+ const paramsType = hasParams ? Params : 'undefined';
358
+ const defaultStatement = metadata.plan.statements.find(
359
+ (statement) =>
360
+ (statement.activationMask === undefined ||
361
+ statement.activationMask === 0) &&
362
+ (sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile),
363
+ );
364
+ lines.push(
365
+ `/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
366
+ `export const ${query.name}Query: NamedQuery<${Row}, ${paramsType}> = {`,
367
+ ` id: ${quote(`${hash}/${query.name}`)},`,
368
+ ` hasParams: ${hasParams},`,
369
+ ` sql: ${quote(defaultStatement?.positionalSql ?? query.positionalSql)},`,
370
+ );
371
+ if (hasParams) {
372
+ lines.push(
373
+ ` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`,
374
+ );
375
+ }
376
+ lines.push(` tables: ${query.name}Tables,`);
377
+ const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
378
+ dependency.scopes.some((scope) => scope.params.length > 0),
379
+ );
380
+ lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
381
+ for (const dependency of query.reactive.dependencies) {
382
+ const keys = dependency.scopes.flatMap((scope) =>
383
+ scope.params.map((param) =>
384
+ scopeKeyExpression(query, scope.pattern, scope.variable, param),
385
+ ),
386
+ );
387
+ lines.push(
388
+ ` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`,
389
+ );
390
+ }
391
+ lines.push(' ],');
392
+ const coverageUsesParams = query.reactive.coverage.some(
393
+ (coverage) =>
394
+ coverage.units.length > 0 ||
395
+ coverage.fixedScopes.some((scope) => scope.params.length > 0),
396
+ );
397
+ lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
398
+ for (const coverage of query.reactive.coverage) {
399
+ const fixed = coverage.fixedScopes
400
+ .map(
401
+ (scope) =>
402
+ `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`,
403
+ )
404
+ .join(', ');
405
+ lines.push(
406
+ ` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : ''} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`,
407
+ );
408
+ }
409
+ lines.push(
410
+ ' ],',
411
+ hasParams
412
+ ? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
413
+ : ` bind: () => ${query.name}Select().bind,`,
414
+ );
415
+ if (query.reactive.rowKey !== undefined) {
416
+ lines.push(
417
+ ` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`,
418
+ );
419
+ }
420
+ lines.push('};');
421
+ return lines.join('\n');
78
422
  }
79
423
 
80
424
  function scopeKeyExpression(
@@ -92,12 +436,11 @@ function scopeKeyExpression(
92
436
  }
93
437
 
94
438
  function emitQuery(query: AnalyzedQuery, hash: string): string {
439
+ if (query.syql !== undefined) return emitSyqlQuery(query, hash);
95
440
  const Row = `${pascalCase(query.name)}Row`;
96
441
  const Params = `${pascalCase(query.name)}Params`;
97
442
  const lines: string[] = [];
98
443
 
99
- // Row interface — keyed by the language-facing names, which ARE the
100
- // runtime result keys (§5 projection lowering aliases them in SQL).
101
444
  lines.push(
102
445
  `/** One row of the ${quote(query.name)} query (its projection). */`,
103
446
  );
@@ -110,45 +453,19 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
110
453
  lines.push('}');
111
454
  lines.push('');
112
455
 
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).
456
+ const hasParams = query.params.length > 0;
119
457
  if (hasParams) {
120
458
  lines.push(`/** Named parameters for ${quote(query.name)}. */`);
121
459
  lines.push(`export interface ${Params} {`);
122
460
  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
461
  lines.push(
143
- ` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`,
462
+ ` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`,
144
463
  );
145
- lines.push(' limit?: number;');
146
464
  }
147
465
  lines.push('}');
148
466
  lines.push('');
149
467
  }
150
468
 
151
- // Tables dependency set (for useRawSql {tables} / useQuery).
152
469
  lines.push(
153
470
  `/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
154
471
  );
@@ -157,121 +474,28 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
157
474
  );
158
475
  lines.push('');
159
476
 
160
- // SQL constants: the static default statement, plus (orderBy knob) the
161
- // baked base + column map + compose function.
162
477
  const sqlConst = `${query.name}Sql`;
163
478
  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
479
  const positional = hasParams
193
- ? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
480
+ ? `[${query.params.map(bindExpr).join(', ')}]`
194
481
  : '[]';
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
482
  lines.push('');
246
483
 
247
- // The runner.
248
- const paramArg = !hasParams ? '' : paramArgDecl;
249
484
  lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
250
485
  lines.push(
251
- `export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`,
486
+ `export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`,
252
487
  );
253
- if (query.variants !== undefined) {
254
- lines.push(` const variant = ${selectFn}(params);`);
255
- lines.push(' const rows = await client.query(variant.sql, variant.bind);');
488
+ if (hasParams) {
489
+ lines.push(
490
+ ` const rows = await client.query(${sqlConst}, ${positional});`,
491
+ );
256
492
  } 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
- }
493
+ lines.push(` const rows = await client.query(${sqlConst});`);
266
494
  }
267
495
  lines.push(` return rows as unknown as ${Row}[];`);
268
496
  lines.push('}');
269
497
  lines.push('');
270
498
 
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
499
  lines.push(
276
500
  `/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
277
501
  );
@@ -282,11 +506,6 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
282
506
  lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
283
507
  lines.push(` hasParams: ${hasParams},`);
284
508
  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
509
  lines.push(` tables: ${query.name}Tables,`);
291
510
  const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
292
511
  dependency.scopes.some((scope) => scope.params.length > 0),
@@ -322,9 +541,7 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
322
541
  );
323
542
  }
324
543
  lines.push(' ],');
325
- if (query.variants !== undefined) {
326
- lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
327
- } else if (hasParams) {
544
+ if (hasParams) {
328
545
  lines.push(` bind: (params: ${Params}) => ${positional},`);
329
546
  } else {
330
547
  lines.push(' bind: () => [],');
@@ -351,6 +568,35 @@ export function emitQueriesModule(
351
568
  `// irHash: ${hash}`,
352
569
  ].join('\n'),
353
570
  );
571
+ if (queries.some((query) => query.syql !== undefined)) {
572
+ parts.push(
573
+ [
574
+ 'export type SyqlRuntimeErrorCode =',
575
+ " | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
576
+ " | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
577
+ " | 'SYQL_RUNTIME_INVALID_INPUT'",
578
+ " | 'SYQL_RUNTIME_INVALID_GROUP'",
579
+ " | 'SYQL_RUNTIME_INVALID_SORT'",
580
+ " | 'SYQL_RUNTIME_INVALID_LIMIT';",
581
+ '',
582
+ 'export class SyqlInputError extends Error {',
583
+ " readonly name = 'SyqlInputError';",
584
+ ' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
585
+ ' super(message);',
586
+ ' }',
587
+ '}',
588
+ '',
589
+ 'export interface SyqlPresent<T> {',
590
+ ' readonly present: true;',
591
+ ' readonly value: T;',
592
+ '}',
593
+ '',
594
+ 'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
595
+ ' return { present: true, value };',
596
+ '}',
597
+ ].join('\n'),
598
+ );
599
+ }
354
600
  parts.push(
355
601
  [
356
602
  "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
@@ -376,8 +622,7 @@ export function emitQueriesModule(
376
622
  ' * `bind(params)` → positional args. Consumed by',
377
623
  " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
378
624
  ' * 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. */',
625
+ ' * selects a checked revision-1 SYQL physical statement when needed. */',
381
626
  'export interface NamedQuery<Row, Params = undefined> {',
382
627
  ' readonly id: string;',
383
628
  ' readonly hasParams: boolean;',