@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.
- package/README.md +436 -94
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +159 -73
- package/dist/emit-dart.d.ts +17 -0
- package/dist/emit-dart.js +201 -0
- package/dist/emit-kotlin.d.ts +16 -0
- package/dist/emit-kotlin.js +201 -0
- package/dist/emit-queries-dart.d.ts +2 -0
- package/dist/emit-queries-dart.js +160 -0
- package/dist/emit-queries-kotlin.d.ts +2 -0
- package/dist/emit-queries-kotlin.js +162 -0
- package/dist/emit-queries-swift.d.ts +2 -0
- package/dist/emit-queries-swift.js +146 -0
- package/dist/emit-queries.d.ts +2 -0
- package/dist/emit-queries.js +129 -0
- package/dist/emit-swift.d.ts +18 -0
- package/dist/emit-swift.js +235 -0
- package/dist/emit.d.ts +16 -0
- package/dist/emit.js +191 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +10 -0
- package/dist/generate.d.ts +53 -18
- package/dist/generate.js +391 -77
- package/dist/index.d.ts +17 -14
- package/dist/index.js +16 -13
- package/dist/init.d.ts +12 -0
- package/dist/init.js +85 -0
- package/dist/ir.d.ts +90 -0
- package/dist/ir.js +94 -0
- package/dist/manifest.d.ts +76 -0
- package/dist/manifest.js +303 -0
- package/dist/query.d.ts +96 -0
- package/dist/query.js +719 -0
- package/dist/sql.d.ts +13 -0
- package/dist/sql.js +440 -0
- package/package.json +14 -34
- package/src/cli.ts +161 -82
- package/src/emit-dart.ts +245 -0
- package/src/emit-kotlin.ts +257 -0
- package/src/emit-queries-dart.ts +203 -0
- package/src/emit-queries-kotlin.ts +209 -0
- package/src/emit-queries-swift.ts +198 -0
- package/src/emit-queries.ts +183 -0
- package/src/emit-swift.ts +295 -0
- package/src/emit.ts +239 -0
- package/src/errors.ts +11 -0
- package/src/generate.ts +574 -105
- package/src/index.ts +16 -13
- package/src/init.ts +100 -0
- package/src/ir.ts +197 -0
- package/src/manifest.ts +445 -0
- package/src/query.ts +918 -0
- package/src/sql.ts +533 -0
- package/dist/app-contract.d.ts +0 -154
- package/dist/app-contract.d.ts.map +0 -1
- package/dist/app-contract.js +0 -250
- package/dist/app-contract.js.map +0 -1
- package/dist/checksums.d.ts +0 -6
- package/dist/checksums.d.ts.map +0 -1
- package/dist/checksums.js +0 -173
- package/dist/checksums.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/introspect-postgres.d.ts +0 -8
- package/dist/introspect-postgres.d.ts.map +0 -1
- package/dist/introspect-postgres.js +0 -94
- package/dist/introspect-postgres.js.map +0 -1
- package/dist/introspect-sqlite.d.ts +0 -10
- package/dist/introspect-sqlite.d.ts.map +0 -1
- package/dist/introspect-sqlite.js +0 -97
- package/dist/introspect-sqlite.js.map +0 -1
- package/dist/introspect.d.ts +0 -8
- package/dist/introspect.d.ts.map +0 -1
- package/dist/introspect.js +0 -18
- package/dist/introspect.js.map +0 -1
- package/dist/map-types.d.ts +0 -20
- package/dist/map-types.d.ts.map +0 -1
- package/dist/map-types.js +0 -151
- package/dist/map-types.js.map +0 -1
- package/dist/render.d.ts +0 -23
- package/dist/render.d.ts.map +0 -1
- package/dist/render.js +0 -140
- package/dist/render.js.map +0 -1
- package/dist/types.d.ts +0 -122
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/app-contract.ts +0 -531
- package/src/checksums.ts +0 -257
- package/src/introspect-postgres.ts +0 -149
- package/src/introspect-sqlite.ts +0 -156
- package/src/introspect.ts +0 -36
- package/src/map-types.ts +0 -189
- package/src/render.ts +0 -196
- 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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|