@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
@@ -20,25 +20,261 @@ function propertyKey(name) {
20
20
  }
21
21
  /** A named-query param value is the SqlValue subset its type maps to. */
22
22
  const PARAM_TS_TYPE = TS_TYPE;
23
- function isOptional(param) {
24
- return param.optional === true || param.flag === true;
25
- }
26
- /** The positional bind expression for one param. `access` is `params` or
27
- * `params?` depending on whether the params object itself may be absent. */
28
- function bindExpr(query, param, access) {
29
- if (query.limit !== undefined && param.name === 'limit') {
30
- // The default + clamp live IN the SQL (`min(coalesce(?, d), m)`).
31
- return `${access}.limit ?? null`;
32
- }
33
- const key = propertyKey(param.langName);
34
- return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
23
+ const SYQL_PARAM_TS_TYPE = {
24
+ ...TS_TYPE,
25
+ integer: 'bigint',
26
+ };
27
+ function bindExpr(param) {
28
+ return `params.${propertyKey(param.langName)}`;
35
29
  }
36
30
  function reactiveParam(query, name) {
31
+ if (query.syql !== undefined) {
32
+ const input = query.syql.inputs.find((candidate) => candidate.kind === 'value' && candidate.name === name);
33
+ if (input?.kind !== 'value')
34
+ throw new Error(`unknown reactive input ${name}`);
35
+ const access = `params.${propertyKey(input.langName)}`;
36
+ return `String(${input.default === false ? `${access} ?? false` : access})`;
37
+ }
37
38
  const param = query.params.find((candidate) => candidate.name === name);
38
39
  if (param === undefined)
39
40
  throw new Error(`unknown reactive param ${name}`);
40
41
  return `String(params.${propertyKey(param.langName)})`;
41
42
  }
43
+ function syqlInput(query, name) {
44
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
45
+ if (input === undefined)
46
+ throw new Error(`unknown SYQL input ${name}`);
47
+ return input;
48
+ }
49
+ function syqlTsValueType(type, nullable) {
50
+ return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
51
+ }
52
+ function syqlTsTypeCheck(expression, type, nullable) {
53
+ let check;
54
+ switch (type) {
55
+ case 'integer':
56
+ check = `typeof ${expression} === 'bigint'`;
57
+ break;
58
+ case 'float':
59
+ check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
60
+ break;
61
+ case 'boolean':
62
+ check = `typeof ${expression} === 'boolean'`;
63
+ break;
64
+ case 'bytes':
65
+ case 'crdt':
66
+ check = `${expression} instanceof Uint8Array`;
67
+ break;
68
+ default:
69
+ check = `typeof ${expression} === 'string'`;
70
+ }
71
+ return nullable ? `${expression} === null || (${check})` : check;
72
+ }
73
+ function syqlControlActive(query, control, params) {
74
+ const input = syqlInput(query, control);
75
+ const access = `${params}.${propertyKey(input.langName)}`;
76
+ if (input.kind === 'value' && input.default === false)
77
+ return `${access} === true`;
78
+ if (input.kind === 'value' || input.kind === 'group') {
79
+ return `${access} !== undefined`;
80
+ }
81
+ throw new Error(`${control} is not an activation control`);
82
+ }
83
+ function syqlBindExpr(query, bind, params) {
84
+ if (bind.kind === 'condition-active') {
85
+ return bind.controls
86
+ .map((control) => syqlControlActive(query, control, params))
87
+ .join(' && ');
88
+ }
89
+ const input = syqlInput(query, bind.input);
90
+ const access = `${params}.${propertyKey(input.langName)}`;
91
+ if (bind.kind === 'limit') {
92
+ if (input.kind !== 'limit')
93
+ throw new Error('limit bind/input mismatch');
94
+ return `${access} ?? ${input.defaultSize}`;
95
+ }
96
+ if (bind.kind === 'group-member') {
97
+ if (input.kind !== 'group')
98
+ throw new Error('group bind/input mismatch');
99
+ const member = input.members.find((candidate) => candidate.name === bind.member);
100
+ if (member === undefined)
101
+ throw new Error(`unknown group member ${bind.member}`);
102
+ return `${access}?.${propertyKey(member.langName)} ?? null`;
103
+ }
104
+ if (input.kind !== 'value')
105
+ throw new Error('value bind/input mismatch');
106
+ if (input.default === false)
107
+ return `${access} ?? false`;
108
+ if (input.required)
109
+ return access;
110
+ return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
111
+ }
112
+ function emitSyqlValidation(query, Params) {
113
+ const inputs = query.syql?.inputs ?? [];
114
+ const lines = [];
115
+ lines.push(`function ${query.name}Validate(raw?: ${Params}): ${Params} {`, ` const params = raw ?? ({} as ${Params});`, ` const allowed = new Set([${inputs.map((input) => quote(input.langName)).join(', ')}]);`, ' for (const key of Object.keys(params)) {', ` if (!allowed.has(key)) throw new SyqlInputError('SYQL_RUNTIME_UNKNOWN_INPUT', ${quote(query.name)} + ': unknown input ' + key);`, ' }');
116
+ for (const input of inputs) {
117
+ const key = propertyKey(input.langName);
118
+ const access = `params.${key}`;
119
+ if (input.kind === 'value') {
120
+ if (input.required) {
121
+ lines.push(` 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}`)});`, ` if (!(${syqlTsTypeCheck(access, input.type, input.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid input ${input.name}`)});`);
122
+ }
123
+ else if (input.nullable) {
124
+ lines.push(` 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}`)});`);
125
+ }
126
+ else {
127
+ lines.push(` if (${access} !== undefined && !(${syqlTsTypeCheck(access, input.type, false)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`);
128
+ }
129
+ }
130
+ else if (input.kind === 'group') {
131
+ lines.push(` if (${access} !== undefined) {`, ` if (typeof ${access} !== 'object' || ${access} === null) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid group ${input.name}`)});`, ` const allowedMembers = new Set([${input.members.map((member) => quote(member.langName)).join(', ')}]);`, ` 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}`)});`);
132
+ for (const member of input.members) {
133
+ const memberAccess = `${access}.${propertyKey(member.langName)}`;
134
+ lines.push(` 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}`)});`);
135
+ }
136
+ lines.push(' }');
137
+ }
138
+ else if (input.kind === 'sort') {
139
+ lines.push(` 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`)});`);
140
+ }
141
+ else {
142
+ lines.push(` 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}`)});`);
143
+ }
144
+ }
145
+ lines.push(' return params;', '}');
146
+ return lines;
147
+ }
148
+ function emitSyqlQuery(query, hash) {
149
+ const metadata = query.syql;
150
+ if (metadata === undefined)
151
+ throw new Error('missing SYQL metadata');
152
+ const Row = `${pascalCase(query.name)}Row`;
153
+ const Params = `${pascalCase(query.name)}Params`;
154
+ const inputs = metadata.inputs;
155
+ const hasParams = inputs.length > 0;
156
+ const requiresParams = inputs.some((input) => input.kind === 'value' && input.required);
157
+ const lines = [];
158
+ lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
159
+ lines.push(`export interface ${Row} {`);
160
+ for (const column of query.columns) {
161
+ lines.push(` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`);
162
+ }
163
+ lines.push('}', '');
164
+ for (const input of inputs) {
165
+ if (input.kind === 'group') {
166
+ lines.push(`export interface ${pascalCase(query.name)}${pascalCase(input.langName)} {`);
167
+ for (const member of input.members) {
168
+ lines.push(` ${propertyKey(member.langName)}: ${syqlTsValueType(member.type, member.nullable)};`);
169
+ }
170
+ lines.push('}', '');
171
+ }
172
+ }
173
+ if (hasParams) {
174
+ lines.push(`/** Public revision-1 SYQL inputs for ${quote(query.name)}. */`);
175
+ lines.push(`export interface ${Params} {`);
176
+ for (const input of inputs) {
177
+ const key = propertyKey(input.langName);
178
+ if (input.kind === 'value') {
179
+ const optional = !input.required;
180
+ const type = syqlTsValueType(input.type, input.nullable);
181
+ lines.push(` ${key}${optional ? '?' : ''}: ${optional && input.nullable ? `SyqlPresent<${type}>` : type};`);
182
+ }
183
+ else if (input.kind === 'group') {
184
+ lines.push(` ${key}?: ${pascalCase(query.name)}${pascalCase(input.langName)};`);
185
+ }
186
+ else if (input.kind === 'sort') {
187
+ lines.push(` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`);
188
+ }
189
+ else {
190
+ lines.push(` ${key}?: number;`);
191
+ }
192
+ }
193
+ lines.push('}', '');
194
+ lines.push(...emitSyqlValidation(query, Params), '');
195
+ }
196
+ lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`, `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`, '');
197
+ const paramDecl = !hasParams
198
+ ? ''
199
+ : requiresParams
200
+ ? `raw: ${Params}`
201
+ : `raw?: ${Params}`;
202
+ const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
203
+ const statementType = hasParams
204
+ ? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
205
+ : '{ sql: string; bind: () => QueryValue[] }';
206
+ lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
207
+ for (const statement of metadata.plan.statements) {
208
+ const binds = statement.binds
209
+ .map((bind) => syqlBindExpr(query, bind, 'params'))
210
+ .join(', ');
211
+ lines.push(` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`);
212
+ }
213
+ lines.push('];');
214
+ const sort = inputs.find((input) => input.kind === 'sort');
215
+ if (sort?.kind === 'sort') {
216
+ lines.push(`const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`);
217
+ }
218
+ lines.push(`function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`);
219
+ if (validated !== undefined)
220
+ lines.push(` const params = ${validated};`);
221
+ let maskExpression = '0';
222
+ if (metadata.plan.backend === 'variants') {
223
+ lines.push(' let mask = 0;');
224
+ metadata.plan.activationControls.forEach((control, index) => {
225
+ lines.push(` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`);
226
+ });
227
+ maskExpression = 'mask';
228
+ }
229
+ let sortExpression = '0';
230
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
231
+ if (sort?.kind === 'sort') {
232
+ sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
233
+ }
234
+ const indexExpression = metadata.plan.backend === 'variants'
235
+ ? `${maskExpression} * ${profileCount} + ${sortExpression}`
236
+ : sortExpression;
237
+ lines.push(` const statement = ${query.name}Statements[${indexExpression}];`, ` if (statement === undefined) throw new Error('invalid generated SYQL statement index');`, ` return { sql: statement.sql, bind: statement.bind(${hasParams ? 'params' : ''}) };`, '}', '');
238
+ const runnerParam = !hasParams
239
+ ? ''
240
+ : requiresParams
241
+ ? `, params: ${Params}`
242
+ : `, params?: ${Params}`;
243
+ lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`, `export async function ${query.name}(client: QueryClient${runnerParam}): Promise<${Row}[]> {`, ` const selected = ${query.name}Select(${hasParams ? 'params' : ''});`, ' const rows = await client.query(selected.sql, selected.bind);', ` return rows as unknown as ${Row}[];`, '}', '');
244
+ const paramsType = hasParams ? Params : 'undefined';
245
+ const defaultStatement = metadata.plan.statements.find((statement) => (statement.activationMask === undefined ||
246
+ statement.activationMask === 0) &&
247
+ (sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile));
248
+ lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`, `export const ${query.name}Query: NamedQuery<${Row}, ${paramsType}> = {`, ` id: ${quote(`${hash}/${query.name}`)},`, ` hasParams: ${hasParams},`, ` sql: ${quote(defaultStatement?.positionalSql ?? query.positionalSql)},`);
249
+ if (hasParams) {
250
+ lines.push(` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`);
251
+ }
252
+ lines.push(` tables: ${query.name}Tables,`);
253
+ const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
254
+ lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
255
+ for (const dependency of query.reactive.dependencies) {
256
+ const keys = dependency.scopes.flatMap((scope) => scope.params.map((param) => scopeKeyExpression(query, scope.pattern, scope.variable, param)));
257
+ lines.push(` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`);
258
+ }
259
+ lines.push(' ],');
260
+ const coverageUsesParams = query.reactive.coverage.some((coverage) => coverage.units.length > 0 ||
261
+ coverage.fixedScopes.some((scope) => scope.params.length > 0));
262
+ lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
263
+ for (const coverage of query.reactive.coverage) {
264
+ const fixed = coverage.fixedScopes
265
+ .map((scope) => `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`)
266
+ .join(', ');
267
+ lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : ''} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
268
+ }
269
+ lines.push(' ],', hasParams
270
+ ? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
271
+ : ` bind: () => ${query.name}Select().bind,`);
272
+ if (query.reactive.rowKey !== undefined) {
273
+ lines.push(` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`);
274
+ }
275
+ lines.push('};');
276
+ return lines.join('\n');
277
+ }
42
278
  function scopeKeyExpression(query, pattern, variable, param) {
43
279
  const marker = `{${variable}}`;
44
280
  const index = pattern.indexOf(marker);
@@ -49,11 +285,11 @@ function scopeKeyExpression(query, pattern, variable, param) {
49
285
  return `${quote(prefix)} + ${reactiveParam(query, param)} + ${quote(suffix)}`;
50
286
  }
51
287
  function emitQuery(query, hash) {
288
+ if (query.syql !== undefined)
289
+ return emitSyqlQuery(query, hash);
52
290
  const Row = `${pascalCase(query.name)}Row`;
53
291
  const Params = `${pascalCase(query.name)}Params`;
54
292
  const lines = [];
55
- // Row interface — keyed by the language-facing names, which ARE the
56
- // runtime result keys (§5 projection lowering aliases them in SQL).
57
293
  lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
58
294
  lines.push(`export interface ${Row} {`);
59
295
  for (const column of query.columns) {
@@ -61,138 +297,42 @@ function emitQuery(query, hash) {
61
297
  }
62
298
  lines.push('}');
63
299
  lines.push('');
64
- const hasParams = query.params.length > 0 || query.orderBy !== undefined;
65
- const requiresParams = query.params.some((p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'));
66
- // Params interface (params and/or knob keys).
300
+ const hasParams = query.params.length > 0;
67
301
  if (hasParams) {
68
302
  lines.push(`/** Named parameters for ${quote(query.name)}. */`);
69
303
  lines.push(`export interface ${Params} {`);
70
304
  for (const param of query.params) {
71
- if (query.limit !== undefined && param.name === 'limit')
72
- continue;
73
- const opt = isOptional(param);
74
- lines.push(` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`);
75
- }
76
- if (query.orderBy !== undefined) {
77
- const keys = query.orderBy.allowed
78
- .map((c) => quote(c.langName))
79
- .join(' | ');
80
- lines.push(` /** §6 orderBy knob — a generate-time-checked allowlist. */`);
81
- lines.push(` orderBy?: ${keys};`);
82
- lines.push(` dir?: 'asc' | 'desc';`);
83
- }
84
- if (query.limit !== undefined) {
85
- const clamp = query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
86
- lines.push(` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`);
87
- lines.push(' limit?: number;');
305
+ lines.push(` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`);
88
306
  }
89
307
  lines.push('}');
90
308
  lines.push('');
91
309
  }
92
- // Tables dependency set (for useRawSql {tables} / useQuery).
93
310
  lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`);
94
311
  lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
95
312
  lines.push('');
96
- // SQL constants: the static default statement, plus (orderBy knob) the
97
- // baked base + column map + compose function.
98
313
  const sqlConst = `${query.name}Sql`;
99
314
  lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
100
- const composeFn = `${query.name}ComposeSql`;
101
- if (query.orderBy !== undefined) {
102
- const base = `${query.name}SqlBase`;
103
- const colsConst = `${query.name}OrderColumns`;
104
- const defaultLang = query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
105
- ?.langName ?? query.orderBy.defaultColumn;
106
- lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
107
- lines.push(`const ${colsConst} = { ${query.orderBy.allowed
108
- .map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
109
- .join(', ')} } as const;`);
110
- lines.push(`function ${composeFn}(params?: ${Params}): string {`);
111
- lines.push(` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`);
112
- lines.push(` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`);
113
- lines.push(` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`);
114
- lines.push('}');
115
- }
116
- // Positional bind list.
117
- const access = requiresParams ? 'params' : 'params?';
118
315
  const positional = hasParams
119
- ? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
316
+ ? `[${query.params.map(bindExpr).join(', ')}]`
120
317
  : '[]';
121
- // §7 variant backend: one statement per provided-combination, selected by
122
- // a bitmask over the optional groups (bit i = group i provided/true). The
123
- // neutralization statement above stays the canonical/default form.
124
- const selectFn = `${query.name}SelectVariant`;
125
- const paramArgDecl = requiresParams
126
- ? `params: ${Params}`
127
- : `params?: ${Params}`;
128
- if (query.variants !== undefined && query.variantGroups !== undefined) {
129
- const variantsConst = `${query.name}Variants`;
130
- lines.push(`const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`);
131
- for (const variant of query.variants) {
132
- const binds = variant.params
133
- .map((name) => {
134
- const param = query.params.find((p) => p.name === name);
135
- if (param === undefined)
136
- throw new Error(`unknown param ${name}`);
137
- return bindExpr(query, param, access);
138
- })
139
- .join(', ');
140
- lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
141
- lines.push(` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`);
142
- }
143
- lines.push('];');
144
- lines.push(`function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`);
145
- lines.push(' let mask = 0;');
146
- query.variantGroups.forEach((group, index) => {
147
- const langOf = (name) => query.params.find((p) => p.name === name)?.langName ?? name;
148
- const condition = group.flag
149
- ? `params?.${propertyKey(langOf(group.params[0]))} === true`
150
- : group.params
151
- .map((name) => `(params?.${propertyKey(langOf(name))} ?? null) !== null`)
152
- .join(' && ');
153
- lines.push(` if (${condition}) mask |= ${1 << index};`);
154
- });
155
- lines.push(` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`);
156
- lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
157
- lines.push('}');
158
- }
159
318
  lines.push('');
160
- // The runner.
161
- const paramArg = !hasParams ? '' : paramArgDecl;
162
319
  lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
163
- lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`);
164
- if (query.variants !== undefined) {
165
- lines.push(` const variant = ${selectFn}(params);`);
166
- lines.push(' const rows = await client.query(variant.sql, variant.bind);');
320
+ lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`);
321
+ if (hasParams) {
322
+ lines.push(` const rows = await client.query(${sqlConst}, ${positional});`);
167
323
  }
168
324
  else {
169
- const sqlExpr = query.orderBy !== undefined ? `${composeFn}(params)` : sqlConst;
170
- if (hasParams) {
171
- lines.push(` const rows = await client.query(${sqlExpr}, ${positional});`);
172
- }
173
- else {
174
- lines.push(` const rows = await client.query(${sqlExpr});`);
175
- }
325
+ lines.push(` const rows = await client.query(${sqlConst});`);
176
326
  }
177
327
  lines.push(` return rows as unknown as ${Row}[];`);
178
328
  lines.push('}');
179
329
  lines.push('');
180
- // A descriptor for react's `useQuery` — the SQL (static, composed from
181
- // the baked allowlist, or variant-selected), the exact table dependency
182
- // set, and a `bind(params)` → positional array (always paired with the
183
- // sqlFor selection). Typed by the query's own Row/Params.
184
330
  lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`);
185
331
  const paramsTypeArg = hasParams ? Params : 'undefined';
186
332
  lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
187
333
  lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
188
334
  lines.push(` hasParams: ${hasParams},`);
189
335
  lines.push(` sql: ${sqlConst},`);
190
- if (query.variants !== undefined) {
191
- lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
192
- }
193
- else if (query.orderBy !== undefined) {
194
- lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
195
- }
196
336
  lines.push(` tables: ${query.name}Tables,`);
197
337
  const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
198
338
  lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
@@ -212,10 +352,7 @@ function emitQuery(query, hash) {
212
352
  lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixedPart} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
213
353
  }
214
354
  lines.push(' ],');
215
- if (query.variants !== undefined) {
216
- lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
217
- }
218
- else if (hasParams) {
355
+ if (hasParams) {
219
356
  lines.push(` bind: (params: ${Params}) => ${positional},`);
220
357
  }
221
358
  else {
@@ -234,6 +371,33 @@ export function emitQueriesModule(queries, hash, irVersion) {
234
371
  `// irVersion: ${irVersion}`,
235
372
  `// irHash: ${hash}`,
236
373
  ].join('\n'));
374
+ if (queries.some((query) => query.syql !== undefined)) {
375
+ parts.push([
376
+ 'export type SyqlRuntimeErrorCode =',
377
+ " | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
378
+ " | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
379
+ " | 'SYQL_RUNTIME_INVALID_INPUT'",
380
+ " | 'SYQL_RUNTIME_INVALID_GROUP'",
381
+ " | 'SYQL_RUNTIME_INVALID_SORT'",
382
+ " | 'SYQL_RUNTIME_INVALID_LIMIT';",
383
+ '',
384
+ 'export class SyqlInputError extends Error {',
385
+ " readonly name = 'SyqlInputError';",
386
+ ' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
387
+ ' super(message);',
388
+ ' }',
389
+ '}',
390
+ '',
391
+ 'export interface SyqlPresent<T> {',
392
+ ' readonly present: true;',
393
+ ' readonly value: T;',
394
+ '}',
395
+ '',
396
+ 'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
397
+ ' return { present: true, value };',
398
+ '}',
399
+ ].join('\n'));
400
+ }
237
401
  parts.push([
238
402
  "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
239
403
  'export type QueryValue =',
@@ -258,8 +422,7 @@ export function emitQueriesModule(queries, hash, irVersion) {
258
422
  ' * `bind(params)` → positional args. Consumed by',
259
423
  " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
260
424
  ' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
261
- ' * (present only with an orderBy knob) composes the statement from a',
262
- ' * generate-time-checked column allowlist. */',
425
+ ' * selects a checked revision-1 SYQL physical statement when needed. */',
263
426
  'export interface NamedQuery<Row, Params = undefined> {',
264
427
  ' readonly id: string;',
265
428
  ' readonly hasParams: boolean;',
package/dist/fmt.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- /** Format one `.syql` source file into its canonical form. Throws
2
- * {@link TypegenError} when the source does not parse. */
1
+ /** Format one `.syql` source. Invalid or non-equivalent output is rejected so
2
+ * the CLI never writes a partially understood file. */
3
3
  export declare function formatSyql(file: string, source: string): string;