@syncular/typegen 0.15.13 → 0.15.15

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.
@@ -49,6 +49,29 @@ function propertyKey(name: string): string {
49
49
  return IDENTIFIER_RE.test(name) ? name : quote(name);
50
50
  }
51
51
 
52
+ function emitResultMapper(query: AnalyzedQuery, Row: string): string[] {
53
+ const booleans = query.columns.filter((column) => column.type === 'boolean');
54
+ const lines = [
55
+ `/** Decode one storage-shaped result row for ${quote(query.name)}. */`,
56
+ `export function ${query.name}MapRow(row: Readonly<Record<string, unknown>>): ${Row} {`,
57
+ ];
58
+ if (booleans.length === 0) {
59
+ lines.push(` return row as unknown as ${Row};`);
60
+ } else {
61
+ lines.push(' return {', ' ...row,');
62
+ for (const column of booleans) {
63
+ const access = `row[${quote(column.langName)}]`;
64
+ const decoded = `decodeQueryBoolean(${access}, ${quote(query.name)}, ${quote(column.langName)})`;
65
+ lines.push(
66
+ ` ${propertyKey(column.langName)}: ${column.nullable ? `${access} === null ? null : ${decoded}` : decoded},`,
67
+ );
68
+ }
69
+ lines.push(` } as unknown as ${Row};`);
70
+ }
71
+ lines.push('}');
72
+ return lines;
73
+ }
74
+
52
75
  /** A named-query param value is the SqlValue subset its type maps to. */
53
76
  const PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = TS_TYPE;
54
77
  const SYQL_PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = {
@@ -235,6 +258,7 @@ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
235
258
  );
236
259
  }
237
260
  lines.push('}', '');
261
+ lines.push(...emitResultMapper(query, Row), '');
238
262
 
239
263
  for (const input of inputs) {
240
264
  if (input.kind === 'group') {
@@ -349,7 +373,7 @@ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
349
373
  `export async function ${query.name}(client: QueryClient${runnerParam}): Promise<${Row}[]> {`,
350
374
  ` const selected = ${query.name}Select(${hasParams ? 'params' : ''});`,
351
375
  ' const rows = await client.query(selected.sql, selected.bind);',
352
- ` return rows as unknown as ${Row}[];`,
376
+ ` return rows.map((row) => ${query.name}MapRow(row as Readonly<Record<string, unknown>>));`,
353
377
  '}',
354
378
  '',
355
379
  );
@@ -367,6 +391,7 @@ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
367
391
  ` id: ${quote(`${hash}/${query.name}`)},`,
368
392
  ` hasParams: ${hasParams},`,
369
393
  ` sql: ${quote(defaultStatement?.positionalSql ?? query.positionalSql)},`,
394
+ ` mapRow: ${query.name}MapRow,`,
370
395
  );
371
396
  if (hasParams) {
372
397
  lines.push(
@@ -452,6 +477,8 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
452
477
  }
453
478
  lines.push('}');
454
479
  lines.push('');
480
+ lines.push(...emitResultMapper(query, Row));
481
+ lines.push('');
455
482
 
456
483
  const hasParams = query.params.length > 0;
457
484
  if (hasParams) {
@@ -492,7 +519,9 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
492
519
  } else {
493
520
  lines.push(` const rows = await client.query(${sqlConst});`);
494
521
  }
495
- lines.push(` return rows as unknown as ${Row}[];`);
522
+ lines.push(
523
+ ` return rows.map((row) => ${query.name}MapRow(row as Readonly<Record<string, unknown>>));`,
524
+ );
496
525
  lines.push('}');
497
526
  lines.push('');
498
527
 
@@ -506,6 +535,7 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
506
535
  lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
507
536
  lines.push(` hasParams: ${hasParams},`);
508
537
  lines.push(` sql: ${sqlConst},`);
538
+ lines.push(` mapRow: ${query.name}MapRow,`);
509
539
  lines.push(` tables: ${query.name}Tables,`);
510
540
  const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
511
541
  dependency.scopes.some((scope) => scope.params.length > 0),
@@ -597,6 +627,30 @@ export function emitQueriesModule(
597
627
  ].join('\n'),
598
628
  );
599
629
  }
630
+ if (
631
+ queries.some((query) =>
632
+ query.columns.some((column) => column.type === 'boolean'),
633
+ )
634
+ ) {
635
+ parts.push(
636
+ [
637
+ '/** A generated named-query row did not match its analyzed result type. */',
638
+ 'export class QueryResultDecodeError extends TypeError {',
639
+ " readonly name = 'QueryResultDecodeError';",
640
+ ' constructor(readonly query: string, readonly column: string) {',
641
+ " super(query + ': result column ' + column + ' is not a SQLite boolean');",
642
+ ' }',
643
+ '}',
644
+ '',
645
+ '/** Lift a SQLite boolean: preserve booleans, map 0 to false and finite non-zero numbers to true. */',
646
+ 'function decodeQueryBoolean(value: unknown, query: string, column: string): boolean {',
647
+ " if (typeof value === 'boolean') return value;",
648
+ " if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;",
649
+ ' throw new QueryResultDecodeError(query, column);',
650
+ '}',
651
+ ].join('\n'),
652
+ );
653
+ }
600
654
  parts.push(
601
655
  [
602
656
  "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
@@ -627,6 +681,7 @@ export function emitQueriesModule(
627
681
  ' readonly id: string;',
628
682
  ' readonly hasParams: boolean;',
629
683
  ' readonly sql: string;',
684
+ ' readonly mapRow: (row: Readonly<Record<string, unknown>>) => Row;',
630
685
  ' readonly tables: readonly string[];',
631
686
  ' readonly bind: (params: Params) => readonly QueryValue[];',
632
687
  ' readonly sqlFor?: (params: Params) => string;',
package/src/generate.ts CHANGED
@@ -18,6 +18,7 @@ import { emitKotlinModule } from './emit-kotlin';
18
18
  import { emitQueriesModule } from './emit-queries';
19
19
  import { emitQueriesDartModule } from './emit-queries-dart';
20
20
  import { emitQueriesKotlinModule } from './emit-queries-kotlin';
21
+ import { emitQueriesRustModule } from './emit-queries-rust';
21
22
  import { emitQueriesSwiftModule } from './emit-queries-swift';
22
23
  import { emitSwiftModule } from './emit-swift';
23
24
  import { TypegenError } from './errors';
@@ -555,6 +556,7 @@ export function generate(manifestDir: string): GenerateResult {
555
556
  if (manifest.output.swift !== undefined) targets.push('swift');
556
557
  if (manifest.output.kotlin !== undefined) targets.push('kotlin');
557
558
  if (manifest.output.dart !== undefined) targets.push('dart');
559
+ if (manifest.output.rust !== undefined) targets.push('rust');
558
560
  const naming: QueryNamingOptions = {
559
561
  naming: manifest.naming,
560
562
  targets,
@@ -586,6 +588,7 @@ export function generate(manifestDir: string): GenerateResult {
586
588
  swift,
587
589
  kotlin,
588
590
  dart,
591
+ rust,
589
592
  } = manifest.output;
590
593
 
591
594
  // Named queries: analyzed once (SELECT-only, typed against the IR via
@@ -596,7 +599,8 @@ export function generate(manifestDir: string): GenerateResult {
596
599
  tsQueriesPath !== undefined ||
597
600
  swift?.queriesPath !== undefined ||
598
601
  kotlin?.queriesPath !== undefined ||
599
- dart?.queriesPath !== undefined;
602
+ dart?.queriesPath !== undefined ||
603
+ rust?.queriesPath !== undefined;
600
604
  const analyzedQueries = wantsQueries
601
605
  ? analyzeQueries(
602
606
  ir,
@@ -678,6 +682,17 @@ export function generate(manifestDir: string): GenerateResult {
678
682
  });
679
683
  }
680
684
  }
685
+ if (rust !== undefined) {
686
+ outputs.push({
687
+ path: resolve(manifestDir, rust.queriesPath),
688
+ content: emitQueriesRustModule(
689
+ analyzedQueries,
690
+ queryHash,
691
+ ir.irVersion,
692
+ rust.clientCrate,
693
+ ),
694
+ });
695
+ }
681
696
  return {
682
697
  ir,
683
698
  irJson,
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './emit-kotlin';
9
9
  export * from './emit-queries';
10
10
  export * from './emit-queries-dart';
11
11
  export * from './emit-queries-kotlin';
12
+ export * from './emit-queries-rust';
12
13
  export * from './emit-queries-swift';
13
14
  export * from './emit-swift';
14
15
  export * from './errors';
package/src/lsp.ts CHANGED
@@ -269,6 +269,7 @@ export class SyqlLanguageServer {
269
269
  if (manifest.output.swift !== undefined) targets.push('swift');
270
270
  if (manifest.output.kotlin !== undefined) targets.push('kotlin');
271
271
  if (manifest.output.dart !== undefined) targets.push('dart');
272
+ if (manifest.output.rust !== undefined) targets.push('rust');
272
273
  const lookup: ProjectLookup = {
273
274
  kind: 'ready',
274
275
  context: {
package/src/manifest.ts CHANGED
@@ -28,20 +28,21 @@
28
28
  * placeholders (partial templates are unsupported).
29
29
  * - `output.ir` / `output.module` are the always-emitted TS defaults;
30
30
  * `output.queryIr` opts into the deterministic analyzed-query document.
31
- * `output.swift` / `output.kotlin` / `output.dart` are OPT-IN native
31
+ * `output.swift` / `output.kotlin` / `output.dart` are OPT-IN native schema
32
32
  * emitters — a bare string is the output path; an object carries
33
33
  * language-appropriate options (Kotlin `package`/`objectName`, Swift
34
34
  * `enumName`; Dart takes `path` only). This is additive within the `output`
35
35
  * object (the same forward-extension shape `output.ir`/`output.module`
36
36
  * already use); no `manifestVersion` bump — new *recognized* keys, not
37
37
  * tolerated-unknown ones. Absent → that language is not generated (TS
38
- * stays the default).
38
+ * stays the default). `output.rust.queriesPath` opts into Rust named-query
39
+ * source over the neutral schema IR consumed by `syncular-client`.
39
40
  * - Unknown keys are hard errors everywhere (fail loud; growth happens by
40
41
  * bumping `manifestVersion`), except inside `extensions`, the reserved
41
42
  * WP-49 passthrough slot copied verbatim into the IR.
42
43
  */
43
44
  import { TypegenError } from './errors';
44
- import type { NamingMode } from './naming';
45
+ import { isRustKeyword, type NamingMode } from './naming';
45
46
 
46
47
  /** §7/§8 `.syql` conditional-lowering backend selection. */
47
48
  export type ManifestQueryBackend = 'neutralize' | 'variants' | 'auto';
@@ -103,6 +104,14 @@ export interface DartOutput {
103
104
  readonly queriesPath?: string;
104
105
  }
105
106
 
107
+ /** Rust named-query output. Rust consumes neutral schema IR at runtime, so
108
+ * revision 1 has no separate generated schema source path. */
109
+ export interface RustOutput {
110
+ readonly queriesPath: string;
111
+ /** Rust module identifier for the `syncular-client` Cargo dependency. */
112
+ readonly clientCrate: string;
113
+ }
114
+
106
115
  export interface ManifestOutput {
107
116
  readonly ir: string;
108
117
  readonly module: string;
@@ -116,6 +125,7 @@ export interface ManifestOutput {
116
125
  readonly swift?: SwiftOutput;
117
126
  readonly kotlin?: KotlinOutput;
118
127
  readonly dart?: DartOutput;
128
+ readonly rust?: RustOutput;
119
129
  }
120
130
 
121
131
  export interface Manifest {
@@ -254,6 +264,26 @@ function parseDartOutput(value: unknown): DartOutput {
254
264
  };
255
265
  }
256
266
 
267
+ const RUST_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
268
+
269
+ function parseRustOutput(value: unknown): RustOutput {
270
+ const obj = asObject(value, 'output.rust');
271
+ rejectUnknownKeys(obj, ['queriesPath', 'clientCrate'], 'output.rust');
272
+ const clientCrate =
273
+ obj.clientCrate === undefined
274
+ ? 'syncular_client'
275
+ : asString(obj.clientCrate, 'output.rust.clientCrate');
276
+ if (!RUST_IDENTIFIER_RE.test(clientCrate) || isRustKeyword(clientCrate)) {
277
+ fail(
278
+ `output.rust.clientCrate must be one Rust identifier, got ${JSON.stringify(clientCrate)}`,
279
+ );
280
+ }
281
+ return {
282
+ queriesPath: asString(obj.queriesPath, 'output.rust.queriesPath'),
283
+ clientCrate,
284
+ };
285
+ }
286
+
257
287
  function parseTable(value: unknown, index: number): ManifestTable {
258
288
  const context = `tables[${index}]`;
259
289
  const obj = asObject(value, context);
@@ -407,11 +437,12 @@ export function parseManifest(raw: unknown): Manifest {
407
437
  let swift: SwiftOutput | undefined;
408
438
  let kotlin: KotlinOutput | undefined;
409
439
  let dart: DartOutput | undefined;
440
+ let rust: RustOutput | undefined;
410
441
  if (obj.output !== undefined) {
411
442
  const output = asObject(obj.output, 'output');
412
443
  rejectUnknownKeys(
413
444
  output,
414
- ['ir', 'module', 'queryIr', 'queries', 'swift', 'kotlin', 'dart'],
445
+ ['ir', 'module', 'queryIr', 'queries', 'swift', 'kotlin', 'dart', 'rust'],
415
446
  'output',
416
447
  );
417
448
  if (output.ir !== undefined) ir = asString(output.ir, 'output.ir');
@@ -427,6 +458,7 @@ export function parseManifest(raw: unknown): Manifest {
427
458
  if (output.swift !== undefined) swift = parseSwiftOutput(output.swift);
428
459
  if (output.kotlin !== undefined) kotlin = parseKotlinOutput(output.kotlin);
429
460
  if (output.dart !== undefined) dart = parseDartOutput(output.dart);
461
+ if (output.rust !== undefined) rust = parseRustOutput(output.rust);
430
462
  }
431
463
  // Build `output` with only the keys that are set — `exactOptionalPropertyTypes`
432
464
  // forbids explicit `undefined` on optional properties.
@@ -438,6 +470,7 @@ export function parseManifest(raw: unknown): Manifest {
438
470
  ...(swift !== undefined ? { swift } : {}),
439
471
  ...(kotlin !== undefined ? { kotlin } : {}),
440
472
  ...(dart !== undefined ? { dart } : {}),
473
+ ...(rust !== undefined ? { rust } : {}),
441
474
  };
442
475
  if (!Array.isArray(obj.schemaVersions) || obj.schemaVersions.length === 0) {
443
476
  fail('schemaVersions must be a non-empty array (§1.5 version history)');
package/src/naming.ts CHANGED
@@ -47,7 +47,7 @@ export function snakeToCamel(name: string): string {
47
47
  /** The emitter targets whose keyword sets we police. `ts` is included for
48
48
  * completeness (its emitters can quote any property, so its list is the
49
49
  * small set that breaks generated FUNCTION/const identifiers). */
50
- export type NamingTarget = 'ts' | 'swift' | 'kotlin' | 'dart';
50
+ export type NamingTarget = 'ts' | 'swift' | 'kotlin' | 'dart' | 'rust';
51
51
 
52
52
  /** Reserved words that cannot be a generated field/property name on each
53
53
  * target. Deliberately the core keyword lists — mechanical and predictable
@@ -211,8 +211,157 @@ const TARGET_KEYWORDS: Readonly<Record<NamingTarget, ReadonlySet<string>>> = {
211
211
  'while',
212
212
  'with',
213
213
  ]),
214
+ rust: new Set([
215
+ 'abstract',
216
+ 'as',
217
+ 'async',
218
+ 'await',
219
+ 'become',
220
+ 'box',
221
+ 'break',
222
+ 'const',
223
+ 'continue',
224
+ 'crate',
225
+ 'do',
226
+ 'dyn',
227
+ 'else',
228
+ 'enum',
229
+ 'extern',
230
+ 'false',
231
+ 'final',
232
+ 'fn',
233
+ 'for',
234
+ 'gen',
235
+ 'if',
236
+ 'impl',
237
+ 'in',
238
+ 'let',
239
+ 'loop',
240
+ 'macro',
241
+ 'match',
242
+ 'mod',
243
+ 'move',
244
+ 'mut',
245
+ 'override',
246
+ 'priv',
247
+ 'pub',
248
+ 'ref',
249
+ 'return',
250
+ 'self',
251
+ 'static',
252
+ 'struct',
253
+ 'super',
254
+ 'trait',
255
+ 'true',
256
+ 'try',
257
+ 'type',
258
+ 'typeof',
259
+ 'unsafe',
260
+ 'unsized',
261
+ 'use',
262
+ 'virtual',
263
+ 'where',
264
+ 'while',
265
+ 'yield',
266
+ ]),
214
267
  };
215
268
 
269
+ export function isRustKeyword(name: string): boolean {
270
+ return TARGET_KEYWORDS.rust.has(name);
271
+ }
272
+
273
+ /** Pinned Rust identifier conversion from RFC 0006. */
274
+ export function rustSnakeCase(name: string): string {
275
+ if (!/^_*[A-Za-z][A-Za-z0-9_]*$/.test(name)) return name;
276
+ const lead = /^_*/.exec(name)?.[0] ?? '';
277
+ const withoutLead = name.slice(lead.length);
278
+ const trail = /_*$/.exec(withoutLead)?.[0] ?? '';
279
+ const middle = withoutLead.slice(0, withoutLead.length - trail.length);
280
+ const out: string[] = [];
281
+ for (let index = 0; index < middle.length; index++) {
282
+ const char = middle[index] as string;
283
+ if (char === '_') {
284
+ if (out.length > 0 && out[out.length - 1] !== '_') out.push('_');
285
+ continue;
286
+ }
287
+ const previous = index > 0 ? middle[index - 1] : undefined;
288
+ const next = index + 1 < middle.length ? middle[index + 1] : undefined;
289
+ const uppercase = char >= 'A' && char <= 'Z';
290
+ const previousLowerOrDigit =
291
+ previous !== undefined && /[a-z0-9]/.test(previous);
292
+ const acronymBoundary =
293
+ uppercase &&
294
+ previous !== undefined &&
295
+ /[A-Z]/.test(previous) &&
296
+ next !== undefined &&
297
+ /[a-z]/.test(next);
298
+ if (
299
+ uppercase &&
300
+ (previousLowerOrDigit || acronymBoundary) &&
301
+ out.length > 0 &&
302
+ out[out.length - 1] !== '_'
303
+ ) {
304
+ out.push('_');
305
+ }
306
+ out.push(char.toLowerCase());
307
+ }
308
+ return lead + out.join('') + trail;
309
+ }
310
+
311
+ export function rustPascalCase(name: string): string {
312
+ const snake = rustSnakeCase(name);
313
+ const lead = /^_*/.exec(snake)?.[0] ?? '';
314
+ const bare = snake.slice(lead.length).replace(/_*$/, '');
315
+ const suffix = snake.slice(lead.length + bare.length);
316
+ return (
317
+ lead +
318
+ bare
319
+ .split('_')
320
+ .filter((part) => part.length > 0)
321
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
322
+ .join('') +
323
+ suffix
324
+ );
325
+ }
326
+
327
+ export interface RustNameMapping {
328
+ readonly langName: string;
329
+ readonly rustName: string;
330
+ }
331
+
332
+ /** Validate and map QueryIR runtime names to emitted Rust identifiers. */
333
+ export function buildRustNamingMap(
334
+ langNames: readonly string[],
335
+ context: string,
336
+ scope: string,
337
+ ): RustNameMapping[] {
338
+ const seen = new Map<string, string>();
339
+ return langNames.map((langName) => {
340
+ const rustName = rustSnakeCase(langName);
341
+ if (!/^_*[a-z][a-z0-9_]*$/.test(rustName)) {
342
+ throw new TypegenError(
343
+ context,
344
+ `${scope}: ${JSON.stringify(langName)} cannot be emitted as a Rust identifier — alias it in SQL (AS)`,
345
+ );
346
+ }
347
+ if (TARGET_KEYWORDS.rust.has(rustName)) {
348
+ throw new TypegenError(
349
+ context,
350
+ `${scope}: ${JSON.stringify(langName)} maps to ${JSON.stringify(rustName)}, a reserved word on the rust target — alias it in SQL (AS)`,
351
+ );
352
+ }
353
+ const clash = seen.get(rustName);
354
+ if (clash !== undefined && clash !== langName) {
355
+ throw new TypegenError(
356
+ context,
357
+ `${scope}: ${JSON.stringify(clash)} and ${JSON.stringify(langName)} both map to ${JSON.stringify(rustName)} on the rust target — alias one in SQL (AS)`,
358
+ );
359
+ }
360
+ seen.set(rustName, langName);
361
+ return { langName, rustName };
362
+ });
363
+ }
364
+
216
365
  /** One naming-map entry: the SQL-truth name and its language-facing name. */
217
366
  export interface NameMapping {
218
367
  readonly sqlName: string;
@@ -248,10 +397,11 @@ export function buildNamingMap(
248
397
  );
249
398
  }
250
399
  for (const target of targets) {
251
- if (TARGET_KEYWORDS[target].has(langName)) {
400
+ const targetName = target === 'rust' ? rustSnakeCase(langName) : langName;
401
+ if (TARGET_KEYWORDS[target].has(targetName)) {
252
402
  throw new TypegenError(
253
403
  context,
254
- `${scope}: ${JSON.stringify(sqlName)} maps to ${JSON.stringify(langName)}, a reserved word on the ${target} target — rename it, alias it in SQL (AS), or set "naming": "preserve" in syncular.json`,
404
+ `${scope}: ${JSON.stringify(sqlName)} maps to ${JSON.stringify(targetName)}, a reserved word on the ${target} target — rename it, alias it in SQL (AS), or set "naming": "preserve" in syncular.json`,
255
405
  );
256
406
  }
257
407
  if (target === 'dart' && langName.startsWith('_')) {