@syncular/typegen 0.1.3 → 0.2.1

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 (99) hide show
  1. package/README.md +436 -94
  2. package/dist/cli.d.ts +1 -2
  3. package/dist/cli.js +159 -73
  4. package/dist/emit-dart.d.ts +17 -0
  5. package/dist/emit-dart.js +201 -0
  6. package/dist/emit-kotlin.d.ts +16 -0
  7. package/dist/emit-kotlin.js +201 -0
  8. package/dist/emit-queries-dart.d.ts +2 -0
  9. package/dist/emit-queries-dart.js +160 -0
  10. package/dist/emit-queries-kotlin.d.ts +2 -0
  11. package/dist/emit-queries-kotlin.js +162 -0
  12. package/dist/emit-queries-swift.d.ts +2 -0
  13. package/dist/emit-queries-swift.js +146 -0
  14. package/dist/emit-queries.d.ts +2 -0
  15. package/dist/emit-queries.js +129 -0
  16. package/dist/emit-swift.d.ts +18 -0
  17. package/dist/emit-swift.js +235 -0
  18. package/dist/emit.d.ts +16 -0
  19. package/dist/emit.js +191 -0
  20. package/dist/errors.d.ts +6 -0
  21. package/dist/errors.js +10 -0
  22. package/dist/generate.d.ts +53 -18
  23. package/dist/generate.js +391 -77
  24. package/dist/index.d.ts +17 -14
  25. package/dist/index.js +16 -13
  26. package/dist/init.d.ts +12 -0
  27. package/dist/init.js +85 -0
  28. package/dist/ir.d.ts +90 -0
  29. package/dist/ir.js +94 -0
  30. package/dist/manifest.d.ts +76 -0
  31. package/dist/manifest.js +303 -0
  32. package/dist/query.d.ts +96 -0
  33. package/dist/query.js +719 -0
  34. package/dist/sql.d.ts +13 -0
  35. package/dist/sql.js +440 -0
  36. package/package.json +14 -34
  37. package/src/cli.ts +161 -82
  38. package/src/emit-dart.ts +245 -0
  39. package/src/emit-kotlin.ts +257 -0
  40. package/src/emit-queries-dart.ts +203 -0
  41. package/src/emit-queries-kotlin.ts +209 -0
  42. package/src/emit-queries-swift.ts +198 -0
  43. package/src/emit-queries.ts +183 -0
  44. package/src/emit-swift.ts +295 -0
  45. package/src/emit.ts +239 -0
  46. package/src/errors.ts +11 -0
  47. package/src/generate.ts +574 -105
  48. package/src/index.ts +16 -13
  49. package/src/init.ts +100 -0
  50. package/src/ir.ts +197 -0
  51. package/src/manifest.ts +445 -0
  52. package/src/query.ts +918 -0
  53. package/src/sql.ts +533 -0
  54. package/dist/app-contract.d.ts +0 -154
  55. package/dist/app-contract.d.ts.map +0 -1
  56. package/dist/app-contract.js +0 -250
  57. package/dist/app-contract.js.map +0 -1
  58. package/dist/checksums.d.ts +0 -6
  59. package/dist/checksums.d.ts.map +0 -1
  60. package/dist/checksums.js +0 -173
  61. package/dist/checksums.js.map +0 -1
  62. package/dist/cli.d.ts.map +0 -1
  63. package/dist/cli.js.map +0 -1
  64. package/dist/generate.d.ts.map +0 -1
  65. package/dist/generate.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/introspect-postgres.d.ts +0 -8
  69. package/dist/introspect-postgres.d.ts.map +0 -1
  70. package/dist/introspect-postgres.js +0 -94
  71. package/dist/introspect-postgres.js.map +0 -1
  72. package/dist/introspect-sqlite.d.ts +0 -10
  73. package/dist/introspect-sqlite.d.ts.map +0 -1
  74. package/dist/introspect-sqlite.js +0 -97
  75. package/dist/introspect-sqlite.js.map +0 -1
  76. package/dist/introspect.d.ts +0 -8
  77. package/dist/introspect.d.ts.map +0 -1
  78. package/dist/introspect.js +0 -18
  79. package/dist/introspect.js.map +0 -1
  80. package/dist/map-types.d.ts +0 -20
  81. package/dist/map-types.d.ts.map +0 -1
  82. package/dist/map-types.js +0 -151
  83. package/dist/map-types.js.map +0 -1
  84. package/dist/render.d.ts +0 -23
  85. package/dist/render.d.ts.map +0 -1
  86. package/dist/render.js +0 -140
  87. package/dist/render.js.map +0 -1
  88. package/dist/types.d.ts +0 -122
  89. package/dist/types.d.ts.map +0 -1
  90. package/dist/types.js +0 -5
  91. package/dist/types.js.map +0 -1
  92. package/src/app-contract.ts +0 -531
  93. package/src/checksums.ts +0 -257
  94. package/src/introspect-postgres.ts +0 -149
  95. package/src/introspect-sqlite.ts +0 -156
  96. package/src/introspect.ts +0 -36
  97. package/src/map-types.ts +0 -189
  98. package/src/render.ts +0 -196
  99. package/src/types.ts +0 -137
package/src/cli.ts CHANGED
@@ -1,104 +1,183 @@
1
1
  #!/usr/bin/env bun
2
+ /**
3
+ * The `syncular` CLI. Subcommands:
4
+ *
5
+ * generate [--manifest-dir <dir>] [--check] [--watch]
6
+ * Reads `syncular.json` + `migrations/` under the manifest dir and writes
7
+ * the neutral IR JSON and generated TS module (paths from the manifest's
8
+ * `output`). `--check` regenerates in memory and fails (exit 1) unless the
9
+ * files on disk match byte-exactly. `--watch` regenerates on change.
10
+ *
11
+ * init [--manifest-dir <dir>]
12
+ * Drops a starter `syncular.json` + `migrations/0001_initial` into an
13
+ * existing project (the "add syncular to my app" path).
14
+ *
15
+ * The generate CONTRACT (inputs, outputs, byte-exact `--check`) is unchanged;
16
+ * this file only adds `init`, `--watch`, and friendlier errors.
17
+ */
18
+ import { watch } from 'node:fs';
19
+ import { resolve } from 'node:path';
20
+ import { checkOutputs, generate, writeOutputs } from './generate';
21
+ import { InitError, initProject } from './init';
22
+ import { MANIFEST_FILENAME } from './manifest';
2
23
 
3
- import { readFile } from 'node:fs/promises';
4
- import { dirname, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import {
7
- loadSyncularClientContract,
8
- toSyncularCodegenJson,
9
- writeSyncularCodegenJsonFromModule,
10
- } from './app-contract';
24
+ const DOCS_HINT =
25
+ 'See the schema guide: https://github.com/bkniffler/syncular (guide-schema), ' +
26
+ 'or run `syncular init` to scaffold a starter manifest + migration.';
11
27
 
12
- interface CodegenConfigCommand {
13
- app: string;
14
- out: string;
15
- exportName?: string;
28
+ const USAGE = `usage: syncular <command> [options]
29
+
30
+ commands:
31
+ generate build the IR + typed module from syncular.json + migrations/
32
+ init scaffold a starter syncular.json + migrations/0001_initial
33
+
34
+ generate options:
35
+ --manifest-dir <dir> directory holding syncular.json (default: .)
36
+ --check fail unless generated files are byte-exactly fresh
37
+ --watch regenerate on file change (Ctrl-C to stop)
38
+
39
+ init options:
40
+ --manifest-dir <dir> directory to scaffold into (default: .)`;
41
+
42
+ function fail(message: string): never {
43
+ console.error(message);
44
+ process.exit(1);
45
+ }
46
+
47
+ interface GenerateArgs {
48
+ manifestDir: string;
16
49
  check: boolean;
50
+ watch: boolean;
17
51
  }
18
52
 
19
- async function main(argv: string[]): Promise<void> {
20
- const command = argv[0];
21
- if (command !== 'codegen-config') {
22
- printUsageAndExit(command ? `Unknown command ${command}` : undefined);
23
- }
24
- const options = parseCodegenConfigArgs(argv.slice(1));
25
- if (options.check) {
26
- const contract = await loadSyncularClientContract({
27
- modulePath: options.app,
28
- exportName: options.exportName,
29
- });
30
- const expected = toSyncularCodegenJson(contract);
31
- const actual = await readFile(options.out, 'utf8').catch((error) => {
32
- throw new Error(
33
- `Cannot read ${options.out}; run without --check to generate it first: ${error.message}`
34
- );
35
- });
36
- if (actual !== expected) {
37
- throw new Error(
38
- `${options.out} does not match ${options.app}; run syncular generate --app ${options.app}`
39
- );
53
+ function parseGenerateArgs(argv: readonly string[]): GenerateArgs {
54
+ const args: GenerateArgs = { manifestDir: '.', check: false, watch: false };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const arg = argv[i];
57
+ if (arg === '--check') {
58
+ args.check = true;
59
+ } else if (arg === '--watch') {
60
+ args.watch = true;
61
+ } else if (arg === '--manifest-dir') {
62
+ const value = argv[i + 1];
63
+ if (value === undefined) fail('--manifest-dir requires a value');
64
+ args.manifestDir = value;
65
+ i += 1;
66
+ } else {
67
+ fail(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
40
68
  }
41
- return;
42
69
  }
43
- await writeSyncularCodegenJsonFromModule({
44
- modulePath: options.app,
45
- exportName: options.exportName,
46
- outputPath: options.out,
47
- });
70
+ if (args.check && args.watch) {
71
+ fail('--check and --watch cannot be combined');
72
+ }
73
+ return args;
48
74
  }
49
75
 
50
- function parseCodegenConfigArgs(args: string[]): CodegenConfigCommand {
51
- let app: string | undefined;
52
- let out: string | undefined;
53
- let exportName: string | undefined;
54
- let check = false;
55
- for (let index = 0; index < args.length; index += 1) {
56
- const arg = args[index];
57
- if (arg === '--app') {
58
- app = requireValue(args, (index += 1), '--app');
59
- } else if (arg === '--out') {
60
- out = requireValue(args, (index += 1), '--out');
61
- } else if (arg === '--export') {
62
- exportName = requireValue(args, (index += 1), '--export');
63
- } else if (arg === '--check') {
64
- check = true;
65
- } else if (arg === '--help' || arg === '-h') {
66
- printUsageAndExit();
76
+ function parseManifestDir(argv: readonly string[]): string {
77
+ let manifestDir = '.';
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const arg = argv[i];
80
+ if (arg === '--manifest-dir') {
81
+ const value = argv[i + 1];
82
+ if (value === undefined) fail('--manifest-dir requires a value');
83
+ manifestDir = value;
84
+ i += 1;
67
85
  } else {
68
- printUsageAndExit(`Unknown option ${arg}`);
86
+ fail(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
69
87
  }
70
88
  }
71
- if (!app) printUsageAndExit('Missing --app');
72
- return {
73
- app,
74
- out: out ?? defaultCodegenConfigPath(app),
75
- exportName,
76
- check,
77
- };
89
+ return manifestDir;
78
90
  }
79
91
 
80
- function defaultCodegenConfigPath(app: string): string {
81
- const appPath = app.startsWith('file:') ? fileURLToPath(app) : app;
82
- return join(dirname(appPath), 'generated', 'syncular.codegen.json');
92
+ /** Add a docs pointer to the fail-loud missing-input errors. */
93
+ function friendlyGenerateError(error: unknown): string {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ if (
96
+ message.includes('manifest not found') ||
97
+ message.includes('migrations directory does not exist') ||
98
+ message.includes('no migrations found')
99
+ ) {
100
+ return `${message}\n${DOCS_HINT}`;
101
+ }
102
+ return message;
83
103
  }
84
104
 
85
- function requireValue(args: string[], index: number, option: string): string {
86
- const value = args[index];
87
- if (!value || value.startsWith('--')) {
88
- printUsageAndExit(`${option} requires a value`);
105
+ function runGenerateOnce(args: GenerateArgs): void {
106
+ let result: ReturnType<typeof generate>;
107
+ try {
108
+ result = generate(args.manifestDir);
109
+ } catch (error) {
110
+ fail(friendlyGenerateError(error));
111
+ }
112
+ if (args.check) {
113
+ const drift = checkOutputs(result);
114
+ if (drift.length > 0) {
115
+ fail(`generated output is out of date:\n${drift.join('\n')}`);
116
+ }
117
+ console.log('generated output is up to date');
118
+ return;
119
+ }
120
+ writeOutputs(result);
121
+ for (const output of result.outputs) {
122
+ console.log(`wrote ${output.path}`);
89
123
  }
90
- return value;
91
124
  }
92
125
 
93
- function printUsageAndExit(message?: string): never {
94
- if (message) console.error(message);
95
- console.error(
96
- [
97
- 'Usage:',
98
- ' syncular-typegen codegen-config --app ./syncular.app.ts [--out ./generated/syncular.codegen.json] [--export app] [--check]',
99
- ].join('\n')
100
- );
101
- process.exit(message ? 1 : 0);
126
+ /** `--watch`: regenerate on any change under the manifest dir. Uses Bun's
127
+ * `fs.watch` recursively; debounced so a burst of writes triggers one run. */
128
+ function runGenerateWatch(args: GenerateArgs): void {
129
+ const dir = resolve(args.manifestDir);
130
+ const safeRun = (): void => {
131
+ try {
132
+ const result = generate(args.manifestDir);
133
+ // Skip the write when the outputs are already fresh — otherwise writing
134
+ // them (they live under the watched dir) would re-trigger the watcher in
135
+ // a loop.
136
+ if (checkOutputs(result).length === 0) return;
137
+ writeOutputs(result);
138
+ console.log(`regenerated ${result.modulePath}`);
139
+ } catch (error) {
140
+ // In watch mode a bad intermediate state is expected — report, don't die.
141
+ console.error(friendlyGenerateError(error));
142
+ }
143
+ };
144
+ safeRun();
145
+ console.log(`watching ${dir} for changes (Ctrl-C to stop)…`);
146
+ let timer: ReturnType<typeof setTimeout> | undefined;
147
+ watch(dir, { recursive: true }, () => {
148
+ if (timer !== undefined) clearTimeout(timer);
149
+ timer = setTimeout(safeRun, 50);
150
+ });
102
151
  }
103
152
 
104
- await main(process.argv.slice(2));
153
+ export function runCli(argv: readonly string[]): void {
154
+ const command = argv[0];
155
+ if (command === 'generate') {
156
+ const args = parseGenerateArgs(argv.slice(1));
157
+ if (args.watch) {
158
+ runGenerateWatch(args);
159
+ return;
160
+ }
161
+ runGenerateOnce(args);
162
+ return;
163
+ }
164
+ if (command === 'init') {
165
+ const dir = parseManifestDir(argv.slice(1));
166
+ try {
167
+ const result = initProject(dir);
168
+ for (const path of result.written) console.log(`wrote ${path}`);
169
+ console.log(
170
+ '\nNext: `syncular generate` to build the typed schema, then wire ' +
171
+ `${MANIFEST_FILENAME}'s "output.module" into your server + client.`,
172
+ );
173
+ } catch (error) {
174
+ fail(error instanceof InitError ? error.message : String(error));
175
+ }
176
+ return;
177
+ }
178
+ fail(USAGE);
179
+ }
180
+
181
+ if (import.meta.main) {
182
+ runCli(process.argv.slice(2));
183
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Dart emitter: neutral IR → one standalone `.dart` file exporting
3
+ *
4
+ * - `const syncularSchema` — the ServerSchema-compatible `Map<String,
5
+ * Object?>` built from the IR (fed straight into `SyncularClient.create(
6
+ * schema: …)`),
7
+ * - one class per table with typed fields (per the six §2.4 column types +
8
+ * blob_ref/crdt) and a `fromRow(Map<String, Object?>)` factory,
9
+ * - a `syncular<Name>Subscription` helper per subscription with a typed
10
+ * `scopes(...)` builder.
11
+ *
12
+ * The row shape is `Map<String, Object?>` — the exact type the Dart wrapper's
13
+ * `query`/`readRows` return. The header carries the IR hash so `--check`
14
+ * verifies freshness byte-exactly.
15
+ */
16
+ import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
17
+
18
+ /** §2.4 column type → honest Dart type. `json`/`blob_ref` are the raw
19
+ * canonical JSON string; `bytes`/`crdt` are opaque `List<int>`. */
20
+ const DART_TYPE: Readonly<Record<IrColumnType, string>> = {
21
+ string: 'String',
22
+ integer: 'int',
23
+ float: 'double',
24
+ boolean: 'bool',
25
+ json: 'String',
26
+ bytes: 'List<int>',
27
+ blob_ref: 'String',
28
+ crdt: 'List<int>',
29
+ };
30
+
31
+ /** §5.11: app-side Dart type — declaredType for an encrypted column. */
32
+ function appDartType(column: IrTable['columns'][number]): string {
33
+ const type =
34
+ column.encrypted === true && column.declaredType !== undefined
35
+ ? column.declaredType
36
+ : column.type;
37
+ return DART_TYPE[type];
38
+ }
39
+
40
+ function pascalCase(name: string): string {
41
+ return name
42
+ .split(/[_-]+/)
43
+ .filter((part) => part.length > 0)
44
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
45
+ .join('');
46
+ }
47
+
48
+ function camelCase(name: string): string {
49
+ const pascal = pascalCase(name);
50
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
51
+ }
52
+
53
+ function quote(value: string): string {
54
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$')}'`;
55
+ }
56
+
57
+ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
58
+ const lines: string[] = [];
59
+ const i = indent;
60
+ lines.push(`${i}<String, Object?>{`);
61
+ lines.push(`${i} 'version': ${ir.schemaVersion},`);
62
+ lines.push(`${i} 'tables': [`);
63
+ for (const table of ir.tables) {
64
+ lines.push(`${i} {`);
65
+ lines.push(`${i} 'name': ${quote(table.name)},`);
66
+ lines.push(`${i} 'primaryKey': ${quote(table.primaryKey)},`);
67
+ lines.push(`${i} 'columns': [`);
68
+ for (const column of table.columns) {
69
+ const parts = [
70
+ `'name': ${quote(column.name)}`,
71
+ `'type': ${quote(column.type)}`,
72
+ `'nullable': ${column.nullable}`,
73
+ ];
74
+ if (column.crdtType !== undefined) {
75
+ parts.push(`'crdtType': ${quote(column.crdtType)}`);
76
+ }
77
+ if (column.encrypted === true) {
78
+ parts.push("'encrypted': true");
79
+ parts.push(
80
+ `'declaredType': ${quote(column.declaredType ?? column.type)}`,
81
+ );
82
+ }
83
+ lines.push(`${i} {${parts.join(', ')}},`);
84
+ }
85
+ lines.push(`${i} ],`);
86
+ lines.push(`${i} 'scopes': [`);
87
+ for (const scope of table.scopes) {
88
+ lines.push(
89
+ `${i} {'pattern': ${quote(scope.pattern)}, 'column': ${quote(scope.column)}},`,
90
+ );
91
+ }
92
+ lines.push(`${i} ],`);
93
+ lines.push(`${i} },`);
94
+ }
95
+ lines.push(`${i} ],`);
96
+ lines.push(`${i}}`);
97
+ return lines;
98
+ }
99
+
100
+ /** The accessor lifting a §2.4 value out of a `Map<String, Object?>` row. */
101
+ function rowAccessor(column: { type: IrColumnType; name: string }): string {
102
+ const key = `row[${quote(column.name)}]`;
103
+ switch (column.type) {
104
+ case 'string':
105
+ case 'json':
106
+ case 'blob_ref':
107
+ return `${key} as String?`;
108
+ case 'integer':
109
+ return `(${key} as num?)?.toInt()`;
110
+ case 'float':
111
+ return `(${key} as num?)?.toDouble()`;
112
+ case 'boolean':
113
+ return `_rowBool(${key})`;
114
+ case 'bytes':
115
+ case 'crdt':
116
+ return `_rowBytes(${key})`;
117
+ }
118
+ }
119
+
120
+ function emitClass(table: IrTable): string[] {
121
+ const type = pascalCase(table.name);
122
+ const lines: string[] = [];
123
+ lines.push(`/// One ${table.name} row (§2.4 column order).`);
124
+ lines.push(`class ${type} {`);
125
+ for (const column of table.columns) {
126
+ const dart = appDartType(column);
127
+ const opt = column.nullable ? '?' : '';
128
+ lines.push(` final ${dart}${opt} ${camelCase(column.name)};`);
129
+ }
130
+ lines.push('');
131
+ const ctorParams = table.columns
132
+ .map((c) => `${c.nullable ? '' : 'required '}this.${camelCase(c.name)}`)
133
+ .join(', ');
134
+ lines.push(` const ${type}({${ctorParams}});`);
135
+ lines.push('');
136
+ lines.push(
137
+ ` /// Build from a \`query\`/\`readRows\` row. Returns null when a`,
138
+ );
139
+ lines.push(` /// non-nullable column is missing or mistyped.`);
140
+ lines.push(` static ${type}? fromRow(Map<String, Object?> row) {`);
141
+ for (const column of table.columns) {
142
+ if (!column.nullable) {
143
+ const name = camelCase(column.name);
144
+ lines.push(` final ${name} = ${rowAccessor(column)};`);
145
+ lines.push(` if (${name} == null) return null;`);
146
+ }
147
+ }
148
+ const args = table.columns
149
+ .map((c) => {
150
+ const name = camelCase(c.name);
151
+ return c.nullable ? `${name}: ${rowAccessor(c)}` : `${name}: ${name}`;
152
+ })
153
+ .join(', ');
154
+ lines.push(` return ${type}(${args});`);
155
+ lines.push(' }');
156
+ lines.push('}');
157
+ return lines;
158
+ }
159
+
160
+ function emitSubscription(sub: IrSubscription): string[] {
161
+ const params: string[] = [];
162
+ for (const scope of sub.scopes) {
163
+ for (const value of scope.values) {
164
+ if (value.kind === 'parameter' && !params.includes(value.name)) {
165
+ params.push(value.name);
166
+ }
167
+ }
168
+ }
169
+ const lines: string[] = [];
170
+ lines.push(
171
+ `/// Requested-scope template for the ${quote(sub.name)} subscription.`,
172
+ );
173
+ lines.push(`class Syncular${pascalCase(sub.name)}Subscription {`);
174
+ lines.push(` static const String name = ${quote(sub.name)};`);
175
+ lines.push(` static const String table = ${quote(sub.table)};`);
176
+ const args = params.map((p) => `required String ${camelCase(p)}`).join(', ');
177
+ const argsClause = args.length > 0 ? `{${args}}` : '';
178
+ lines.push(` static Map<String, List<String>> scopes(${argsClause}) => {`);
179
+ for (const scope of sub.scopes) {
180
+ const values = scope.values
181
+ .map((value) =>
182
+ value.kind === 'literal' ? quote(value.value) : camelCase(value.name),
183
+ )
184
+ .join(', ');
185
+ lines.push(` ${quote(scope.variable)}: [${values}],`);
186
+ }
187
+ lines.push(' };');
188
+ lines.push('}');
189
+ return lines;
190
+ }
191
+
192
+ export function emitDartModule(ir: IrDocument, hash: string): string {
193
+ const parts: string[] = [];
194
+ parts.push(
195
+ [
196
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
197
+ `// irVersion: ${ir.irVersion}`,
198
+ `// irHash: ${hash}`,
199
+ '// ignore_for_file: type=lint',
200
+ ].join('\n'),
201
+ );
202
+
203
+ // The schema constant.
204
+ const schemaLines: string[] = [];
205
+ schemaLines.push('/// ServerSchema-compatible schema (SPEC §2.4, §3.1) —');
206
+ schemaLines.push('/// pass to `SyncularClient.create(schema: …)`.');
207
+ const schemaValue = emitSchemaValue(ir, '');
208
+ schemaValue[0] = `const Map<String, Object?> syncularSchema = ${schemaValue[0]}`;
209
+ schemaValue[schemaValue.length - 1] =
210
+ `${schemaValue[schemaValue.length - 1]};`;
211
+ for (const line of schemaValue) schemaLines.push(line);
212
+ parts.push(schemaLines.join('\n'));
213
+
214
+ // Shared row-decode helpers.
215
+ parts.push(
216
+ [
217
+ '/// Lift a SQLite boolean: a real bool, or 0/1 as a number.',
218
+ 'bool? _rowBool(Object? value) {',
219
+ ' if (value is bool) return value;',
220
+ ' if (value is num) return value != 0;',
221
+ ' return null;',
222
+ '}',
223
+ '',
224
+ "/// Decode the core's {'\\$bytes': '<hex>'} marshaling (bytes as hex).",
225
+ 'List<int>? _rowBytes(Object? value) {',
226
+ ' if (value is! Map) return null;',
227
+ " final hex = value[r'$bytes'];",
228
+ ' if (hex is! String || hex.length % 2 != 0) return null;',
229
+ ' return [',
230
+ ' for (var i = 0; i < hex.length; i += 2)',
231
+ ' int.parse(hex.substring(i, i + 2), radix: 16),',
232
+ ' ];',
233
+ '}',
234
+ ].join('\n'),
235
+ );
236
+
237
+ for (const table of ir.tables) {
238
+ parts.push(emitClass(table).join('\n'));
239
+ }
240
+ for (const sub of ir.subscriptions) {
241
+ parts.push(emitSubscription(sub).join('\n'));
242
+ }
243
+
244
+ return `${parts.join('\n\n')}\n`;
245
+ }