@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
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,54 @@ 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
+ inferred scope dependencies, explicit sync coverage, complete sort profiles,
334
+ bounded limits, and inferred 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
+ sync query listTodos(
349
+ listId,
350
+ status?: string | null,
351
+ range?,
352
+ q?: string,
353
+ unassigned: bool = false,
354
+ ) {
355
+ select id, title, status, created_at
356
+ from todos
357
+ where todos.list_id = :listId
358
+ and when(status) status is :status
359
+ and when(range) created_at between :range
360
+ and when(unassigned) assignee_id is null
361
+ and when(q) matchesTitle(:q)
362
+ order by sortBy default newest {
363
+ newest: created_at desc, id desc;
364
+ oldest: created_at asc, id asc;
365
+ }
366
+ limit pageSize default 50 max 200;
367
+ }
368
+ ```
369
+
370
+ Optional nullable scalars use a generated presence wrapper so absent,
371
+ present-null, and present-value remain distinct. A group is one optional host
372
+ object whose members are all required. A `bool = false` flag activates on
373
+ true. Sort is a generated enum/union of complete checked profiles; limit is validated as a
374
+ positive bounded integer before execution. `integer` inputs are exact signed
375
+ 64-bit values in the SYQL API (`bigint` in TypeScript, `Int64`/`Long`/`int` on
376
+ native targets).
341
377
 
342
378
  **File & naming convention.** Named queries live in a `queries/` directory
343
379
  next to `migrations/` (override with the top-level `"queries"` manifest key).
@@ -412,6 +448,27 @@ boundary):
412
448
  | **plain column ref** (`title`, `t.title`, `title AS x`) | resolved to the IR column (decltype confirms) — exact IR type, incl. `json`/`blob_ref`/`crdt` | the IR column's `NOT NULL` | **exact** |
413
449
  | **computed expression** (`count(*)`, `done + 1`, `:p AS l`) | decltype is null → documented fallback: aggregate/arithmetic → number, else string | always nullable (an expression's nullability is not knowable from decltype) | **fallback** |
414
450
 
451
+ Every local synced table also has the protocol-owned `_sync_version` column.
452
+ Named queries may project it with an explicit public alias when an application
453
+ needs the observed server version for optimistic concurrency:
454
+
455
+ ```syql
456
+ query getTodo(listId, todoId) {
457
+ select id, title, _sync_version as server_version
458
+ from todos
459
+ where todos.list_id = :listId and todos.id = :todoId;
460
+ }
461
+ ```
462
+
463
+ The generated `serverVersion` field is an exact, non-null `integer`. The
464
+ physical name is intentionally not a public result name: project it with an
465
+ alias such as `server_version`. `_sync_version` is query-only, is excluded from
466
+ schema and mutation types, and is not added by `select *`; this prevents client
467
+ code from writing engine-owned concurrency state. Pass a positive observed
468
+ value as a mutation `baseVersion`. Locally-created or still-unacknowledged rows
469
+ can carry a non-positive sentinel and should omit the base version until the
470
+ server assigns one.
471
+
415
472
  `bun:sqlite` exposes `columnNames`, `declaredTypes` (once executed once — we
416
473
  run the statement against the empty DB), and `paramsCount`. It does **not**
417
474
  expose column origin (table/column), param **names**, or `NOT NULL` flags — so
@@ -455,30 +512,20 @@ identifier and keeping those that name an IR table. This captures subquery /
455
512
  prepare() still guarantees the SQL itself is correct.
456
513
 
457
514
  Inference is deliberately conservative around `OR`, joins, grouping, and
458
- computed identity. A `.syql` query can state the missing fact explicitly:
515
+ computed identity. Ordinary scope predicates construct exact reactive facts;
516
+ `sync query` additionally requests checked coverage:
459
517
 
460
518
  ```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
- {
519
+ sync query compareLists(left, right) {
466
520
  select id, title from tasks
467
- where project_id = :left or project_id = :right
521
+ where tasks.project_id in (:left, :right);
468
522
  }
469
523
  ```
470
524
 
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.
525
+ Coverage must resolve one table instance and bind every declared scope. Only
526
+ required, non-null, exactly typed binds are allowed. Result identity is inferred
527
+ from schema keys and the projection. Without constructive proof the compiler
528
+ falls back to table-wide dependency, no coverage, and/or unkeyed reconciliation.
482
529
 
483
530
  **Emitted shape, per language** (one query, five outputs — abbreviated):
484
531
 
package/dist/cli.js CHANGED
@@ -109,29 +109,52 @@ 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 === 'sort') {
125
+ console.log(`-- ${input.name}: sort [${input.profiles.map((profile) => profile.name).join(', ')}] (default ${input.defaultProfile})`);
126
+ }
127
+ else {
128
+ console.log(`-- ${input.name}: limit 1..${input.maxSize} (default ${input.defaultSize})`);
129
+ }
130
+ }
131
+ }
132
+ if (query.syql.identity !== undefined) {
133
+ console.log(`-- identity: ${query.syql.identity.join(', ')}`);
134
+ }
135
+ console.log(`-- checked statements: ${query.syql.plan.statements.length} (activation controls: ${query.syql.plan.activationControls.join(', ') || 'none'})`);
136
+ for (const statement of query.syql.plan.statements) {
137
+ const selectors = [
138
+ ...(statement.activationMask === undefined
139
+ ? []
140
+ : [`mask=${statement.activationMask}`]),
141
+ ...(statement.sortProfile === undefined
142
+ ? []
143
+ : [`sort=${statement.sortProfile}`]),
144
+ ];
145
+ console.log(`-- [${selectors.join(', ') || 'default'}]`);
146
+ console.log(statement.sql);
147
+ console.log(`-- binds: ${statement.binds.map((bind) => `:${bind.name}`).join(', ') || '(none)'}`);
148
+ }
149
+ return;
150
+ }
112
151
  if (query.params.length > 0) {
113
152
  console.log('-- params:');
114
153
  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})`);
154
+ console.log(`-- :${param.name} ${param.type} (required)`);
121
155
  }
122
156
  }
123
157
  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
158
  }
136
159
  function parseManifestDir(argv) {
137
160
  let manifestDir = '.';
@@ -65,25 +65,148 @@ 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 === 'value' && input.default === false)
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 === 'limit')
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.default === false)
114
+ return paramValue(input.type, access);
115
+ if (input.required) {
116
+ return input.nullable
117
+ ? optionalParamValue(input.type, access)
118
+ : paramValue(input.type, access);
119
+ }
120
+ return input.nullable
121
+ ? `${access}.isPresent ? ${optionalParamValue(input.type, `${access}.value`)} : null`
122
+ : optionalParamValue(input.type, access);
123
+ }
124
+ function emitSyqlDartTypes(query) {
77
125
  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('}');
126
+ for (const input of query.syql?.inputs ?? []) {
127
+ if (input.kind === 'group') {
128
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
129
+ lines.push(`class ${name} {`);
130
+ for (const member of input.members) {
131
+ lines.push(` final ${syqlDartType(member.type, member.nullable)} ${camelCase(member.langName)};`);
132
+ }
133
+ const args = input.members
134
+ .map((member) => `required this.${camelCase(member.langName)}`)
135
+ .join(', ');
136
+ lines.push(` const ${name}({${args}});`, '}', '');
137
+ }
138
+ else if (input.kind === 'sort') {
139
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
140
+ lines.push(`enum ${name} { ${input.profiles.map((profile) => camelCase(profile.langName)).join(', ')} }`, '');
141
+ }
142
+ }
143
+ if (lines[lines.length - 1] === '')
144
+ lines.pop();
145
+ return lines;
146
+ }
147
+ function emitSyqlDartRunner(query) {
148
+ const metadata = query.syql;
149
+ if (metadata === undefined)
150
+ throw new Error('missing SYQL metadata');
151
+ const Row = `${typeName(query.name)}Row`;
152
+ const Pascal = pascalCase(query.name);
153
+ const lines = [];
154
+ 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.`);
155
+ const args = [];
156
+ for (const input of metadata.inputs) {
157
+ const name = camelCase(input.langName);
158
+ if (input.kind === 'value') {
159
+ const type = syqlDartType(input.type, input.nullable);
160
+ if (input.required)
161
+ args.push(`required ${type} ${name}`);
162
+ else if (input.default === false)
163
+ args.push(`bool ${name} = false`);
164
+ else if (input.nullable) {
165
+ args.push(`SyqlQueryPresence<${type}> ${name} = const SyqlQueryPresence.absent()`);
166
+ }
167
+ else
168
+ args.push(`${type}? ${name}`);
169
+ }
170
+ else if (input.kind === 'group') {
171
+ args.push(`${typeName(query.name)}${typeName(input.langName)}? ${name}`);
172
+ }
173
+ else if (input.kind === 'sort') {
174
+ const defaultCase = input.profiles.find((profile) => profile.name === input.defaultProfile)
175
+ ?.langName ?? input.defaultProfile;
176
+ const type = `${typeName(query.name)}${typeName(input.langName)}`;
177
+ args.push(`${type} ${name} = ${type}.${camelCase(defaultCase)}`);
178
+ }
179
+ else {
180
+ args.push(`int? ${name}`);
181
+ }
182
+ }
183
+ const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
184
+ lines.push(`List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`);
185
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
186
+ if (page?.kind === 'limit') {
187
+ const name = camelCase(page.langName);
188
+ lines.push(` final effective${typeName(name)} = ${name} ?? ${page.defaultSize};`, ` if (effective${typeName(name)} < 1 || effective${typeName(name)} > ${page.maxSize}) {`, ` throw SyqlQueryInputException('SYQL_RUNTIME_INVALID_LIMIT', ${quote(`${query.name}: invalid limit`)});`, ' }');
189
+ }
190
+ if (metadata.plan.backend === 'variants') {
191
+ lines.push(' var activationMask = 0;');
192
+ metadata.plan.activationControls.forEach((control, index) => {
193
+ lines.push(` if (${syqlControlActive(query, control)}) activationMask |= ${2 ** index};`);
194
+ });
195
+ }
196
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
197
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
198
+ const sortIndex = sort?.kind === 'sort' ? `${camelCase(sort.langName)}.index` : '0';
199
+ const index = metadata.plan.backend === 'variants'
200
+ ? `activationMask * ${profileCount} + ${sortIndex}`
201
+ : sortIndex;
202
+ lines.push(` final statementIndex = ${index};`, ' late final String sql;', ' late final List<Object?> params;', ' switch (statementIndex) {');
203
+ metadata.plan.statements.forEach((statement, statementIndex) => {
204
+ const binds = statement.binds
205
+ .map((bind) => syqlBindExpr(query, bind))
206
+ .join(', ');
207
+ lines.push(` case ${statementIndex}: sql = ${quote(statement.positionalSql)}; params = <Object?>[${binds}]; break;`);
208
+ });
209
+ lines.push(" default: throw StateError('invalid generated SYQL statement index');", ' }', ` return client.query(sql, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`, '}');
87
210
  return lines;
88
211
  }
89
212
  function emitClass(query) {
@@ -121,52 +244,30 @@ function emitClass(query) {
121
244
  return lines;
122
245
  }
123
246
  function emitRunner(query) {
247
+ if (query.syql !== undefined)
248
+ return emitSyqlDartRunner(query);
124
249
  const Row = `${typeName(query.name)}Row`;
125
250
  const Pascal = pascalCase(query.name);
126
251
  const lines = [];
127
252
  lines.push(`/// Tables the ${query.name} query reads (exact invalidation set).`);
128
253
  lines.push(`const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`);
129
254
  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
- }
255
+ lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
136
256
  lines.push('');
137
257
  lines.push(`/// Run the ${query.name} named query (SELECT-only).`);
138
258
  const args = [];
139
259
  for (const p of query.params) {
140
260
  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}`);
261
+ args.push(`required ${DART_TYPE[p.type]} ${name}`);
153
262
  }
154
263
  const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
155
264
  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`;
265
+ const sqlRef = `_${query.name}Sql`;
163
266
  if (query.params.length > 0) {
164
267
  const binds = query.params
165
268
  .map((p) => {
166
269
  const name = camelCase(p.langName);
167
- return isOptionalParam(query, p)
168
- ? optionalParamValue(p.type, name)
169
- : paramValue(p.type, name);
270
+ return paramValue(p.type, name);
170
271
  })
171
272
  .join(', ');
172
273
  lines.push(` final params = <Object?>[${binds}];`);
@@ -188,6 +289,24 @@ export function emitQueriesDartModule(queries, hash, irVersion) {
188
289
  '',
189
290
  "import 'package:syncular/syncular.dart';",
190
291
  ].join('\n'));
292
+ if (queries.some((query) => query.syql !== undefined)) {
293
+ parts.push([
294
+ 'class SyqlQueryPresence<T> {',
295
+ ' final bool isPresent;',
296
+ ' final T? value;',
297
+ ' const SyqlQueryPresence.absent() : isPresent = false, value = null;',
298
+ ' const SyqlQueryPresence.present(this.value) : isPresent = true;',
299
+ '}',
300
+ '',
301
+ 'class SyqlQueryInputException implements Exception {',
302
+ ' final String code;',
303
+ ' final String message;',
304
+ ' const SyqlQueryInputException(this.code, this.message);',
305
+ ' @override',
306
+ " String toString() => '$code: $message';",
307
+ '}',
308
+ ].join('\n'));
309
+ }
191
310
  parts.push([
192
311
  '/// Lift a SQLite boolean: a real bool, or 0/1 as a number.',
193
312
  'bool? _queryRowBool(Object? value) {',
@@ -213,17 +332,11 @@ export function emitQueriesDartModule(queries, hash, irVersion) {
213
332
  " return {r'$bytes': hex};",
214
333
  '}',
215
334
  ].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
335
  for (const query of queries) {
336
+ const syqlTypes = emitSyqlDartTypes(query);
337
+ if (syqlTypes.length > 0)
338
+ parts.push(syqlTypes.join('\n'));
223
339
  parts.push(emitClass(query).join('\n'));
224
- const orderByEnum = emitOrderByEnum(query);
225
- if (orderByEnum.length > 0)
226
- parts.push(orderByEnum.join('\n'));
227
340
  }
228
341
  for (const query of queries) {
229
342
  parts.push(emitRunner(query).join('\n'));