@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,129 @@
1
+ const TS_TYPE = {
2
+ string: 'string',
3
+ integer: 'number',
4
+ float: 'number',
5
+ boolean: 'boolean',
6
+ json: 'string',
7
+ bytes: 'Uint8Array',
8
+ blob_ref: 'string',
9
+ crdt: 'Uint8Array',
10
+ };
11
+ function pascalCase(name) {
12
+ return name.charAt(0).toUpperCase() + name.slice(1);
13
+ }
14
+ function quote(value) {
15
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
16
+ }
17
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
18
+ function propertyKey(name) {
19
+ return IDENTIFIER_RE.test(name) ? name : quote(name);
20
+ }
21
+ /** A named-query param value is the SqlValue subset its type maps to. */
22
+ const PARAM_TS_TYPE = TS_TYPE;
23
+ function emitQuery(query) {
24
+ const Row = `${pascalCase(query.name)}Row`;
25
+ const Params = `${pascalCase(query.name)}Params`;
26
+ const lines = [];
27
+ // Row interface.
28
+ lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
29
+ lines.push(`export interface ${Row} {`);
30
+ for (const column of query.columns) {
31
+ lines.push(` ${propertyKey(column.name)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`);
32
+ }
33
+ lines.push('}');
34
+ lines.push('');
35
+ // Params interface (only when there are params).
36
+ const hasParams = query.params.length > 0;
37
+ if (hasParams) {
38
+ lines.push(`/** Named parameters for ${quote(query.name)}. */`);
39
+ lines.push(`export interface ${Params} {`);
40
+ for (const param of query.params) {
41
+ lines.push(` ${propertyKey(param.name)}: ${PARAM_TS_TYPE[param.type]};`);
42
+ }
43
+ lines.push('}');
44
+ lines.push('');
45
+ }
46
+ // Tables dependency set (for useSyncQuery {tables} / useNamedQuery).
47
+ lines.push(`/** Tables ${quote(query.name)} reads — the exact useSyncQuery \`{tables}\` set. */`);
48
+ lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
49
+ lines.push('');
50
+ // The runner. SQL is the positional form; params reorder into the array.
51
+ const paramArg = hasParams ? `params: ${Params}` : '';
52
+ const sqlConst = `${query.name}Sql`;
53
+ const positional = hasParams
54
+ ? `[${query.params.map((p) => `params.${propertyKey(p.name)}`).join(', ')}]`
55
+ : '[]';
56
+ lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
57
+ lines.push('');
58
+ lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
59
+ lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`);
60
+ if (hasParams) {
61
+ lines.push(` const rows = await client.query(${sqlConst}, ${positional});`);
62
+ }
63
+ else {
64
+ lines.push(` const rows = await client.query(${sqlConst});`);
65
+ }
66
+ lines.push(` return rows as unknown as ${Row}[];`);
67
+ lines.push('}');
68
+ lines.push('');
69
+ // A descriptor for react's `useNamedQuery` — the SQL, the exact table
70
+ // dependency set, and a `bind(params)` → positional array. Typed by the
71
+ // query's own Row/Params so the hook stays fully typed.
72
+ lines.push(`/** Descriptor for \`useNamedQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`);
73
+ const paramsTypeArg = hasParams ? Params : 'undefined';
74
+ lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
75
+ lines.push(` sql: ${sqlConst},`);
76
+ lines.push(` tables: ${query.name}Tables,`);
77
+ if (hasParams) {
78
+ lines.push(` bind: (params: ${Params}) => ${positional},`);
79
+ }
80
+ else {
81
+ lines.push(' bind: () => [],');
82
+ }
83
+ lines.push('};');
84
+ return lines.join('\n');
85
+ }
86
+ export function emitQueriesModule(queries, hash, irVersion) {
87
+ const parts = [];
88
+ parts.push([
89
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
90
+ `// irVersion: ${irVersion}`,
91
+ `// irHash: ${hash}`,
92
+ ].join('\n'));
93
+ parts.push([
94
+ "/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
95
+ 'export type QueryValue =',
96
+ ' | string',
97
+ ' | number',
98
+ ' | bigint',
99
+ ' | boolean',
100
+ ' | Uint8Array',
101
+ ' | null;',
102
+ '',
103
+ "/** Minimal structural client surface — the wrapper's positional",
104
+ ' * `query(sql, params?)`. `SyncClient`/`SyncClientHandle`/`SyncClientLike`',
105
+ ' * all satisfy it, so this module imports nothing. */',
106
+ 'export interface QueryClient {',
107
+ ' query(',
108
+ ' sql: string,',
109
+ ' params?: readonly QueryValue[],',
110
+ ' ): unknown[] | Promise<unknown[]>;',
111
+ '}',
112
+ '',
113
+ '/** A named-query descriptor — sql + its exact table dependency set + a',
114
+ ' * `bind(params)` → positional args. Consumed by',
115
+ " * `@syncular/react`'s `useNamedQuery`. `Row` is the projection row",
116
+ ' * type; `Params` is `undefined` for a param-less query. */',
117
+ 'export interface NamedQuery<Row, Params = undefined> {',
118
+ ' readonly sql: string;',
119
+ ' readonly tables: readonly string[];',
120
+ ' readonly bind: (params: Params) => readonly QueryValue[];',
121
+ ' /** Phantom — carries the Row type for `useNamedQuery` inference. */',
122
+ ' readonly __row?: Row;',
123
+ '}',
124
+ ].join('\n'));
125
+ for (const query of queries) {
126
+ parts.push(emitQuery(query));
127
+ }
128
+ return `${parts.join('\n\n')}\n`;
129
+ }
@@ -0,0 +1,18 @@
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 { IrDocument } from './ir.js';
18
+ export declare function emitSwiftModule(ir: IrDocument, hash: string, enumName: string): string;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * §2.4 column type → honest Swift type. `json`/`blob_ref` are the raw
3
+ * canonical JSON string (the client's blob API parses blob_ref); `bytes`/
4
+ * `crdt` are opaque `[UInt8]`.
5
+ */
6
+ const SWIFT_TYPE = {
7
+ string: 'String',
8
+ integer: 'Int',
9
+ float: 'Double',
10
+ boolean: 'Bool',
11
+ json: 'String',
12
+ bytes: '[UInt8]',
13
+ blob_ref: 'String',
14
+ crdt: '[UInt8]',
15
+ };
16
+ /** §5.11: app-side Swift type — declaredType for an encrypted column. */
17
+ function appSwiftType(column) {
18
+ const type = column.encrypted === true && column.declaredType !== undefined
19
+ ? column.declaredType
20
+ : column.type;
21
+ return SWIFT_TYPE[type];
22
+ }
23
+ function pascalCase(name) {
24
+ return name
25
+ .split(/[_-]+/)
26
+ .filter((part) => part.length > 0)
27
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
28
+ .join('');
29
+ }
30
+ function camelCase(name) {
31
+ const pascal = pascalCase(name);
32
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
33
+ }
34
+ function quote(value) {
35
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
36
+ }
37
+ /** Emit a `JSONValue` literal for the schema object (built from the IR). */
38
+ function emitSchemaValue(ir, indent) {
39
+ const lines = [];
40
+ const i = indent;
41
+ lines.push(`${i}.object([`);
42
+ lines.push(`${i} "version": .number(${ir.schemaVersion}),`);
43
+ lines.push(`${i} "tables": .array([`);
44
+ for (const table of ir.tables) {
45
+ lines.push(`${i} .object([`);
46
+ lines.push(`${i} "name": .string(${quote(table.name)}),`);
47
+ lines.push(`${i} "primaryKey": .string(${quote(table.primaryKey)}),`);
48
+ lines.push(`${i} "columns": .array([`);
49
+ for (const column of table.columns) {
50
+ const parts = [
51
+ `"name": .string(${quote(column.name)})`,
52
+ `"type": .string(${quote(column.type)})`,
53
+ `"nullable": .bool(${column.nullable})`,
54
+ ];
55
+ if (column.crdtType !== undefined) {
56
+ parts.push(`"crdtType": .string(${quote(column.crdtType)})`);
57
+ }
58
+ if (column.encrypted === true) {
59
+ parts.push('"encrypted": .bool(true)');
60
+ parts.push(`"declaredType": .string(${quote(column.declaredType ?? column.type)})`);
61
+ }
62
+ lines.push(`${i} .object([${parts.join(', ')}]),`);
63
+ }
64
+ lines.push(`${i} ]),`);
65
+ lines.push(`${i} "scopes": .array([`);
66
+ for (const scope of table.scopes) {
67
+ lines.push(`${i} .object(["pattern": .string(${quote(scope.pattern)}), "column": .string(${quote(scope.column)})]),`);
68
+ }
69
+ lines.push(`${i} ]),`);
70
+ lines.push(`${i} ]),`);
71
+ }
72
+ lines.push(`${i} ]),`);
73
+ lines.push(`${i}])`);
74
+ return lines;
75
+ }
76
+ /** The accessor that lifts a §2.4 value out of a `[String: JSONValue]` row. */
77
+ function rowAccessor(column) {
78
+ const key = `row[${quote(column.name)}]`;
79
+ switch (column.type) {
80
+ case 'string':
81
+ case 'json':
82
+ case 'blob_ref':
83
+ return `${key}?.stringValue`;
84
+ case 'integer':
85
+ // SQLite integers ride as JSON numbers; round to Int. Uses `.map(Int.init)`
86
+ // (not a trailing closure) so it parses inside a `guard let … else`.
87
+ return `${key}?.numberValue.map(Int.init)`;
88
+ case 'float':
89
+ return `${key}?.numberValue`;
90
+ case 'boolean':
91
+ // SQLite has no bool: it stores 0/1. Accept a real JSON bool OR 0/1.
92
+ return `SyncularSchemaRow.bool(${key})`;
93
+ case 'bytes':
94
+ case 'crdt':
95
+ // The core marshals bytes as {"$bytes":"<hex>"}; decode to [UInt8].
96
+ return `SyncularSchemaRow.bytes(${key})`;
97
+ }
98
+ }
99
+ function emitStruct(table) {
100
+ const type = pascalCase(table.name);
101
+ const lines = [];
102
+ lines.push(`/// One ${table.name} row (§2.4 column order).`);
103
+ lines.push(`public struct ${type}: Sendable, Equatable {`);
104
+ for (const column of table.columns) {
105
+ const swift = appSwiftType(column);
106
+ const opt = column.nullable ? '?' : '';
107
+ lines.push(` public let ${camelCase(column.name)}: ${swift}${opt}`);
108
+ }
109
+ lines.push('');
110
+ // Memberwise init (public, so app code can build rows too).
111
+ const params = table.columns
112
+ .map((c) => {
113
+ const swift = SWIFT_TYPE[c.type];
114
+ const opt = c.nullable ? '? = nil' : '';
115
+ return `${camelCase(c.name)}: ${swift}${opt}`;
116
+ })
117
+ .join(', ');
118
+ lines.push(` public init(${params}) {`);
119
+ for (const column of table.columns) {
120
+ const name = camelCase(column.name);
121
+ lines.push(` self.${name} = ${name}`);
122
+ }
123
+ lines.push(' }');
124
+ lines.push('');
125
+ lines.push(` /// Build from a \`query\`/\`readRows\` row (\`[String: JSONValue]\`).`);
126
+ lines.push(` /// Returns nil when a non-nullable column is missing or mistyped.`);
127
+ lines.push(' public init?(row: [String: JSONValue]) {');
128
+ for (const column of table.columns) {
129
+ const name = camelCase(column.name);
130
+ const accessor = rowAccessor(column);
131
+ if (column.nullable) {
132
+ lines.push(` self.${name} = ${accessor}`);
133
+ }
134
+ else {
135
+ lines.push(` guard let ${name} = ${accessor} else { return nil }`);
136
+ lines.push(` self.${name} = ${name}`);
137
+ }
138
+ }
139
+ lines.push(' }');
140
+ lines.push('}');
141
+ return lines;
142
+ }
143
+ function emitSubscription(sub) {
144
+ const params = [];
145
+ for (const scope of sub.scopes) {
146
+ for (const value of scope.values) {
147
+ if (value.kind === 'parameter' && !params.includes(value.name)) {
148
+ params.push(value.name);
149
+ }
150
+ }
151
+ }
152
+ const lines = [];
153
+ lines.push(` /// Requested-scope template for the ${quote(sub.name)} subscription.`);
154
+ lines.push(` public enum ${pascalCase(sub.name)} {`);
155
+ lines.push(` public static let name = ${quote(sub.name)}`);
156
+ lines.push(` public static let table = ${quote(sub.table)}`);
157
+ const args = params.map((p) => `${camelCase(p)}: String`).join(', ');
158
+ lines.push(` public static func scopes(${args}) -> [String: [String]] {`);
159
+ lines.push(' [');
160
+ for (const scope of sub.scopes) {
161
+ const values = scope.values
162
+ .map((value) => value.kind === 'literal' ? quote(value.value) : camelCase(value.name))
163
+ .join(', ');
164
+ lines.push(` ${quote(scope.variable)}: [${values}],`);
165
+ }
166
+ lines.push(' ]');
167
+ lines.push(' }');
168
+ lines.push(' }');
169
+ return lines;
170
+ }
171
+ export function emitSwiftModule(ir, hash, enumName) {
172
+ const parts = [];
173
+ parts.push([
174
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
175
+ `// irVersion: ${ir.irVersion}`,
176
+ `// irHash: ${hash}`,
177
+ '',
178
+ 'import Foundation',
179
+ 'import Syncular',
180
+ ].join('\n'));
181
+ // The schema namespace.
182
+ const schemaLines = [];
183
+ schemaLines.push(`/// The generated syncular schema (SPEC §2.4, §3.1) + typed rows.`);
184
+ schemaLines.push(`public enum ${enumName} {`);
185
+ schemaLines.push(` /// ServerSchema-compatible schema — pass to \`SyncularClient(schema:)\`.`);
186
+ schemaLines.push(' public static let schema: JSONValue =');
187
+ for (const line of emitSchemaValue(ir, ' ')) {
188
+ schemaLines.push(line);
189
+ }
190
+ schemaLines.push('');
191
+ // Subscriptions namespace nested in the schema enum.
192
+ schemaLines.push(' /// Typed requested-scope builders per subscription.');
193
+ schemaLines.push(' public enum subscriptions {');
194
+ if (ir.subscriptions.length === 0) {
195
+ schemaLines.push(' // (no subscriptions declared)');
196
+ }
197
+ for (const sub of ir.subscriptions) {
198
+ for (const line of emitSubscription(sub))
199
+ schemaLines.push(line);
200
+ }
201
+ schemaLines.push(' }');
202
+ schemaLines.push('}');
203
+ parts.push(schemaLines.join('\n'));
204
+ // Shared row-decode helpers (0/1 booleans, {$bytes} decode).
205
+ parts.push([
206
+ '/// Row-decode helpers shared by the generated structs.',
207
+ 'enum SyncularSchemaRow {',
208
+ ' /// Lift a SQLite boolean: a real JSON bool, or 0/1 as a number.',
209
+ ' static func bool(_ value: JSONValue?) -> Bool? {',
210
+ ' if let b = value?.boolValue { return b }',
211
+ ' if let n = value?.numberValue { return n != 0 }',
212
+ ' return nil',
213
+ ' }',
214
+ '',
215
+ ' /// Decode the core\'s `{"$bytes":"<hex>"}` marshaling into bytes.',
216
+ ' static func bytes(_ value: JSONValue?) -> [UInt8]? {',
217
+ ' guard let hex = value?["$bytes"]?.stringValue else { return nil }',
218
+ ' var out: [UInt8] = []',
219
+ ' out.reserveCapacity(hex.count / 2)',
220
+ ' var index = hex.startIndex',
221
+ ' while index < hex.endIndex {',
222
+ ' let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex',
223
+ ' guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }',
224
+ ' out.append(byte)',
225
+ ' index = next',
226
+ ' }',
227
+ ' return out',
228
+ ' }',
229
+ '}',
230
+ ].join('\n'));
231
+ for (const table of ir.tables) {
232
+ parts.push(emitStruct(table).join('\n'));
233
+ }
234
+ return `${parts.join('\n\n')}\n`;
235
+ }
package/dist/emit.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * TS emitter: neutral IR → one standalone generated module exporting
3
+ *
4
+ * - `schema` — a ServerSchema-compatible object (structurally typed; the
5
+ * generated file imports nothing),
6
+ * - a `<Table>Row` interface per table,
7
+ * - `<Table>Insert` / `<Table>Update` mutation-input shapes honoring
8
+ * nullability,
9
+ * - a `<name>Subscription` helper constant per manifest subscription with
10
+ * a typed requested-scopes builder.
11
+ *
12
+ * The header carries the IR hash so `--check` verifies freshness
13
+ * byte-exactly.
14
+ */
15
+ import type { IrDocument } from './ir.js';
16
+ export declare function emitModule(ir: IrDocument, hash: string): string;
package/dist/emit.js ADDED
@@ -0,0 +1,191 @@
1
+ const TS_TYPE = {
2
+ string: 'string',
3
+ integer: 'number',
4
+ float: 'number',
5
+ boolean: 'boolean',
6
+ json: 'string',
7
+ bytes: 'Uint8Array',
8
+ // §5.9: a blob_ref value is the raw canonical BlobRef JSON string
9
+ // (like `json`); the client's blob API parses it into a BlobRef object.
10
+ blob_ref: 'string',
11
+ // §5.10: a crdt value is opaque bytes (like `bytes`); the Y.Doc accessor
12
+ // is an app-level helper in @syncular/crdt-yjs, not generated code.
13
+ crdt: 'Uint8Array',
14
+ };
15
+ /**
16
+ * §5.11: the app-side TS type of a column. An encrypted column's wire type is
17
+ * `bytes`, but the app sees its declared type (the local mirror is plaintext).
18
+ */
19
+ function appTsType(column) {
20
+ const type = column.encrypted === true && column.declaredType !== undefined
21
+ ? column.declaredType
22
+ : column.type;
23
+ return TS_TYPE[type];
24
+ }
25
+ function pascalCase(name) {
26
+ return name
27
+ .split(/[_-]+/)
28
+ .filter((part) => part.length > 0)
29
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
30
+ .join('');
31
+ }
32
+ function quote(value) {
33
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
34
+ }
35
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
36
+ function propertyKey(name) {
37
+ return IDENTIFIER_RE.test(name) ? name : quote(name);
38
+ }
39
+ function emitSchema(ir) {
40
+ const lines = [];
41
+ lines.push('/** ServerSchema-compatible schema object (SPEC §2.4, §3.1). */');
42
+ lines.push('export const schema = {');
43
+ lines.push(` version: ${ir.schemaVersion},`);
44
+ lines.push(' tables: [');
45
+ for (const table of ir.tables) {
46
+ lines.push(' {');
47
+ lines.push(` name: ${quote(table.name)},`);
48
+ lines.push(' columns: [');
49
+ for (const column of table.columns) {
50
+ // §5.10.1: emit crdtType on crdt columns so a generated server schema
51
+ // can select the merger; omitted (and thus unchanged) for others.
52
+ const crdtType = column.crdtType !== undefined
53
+ ? `, crdtType: ${quote(column.crdtType)}`
54
+ : '';
55
+ // §5.11: emit encrypted/declaredType on encrypted columns so the client
56
+ // codec encrypts/decrypts them; omitted for others.
57
+ const encrypted = column.encrypted === true
58
+ ? `, encrypted: true, declaredType: ${quote(column.declaredType ?? column.type)}`
59
+ : '';
60
+ lines.push(` { name: ${quote(column.name)}, type: ${quote(column.type)}, nullable: ${column.nullable}${crdtType}${encrypted} },`);
61
+ }
62
+ lines.push(' ],');
63
+ lines.push(` primaryKey: ${quote(table.primaryKey)},`);
64
+ lines.push(' scopes: [');
65
+ for (const scope of table.scopes) {
66
+ lines.push(` { pattern: ${quote(scope.pattern)}, column: ${quote(scope.column)} },`);
67
+ }
68
+ lines.push(' ],');
69
+ // Local secondary indexes — emitted only when the table declares any, so
70
+ // index-free tables keep their exact pre-index generated bytes.
71
+ if (table.indexes.length > 0) {
72
+ lines.push(' indexes: [');
73
+ for (const index of table.indexes) {
74
+ const cols = index.columns.map((c) => quote(c)).join(', ');
75
+ lines.push(` { name: ${quote(index.name)}, columns: [${cols}], unique: ${index.unique} },`);
76
+ }
77
+ lines.push(' ],');
78
+ }
79
+ lines.push(' },');
80
+ }
81
+ lines.push(' ],');
82
+ lines.push('} as const;');
83
+ return lines.join('\n');
84
+ }
85
+ function emitRowInterfaces(table) {
86
+ const type = pascalCase(table.name);
87
+ const lines = [];
88
+ lines.push(`/** One ${table.name} row (§2.4 column order). */`);
89
+ lines.push(`export interface ${type}Row {`);
90
+ for (const column of table.columns) {
91
+ const ts = appTsType(column);
92
+ lines.push(` ${propertyKey(column.name)}: ${ts}${column.nullable ? ' | null' : ''};`);
93
+ }
94
+ lines.push('}');
95
+ lines.push('');
96
+ lines.push(`/** Insert shape: nullable columns may be omitted. */`);
97
+ lines.push(`export interface ${type}Insert {`);
98
+ for (const column of table.columns) {
99
+ const ts = appTsType(column);
100
+ lines.push(column.nullable
101
+ ? ` ${propertyKey(column.name)}?: ${ts} | null;`
102
+ : ` ${propertyKey(column.name)}: ${ts};`);
103
+ }
104
+ lines.push('}');
105
+ lines.push('');
106
+ lines.push(`/** Update shape: primary key required, all other columns optional. */`);
107
+ lines.push(`export interface ${type}Update {`);
108
+ for (const column of table.columns) {
109
+ const ts = appTsType(column);
110
+ if (column.name === table.primaryKey) {
111
+ lines.push(` ${propertyKey(column.name)}: ${ts};`);
112
+ }
113
+ else {
114
+ lines.push(` ${propertyKey(column.name)}?: ${ts}${column.nullable ? ' | null' : ''};`);
115
+ }
116
+ }
117
+ lines.push('}');
118
+ return lines.join('\n');
119
+ }
120
+ /**
121
+ * A Kysely-compatible `Database` interface: table name → Row type. This is
122
+ * the generic `@syncular/kysely`'s `SyncularDialect` is parameterized by
123
+ * (`new Kysely<Database>({ dialect })`). Additive — the Row interfaces it
124
+ * references are already emitted above. Property keys use the raw table name
125
+ * (quoted when not an identifier) so `db.selectFrom('table-name')` types.
126
+ */
127
+ function emitDatabaseInterface(ir) {
128
+ const lines = [];
129
+ lines.push('/** Kysely `Database` interface (table → Row); the generic for');
130
+ lines.push(" * @syncular/kysely's SyncularDialect. */");
131
+ lines.push('export interface Database {');
132
+ for (const table of ir.tables) {
133
+ lines.push(` ${propertyKey(table.name)}: ${pascalCase(table.name)}Row;`);
134
+ }
135
+ lines.push('}');
136
+ return lines.join('\n');
137
+ }
138
+ function emitSubscription(sub) {
139
+ const params = [];
140
+ for (const scope of sub.scopes) {
141
+ for (const value of scope.values) {
142
+ if (value.kind === 'parameter' && !params.includes(value.name)) {
143
+ params.push(value.name);
144
+ }
145
+ }
146
+ }
147
+ const lines = [];
148
+ const paramsType = `${pascalCase(sub.name)}Params`;
149
+ if (params.length > 0) {
150
+ lines.push(`export interface ${paramsType} {`);
151
+ for (const param of params) {
152
+ lines.push(` ${propertyKey(param)}: string;`);
153
+ }
154
+ lines.push('}');
155
+ lines.push('');
156
+ }
157
+ lines.push(`/** Requested-scope template for the ${quote(sub.name)} subscription. */`);
158
+ lines.push(`export const ${sub.name}Subscription = {`);
159
+ lines.push(` name: ${quote(sub.name)},`);
160
+ lines.push(` table: ${quote(sub.table)},`);
161
+ const args = params.length > 0 ? `params: ${paramsType}` : '';
162
+ lines.push(` scopes: (${args}): Record<string, string[]> => ({`);
163
+ for (const scope of sub.scopes) {
164
+ const values = scope.values
165
+ .map((value) => value.kind === 'literal'
166
+ ? quote(value.value)
167
+ : `params.${propertyKey(value.name)}`)
168
+ .join(', ');
169
+ lines.push(` ${propertyKey(scope.variable)}: [${values}],`);
170
+ }
171
+ lines.push(' }),');
172
+ lines.push('} as const;');
173
+ return lines.join('\n');
174
+ }
175
+ export function emitModule(ir, hash) {
176
+ const parts = [];
177
+ parts.push([
178
+ '// Generated by @syncular/typegen — DO NOT EDIT.',
179
+ `// irVersion: ${ir.irVersion}`,
180
+ `// irHash: ${hash}`,
181
+ ].join('\n'));
182
+ parts.push(emitSchema(ir));
183
+ for (const table of ir.tables) {
184
+ parts.push(emitRowInterfaces(table));
185
+ }
186
+ parts.push(emitDatabaseInterface(ir));
187
+ for (const sub of ir.subscriptions) {
188
+ parts.push(emitSubscription(sub));
189
+ }
190
+ return `${parts.join('\n\n')}\n`;
191
+ }
@@ -0,0 +1,6 @@
1
+ /** Fail-loud codegen error: every message names the offending construct. */
2
+ export declare class TypegenError extends Error {
3
+ /** Input the error came from (a migration file, the manifest, …). */
4
+ readonly source: string;
5
+ constructor(source: string, message: string);
6
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,10 @@
1
+ /** Fail-loud codegen error: every message names the offending construct. */
2
+ export class TypegenError extends Error {
3
+ /** Input the error came from (a migration file, the manifest, …). */
4
+ source;
5
+ constructor(source, message) {
6
+ super(`${source}: ${message}`);
7
+ this.name = 'TypegenError';
8
+ this.source = source;
9
+ }
10
+ }