@syncular/typegen 0.1.3 → 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,198 @@
1
+ /**
2
+ * Swift named-query emitter: analyzed queries → a standalone
3
+ * `Syncular.queries.swift` file. Per query `q` it emits:
4
+ *
5
+ * - `struct QRow` + `init?(row:)` — the projection's OWN typed row (decoded
6
+ * through the shared row helpers already in `Syncular.generated.swift`),
7
+ * - `enum <EnumName>Queries { static func q(client:params) -> [QRow] }` —
8
+ * runs the query over the wrapper's positional `query(_:params:)`, binding
9
+ * the named params in positional order,
10
+ * - `static let qTables` — the table-dependency set.
11
+ *
12
+ * It reuses the generated schema file's `SyncularSchemaRow` helpers (bool /
13
+ * bytes decode). Header carries the IR hash for byte-exact `--check`.
14
+ */
15
+ import type { IrColumnType } from './ir';
16
+ import type { AnalyzedQuery, QueryColumn } from './query';
17
+
18
+ const SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
19
+ string: 'String',
20
+ integer: 'Int',
21
+ float: 'Double',
22
+ boolean: 'Bool',
23
+ json: 'String',
24
+ bytes: '[UInt8]',
25
+ blob_ref: 'String',
26
+ crdt: '[UInt8]',
27
+ };
28
+
29
+ function pascalCase(name: string): string {
30
+ return name
31
+ .split(/[_-]+/)
32
+ .filter((p) => p.length > 0)
33
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
34
+ .join('');
35
+ }
36
+
37
+ function camelCase(name: string): string {
38
+ const pascal = pascalCase(name);
39
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
40
+ }
41
+
42
+ /** The query function/type name: already camelCase; PascalCase for the type. */
43
+ function typeName(name: string): string {
44
+ return name.charAt(0).toUpperCase() + name.slice(1);
45
+ }
46
+
47
+ function quote(value: string): string {
48
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
49
+ }
50
+
51
+ function rowAccessor(column: QueryColumn): string {
52
+ const key = `row[${quote(column.name)}]`;
53
+ switch (column.type) {
54
+ case 'string':
55
+ case 'json':
56
+ case 'blob_ref':
57
+ return `${key}?.stringValue`;
58
+ case 'integer':
59
+ return `${key}?.numberValue.map(Int.init)`;
60
+ case 'float':
61
+ return `${key}?.numberValue`;
62
+ case 'boolean':
63
+ return `SyncularSchemaRow.bool(${key})`;
64
+ case 'bytes':
65
+ case 'crdt':
66
+ return `SyncularSchemaRow.bytes(${key})`;
67
+ }
68
+ }
69
+
70
+ function paramValue(type: IrColumnType, name: string): string {
71
+ switch (type) {
72
+ case 'integer':
73
+ case 'float':
74
+ return `.number(Double(${name}))`;
75
+ case 'boolean':
76
+ return `.bool(${name})`;
77
+ case 'bytes':
78
+ case 'crdt':
79
+ return `SyncularSchemaQueryBind.bytes(${name})`;
80
+ default:
81
+ return `.string(${name})`;
82
+ }
83
+ }
84
+
85
+ function emitRowStruct(query: AnalyzedQuery): string[] {
86
+ const Row = `${typeName(query.name)}Row`;
87
+ const lines: string[] = [];
88
+ lines.push(`/// One row of the ${query.name} query (its projection).`);
89
+ lines.push(`public struct ${Row}: Sendable, Equatable {`);
90
+ for (const column of query.columns) {
91
+ const opt = column.nullable ? '?' : '';
92
+ lines.push(
93
+ ` public let ${camelCase(column.name)}: ${SWIFT_TYPE[column.type]}${opt}`,
94
+ );
95
+ }
96
+ lines.push('');
97
+ lines.push(' public init?(row: [String: JSONValue]) {');
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(` self.${name} = ${accessor}`);
103
+ } else {
104
+ lines.push(` guard let ${name} = ${accessor} else { return nil }`);
105
+ lines.push(` self.${name} = ${name}`);
106
+ }
107
+ }
108
+ lines.push(' }');
109
+ lines.push('}');
110
+ return lines;
111
+ }
112
+
113
+ function emitRunner(query: AnalyzedQuery): string[] {
114
+ const Row = `${typeName(query.name)}Row`;
115
+ const lines: string[] = [];
116
+ lines.push(
117
+ ` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`,
118
+ );
119
+ lines.push(
120
+ ` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
121
+ );
122
+ lines.push('');
123
+ lines.push(` /// Run the ${query.name} named query (SELECT-only).`);
124
+ const args = query.params
125
+ .map((p) => `${camelCase(p.name)}: ${SWIFT_TYPE[p.type]}`)
126
+ .join(', ');
127
+ const signature =
128
+ query.params.length > 0
129
+ ? `client: SyncularClient, ${args}`
130
+ : 'client: SyncularClient';
131
+ lines.push(
132
+ ` public static func ${query.name}(${signature}) throws -> [${Row}] {`,
133
+ );
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(` let params: [JSONValue] = [${binds}]`);
139
+ lines.push(
140
+ ` return try client.query(${query.name}Sql, params: params).compactMap { row in`,
141
+ );
142
+ } else {
143
+ lines.push(
144
+ ` return try client.query(${query.name}Sql).compactMap { row in`,
145
+ );
146
+ }
147
+ lines.push(
148
+ ' guard case let .object(fields) = row else { return nil }',
149
+ );
150
+ lines.push(` return ${Row}(row: fields)`);
151
+ lines.push(' }');
152
+ lines.push(' }');
153
+ return lines;
154
+ }
155
+
156
+ export function emitQueriesSwiftModule(
157
+ queries: readonly AnalyzedQuery[],
158
+ hash: string,
159
+ irVersion: number,
160
+ enumName: string,
161
+ ): string {
162
+ const parts: string[] = [];
163
+ parts.push(
164
+ [
165
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
166
+ `// irVersion: ${irVersion}`,
167
+ `// irHash: ${hash}`,
168
+ '',
169
+ 'import Foundation',
170
+ 'import Syncular',
171
+ ].join('\n'),
172
+ );
173
+
174
+ parts.push(
175
+ [
176
+ '/// Param-bind helpers shared by the generated query runners.',
177
+ 'enum SyncularSchemaQueryBind {',
178
+ ' static func bytes(_ value: [UInt8]) -> JSONValue {',
179
+ ' let hex = value.map { String(format: "%02x", $0) }.joined()',
180
+ ' return .object(["$bytes": .string(hex)])',
181
+ ' }',
182
+ '}',
183
+ ].join('\n'),
184
+ );
185
+
186
+ for (const query of queries) {
187
+ parts.push(emitRowStruct(query).join('\n'));
188
+ }
189
+
190
+ const enumLines: string[] = [];
191
+ enumLines.push('/// Typed named queries (the sqlc/SQLDelight tier).');
192
+ enumLines.push(`public enum ${enumName}Queries {`);
193
+ enumLines.push(queries.map((q) => emitRunner(q).join('\n')).join('\n\n'));
194
+ enumLines.push('}');
195
+ parts.push(enumLines.join('\n'));
196
+
197
+ return `${parts.join('\n\n')}\n`;
198
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TS named-query emitter: analyzed queries → a standalone
3
+ * `syncular.queries.ts` module. Per query `q` it emits:
4
+ *
5
+ * - `QRow` — the projection row interface (its OWN type per query — the
6
+ * drift-kill: the row shape is exactly what the SELECT returns),
7
+ * - `QParams` — the typed params object (when the query has params),
8
+ * - `qTables` — the readonly table-dependency set (feeds useSyncQuery `{tables}`
9
+ * for EXACT invalidation),
10
+ * - `q(client, params?): Promise<QRow[]>` — runs the query over the wrapper's
11
+ * positional `query(sql, params[])` surface, reordering the named params
12
+ * object into the positional array the wire expects.
13
+ *
14
+ * A tiny structural `QueryClient` interface (just `query(sql, params?)`) keeps
15
+ * the module import-free — it structurally accepts `SyncClientLike` /
16
+ * `SyncClient` / `SyncClientHandle` alike. Header carries the IR hash so
17
+ * `--check` gates freshness byte-exactly, like every other emitter.
18
+ */
19
+ import type { IrColumnType } from './ir';
20
+ import type { AnalyzedQuery } from './query';
21
+
22
+ const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
23
+ string: 'string',
24
+ integer: 'number',
25
+ float: 'number',
26
+ boolean: 'boolean',
27
+ json: 'string',
28
+ bytes: 'Uint8Array',
29
+ blob_ref: 'string',
30
+ crdt: 'Uint8Array',
31
+ };
32
+
33
+ function pascalCase(name: string): string {
34
+ return name.charAt(0).toUpperCase() + name.slice(1);
35
+ }
36
+
37
+ function quote(value: string): string {
38
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
39
+ }
40
+
41
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
42
+ function propertyKey(name: string): string {
43
+ return IDENTIFIER_RE.test(name) ? name : quote(name);
44
+ }
45
+
46
+ /** A named-query param value is the SqlValue subset its type maps to. */
47
+ const PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = TS_TYPE;
48
+
49
+ function emitQuery(query: AnalyzedQuery): string {
50
+ const Row = `${pascalCase(query.name)}Row`;
51
+ const Params = `${pascalCase(query.name)}Params`;
52
+ const lines: string[] = [];
53
+
54
+ // Row interface.
55
+ lines.push(
56
+ `/** One row of the ${quote(query.name)} query (its projection). */`,
57
+ );
58
+ lines.push(`export interface ${Row} {`);
59
+ for (const column of query.columns) {
60
+ lines.push(
61
+ ` ${propertyKey(column.name)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
62
+ );
63
+ }
64
+ lines.push('}');
65
+ lines.push('');
66
+
67
+ // Params interface (only when there are params).
68
+ const hasParams = query.params.length > 0;
69
+ if (hasParams) {
70
+ lines.push(`/** Named parameters for ${quote(query.name)}. */`);
71
+ lines.push(`export interface ${Params} {`);
72
+ for (const param of query.params) {
73
+ lines.push(` ${propertyKey(param.name)}: ${PARAM_TS_TYPE[param.type]};`);
74
+ }
75
+ lines.push('}');
76
+ lines.push('');
77
+ }
78
+
79
+ // Tables dependency set (for useSyncQuery {tables} / useNamedQuery).
80
+ lines.push(
81
+ `/** Tables ${quote(query.name)} reads — the exact useSyncQuery \`{tables}\` set. */`,
82
+ );
83
+ lines.push(
84
+ `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
85
+ );
86
+ lines.push('');
87
+
88
+ // The runner. SQL is the positional form; params reorder into the array.
89
+ const paramArg = hasParams ? `params: ${Params}` : '';
90
+ const sqlConst = `${query.name}Sql`;
91
+ const positional = hasParams
92
+ ? `[${query.params.map((p) => `params.${propertyKey(p.name)}`).join(', ')}]`
93
+ : '[]';
94
+ lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
95
+ lines.push('');
96
+ lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
97
+ lines.push(
98
+ `export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`,
99
+ );
100
+ if (hasParams) {
101
+ lines.push(
102
+ ` const rows = await client.query(${sqlConst}, ${positional});`,
103
+ );
104
+ } else {
105
+ lines.push(` const rows = await client.query(${sqlConst});`);
106
+ }
107
+ lines.push(` return rows as unknown as ${Row}[];`);
108
+ lines.push('}');
109
+ lines.push('');
110
+
111
+ // A descriptor for react's `useNamedQuery` — the SQL, the exact table
112
+ // dependency set, and a `bind(params)` → positional array. Typed by the
113
+ // query's own Row/Params so the hook stays fully typed.
114
+ lines.push(
115
+ `/** Descriptor for \`useNamedQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`,
116
+ );
117
+ const paramsTypeArg = hasParams ? Params : 'undefined';
118
+ lines.push(
119
+ `export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`,
120
+ );
121
+ lines.push(` sql: ${sqlConst},`);
122
+ lines.push(` tables: ${query.name}Tables,`);
123
+ if (hasParams) {
124
+ lines.push(` bind: (params: ${Params}) => ${positional},`);
125
+ } else {
126
+ lines.push(' bind: () => [],');
127
+ }
128
+ lines.push('};');
129
+ return lines.join('\n');
130
+ }
131
+
132
+ export function emitQueriesModule(
133
+ queries: readonly AnalyzedQuery[],
134
+ hash: string,
135
+ irVersion: number,
136
+ ): string {
137
+ const parts: string[] = [];
138
+ parts.push(
139
+ [
140
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
141
+ `// irVersion: ${irVersion}`,
142
+ `// irHash: ${hash}`,
143
+ ].join('\n'),
144
+ );
145
+ parts.push(
146
+ [
147
+ "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
148
+ 'export type QueryValue =',
149
+ ' | string',
150
+ ' | number',
151
+ ' | bigint',
152
+ ' | boolean',
153
+ ' | Uint8Array',
154
+ ' | null;',
155
+ '',
156
+ "/** Minimal structural client surface — the wrapper's positional",
157
+ ' * `query(sql, params?)`. `SyncClient`/`SyncClientHandle`/`SyncClientLike`',
158
+ ' * all satisfy it, so this module imports nothing. */',
159
+ 'export interface QueryClient {',
160
+ ' query(',
161
+ ' sql: string,',
162
+ ' params?: readonly QueryValue[],',
163
+ ' ): unknown[] | Promise<unknown[]>;',
164
+ '}',
165
+ '',
166
+ '/** A named-query descriptor — sql + its exact table dependency set + a',
167
+ ' * `bind(params)` → positional args. Consumed by',
168
+ " * `@syncular/react`'s `useNamedQuery`. `Row` is the projection row",
169
+ ' * type; `Params` is `undefined` for a param-less query. */',
170
+ 'export interface NamedQuery<Row, Params = undefined> {',
171
+ ' readonly sql: string;',
172
+ ' readonly tables: readonly string[];',
173
+ ' readonly bind: (params: Params) => readonly QueryValue[];',
174
+ ' /** Phantom — carries the Row type for `useNamedQuery` inference. */',
175
+ ' readonly __row?: Row;',
176
+ '}',
177
+ ].join('\n'),
178
+ );
179
+ for (const query of queries) {
180
+ parts.push(emitQuery(query));
181
+ }
182
+ return `${parts.join('\n\n')}\n`;
183
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Swift emitter: neutral IR → one standalone `.swift` file exporting
3
+ *
4
+ * - `enum <EnumName> { static let schema: JSONValue }` — the ServerSchema
5
+ * object, built from the IR with byte-stable ordering (fed straight into
6
+ * `SyncularClient(schema:)`),
7
+ * - one `struct` per table with typed properties (per the six §2.4 column
8
+ * types, plus blob_ref/crdt in their honest Swift shapes) and a
9
+ * `init?(row:)` from a `[String: JSONValue]` (the shape `query`/`readRows`
10
+ * return),
11
+ * - a `subscriptions` namespace of typed requested-scope builders.
12
+ *
13
+ * The file imports only `Syncular` (for `JSONValue`). The header carries the
14
+ * IR hash so `--check` verifies freshness byte-exactly, exactly like the TS
15
+ * emitter.
16
+ */
17
+ import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
18
+
19
+ /**
20
+ * §2.4 column type → honest Swift type. `json`/`blob_ref` are the raw
21
+ * canonical JSON string (the client's blob API parses blob_ref); `bytes`/
22
+ * `crdt` are opaque `[UInt8]`.
23
+ */
24
+ const SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
25
+ string: 'String',
26
+ integer: 'Int',
27
+ float: 'Double',
28
+ boolean: 'Bool',
29
+ json: 'String',
30
+ bytes: '[UInt8]',
31
+ blob_ref: 'String',
32
+ crdt: '[UInt8]',
33
+ };
34
+
35
+ /** §5.11: app-side Swift type — declaredType for an encrypted column. */
36
+ function appSwiftType(column: IrTable['columns'][number]): string {
37
+ const type =
38
+ column.encrypted === true && column.declaredType !== undefined
39
+ ? column.declaredType
40
+ : column.type;
41
+ return SWIFT_TYPE[type];
42
+ }
43
+
44
+ function pascalCase(name: string): string {
45
+ return name
46
+ .split(/[_-]+/)
47
+ .filter((part) => part.length > 0)
48
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
49
+ .join('');
50
+ }
51
+
52
+ function camelCase(name: string): string {
53
+ const pascal = pascalCase(name);
54
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
55
+ }
56
+
57
+ function quote(value: string): string {
58
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
59
+ }
60
+
61
+ /** Emit a `JSONValue` literal for the schema object (built from the IR). */
62
+ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
63
+ const lines: string[] = [];
64
+ const i = indent;
65
+ lines.push(`${i}.object([`);
66
+ lines.push(`${i} "version": .number(${ir.schemaVersion}),`);
67
+ lines.push(`${i} "tables": .array([`);
68
+ for (const table of ir.tables) {
69
+ lines.push(`${i} .object([`);
70
+ lines.push(`${i} "name": .string(${quote(table.name)}),`);
71
+ lines.push(`${i} "primaryKey": .string(${quote(table.primaryKey)}),`);
72
+ lines.push(`${i} "columns": .array([`);
73
+ for (const column of table.columns) {
74
+ const parts = [
75
+ `"name": .string(${quote(column.name)})`,
76
+ `"type": .string(${quote(column.type)})`,
77
+ `"nullable": .bool(${column.nullable})`,
78
+ ];
79
+ if (column.crdtType !== undefined) {
80
+ parts.push(`"crdtType": .string(${quote(column.crdtType)})`);
81
+ }
82
+ if (column.encrypted === true) {
83
+ parts.push('"encrypted": .bool(true)');
84
+ parts.push(
85
+ `"declaredType": .string(${quote(column.declaredType ?? column.type)})`,
86
+ );
87
+ }
88
+ lines.push(`${i} .object([${parts.join(', ')}]),`);
89
+ }
90
+ lines.push(`${i} ]),`);
91
+ lines.push(`${i} "scopes": .array([`);
92
+ for (const scope of table.scopes) {
93
+ lines.push(
94
+ `${i} .object(["pattern": .string(${quote(scope.pattern)}), "column": .string(${quote(scope.column)})]),`,
95
+ );
96
+ }
97
+ lines.push(`${i} ]),`);
98
+ lines.push(`${i} ]),`);
99
+ }
100
+ lines.push(`${i} ]),`);
101
+ lines.push(`${i}])`);
102
+ return lines;
103
+ }
104
+
105
+ /** The accessor that lifts a §2.4 value out of a `[String: JSONValue]` row. */
106
+ function rowAccessor(column: {
107
+ type: IrColumnType;
108
+ name: string;
109
+ nullable: boolean;
110
+ }): string {
111
+ const key = `row[${quote(column.name)}]`;
112
+ switch (column.type) {
113
+ case 'string':
114
+ case 'json':
115
+ case 'blob_ref':
116
+ return `${key}?.stringValue`;
117
+ case 'integer':
118
+ // SQLite integers ride as JSON numbers; round to Int. Uses `.map(Int.init)`
119
+ // (not a trailing closure) so it parses inside a `guard let … else`.
120
+ return `${key}?.numberValue.map(Int.init)`;
121
+ case 'float':
122
+ return `${key}?.numberValue`;
123
+ case 'boolean':
124
+ // SQLite has no bool: it stores 0/1. Accept a real JSON bool OR 0/1.
125
+ return `SyncularSchemaRow.bool(${key})`;
126
+ case 'bytes':
127
+ case 'crdt':
128
+ // The core marshals bytes as {"$bytes":"<hex>"}; decode to [UInt8].
129
+ return `SyncularSchemaRow.bytes(${key})`;
130
+ }
131
+ }
132
+
133
+ function emitStruct(table: IrTable): string[] {
134
+ const type = pascalCase(table.name);
135
+ const lines: string[] = [];
136
+ lines.push(`/// One ${table.name} row (§2.4 column order).`);
137
+ lines.push(`public struct ${type}: Sendable, Equatable {`);
138
+ for (const column of table.columns) {
139
+ const swift = appSwiftType(column);
140
+ const opt = column.nullable ? '?' : '';
141
+ lines.push(` public let ${camelCase(column.name)}: ${swift}${opt}`);
142
+ }
143
+ lines.push('');
144
+ // Memberwise init (public, so app code can build rows too).
145
+ const params = table.columns
146
+ .map((c) => {
147
+ const swift = SWIFT_TYPE[c.type];
148
+ const opt = c.nullable ? '? = nil' : '';
149
+ return `${camelCase(c.name)}: ${swift}${opt}`;
150
+ })
151
+ .join(', ');
152
+ lines.push(` public init(${params}) {`);
153
+ for (const column of table.columns) {
154
+ const name = camelCase(column.name);
155
+ lines.push(` self.${name} = ${name}`);
156
+ }
157
+ lines.push(' }');
158
+ lines.push('');
159
+ lines.push(
160
+ ` /// Build from a \`query\`/\`readRows\` row (\`[String: JSONValue]\`).`,
161
+ );
162
+ lines.push(
163
+ ` /// Returns nil when a non-nullable column is missing or mistyped.`,
164
+ );
165
+ lines.push(' public init?(row: [String: JSONValue]) {');
166
+ for (const column of table.columns) {
167
+ const name = camelCase(column.name);
168
+ const accessor = rowAccessor(column);
169
+ if (column.nullable) {
170
+ lines.push(` self.${name} = ${accessor}`);
171
+ } else {
172
+ lines.push(` guard let ${name} = ${accessor} else { return nil }`);
173
+ lines.push(` self.${name} = ${name}`);
174
+ }
175
+ }
176
+ lines.push(' }');
177
+ lines.push('}');
178
+ return lines;
179
+ }
180
+
181
+ function emitSubscription(sub: IrSubscription): string[] {
182
+ const params: string[] = [];
183
+ for (const scope of sub.scopes) {
184
+ for (const value of scope.values) {
185
+ if (value.kind === 'parameter' && !params.includes(value.name)) {
186
+ params.push(value.name);
187
+ }
188
+ }
189
+ }
190
+ const lines: string[] = [];
191
+ lines.push(
192
+ ` /// Requested-scope template for the ${quote(sub.name)} subscription.`,
193
+ );
194
+ lines.push(` public enum ${pascalCase(sub.name)} {`);
195
+ lines.push(` public static let name = ${quote(sub.name)}`);
196
+ lines.push(` public static let table = ${quote(sub.table)}`);
197
+ const args = params.map((p) => `${camelCase(p)}: String`).join(', ');
198
+ lines.push(
199
+ ` public static func scopes(${args}) -> [String: [String]] {`,
200
+ );
201
+ lines.push(' [');
202
+ for (const scope of sub.scopes) {
203
+ const values = scope.values
204
+ .map((value) =>
205
+ value.kind === 'literal' ? quote(value.value) : camelCase(value.name),
206
+ )
207
+ .join(', ');
208
+ lines.push(` ${quote(scope.variable)}: [${values}],`);
209
+ }
210
+ lines.push(' ]');
211
+ lines.push(' }');
212
+ lines.push(' }');
213
+ return lines;
214
+ }
215
+
216
+ export function emitSwiftModule(
217
+ ir: IrDocument,
218
+ hash: string,
219
+ enumName: string,
220
+ ): string {
221
+ const parts: string[] = [];
222
+ parts.push(
223
+ [
224
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
225
+ `// irVersion: ${ir.irVersion}`,
226
+ `// irHash: ${hash}`,
227
+ '',
228
+ 'import Foundation',
229
+ 'import Syncular',
230
+ ].join('\n'),
231
+ );
232
+
233
+ // The schema namespace.
234
+ const schemaLines: string[] = [];
235
+ schemaLines.push(
236
+ `/// The generated syncular schema (SPEC §2.4, §3.1) + typed rows.`,
237
+ );
238
+ schemaLines.push(`public enum ${enumName} {`);
239
+ schemaLines.push(
240
+ ` /// ServerSchema-compatible schema — pass to \`SyncularClient(schema:)\`.`,
241
+ );
242
+ schemaLines.push(' public static let schema: JSONValue =');
243
+ for (const line of emitSchemaValue(ir, ' ')) {
244
+ schemaLines.push(line);
245
+ }
246
+ schemaLines.push('');
247
+ // Subscriptions namespace nested in the schema enum.
248
+ schemaLines.push(' /// Typed requested-scope builders per subscription.');
249
+ schemaLines.push(' public enum subscriptions {');
250
+ if (ir.subscriptions.length === 0) {
251
+ schemaLines.push(' // (no subscriptions declared)');
252
+ }
253
+ for (const sub of ir.subscriptions) {
254
+ for (const line of emitSubscription(sub)) schemaLines.push(line);
255
+ }
256
+ schemaLines.push(' }');
257
+ schemaLines.push('}');
258
+ parts.push(schemaLines.join('\n'));
259
+
260
+ // Shared row-decode helpers (0/1 booleans, {$bytes} decode).
261
+ parts.push(
262
+ [
263
+ '/// Row-decode helpers shared by the generated structs.',
264
+ 'enum SyncularSchemaRow {',
265
+ ' /// Lift a SQLite boolean: a real JSON bool, or 0/1 as a number.',
266
+ ' static func bool(_ value: JSONValue?) -> Bool? {',
267
+ ' if let b = value?.boolValue { return b }',
268
+ ' if let n = value?.numberValue { return n != 0 }',
269
+ ' return nil',
270
+ ' }',
271
+ '',
272
+ ' /// Decode the core\'s `{"$bytes":"<hex>"}` marshaling into bytes.',
273
+ ' static func bytes(_ value: JSONValue?) -> [UInt8]? {',
274
+ ' guard let hex = value?["$bytes"]?.stringValue else { return nil }',
275
+ ' var out: [UInt8] = []',
276
+ ' out.reserveCapacity(hex.count / 2)',
277
+ ' var index = hex.startIndex',
278
+ ' while index < hex.endIndex {',
279
+ ' let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex',
280
+ ' guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }',
281
+ ' out.append(byte)',
282
+ ' index = next',
283
+ ' }',
284
+ ' return out',
285
+ ' }',
286
+ '}',
287
+ ].join('\n'),
288
+ );
289
+
290
+ for (const table of ir.tables) {
291
+ parts.push(emitStruct(table).join('\n'));
292
+ }
293
+
294
+ return `${parts.join('\n\n')}\n`;
295
+ }