@syncular/typegen 0.1.2 → 0.2.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 (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
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Kotlin emitter: neutral IR → one standalone `.kt` file exporting
3
+ *
4
+ * - `object <ObjectName> { val schema: JsonValue }` — the ServerSchema
5
+ * object built from the IR (fed straight into `SyncularClient.create(
6
+ * schema = …)`),
7
+ * - one `data class` per table with typed properties (per the six §2.4 column
8
+ * types + blob_ref/crdt) and a `fromRow(row: JsonValue)` companion factory,
9
+ * - a `Subscriptions` object of typed requested-scope builders.
10
+ *
11
+ * The row shape is `JsonValue` — the exact type the Kotlin wrapper's
12
+ * `query`/`readRows` return (each row a `JsonValue.Obj`). The header carries
13
+ * the IR hash so `--check` verifies freshness byte-exactly.
14
+ */
15
+ import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
16
+
17
+ /** §2.4 column type → honest Kotlin type. `json`/`blob_ref` are the raw
18
+ * canonical JSON string; `bytes`/`crdt` are opaque `ByteArray`. */
19
+ const KOTLIN_TYPE: Readonly<Record<IrColumnType, string>> = {
20
+ string: 'String',
21
+ integer: 'Long',
22
+ float: 'Double',
23
+ boolean: 'Boolean',
24
+ json: 'String',
25
+ bytes: 'ByteArray',
26
+ blob_ref: 'String',
27
+ crdt: 'ByteArray',
28
+ };
29
+
30
+ /** §5.11: app-side Kotlin type — declaredType for an encrypted column. */
31
+ function appKotlinType(column: IrTable['columns'][number]): string {
32
+ const type =
33
+ column.encrypted === true && column.declaredType !== undefined
34
+ ? column.declaredType
35
+ : column.type;
36
+ return KOTLIN_TYPE[type];
37
+ }
38
+
39
+ function pascalCase(name: string): string {
40
+ return name
41
+ .split(/[_-]+/)
42
+ .filter((part) => part.length > 0)
43
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
44
+ .join('');
45
+ }
46
+
47
+ function camelCase(name: string): string {
48
+ const pascal = pascalCase(name);
49
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
50
+ }
51
+
52
+ function quote(value: string): string {
53
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
54
+ }
55
+
56
+ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
57
+ const lines: string[] = [];
58
+ const i = indent;
59
+ lines.push(`${i}JsonValue.obj(`);
60
+ lines.push(`${i} "version" to JsonValue.of(${ir.schemaVersion}),`);
61
+ lines.push(`${i} "tables" to JsonValue.arr(listOf(`);
62
+ for (const table of ir.tables) {
63
+ lines.push(`${i} JsonValue.obj(`);
64
+ lines.push(`${i} "name" to JsonValue.of(${quote(table.name)}),`);
65
+ lines.push(
66
+ `${i} "primaryKey" to JsonValue.of(${quote(table.primaryKey)}),`,
67
+ );
68
+ lines.push(`${i} "columns" to JsonValue.arr(listOf(`);
69
+ for (const column of table.columns) {
70
+ const parts = [
71
+ `"name" to JsonValue.of(${quote(column.name)})`,
72
+ `"type" to JsonValue.of(${quote(column.type)})`,
73
+ `"nullable" to JsonValue.of(${column.nullable})`,
74
+ ];
75
+ if (column.crdtType !== undefined) {
76
+ parts.push(`"crdtType" to JsonValue.of(${quote(column.crdtType)})`);
77
+ }
78
+ if (column.encrypted === true) {
79
+ parts.push('"encrypted" to JsonValue.of(true)');
80
+ parts.push(
81
+ `"declaredType" to JsonValue.of(${quote(column.declaredType ?? column.type)})`,
82
+ );
83
+ }
84
+ lines.push(`${i} JsonValue.obj(${parts.join(', ')}),`);
85
+ }
86
+ lines.push(`${i} )),`);
87
+ lines.push(`${i} "scopes" to JsonValue.arr(listOf(`);
88
+ for (const scope of table.scopes) {
89
+ lines.push(
90
+ `${i} JsonValue.obj("pattern" to JsonValue.of(${quote(scope.pattern)}), "column" to JsonValue.of(${quote(scope.column)})),`,
91
+ );
92
+ }
93
+ lines.push(`${i} )),`);
94
+ lines.push(`${i} ),`);
95
+ }
96
+ lines.push(`${i} )),`);
97
+ lines.push(`${i})`);
98
+ return lines;
99
+ }
100
+
101
+ /** The accessor lifting a §2.4 value out of a row `JsonValue`. */
102
+ function rowAccessor(column: { type: IrColumnType; name: string }): string {
103
+ const key = `row[${quote(column.name)}]`;
104
+ switch (column.type) {
105
+ case 'string':
106
+ case 'json':
107
+ case 'blob_ref':
108
+ return `${key}?.string`;
109
+ case 'integer':
110
+ return `${key}?.number?.toLong()`;
111
+ case 'float':
112
+ return `${key}?.number`;
113
+ case 'boolean':
114
+ return `rowBool(${key})`;
115
+ case 'bytes':
116
+ case 'crdt':
117
+ return `rowBytes(${key})`;
118
+ }
119
+ }
120
+
121
+ function emitDataClass(table: IrTable): string[] {
122
+ const type = pascalCase(table.name);
123
+ const lines: string[] = [];
124
+ lines.push(`/** One ${table.name} row (§2.4 column order). */`);
125
+ lines.push(`data class ${type}(`);
126
+ for (const column of table.columns) {
127
+ const kotlin = appKotlinType(column);
128
+ const opt = column.nullable ? '?' : '';
129
+ lines.push(` val ${camelCase(column.name)}: ${kotlin}${opt},`);
130
+ }
131
+ lines.push(') {');
132
+ lines.push(' companion object {');
133
+ lines.push(
134
+ ` /** Build from a \`query\`/\`readRows\` row. Null on a missing/mistyped non-nullable column. */`,
135
+ );
136
+ lines.push(` fun fromRow(row: JsonValue): ${type}? {`);
137
+ for (const column of table.columns) {
138
+ const name = camelCase(column.name);
139
+ const accessor = rowAccessor(column);
140
+ if (column.nullable) {
141
+ lines.push(` val ${name} = ${accessor}`);
142
+ } else {
143
+ lines.push(` val ${name} = ${accessor} ?: return null`);
144
+ }
145
+ }
146
+ const args = table.columns.map((c) => camelCase(c.name)).join(', ');
147
+ lines.push(` return ${type}(${args})`);
148
+ lines.push(' }');
149
+ lines.push(' }');
150
+ lines.push('}');
151
+ return lines;
152
+ }
153
+
154
+ function emitSubscription(sub: IrSubscription): string[] {
155
+ const params: string[] = [];
156
+ for (const scope of sub.scopes) {
157
+ for (const value of scope.values) {
158
+ if (value.kind === 'parameter' && !params.includes(value.name)) {
159
+ params.push(value.name);
160
+ }
161
+ }
162
+ }
163
+ const lines: string[] = [];
164
+ lines.push(
165
+ ` /** Requested-scope template for the ${quote(sub.name)} subscription. */`,
166
+ );
167
+ lines.push(` object ${pascalCase(sub.name)} {`);
168
+ lines.push(` const val name = ${quote(sub.name)}`);
169
+ lines.push(` const val table = ${quote(sub.table)}`);
170
+ const args = params.map((p) => `${camelCase(p)}: String`).join(', ');
171
+ lines.push(
172
+ ` fun scopes(${args}): Map<String, List<String>> = mapOf(`,
173
+ );
174
+ for (const scope of sub.scopes) {
175
+ const values = scope.values
176
+ .map((value) =>
177
+ value.kind === 'literal' ? quote(value.value) : camelCase(value.name),
178
+ )
179
+ .join(', ');
180
+ lines.push(
181
+ ` ${quote(scope.variable)} to listOf(${values}),`,
182
+ );
183
+ }
184
+ lines.push(' )');
185
+ lines.push(' }');
186
+ return lines;
187
+ }
188
+
189
+ export function emitKotlinModule(
190
+ ir: IrDocument,
191
+ hash: string,
192
+ packageName: string,
193
+ objectName: string,
194
+ ): string {
195
+ const parts: string[] = [];
196
+ parts.push(
197
+ [
198
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
199
+ `// irVersion: ${ir.irVersion}`,
200
+ `// irHash: ${hash}`,
201
+ '',
202
+ `package ${packageName}`,
203
+ '',
204
+ 'import dev.syncular.JsonValue',
205
+ ].join('\n'),
206
+ );
207
+
208
+ const schemaLines: string[] = [];
209
+ schemaLines.push('/** The generated syncular schema (SPEC §2.4, §3.1). */');
210
+ schemaLines.push(`object ${objectName} {`);
211
+ schemaLines.push(
212
+ ` /** ServerSchema-compatible schema — pass to \`SyncularClient.create(schema = …)\`. */`,
213
+ );
214
+ schemaLines.push(' val schema: JsonValue =');
215
+ for (const line of emitSchemaValue(ir, ' ')) schemaLines.push(line);
216
+ schemaLines.push('');
217
+ schemaLines.push(
218
+ ' /** Typed requested-scope builders per subscription. */',
219
+ );
220
+ schemaLines.push(' object Subscriptions {');
221
+ if (ir.subscriptions.length === 0) {
222
+ schemaLines.push(' // (no subscriptions declared)');
223
+ }
224
+ for (const sub of ir.subscriptions) {
225
+ for (const line of emitSubscription(sub)) schemaLines.push(line);
226
+ }
227
+ schemaLines.push(' }');
228
+ schemaLines.push('}');
229
+ parts.push(schemaLines.join('\n'));
230
+
231
+ // Shared row-decode helpers.
232
+ parts.push(
233
+ [
234
+ '/** Lift a SQLite boolean: a real JSON bool, or 0/1 as a number. */',
235
+ 'private fun rowBool(value: JsonValue?): Boolean? {',
236
+ ' value?.bool?.let { return it }',
237
+ ' value?.number?.let { return it != 0.0 }',
238
+ ' return null',
239
+ '}',
240
+ '',
241
+ '/** Decode the core\'s `{"$bytes":"<hex>"}` marshaling into a ByteArray. */',
242
+ 'private fun rowBytes(value: JsonValue?): ByteArray? {',
243
+ ' val hex = value?.get("\\$bytes")?.string ?: return null',
244
+ ' if (hex.length % 2 != 0) return null',
245
+ ' return ByteArray(hex.length / 2) { i ->',
246
+ ' hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()',
247
+ ' }',
248
+ '}',
249
+ ].join('\n'),
250
+ );
251
+
252
+ for (const table of ir.tables) {
253
+ parts.push(emitDataClass(table).join('\n'));
254
+ }
255
+
256
+ return `${parts.join('\n\n')}\n`;
257
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Dart named-query emitter: analyzed queries → a standalone
3
+ * `syncular.queries.dart` file. Per query `q` it emits a `class QRow` +
4
+ * `fromRow` factory, and a top-level `syncular<Q>Query(client, …)` function
5
+ * returning `List<QRow>` plus a `syncular<Q>QueryTables` const. The runner
6
+ * binds named params positionally into `query(sql, params: […])`.
7
+ *
8
+ * Header carries the IR hash for byte-exact `--check`.
9
+ */
10
+ import type { IrColumnType } from './ir';
11
+ import type { AnalyzedQuery, QueryColumn } from './query';
12
+
13
+ const DART_TYPE: Readonly<Record<IrColumnType, string>> = {
14
+ string: 'String',
15
+ integer: 'int',
16
+ float: 'double',
17
+ boolean: 'bool',
18
+ json: 'String',
19
+ bytes: 'List<int>',
20
+ blob_ref: 'String',
21
+ crdt: 'List<int>',
22
+ };
23
+
24
+ function pascalCase(name: string): string {
25
+ return name
26
+ .split(/[_-]+/)
27
+ .filter((p) => p.length > 0)
28
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
29
+ .join('');
30
+ }
31
+
32
+ function camelCase(name: string): string {
33
+ const pascal = pascalCase(name);
34
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
35
+ }
36
+
37
+ function typeName(name: string): string {
38
+ return name.charAt(0).toUpperCase() + name.slice(1);
39
+ }
40
+
41
+ function quote(value: string): string {
42
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$')}'`;
43
+ }
44
+
45
+ function rowAccessor(column: QueryColumn): string {
46
+ const key = `row[${quote(column.name)}]`;
47
+ switch (column.type) {
48
+ case 'string':
49
+ case 'json':
50
+ case 'blob_ref':
51
+ return `${key} as String?`;
52
+ case 'integer':
53
+ return `(${key} as num?)?.toInt()`;
54
+ case 'float':
55
+ return `(${key} as num?)?.toDouble()`;
56
+ case 'boolean':
57
+ return `_queryRowBool(${key})`;
58
+ case 'bytes':
59
+ case 'crdt':
60
+ return `_queryRowBytes(${key})`;
61
+ }
62
+ }
63
+
64
+ function paramValue(type: IrColumnType, name: string): string {
65
+ switch (type) {
66
+ case 'bytes':
67
+ case 'crdt':
68
+ return `_queryBindBytes(${name})`;
69
+ default:
70
+ // String/int/double/bool ride as-is in the params list.
71
+ return name;
72
+ }
73
+ }
74
+
75
+ function emitClass(query: AnalyzedQuery): string[] {
76
+ const Row = `${typeName(query.name)}Row`;
77
+ const lines: string[] = [];
78
+ lines.push(`/// One row of the ${query.name} query (its projection).`);
79
+ lines.push(`class ${Row} {`);
80
+ for (const column of query.columns) {
81
+ const opt = column.nullable ? '?' : '';
82
+ lines.push(
83
+ ` final ${DART_TYPE[column.type]}${opt} ${camelCase(column.name)};`,
84
+ );
85
+ }
86
+ lines.push('');
87
+ const ctorParams = query.columns
88
+ .map((c) => `${c.nullable ? '' : 'required '}this.${camelCase(c.name)}`)
89
+ .join(', ');
90
+ lines.push(` const ${Row}({${ctorParams}});`);
91
+ lines.push('');
92
+ lines.push(` static ${Row}? fromRow(Map<String, Object?> row) {`);
93
+ for (const column of query.columns) {
94
+ if (!column.nullable) {
95
+ const name = camelCase(column.name);
96
+ lines.push(` final ${name} = ${rowAccessor(column)};`);
97
+ lines.push(` if (${name} == null) return null;`);
98
+ }
99
+ }
100
+ const args = query.columns
101
+ .map((c) => {
102
+ const name = camelCase(c.name);
103
+ return c.nullable ? `${name}: ${rowAccessor(c)}` : `${name}: ${name}`;
104
+ })
105
+ .join(', ');
106
+ lines.push(` return ${Row}(${args});`);
107
+ lines.push(' }');
108
+ lines.push('}');
109
+ return lines;
110
+ }
111
+
112
+ function emitRunner(query: AnalyzedQuery): string[] {
113
+ const Row = `${typeName(query.name)}Row`;
114
+ const Pascal = pascalCase(query.name);
115
+ const lines: string[] = [];
116
+ lines.push(
117
+ `/// Tables the ${query.name} query reads (exact invalidation set).`,
118
+ );
119
+ lines.push(
120
+ `const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`,
121
+ );
122
+ lines.push('');
123
+ lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
124
+ lines.push('');
125
+ lines.push(`/// Run the ${query.name} named query (SELECT-only).`);
126
+ const args = query.params
127
+ .map((p) => `required ${DART_TYPE[p.type]} ${camelCase(p.name)}`)
128
+ .join(', ');
129
+ const argsClause = query.params.length > 0 ? `, {${args}}` : '';
130
+ lines.push(
131
+ `List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`,
132
+ );
133
+ if (query.params.length > 0) {
134
+ const binds = query.params
135
+ .map((p) => paramValue(p.type, camelCase(p.name)))
136
+ .join(', ');
137
+ lines.push(` final params = <Object?>[${binds}];`);
138
+ lines.push(
139
+ ` return client.query(_${query.name}Sql, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`,
140
+ );
141
+ } else {
142
+ lines.push(
143
+ ` return client.query(_${query.name}Sql).map(${Row}.fromRow).whereType<${Row}>().toList();`,
144
+ );
145
+ }
146
+ lines.push('}');
147
+ return lines;
148
+ }
149
+
150
+ export function emitQueriesDartModule(
151
+ queries: readonly AnalyzedQuery[],
152
+ hash: string,
153
+ irVersion: number,
154
+ ): string {
155
+ const parts: string[] = [];
156
+ parts.push(
157
+ [
158
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
159
+ `// irVersion: ${irVersion}`,
160
+ `// irHash: ${hash}`,
161
+ '// ignore_for_file: type=lint',
162
+ '',
163
+ "import 'package:syncular/syncular.dart';",
164
+ ].join('\n'),
165
+ );
166
+
167
+ parts.push(
168
+ [
169
+ '/// Lift a SQLite boolean: a real bool, or 0/1 as a number.',
170
+ 'bool? _queryRowBool(Object? value) {',
171
+ ' if (value is bool) return value;',
172
+ ' if (value is num) return value != 0;',
173
+ ' return null;',
174
+ '}',
175
+ '',
176
+ "/// Decode the core's {'\\$bytes': '<hex>'} marshaling (bytes as hex).",
177
+ 'List<int>? _queryRowBytes(Object? value) {',
178
+ ' if (value is! Map) return null;',
179
+ " final hex = value[r'$bytes'];",
180
+ ' if (hex is! String || hex.length % 2 != 0) return null;',
181
+ ' return [',
182
+ ' for (var i = 0; i < hex.length; i += 2)',
183
+ ' int.parse(hex.substring(i, i + 2), radix: 16),',
184
+ ' ];',
185
+ '}',
186
+ '',
187
+ "/// Encode bytes as the core's {'\\$bytes': '<hex>'} marshaling for binding.",
188
+ 'Map<String, Object?> _queryBindBytes(List<int> value) {',
189
+ " final hex = value.map((b) => b.toRadixString(16).padLeft(2, '0')).join();",
190
+ " return {r'$bytes': hex};",
191
+ '}',
192
+ ].join('\n'),
193
+ );
194
+
195
+ for (const query of queries) {
196
+ parts.push(emitClass(query).join('\n'));
197
+ }
198
+ for (const query of queries) {
199
+ parts.push(emitRunner(query).join('\n'));
200
+ }
201
+
202
+ return `${parts.join('\n\n')}\n`;
203
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Kotlin named-query emitter: analyzed queries → a standalone
3
+ * `Syncular.queries.kt` file. Per query `q` it emits a `data class QRow` +
4
+ * `fromRow` companion, and an `object <ObjectName>Queries` with a
5
+ * `q(client, …): List<QRow>` runner + `qTables` constant. The runner binds
6
+ * named params positionally into the wrapper's `query(sql, params)`.
7
+ *
8
+ * Reuses the schema file's `rowBool`/`rowBytes` helpers (they are top-level
9
+ * private in the schema module; queries live in the same package so re-declare
10
+ * package-private copies here to stay self-contained — the query file is a
11
+ * separate output). Header carries the IR hash for byte-exact `--check`.
12
+ */
13
+ import type { IrColumnType } from './ir';
14
+ import type { AnalyzedQuery, QueryColumn } from './query';
15
+
16
+ const KOTLIN_TYPE: Readonly<Record<IrColumnType, string>> = {
17
+ string: 'String',
18
+ integer: 'Long',
19
+ float: 'Double',
20
+ boolean: 'Boolean',
21
+ json: 'String',
22
+ bytes: 'ByteArray',
23
+ blob_ref: 'String',
24
+ crdt: 'ByteArray',
25
+ };
26
+
27
+ function pascalCase(name: string): string {
28
+ return name
29
+ .split(/[_-]+/)
30
+ .filter((p) => p.length > 0)
31
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
32
+ .join('');
33
+ }
34
+
35
+ function camelCase(name: string): string {
36
+ const pascal = pascalCase(name);
37
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
38
+ }
39
+
40
+ function typeName(name: string): string {
41
+ return name.charAt(0).toUpperCase() + name.slice(1);
42
+ }
43
+
44
+ function quote(value: string): string {
45
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
46
+ }
47
+
48
+ function rowAccessor(column: QueryColumn): string {
49
+ const key = `row[${quote(column.name)}]`;
50
+ switch (column.type) {
51
+ case 'string':
52
+ case 'json':
53
+ case 'blob_ref':
54
+ return `${key}?.string`;
55
+ case 'integer':
56
+ return `${key}?.number?.toLong()`;
57
+ case 'float':
58
+ return `${key}?.number`;
59
+ case 'boolean':
60
+ return `queryRowBool(${key})`;
61
+ case 'bytes':
62
+ case 'crdt':
63
+ return `queryRowBytes(${key})`;
64
+ }
65
+ }
66
+
67
+ function paramValue(type: IrColumnType, name: string): string {
68
+ switch (type) {
69
+ case 'integer':
70
+ // JsonValue.of has no Long overload; widen to Double (SQLite numbers).
71
+ return `JsonValue.of(${name}.toDouble())`;
72
+ case 'float':
73
+ return `JsonValue.of(${name})`;
74
+ case 'boolean':
75
+ return `JsonValue.of(${name})`;
76
+ case 'bytes':
77
+ case 'crdt':
78
+ return `queryBindBytes(${name})`;
79
+ default:
80
+ return `JsonValue.of(${name})`;
81
+ }
82
+ }
83
+
84
+ function emitDataClass(query: AnalyzedQuery): string[] {
85
+ const Row = `${typeName(query.name)}Row`;
86
+ const lines: string[] = [];
87
+ lines.push(`/** One row of the ${query.name} query (its projection). */`);
88
+ lines.push(`data class ${Row}(`);
89
+ for (const column of query.columns) {
90
+ const opt = column.nullable ? '?' : '';
91
+ lines.push(
92
+ ` val ${camelCase(column.name)}: ${KOTLIN_TYPE[column.type]}${opt},`,
93
+ );
94
+ }
95
+ lines.push(') {');
96
+ lines.push(' companion object {');
97
+ lines.push(` fun fromRow(row: JsonValue): ${Row}? {`);
98
+ for (const column of query.columns) {
99
+ const name = camelCase(column.name);
100
+ const accessor = rowAccessor(column);
101
+ if (column.nullable) {
102
+ lines.push(` val ${name} = ${accessor}`);
103
+ } else {
104
+ lines.push(` val ${name} = ${accessor} ?: return null`);
105
+ }
106
+ }
107
+ const args = query.columns.map((c) => camelCase(c.name)).join(', ');
108
+ lines.push(` return ${Row}(${args})`);
109
+ lines.push(' }');
110
+ lines.push(' }');
111
+ lines.push('}');
112
+ return lines;
113
+ }
114
+
115
+ function emitRunner(query: AnalyzedQuery): string[] {
116
+ const Row = `${typeName(query.name)}Row`;
117
+ const lines: string[] = [];
118
+ lines.push(
119
+ ` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`,
120
+ );
121
+ lines.push(
122
+ ` private const val ${query.name}Sql = ${quote(query.positionalSql)}`,
123
+ );
124
+ lines.push('');
125
+ lines.push(` /** Run the ${query.name} named query (SELECT-only). */`);
126
+ const args = query.params
127
+ .map((p) => `${camelCase(p.name)}: ${KOTLIN_TYPE[p.type]}`)
128
+ .join(', ');
129
+ const signature =
130
+ query.params.length > 0
131
+ ? `client: SyncularClient, ${args}`
132
+ : 'client: SyncularClient';
133
+ lines.push(` fun ${query.name}(${signature}): List<${Row}> {`);
134
+ if (query.params.length > 0) {
135
+ const binds = query.params
136
+ .map((p) => paramValue(p.type, camelCase(p.name)))
137
+ .join(', ');
138
+ lines.push(` val params = listOf(${binds})`);
139
+ lines.push(
140
+ ` return client.query(${query.name}Sql, params).mapNotNull { ${Row}.fromRow(it) }`,
141
+ );
142
+ } else {
143
+ lines.push(
144
+ ` return client.query(${query.name}Sql).mapNotNull { ${Row}.fromRow(it) }`,
145
+ );
146
+ }
147
+ lines.push(' }');
148
+ return lines;
149
+ }
150
+
151
+ export function emitQueriesKotlinModule(
152
+ queries: readonly AnalyzedQuery[],
153
+ hash: string,
154
+ irVersion: number,
155
+ packageName: string,
156
+ objectName: string,
157
+ ): string {
158
+ const parts: string[] = [];
159
+ parts.push(
160
+ [
161
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
162
+ `// irVersion: ${irVersion}`,
163
+ `// irHash: ${hash}`,
164
+ '',
165
+ `package ${packageName}`,
166
+ '',
167
+ 'import dev.syncular.JsonValue',
168
+ 'import dev.syncular.SyncularClient',
169
+ ].join('\n'),
170
+ );
171
+
172
+ // Row-decode + bind helpers (package-private; distinct names so they don't
173
+ // clash with the schema file's rowBool/rowBytes in the same package).
174
+ parts.push(
175
+ [
176
+ 'private fun queryRowBool(value: JsonValue?): Boolean? {',
177
+ ' value?.bool?.let { return it }',
178
+ ' value?.number?.let { return it != 0.0 }',
179
+ ' return null',
180
+ '}',
181
+ '',
182
+ 'private fun queryRowBytes(value: JsonValue?): ByteArray? {',
183
+ ' val hex = value?.get("\\$bytes")?.string ?: return null',
184
+ ' if (hex.length % 2 != 0) return null',
185
+ ' return ByteArray(hex.length / 2) { i ->',
186
+ ' hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()',
187
+ ' }',
188
+ '}',
189
+ '',
190
+ 'private fun queryBindBytes(value: ByteArray): JsonValue {',
191
+ ' val hex = value.joinToString("") { "%02x".format(it) }',
192
+ ' return JsonValue.obj("\\$bytes" to JsonValue.of(hex))',
193
+ '}',
194
+ ].join('\n'),
195
+ );
196
+
197
+ for (const query of queries) {
198
+ parts.push(emitDataClass(query).join('\n'));
199
+ }
200
+
201
+ const objLines: string[] = [];
202
+ objLines.push('/** Typed named queries (the sqlc/SQLDelight tier). */');
203
+ objLines.push(`object ${objectName}Queries {`);
204
+ objLines.push(queries.map((q) => emitRunner(q).join('\n')).join('\n\n'));
205
+ objLines.push('}');
206
+ parts.push(objLines.join('\n'));
207
+
208
+ return `${parts.join('\n\n')}\n`;
209
+ }