@syncular/typegen 0.5.0 → 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
package/README.md CHANGED
@@ -68,8 +68,8 @@ schema guide and suggests `syncular init`.
68
68
  | `manifestVersion` | yes | Must be `1`. Format growth happens by bumping this, not by tolerating unknown keys. |
69
69
  | `migrations` | no (default `./migrations`) | Directory of `NNNN_name/up.sql` migrations, relative to the manifest. |
70
70
  | `queries` | no (default `./queries`) | Directory of `.sql` / `.syql` named-query files (see §6). Only read when some output requests a queries file. |
71
- | `naming` | no (default `"camel"`) | §5-of-DESIGN-queries casing: `"camel"` maps snake_case SQL names to camelCase in generated row types/params (projections lower with `AS` aliases so runtime keys match); `"preserve"` keeps SQL-truth names. Collisions/keyword hazards are generate-time errors. |
72
- | `queryBackend` | no (default `"neutralize"`) | `.syql` conditional lowering: `"neutralize"` (one guarded statement), `"variants"` (enumerate per provided-combination whenever eligible), `"auto"` (enumerate at ≤ 2 optional groups). The per-query `variants` knob always forces. |
71
+ | `naming` | no (default `"camel"`) | `"camel"` maps snake_case SQL names to camelCase in generated row types/params (projections lower with `AS` aliases so runtime keys match); `"preserve"` keeps SQL-truth names. Collisions/keyword hazards are generate-time errors. |
72
+ | `queryBackend` | no (default `"auto"`) | Advanced revision-1 SYQL lowering override: `"neutralize"` emits guarded statements, `"variants"` enumerates activation states, and `"auto"` deterministically enumerates at ≤ 2 activation controls. This never changes source meaning or the public API. |
73
73
  | `output.ir` | no (default `./syncular.ir.json`) | IR output path, relative to the manifest. |
74
74
  | `output.module` | no (default `./syncular.generated.ts`) | Generated TS module path, relative to the manifest. |
75
75
  | `output.queryIr` | no | Deterministic analyzed QueryIR JSON. Its hash keys generated reactive query descriptors, so SQL-only changes invalidate caches. |
@@ -326,18 +326,65 @@ function on every platform — killing query↔type drift *by construction*. It
326
326
  the type-safe **read** tier; raw `query(sql, params)` — guarded read-only —
327
327
  stays the escape hatch for queries built at runtime.
328
328
 
329
- Two frontends feed one pipeline (DESIGN-queries.md is the design of record):
330
- **`.sql`** — plain SQL + `:params`, the compatibility floor, documented in
331
- this section and **`.syql`** the DSL container (declared optional params
332
- with auto-guarded conjuncts, `from+to?` groups, `?: flag` guards with
333
- `if (…) { }`, file-scoped `@fragments`, `orderBy`/`limit` knobs, and the
334
- opt-in `variants` enumeration backend), which lowers at generate time to the
335
- same checked plain SQL. Tooling: `syncular generate --print <name>` (the
336
- lowered SQL), `syncular fmt` (canonical `.syql` style), `syncular lsp`
337
- (diagnostics/hover/definition over stdio). Casing follows the manifest
338
- `naming` mode (§1): generated rows/params are camelCase and projections are
339
- `AS`-aliased so runtime keys match; `mutate` accepts camel and snake keys
340
- both.
329
+ Two frontends feed one QueryIR pipeline. **`.sql`** is plain SQL plus
330
+ `:params`, the compatibility floor documented below. **`.syql`** is the
331
+ revision-1 structured frontend: authoritative typed inputs, explicit
332
+ `when(...)` conjuncts, atomic optional groups, imported hygienic predicates,
333
+ constructive `@scope`/`@cover` facts, complete sort profiles, bounded pages,
334
+ and proven identity. Its complete normative definition is
335
+ [`../../docs/SYQL.md`](../../docs/SYQL.md); the rationale and implementation
336
+ record are [`../../docs/rfcs/0004-syql-language.md`](../../docs/rfcs/0004-syql-language.md).
337
+
338
+ Tooling: `syncular generate --print <name>` prints every selected checked
339
+ statement and bind, `syncular fmt` is the semantic-preserving canonical
340
+ formatter, and `syncular lsp` provides project-aware diagnostics, hover,
341
+ definitions, references, symbols, and formatting. Casing follows the manifest
342
+ `naming` mode (§1): generated rows/inputs are camelCase and projections are
343
+ `AS`-aliased so runtime keys match.
344
+
345
+ ```syql
346
+ import { matchesTitle } from "./predicates.syql";
347
+
348
+ query listTodos(
349
+ listId,
350
+ status?: string | null,
351
+ range?(start: integer, end: integer),
352
+ q?: string,
353
+ unassigned?: switch,
354
+ ) {
355
+ sql {
356
+ select id, title, status, created_at
357
+ from todos
358
+ where @cover(todos.list_id = :listId)
359
+ and when(status) {
360
+ status is :status
361
+ }
362
+ and when(range) {
363
+ created_at between :start and :end
364
+ }
365
+ and when(unassigned) {
366
+ assignee_id is null
367
+ }
368
+ and when(q) {
369
+ @matchesTitle(:q)
370
+ }
371
+ }
372
+ sort sortBy default newest {
373
+ newest { created_at desc, id desc }
374
+ oldest { created_at asc, id asc }
375
+ }
376
+ page pageSize default 50 max 200;
377
+ identity by id;
378
+ }
379
+ ```
380
+
381
+ Optional nullable scalars use a generated presence wrapper so absent,
382
+ present-null, and present-value remain distinct. A group is one optional host
383
+ object whose members are all required. A switch defaults false. Sort is a
384
+ generated enum/union of complete checked profiles; page is validated as a
385
+ positive bounded integer before execution. `integer` inputs are exact signed
386
+ 64-bit values in the SYQL API (`bigint` in TypeScript, `Int64`/`Long`/`int` on
387
+ native targets).
341
388
 
342
389
  **File & naming convention.** Named queries live in a `queries/` directory
343
390
  next to `migrations/` (override with the top-level `"queries"` manifest key).
@@ -455,30 +502,25 @@ identifier and keeping those that name an IR table. This captures subquery /
455
502
  prepare() still guarantees the SQL itself is correct.
456
503
 
457
504
  Inference is deliberately conservative around `OR`, joins, grouping, and
458
- computed identity. A `.syql` query can state the missing fact explicitly:
505
+ computed identity. A `.syql` query constructs exact reactive facts in the same
506
+ node that restricts the SQL:
459
507
 
460
508
  ```syql
461
- query compareLists(left, right)
462
- depends tasks on project_id = left | right
463
- window tasks by project_id = left | right
464
- key by id
465
- {
466
- select id, title from tasks
467
- where project_id = :left or project_id = :right
509
+ query compareLists(left, right) {
510
+ sql {
511
+ select id, title from tasks
512
+ where @cover(tasks.project_id in (:left, :right))
513
+ }
514
+ identity by id;
468
515
  }
469
516
  ```
470
517
 
471
- The declaration grammar is:
472
-
473
- - `depends <table> on <scope> = <param> | <param>`;
474
- - `window <table> by <scope> = <param> | <param>` with optional
475
- `fixed <other_scope> = <param>, ...` for every other scope on that table;
476
- - `key by <result-column> | <result-column>`.
477
-
478
- These escape hatches are checked against the prepared query, schema, inferred
479
- param types, and result projection. They reject unread tables, unknown scopes
480
- or params, optional/incompatible coverage params, incomplete fixed scopes,
481
- duplicate declarations, and unprojected key fields.
518
+ `@scope` emits a real predicate plus exact dependency keys. `@cover` emits the
519
+ same dependency facts plus proven window coverage and must bind every declared
520
+ scope of that table instance. Only required, exactly typed binds are allowed.
521
+ `identity by ...` is accepted only when the projected fields are proven unique;
522
+ it is not an unchecked assertion. Without constructive proof the compiler
523
+ falls back to table-wide dependency, no coverage, and/or unkeyed reconciliation.
482
524
 
483
525
  **Emitted shape, per language** (one query, five outputs — abbreviated):
484
526
 
package/dist/cli.js CHANGED
@@ -109,29 +109,55 @@ function runGeneratePrint(args, name) {
109
109
  }
110
110
  console.log(`-- ${query.name} (${query.file})`);
111
111
  console.log(`-- tables: ${query.tables.join(', ')}`);
112
+ if (query.syql !== undefined) {
113
+ console.log('-- revision: SYQL 1');
114
+ console.log(`-- backend: ${query.syql.plan.backend}`);
115
+ if (query.syql.inputs.length > 0) {
116
+ console.log('-- public inputs:');
117
+ for (const input of query.syql.inputs) {
118
+ if (input.kind === 'value') {
119
+ console.log(`-- ${input.name}: ${input.type}${input.nullable ? ' | null' : ''} (${input.required ? 'required' : 'optional'})`);
120
+ }
121
+ else if (input.kind === 'group') {
122
+ console.log(`-- ${input.name}: optional group (${input.members.map((member) => `${member.name}: ${member.type}${member.nullable ? ' | null' : ''}`).join(', ')})`);
123
+ }
124
+ else if (input.kind === 'switch') {
125
+ console.log(`-- ${input.name}: switch (default false)`);
126
+ }
127
+ else if (input.kind === 'sort') {
128
+ console.log(`-- ${input.name}: sort [${input.profiles.map((profile) => profile.name).join(', ')}] (default ${input.defaultProfile})`);
129
+ }
130
+ else {
131
+ console.log(`-- ${input.name}: page 1..${input.maxSize} (default ${input.defaultSize})`);
132
+ }
133
+ }
134
+ }
135
+ if (query.syql.identity !== undefined) {
136
+ console.log(`-- identity: ${query.syql.identity.join(', ')}`);
137
+ }
138
+ console.log(`-- checked statements: ${query.syql.plan.statements.length} (activation controls: ${query.syql.plan.activationControls.join(', ') || 'none'})`);
139
+ for (const statement of query.syql.plan.statements) {
140
+ const selectors = [
141
+ ...(statement.activationMask === undefined
142
+ ? []
143
+ : [`mask=${statement.activationMask}`]),
144
+ ...(statement.sortProfile === undefined
145
+ ? []
146
+ : [`sort=${statement.sortProfile}`]),
147
+ ];
148
+ console.log(`-- [${selectors.join(', ') || 'default'}]`);
149
+ console.log(statement.sql);
150
+ console.log(`-- binds: ${statement.binds.map((bind) => `:${bind.name}`).join(', ') || '(none)'}`);
151
+ }
152
+ return;
153
+ }
112
154
  if (query.params.length > 0) {
113
155
  console.log('-- params:');
114
156
  for (const param of query.params) {
115
- const marks = [
116
- param.optional === true ? 'optional' : 'required',
117
- ...(param.flag === true ? ['flag'] : []),
118
- ...(param.group !== undefined ? [`group ${param.group}`] : []),
119
- ].join(', ');
120
- console.log(`-- :${param.name} ${param.type} (${marks})`);
157
+ console.log(`-- :${param.name} ${param.type} (required)`);
121
158
  }
122
159
  }
123
160
  console.log(query.sql);
124
- if (query.orderBy !== undefined) {
125
- console.log('-- orderBy variants (each checked against the schema):');
126
- const base = query.positionalSqlBase ?? '';
127
- for (const col of query.orderBy.allowed) {
128
- const isDefault = col.name === query.orderBy.defaultColumn;
129
- console.log(`-- ${col.langName}: ${base} order by ${col.name} asc|desc${query.positionalLimitTail ?? ''}${isDefault ? ' (default)' : ''}`);
130
- }
131
- }
132
- if (query.limit !== undefined) {
133
- console.log(`-- limit: bound value, default ${query.limit.default ?? query.limit.max}${query.limit.max !== undefined ? `, clamped to ${query.limit.max}` : ''}`);
134
- }
135
161
  }
136
162
  function parseManifestDir(argv) {
137
163
  let manifestDir = '.';
@@ -65,25 +65,147 @@ function optionalParamValue(type, name) {
65
65
  return name;
66
66
  }
67
67
  }
68
- function isOptionalParam(query, p) {
69
- return (p.optional === true ||
70
- p.flag === true ||
71
- (query.limit !== undefined && p.name === 'limit'));
72
- }
73
- /** Per-query orderBy allowlist enum (column = the checked SQL column). */
74
- function emitOrderByEnum(query) {
75
- if (query.orderBy === undefined)
76
- return [];
68
+ function syqlInput(query, name) {
69
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
70
+ if (input === undefined)
71
+ throw new Error(`unknown SYQL input ${name}`);
72
+ return input;
73
+ }
74
+ function syqlDartType(type, nullable) {
75
+ return `${DART_TYPE[type]}${nullable ? '?' : ''}`;
76
+ }
77
+ function syqlControlActive(query, name) {
78
+ const input = syqlInput(query, name);
79
+ const access = camelCase(input.langName);
80
+ if (input.kind === 'switch')
81
+ return access;
82
+ if (input.kind === 'value' && input.nullable)
83
+ return `${access}.isPresent`;
84
+ if (input.kind === 'value' || input.kind === 'group') {
85
+ return `${access} != null`;
86
+ }
87
+ throw new Error(`${name} is not an activation control`);
88
+ }
89
+ function syqlBindExpr(query, bind) {
90
+ if (bind.kind === 'condition-active') {
91
+ return bind.controls
92
+ .map((control) => syqlControlActive(query, control))
93
+ .join(' && ');
94
+ }
95
+ const input = syqlInput(query, bind.input);
96
+ const access = camelCase(input.langName);
97
+ if (bind.kind === 'page')
98
+ return `effective${typeName(access)}`;
99
+ if (bind.kind === 'group-member') {
100
+ if (input.kind !== 'group')
101
+ throw new Error('group bind/input mismatch');
102
+ const member = input.members.find((candidate) => candidate.name === bind.member);
103
+ if (member === undefined)
104
+ throw new Error(`unknown group member ${bind.member}`);
105
+ const memberAccess = `${access}.${camelCase(member.langName)}`;
106
+ const value = member.nullable
107
+ ? optionalParamValue(member.type, memberAccess)
108
+ : paramValue(member.type, memberAccess);
109
+ return `${access} == null ? null : ${value}`;
110
+ }
111
+ if (input.kind !== 'value')
112
+ throw new Error('value bind/input mismatch');
113
+ if (input.required) {
114
+ return input.nullable
115
+ ? optionalParamValue(input.type, access)
116
+ : paramValue(input.type, access);
117
+ }
118
+ return input.nullable
119
+ ? `${access}.isPresent ? ${optionalParamValue(input.type, `${access}.value`)} : null`
120
+ : optionalParamValue(input.type, access);
121
+ }
122
+ function emitSyqlDartTypes(query) {
77
123
  const lines = [];
78
- lines.push(`/// §6 orderBy allowlist for ${query.name} checked at generate time.`);
79
- lines.push(`enum ${typeName(query.name)}OrderBy {`);
80
- lines.push(`${query.orderBy.allowed
81
- .map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
82
- .join(',\n')};`);
83
- lines.push('');
84
- lines.push(` const ${typeName(query.name)}OrderBy(this.column);`);
85
- lines.push(' final String column;');
86
- lines.push('}');
124
+ for (const input of query.syql?.inputs ?? []) {
125
+ if (input.kind === 'group') {
126
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
127
+ lines.push(`class ${name} {`);
128
+ for (const member of input.members) {
129
+ lines.push(` final ${syqlDartType(member.type, member.nullable)} ${camelCase(member.langName)};`);
130
+ }
131
+ const args = input.members
132
+ .map((member) => `required this.${camelCase(member.langName)}`)
133
+ .join(', ');
134
+ lines.push(` const ${name}({${args}});`, '}', '');
135
+ }
136
+ else if (input.kind === 'sort') {
137
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
138
+ lines.push(`enum ${name} { ${input.profiles.map((profile) => camelCase(profile.langName)).join(', ')} }`, '');
139
+ }
140
+ }
141
+ if (lines[lines.length - 1] === '')
142
+ lines.pop();
143
+ return lines;
144
+ }
145
+ function emitSyqlDartRunner(query) {
146
+ const metadata = query.syql;
147
+ if (metadata === undefined)
148
+ throw new Error('missing SYQL metadata');
149
+ const Row = `${typeName(query.name)}Row`;
150
+ const Pascal = pascalCase(query.name);
151
+ const lines = [];
152
+ lines.push(`/// Tables the ${query.name} query reads (exact invalidation set).`, `const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`, '', `/// Run the ${query.name} revision-1 SYQL query.`);
153
+ const args = [];
154
+ for (const input of metadata.inputs) {
155
+ const name = camelCase(input.langName);
156
+ if (input.kind === 'value') {
157
+ const type = syqlDartType(input.type, input.nullable);
158
+ if (input.required)
159
+ args.push(`required ${type} ${name}`);
160
+ else if (input.nullable) {
161
+ args.push(`SyqlQueryPresence<${type}> ${name} = const SyqlQueryPresence.absent()`);
162
+ }
163
+ else
164
+ args.push(`${type}? ${name}`);
165
+ }
166
+ else if (input.kind === 'group') {
167
+ args.push(`${typeName(query.name)}${typeName(input.langName)}? ${name}`);
168
+ }
169
+ else if (input.kind === 'switch') {
170
+ args.push(`bool ${name} = false`);
171
+ }
172
+ else if (input.kind === 'sort') {
173
+ const defaultCase = input.profiles.find((profile) => profile.name === input.defaultProfile)
174
+ ?.langName ?? input.defaultProfile;
175
+ const type = `${typeName(query.name)}${typeName(input.langName)}`;
176
+ args.push(`${type} ${name} = ${type}.${camelCase(defaultCase)}`);
177
+ }
178
+ else {
179
+ args.push(`int? ${name}`);
180
+ }
181
+ }
182
+ const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
183
+ lines.push(`List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`);
184
+ const page = metadata.inputs.find((input) => input.kind === 'page');
185
+ if (page?.kind === 'page') {
186
+ const name = camelCase(page.langName);
187
+ lines.push(` final effective${typeName(name)} = ${name} ?? ${page.defaultSize};`, ` if (effective${typeName(name)} < 1 || effective${typeName(name)} > ${page.maxSize}) {`, ` throw SyqlQueryInputException('SYQL_RUNTIME_INVALID_PAGE', ${quote(`${query.name}: invalid page size`)});`, ' }');
188
+ }
189
+ if (metadata.plan.backend === 'variants') {
190
+ lines.push(' var activationMask = 0;');
191
+ metadata.plan.activationControls.forEach((control, index) => {
192
+ lines.push(` if (${syqlControlActive(query, control)}) activationMask |= ${2 ** index};`);
193
+ });
194
+ }
195
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
196
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
197
+ const sortIndex = sort?.kind === 'sort' ? `${camelCase(sort.langName)}.index` : '0';
198
+ const index = metadata.plan.backend === 'variants'
199
+ ? `activationMask * ${profileCount} + ${sortIndex}`
200
+ : sortIndex;
201
+ lines.push(` final statementIndex = ${index};`, ' late final String sql;', ' late final List<Object?> params;', ' switch (statementIndex) {');
202
+ metadata.plan.statements.forEach((statement, statementIndex) => {
203
+ const binds = statement.binds
204
+ .map((bind) => syqlBindExpr(query, bind))
205
+ .join(', ');
206
+ lines.push(` case ${statementIndex}: sql = ${quote(statement.positionalSql)}; params = <Object?>[${binds}]; break;`);
207
+ });
208
+ lines.push(" default: throw StateError('invalid generated SYQL statement index');", ' }', ` return client.query(sql, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`, '}');
87
209
  return lines;
88
210
  }
89
211
  function emitClass(query) {
@@ -121,52 +243,30 @@ function emitClass(query) {
121
243
  return lines;
122
244
  }
123
245
  function emitRunner(query) {
246
+ if (query.syql !== undefined)
247
+ return emitSyqlDartRunner(query);
124
248
  const Row = `${typeName(query.name)}Row`;
125
249
  const Pascal = pascalCase(query.name);
126
250
  const lines = [];
127
251
  lines.push(`/// Tables the ${query.name} query reads (exact invalidation set).`);
128
252
  lines.push(`const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`);
129
253
  lines.push('');
130
- if (query.orderBy !== undefined) {
131
- lines.push(`const String _${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')};`);
132
- }
133
- else {
134
- lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
135
- }
254
+ lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
136
255
  lines.push('');
137
256
  lines.push(`/// Run the ${query.name} named query (SELECT-only).`);
138
257
  const args = [];
139
258
  for (const p of query.params) {
140
259
  const name = camelCase(p.langName);
141
- if (isOptionalParam(query, p)) {
142
- args.push(`${DART_TYPE[p.type]}? ${name}`);
143
- }
144
- else {
145
- args.push(`required ${DART_TYPE[p.type]} ${name}`);
146
- }
147
- }
148
- if (query.orderBy !== undefined) {
149
- const defaultCase = camelCase(query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
150
- ?.langName ?? query.orderBy.defaultColumn);
151
- args.push(`${typeName(query.name)}OrderBy orderBy = ${typeName(query.name)}OrderBy.${defaultCase}`);
152
- args.push(`SyncularQueryDir dir = SyncularQueryDir.${query.orderBy.defaultDir}`);
260
+ args.push(`required ${DART_TYPE[p.type]} ${name}`);
153
261
  }
154
262
  const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
155
263
  lines.push(`List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`);
156
- if (query.orderBy !== undefined) {
157
- const limitTail = query.positionalLimitTail !== undefined
158
- ? ` ${quote(query.positionalLimitTail.trim())}`
159
- : '';
160
- lines.push(` final sql = '$_${query.name}SqlBase order by \${orderBy.column} \${dir.name}'${limitTail === '' ? '' : `\n ' ' ${limitTail.trim()}`};`);
161
- }
162
- const sqlRef = query.orderBy !== undefined ? 'sql' : `_${query.name}Sql`;
264
+ const sqlRef = `_${query.name}Sql`;
163
265
  if (query.params.length > 0) {
164
266
  const binds = query.params
165
267
  .map((p) => {
166
268
  const name = camelCase(p.langName);
167
- return isOptionalParam(query, p)
168
- ? optionalParamValue(p.type, name)
169
- : paramValue(p.type, name);
269
+ return paramValue(p.type, name);
170
270
  })
171
271
  .join(', ');
172
272
  lines.push(` final params = <Object?>[${binds}];`);
@@ -188,6 +288,24 @@ export function emitQueriesDartModule(queries, hash, irVersion) {
188
288
  '',
189
289
  "import 'package:syncular/syncular.dart';",
190
290
  ].join('\n'));
291
+ if (queries.some((query) => query.syql !== undefined)) {
292
+ parts.push([
293
+ 'class SyqlQueryPresence<T> {',
294
+ ' final bool isPresent;',
295
+ ' final T? value;',
296
+ ' const SyqlQueryPresence.absent() : isPresent = false, value = null;',
297
+ ' const SyqlQueryPresence.present(this.value) : isPresent = true;',
298
+ '}',
299
+ '',
300
+ 'class SyqlQueryInputException implements Exception {',
301
+ ' final String code;',
302
+ ' final String message;',
303
+ ' const SyqlQueryInputException(this.code, this.message);',
304
+ ' @override',
305
+ " String toString() => '$code: $message';",
306
+ '}',
307
+ ].join('\n'));
308
+ }
191
309
  parts.push([
192
310
  '/// Lift a SQLite boolean: a real bool, or 0/1 as a number.',
193
311
  'bool? _queryRowBool(Object? value) {',
@@ -213,17 +331,11 @@ export function emitQueriesDartModule(queries, hash, irVersion) {
213
331
  " return {r'$bytes': hex};",
214
332
  '}',
215
333
  ].join('\n'));
216
- if (queries.some((q) => q.orderBy !== undefined)) {
217
- parts.push([
218
- '/// §6 orderBy direction (shared by every orderBy-knob query).',
219
- 'enum SyncularQueryDir { asc, desc }',
220
- ].join('\n'));
221
- }
222
334
  for (const query of queries) {
335
+ const syqlTypes = emitSyqlDartTypes(query);
336
+ if (syqlTypes.length > 0)
337
+ parts.push(syqlTypes.join('\n'));
223
338
  parts.push(emitClass(query).join('\n'));
224
- const orderByEnum = emitOrderByEnum(query);
225
- if (orderByEnum.length > 0)
226
- parts.push(orderByEnum.join('\n'));
227
339
  }
228
340
  for (const query of queries) {
229
341
  parts.push(emitRunner(query).join('\n'));