@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
@@ -20,25 +20,264 @@ 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
+ return `String(params.${propertyKey(input.langName)})`;
36
+ }
37
37
  const param = query.params.find((candidate) => candidate.name === name);
38
38
  if (param === undefined)
39
39
  throw new Error(`unknown reactive param ${name}`);
40
40
  return `String(params.${propertyKey(param.langName)})`;
41
41
  }
42
+ function syqlInput(query, name) {
43
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
44
+ if (input === undefined)
45
+ throw new Error(`unknown SYQL input ${name}`);
46
+ return input;
47
+ }
48
+ function syqlTsValueType(type, nullable) {
49
+ return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
50
+ }
51
+ function syqlTsTypeCheck(expression, type, nullable) {
52
+ let check;
53
+ switch (type) {
54
+ case 'integer':
55
+ check = `typeof ${expression} === 'bigint'`;
56
+ break;
57
+ case 'float':
58
+ check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
59
+ break;
60
+ case 'boolean':
61
+ check = `typeof ${expression} === 'boolean'`;
62
+ break;
63
+ case 'bytes':
64
+ case 'crdt':
65
+ check = `${expression} instanceof Uint8Array`;
66
+ break;
67
+ default:
68
+ check = `typeof ${expression} === 'string'`;
69
+ }
70
+ return nullable ? `${expression} === null || (${check})` : check;
71
+ }
72
+ function syqlControlActive(query, control, params) {
73
+ const input = syqlInput(query, control);
74
+ const access = `${params}.${propertyKey(input.langName)}`;
75
+ if (input.kind === 'switch')
76
+ return `${access} === true`;
77
+ if (input.kind === 'value' || input.kind === 'group') {
78
+ return `${access} !== undefined`;
79
+ }
80
+ throw new Error(`${control} is not an activation control`);
81
+ }
82
+ function syqlBindExpr(query, bind, params) {
83
+ if (bind.kind === 'condition-active') {
84
+ return bind.controls
85
+ .map((control) => syqlControlActive(query, control, params))
86
+ .join(' && ');
87
+ }
88
+ const input = syqlInput(query, bind.input);
89
+ const access = `${params}.${propertyKey(input.langName)}`;
90
+ if (bind.kind === 'page') {
91
+ if (input.kind !== 'page')
92
+ throw new Error('page bind/input mismatch');
93
+ return `${access} ?? ${input.defaultSize}`;
94
+ }
95
+ if (bind.kind === 'group-member') {
96
+ if (input.kind !== 'group')
97
+ throw new Error('group bind/input mismatch');
98
+ const member = input.members.find((candidate) => candidate.name === bind.member);
99
+ if (member === undefined)
100
+ throw new Error(`unknown group member ${bind.member}`);
101
+ return `${access}?.${propertyKey(member.langName)} ?? null`;
102
+ }
103
+ if (input.kind !== 'value')
104
+ throw new Error('value bind/input mismatch');
105
+ if (input.required)
106
+ return access;
107
+ return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
108
+ }
109
+ function emitSyqlValidation(query, Params) {
110
+ const inputs = query.syql?.inputs ?? [];
111
+ const lines = [];
112
+ 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);`, ' }');
113
+ for (const input of inputs) {
114
+ const key = propertyKey(input.langName);
115
+ const access = `params.${key}`;
116
+ if (input.kind === 'value') {
117
+ if (input.required) {
118
+ 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}`)});`);
119
+ }
120
+ else if (input.nullable) {
121
+ 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}`)});`);
122
+ }
123
+ else {
124
+ 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}`)});`);
125
+ }
126
+ }
127
+ else if (input.kind === 'group') {
128
+ 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}`)});`);
129
+ for (const member of input.members) {
130
+ const memberAccess = `${access}.${propertyKey(member.langName)}`;
131
+ 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}`)});`);
132
+ }
133
+ lines.push(' }');
134
+ }
135
+ else if (input.kind === 'switch') {
136
+ lines.push(` if (${access} !== undefined && typeof ${access} !== 'boolean') throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid switch ${input.name}`)});`);
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_PAGE', ${quote(`${query.name}: page size 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 === 'switch') {
187
+ lines.push(` ${key}?: boolean;`);
188
+ }
189
+ else if (input.kind === 'sort') {
190
+ lines.push(` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`);
191
+ }
192
+ else {
193
+ lines.push(` ${key}?: number;`);
194
+ }
195
+ }
196
+ lines.push('}', '');
197
+ lines.push(...emitSyqlValidation(query, Params), '');
198
+ }
199
+ lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`, `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`, '');
200
+ const paramDecl = !hasParams
201
+ ? ''
202
+ : requiresParams
203
+ ? `raw: ${Params}`
204
+ : `raw?: ${Params}`;
205
+ const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
206
+ const statementType = hasParams
207
+ ? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
208
+ : '{ sql: string; bind: () => QueryValue[] }';
209
+ lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
210
+ for (const statement of metadata.plan.statements) {
211
+ const binds = statement.binds
212
+ .map((bind) => syqlBindExpr(query, bind, 'params'))
213
+ .join(', ');
214
+ lines.push(` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`);
215
+ }
216
+ lines.push('];');
217
+ const sort = inputs.find((input) => input.kind === 'sort');
218
+ if (sort?.kind === 'sort') {
219
+ lines.push(`const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`);
220
+ }
221
+ lines.push(`function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`);
222
+ if (validated !== undefined)
223
+ lines.push(` const params = ${validated};`);
224
+ let maskExpression = '0';
225
+ if (metadata.plan.backend === 'variants') {
226
+ lines.push(' let mask = 0;');
227
+ metadata.plan.activationControls.forEach((control, index) => {
228
+ lines.push(` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`);
229
+ });
230
+ maskExpression = 'mask';
231
+ }
232
+ let sortExpression = '0';
233
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
234
+ if (sort?.kind === 'sort') {
235
+ sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
236
+ }
237
+ const indexExpression = metadata.plan.backend === 'variants'
238
+ ? `${maskExpression} * ${profileCount} + ${sortExpression}`
239
+ : sortExpression;
240
+ 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' : ''}) };`, '}', '');
241
+ const runnerParam = !hasParams
242
+ ? ''
243
+ : requiresParams
244
+ ? `, params: ${Params}`
245
+ : `, params?: ${Params}`;
246
+ 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}[];`, '}', '');
247
+ const paramsType = hasParams ? Params : 'undefined';
248
+ const defaultStatement = metadata.plan.statements.find((statement) => (statement.activationMask === undefined ||
249
+ statement.activationMask === 0) &&
250
+ (sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile));
251
+ 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)},`);
252
+ if (hasParams) {
253
+ lines.push(` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`);
254
+ }
255
+ lines.push(` tables: ${query.name}Tables,`);
256
+ const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
257
+ lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
258
+ for (const dependency of query.reactive.dependencies) {
259
+ const keys = dependency.scopes.flatMap((scope) => scope.params.map((param) => scopeKeyExpression(query, scope.pattern, scope.variable, param)));
260
+ lines.push(` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`);
261
+ }
262
+ lines.push(' ],');
263
+ const coverageUsesParams = query.reactive.coverage.some((coverage) => coverage.units.length > 0 ||
264
+ coverage.fixedScopes.some((scope) => scope.params.length > 0));
265
+ lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
266
+ for (const coverage of query.reactive.coverage) {
267
+ const fixed = coverage.fixedScopes
268
+ .map((scope) => `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`)
269
+ .join(', ');
270
+ 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(', ')}] },`);
271
+ }
272
+ lines.push(' ],', hasParams
273
+ ? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
274
+ : ` bind: () => ${query.name}Select().bind,`);
275
+ if (query.reactive.rowKey !== undefined) {
276
+ lines.push(` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`);
277
+ }
278
+ lines.push('};');
279
+ return lines.join('\n');
280
+ }
42
281
  function scopeKeyExpression(query, pattern, variable, param) {
43
282
  const marker = `{${variable}}`;
44
283
  const index = pattern.indexOf(marker);
@@ -49,11 +288,11 @@ function scopeKeyExpression(query, pattern, variable, param) {
49
288
  return `${quote(prefix)} + ${reactiveParam(query, param)} + ${quote(suffix)}`;
50
289
  }
51
290
  function emitQuery(query, hash) {
291
+ if (query.syql !== undefined)
292
+ return emitSyqlQuery(query, hash);
52
293
  const Row = `${pascalCase(query.name)}Row`;
53
294
  const Params = `${pascalCase(query.name)}Params`;
54
295
  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
296
  lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
58
297
  lines.push(`export interface ${Row} {`);
59
298
  for (const column of query.columns) {
@@ -61,138 +300,42 @@ function emitQuery(query, hash) {
61
300
  }
62
301
  lines.push('}');
63
302
  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).
303
+ const hasParams = query.params.length > 0;
67
304
  if (hasParams) {
68
305
  lines.push(`/** Named parameters for ${quote(query.name)}. */`);
69
306
  lines.push(`export interface ${Params} {`);
70
307
  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;');
308
+ lines.push(` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`);
88
309
  }
89
310
  lines.push('}');
90
311
  lines.push('');
91
312
  }
92
- // Tables dependency set (for useRawSql {tables} / useQuery).
93
313
  lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`);
94
314
  lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
95
315
  lines.push('');
96
- // SQL constants: the static default statement, plus (orderBy knob) the
97
- // baked base + column map + compose function.
98
316
  const sqlConst = `${query.name}Sql`;
99
317
  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
318
  const positional = hasParams
119
- ? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
319
+ ? `[${query.params.map(bindExpr).join(', ')}]`
120
320
  : '[]';
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
321
  lines.push('');
160
- // The runner.
161
- const paramArg = !hasParams ? '' : paramArgDecl;
162
322
  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);');
323
+ lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`);
324
+ if (hasParams) {
325
+ lines.push(` const rows = await client.query(${sqlConst}, ${positional});`);
167
326
  }
168
327
  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
- }
328
+ lines.push(` const rows = await client.query(${sqlConst});`);
176
329
  }
177
330
  lines.push(` return rows as unknown as ${Row}[];`);
178
331
  lines.push('}');
179
332
  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
333
  lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`);
185
334
  const paramsTypeArg = hasParams ? Params : 'undefined';
186
335
  lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
187
336
  lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
188
337
  lines.push(` hasParams: ${hasParams},`);
189
338
  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
339
  lines.push(` tables: ${query.name}Tables,`);
197
340
  const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
198
341
  lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
@@ -212,10 +355,7 @@ function emitQuery(query, hash) {
212
355
  lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixedPart} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
213
356
  }
214
357
  lines.push(' ],');
215
- if (query.variants !== undefined) {
216
- lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
217
- }
218
- else if (hasParams) {
358
+ if (hasParams) {
219
359
  lines.push(` bind: (params: ${Params}) => ${positional},`);
220
360
  }
221
361
  else {
@@ -234,6 +374,33 @@ export function emitQueriesModule(queries, hash, irVersion) {
234
374
  `// irVersion: ${irVersion}`,
235
375
  `// irHash: ${hash}`,
236
376
  ].join('\n'));
377
+ if (queries.some((query) => query.syql !== undefined)) {
378
+ parts.push([
379
+ 'export type SyqlRuntimeErrorCode =',
380
+ " | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
381
+ " | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
382
+ " | 'SYQL_RUNTIME_INVALID_INPUT'",
383
+ " | 'SYQL_RUNTIME_INVALID_GROUP'",
384
+ " | 'SYQL_RUNTIME_INVALID_SORT'",
385
+ " | 'SYQL_RUNTIME_INVALID_PAGE';",
386
+ '',
387
+ 'export class SyqlInputError extends Error {',
388
+ " readonly name = 'SyqlInputError';",
389
+ ' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
390
+ ' super(message);',
391
+ ' }',
392
+ '}',
393
+ '',
394
+ 'export interface SyqlPresent<T> {',
395
+ ' readonly present: true;',
396
+ ' readonly value: T;',
397
+ '}',
398
+ '',
399
+ 'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
400
+ ' return { present: true, value };',
401
+ '}',
402
+ ].join('\n'));
403
+ }
237
404
  parts.push([
238
405
  "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
239
406
  'export type QueryValue =',
@@ -258,8 +425,7 @@ export function emitQueriesModule(queries, hash, irVersion) {
258
425
  ' * `bind(params)` → positional args. Consumed by',
259
426
  " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
260
427
  ' * 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. */',
428
+ ' * selects a checked revision-1 SYQL physical statement when needed. */',
263
429
  'export interface NamedQuery<Row, Params = undefined> {',
264
430
  ' readonly id: string;',
265
431
  ' 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;