@syncular/typegen 0.0.1-60

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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @syncular/typegen - SQLite schema introspection
3
+ *
4
+ * Works with both better-sqlite3 (Node.js) and bun:sqlite (Bun runtime).
5
+ */
6
+
7
+ import type { DefinedMigrations } from '@syncular/migrations';
8
+ import { Kysely, SqliteDialect } from 'kysely';
9
+ import type { TableSchema, VersionedSchema } from './types';
10
+
11
+ interface SqliteColumnInfo {
12
+ cid: number;
13
+ name: string;
14
+ type: string;
15
+ notnull: 0 | 1;
16
+ dflt_value: string | null;
17
+ pk: 0 | 1;
18
+ }
19
+
20
+ /** Minimal interface shared by better-sqlite3 and bun:sqlite */
21
+ interface SqliteDb {
22
+ prepare(sql: string): { all(...params: unknown[]): unknown[] };
23
+ close(): void;
24
+ }
25
+
26
+ const isBun = typeof globalThis.Bun !== 'undefined';
27
+
28
+ async function createSqliteDb(): Promise<SqliteDb> {
29
+ if (isBun) {
30
+ const { Database } = await import('bun:sqlite');
31
+ return new Database(':memory:');
32
+ }
33
+ const { default: Database } = await import('better-sqlite3');
34
+ return new Database(':memory:');
35
+ }
36
+
37
+ function createKysely<DB>(sqliteDb: SqliteDb): Kysely<DB> {
38
+ return new Kysely<DB>({
39
+ dialect: new SqliteDialect({
40
+ database: sqliteDb as never,
41
+ }),
42
+ });
43
+ }
44
+
45
+ function introspectTable(sqliteDb: SqliteDb, tableName: string): TableSchema {
46
+ const columns = sqliteDb
47
+ .prepare(`PRAGMA table_info("${tableName}")`)
48
+ .all() as SqliteColumnInfo[];
49
+
50
+ return {
51
+ name: tableName,
52
+ columns: columns.map((col) => {
53
+ const nullable = col.notnull === 0 && col.pk === 0;
54
+ const hasDefault = col.dflt_value !== null;
55
+ return {
56
+ name: col.name,
57
+ sqlType: col.type,
58
+ tsType: '', // resolved later by map-types
59
+ nullable,
60
+ isPrimaryKey: col.pk === 1,
61
+ hasDefault,
62
+ };
63
+ }),
64
+ };
65
+ }
66
+
67
+ function getAllTables(sqliteDb: SqliteDb): string[] {
68
+ const rows = sqliteDb
69
+ .prepare(
70
+ `SELECT name FROM sqlite_master
71
+ WHERE type='table'
72
+ AND name NOT LIKE 'sqlite_%'
73
+ ORDER BY name`
74
+ )
75
+ .all() as { name: string }[];
76
+
77
+ return rows.map((r) => r.name);
78
+ }
79
+
80
+ async function introspectAtVersion<DB = unknown>(
81
+ migrations: DefinedMigrations<DB>,
82
+ targetVersion: number,
83
+ filterTables?: string[]
84
+ ): Promise<VersionedSchema> {
85
+ const sqliteDb = await createSqliteDb();
86
+
87
+ try {
88
+ const db = createKysely<DB>(sqliteDb);
89
+
90
+ for (const migration of migrations.migrations) {
91
+ if (migration.version > targetVersion) break;
92
+ await migration.fn(db);
93
+ }
94
+
95
+ let tableNames = getAllTables(sqliteDb);
96
+
97
+ if (filterTables && filterTables.length > 0) {
98
+ const filterSet = new Set(filterTables);
99
+ tableNames = tableNames.filter((t) => filterSet.has(t));
100
+ }
101
+
102
+ const tables = tableNames.map((name) => introspectTable(sqliteDb, name));
103
+
104
+ await db.destroy();
105
+
106
+ return {
107
+ version: targetVersion,
108
+ tables,
109
+ };
110
+ } finally {
111
+ sqliteDb.close();
112
+ }
113
+ }
114
+
115
+ export async function introspectSqliteAllVersions<DB = unknown>(
116
+ migrations: DefinedMigrations<DB>,
117
+ filterTables?: string[]
118
+ ): Promise<VersionedSchema[]> {
119
+ const schemas: VersionedSchema[] = [];
120
+
121
+ for (const migration of migrations.migrations) {
122
+ const schema = await introspectAtVersion(
123
+ migrations,
124
+ migration.version,
125
+ filterTables
126
+ );
127
+ schemas.push(schema);
128
+ }
129
+
130
+ return schemas;
131
+ }
132
+
133
+ export async function introspectSqliteCurrentSchema<DB = unknown>(
134
+ migrations: DefinedMigrations<DB>,
135
+ filterTables?: string[]
136
+ ): Promise<VersionedSchema> {
137
+ return introspectAtVersion(
138
+ migrations,
139
+ migrations.currentVersion,
140
+ filterTables
141
+ );
142
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @syncular/typegen - Schema introspection dispatcher
3
+ */
4
+
5
+ import type { DefinedMigrations } from '@syncular/migrations';
6
+ import {
7
+ introspectPostgresAllVersions,
8
+ introspectPostgresCurrentSchema,
9
+ } from './introspect-postgres';
10
+ import {
11
+ introspectSqliteAllVersions,
12
+ introspectSqliteCurrentSchema,
13
+ } from './introspect-sqlite';
14
+ import type { TypegenDialect, VersionedSchema } from './types';
15
+
16
+ export async function introspectAllVersions<DB = unknown>(
17
+ migrations: DefinedMigrations<DB>,
18
+ dialect: TypegenDialect,
19
+ filterTables?: string[]
20
+ ): Promise<VersionedSchema[]> {
21
+ if (dialect === 'postgres') {
22
+ return introspectPostgresAllVersions(migrations, filterTables);
23
+ }
24
+ return introspectSqliteAllVersions(migrations, filterTables);
25
+ }
26
+
27
+ export async function introspectCurrentSchema<DB = unknown>(
28
+ migrations: DefinedMigrations<DB>,
29
+ dialect: TypegenDialect,
30
+ filterTables?: string[]
31
+ ): Promise<VersionedSchema> {
32
+ if (dialect === 'postgres') {
33
+ return introspectPostgresCurrentSchema(migrations, filterTables);
34
+ }
35
+ return introspectSqliteCurrentSchema(migrations, filterTables);
36
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @syncular/typegen - Type mapping
3
+ *
4
+ * Maps SQL types to TypeScript types for each dialect,
5
+ * with support for user-provided resolver overrides.
6
+ */
7
+
8
+ import type { ColumnInfo, ResolveTypeFn, TypegenDialect } from './types';
9
+
10
+ export interface ResolvedType {
11
+ tsType: string;
12
+ imports: Array<{ name: string; from: string }>;
13
+ }
14
+
15
+ function mapSqliteType(sqlType: string): string {
16
+ const upper = sqlType.toUpperCase();
17
+
18
+ if (upper.includes('INT')) return 'number';
19
+ if (
20
+ upper.includes('REAL') ||
21
+ upper.includes('FLOAT') ||
22
+ upper.includes('DOUBLE')
23
+ )
24
+ return 'number';
25
+ if (upper.includes('BLOB')) return 'Uint8Array';
26
+ if (upper.includes('BOOL')) return 'number';
27
+ // TEXT, VARCHAR, CHAR, etc.
28
+ return 'string';
29
+ }
30
+
31
+ function mapPostgresType(sqlType: string): string {
32
+ const lower = sqlType.toLowerCase().replace(/\s+/g, ' ').trim();
33
+
34
+ // Array types — strip trailing [] and map the element type
35
+ if (lower.endsWith('[]')) {
36
+ const element = mapPostgresType(lower.slice(0, -2));
37
+ return `${element}[]`;
38
+ }
39
+
40
+ // Integer types
41
+ if (
42
+ lower === 'int2' ||
43
+ lower === 'int4' ||
44
+ lower === 'integer' ||
45
+ lower === 'smallint' ||
46
+ lower === 'serial'
47
+ )
48
+ return 'number';
49
+
50
+ // 64-bit integers — not safe in JS
51
+ if (lower === 'int8' || lower === 'bigint' || lower === 'bigserial')
52
+ return 'string';
53
+
54
+ // Floating-point / numeric
55
+ if (
56
+ lower === 'float4' ||
57
+ lower === 'float8' ||
58
+ lower === 'real' ||
59
+ lower === 'double precision' ||
60
+ lower === 'numeric' ||
61
+ lower === 'decimal'
62
+ )
63
+ return 'number';
64
+
65
+ // Boolean
66
+ if (lower === 'bool' || lower === 'boolean') return 'boolean';
67
+
68
+ // JSON
69
+ if (lower === 'json' || lower === 'jsonb') return 'unknown';
70
+
71
+ // Date/time
72
+ if (
73
+ lower === 'timestamp' ||
74
+ lower === 'timestamptz' ||
75
+ lower === 'timestamp with time zone' ||
76
+ lower === 'timestamp without time zone' ||
77
+ lower === 'date' ||
78
+ lower === 'time' ||
79
+ lower === 'timetz' ||
80
+ lower === 'time with time zone' ||
81
+ lower === 'time without time zone'
82
+ )
83
+ return 'string';
84
+
85
+ // Binary
86
+ if (lower === 'bytea') return 'Uint8Array';
87
+
88
+ // Text types
89
+ if (
90
+ lower === 'uuid' ||
91
+ lower === 'text' ||
92
+ lower === 'varchar' ||
93
+ lower === 'char' ||
94
+ lower === 'citext' ||
95
+ lower.startsWith('character varying') ||
96
+ lower.startsWith('character(') ||
97
+ lower.startsWith('varchar(') ||
98
+ lower.startsWith('char(')
99
+ )
100
+ return 'string';
101
+
102
+ // Interval
103
+ if (lower === 'interval') return 'string';
104
+
105
+ // Network types
106
+ if (lower === 'inet' || lower === 'cidr' || lower === 'macaddr')
107
+ return 'string';
108
+
109
+ // Geometric types
110
+ if (
111
+ lower === 'point' ||
112
+ lower === 'line' ||
113
+ lower === 'box' ||
114
+ lower === 'path' ||
115
+ lower === 'polygon' ||
116
+ lower === 'circle' ||
117
+ lower === 'lseg'
118
+ )
119
+ return 'string';
120
+
121
+ // Range types
122
+ if (
123
+ lower === 'int4range' ||
124
+ lower === 'int8range' ||
125
+ lower === 'tsrange' ||
126
+ lower === 'tstzrange' ||
127
+ lower === 'daterange' ||
128
+ lower === 'numrange'
129
+ )
130
+ return 'string';
131
+
132
+ // Full-text search
133
+ if (lower === 'tsvector' || lower === 'tsquery') return 'string';
134
+
135
+ // Other
136
+ if (lower === 'xml') return 'string';
137
+ if (lower === 'money') return 'string';
138
+ if (
139
+ lower === 'bit' ||
140
+ lower === 'varbit' ||
141
+ lower.startsWith('bit(') ||
142
+ lower.startsWith('bit varying')
143
+ )
144
+ return 'string';
145
+
146
+ return 'string';
147
+ }
148
+
149
+ function defaultMapper(dialect: TypegenDialect): (sqlType: string) => string {
150
+ return dialect === 'postgres' ? mapPostgresType : mapSqliteType;
151
+ }
152
+
153
+ /**
154
+ * Resolve the TypeScript type for a column, applying the user resolver first
155
+ * and falling back to the default dialect mapping.
156
+ */
157
+ export function resolveColumnType(
158
+ col: ColumnInfo,
159
+ userResolver?: ResolveTypeFn
160
+ ): ResolvedType {
161
+ const imports: Array<{ name: string; from: string }> = [];
162
+
163
+ // Try user resolver first
164
+ if (userResolver) {
165
+ const override = userResolver(col);
166
+ if (override !== undefined) {
167
+ if (typeof override === 'string') {
168
+ const baseType = override;
169
+ return {
170
+ tsType: col.nullable ? `${baseType} | null` : baseType,
171
+ imports,
172
+ };
173
+ }
174
+ if (override.import) {
175
+ imports.push(override.import);
176
+ }
177
+ const baseType = override.type;
178
+ return {
179
+ tsType: col.nullable ? `${baseType} | null` : baseType,
180
+ imports,
181
+ };
182
+ }
183
+ }
184
+
185
+ // Default mapping
186
+ const mapper = defaultMapper(col.dialect);
187
+ const baseType = mapper(col.sqlType);
188
+ return {
189
+ tsType: col.nullable ? `${baseType} | null` : baseType,
190
+ imports,
191
+ };
192
+ }
package/src/render.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * @syncular/typegen - TypeScript code generation
3
+ */
4
+
5
+ import type { ColumnSchema, TableSchema, VersionedSchema } from './types';
6
+
7
+ /**
8
+ * Convert a snake_case table/column name to PascalCase.
9
+ */
10
+ function toPascalCase(str: string): string {
11
+ return str
12
+ .split(/[_-]/)
13
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
14
+ .join('');
15
+ }
16
+
17
+ /**
18
+ * Render a single column definition.
19
+ */
20
+ function renderColumn(column: ColumnSchema): string {
21
+ // Use optional modifier for nullable columns with defaults (Generated type pattern)
22
+ const optional = column.hasDefault || column.nullable ? '?' : '';
23
+ return ` ${column.name}${optional}: ${column.tsType};`;
24
+ }
25
+
26
+ /**
27
+ * Render a table interface.
28
+ */
29
+ function renderTableInterface(
30
+ table: TableSchema,
31
+ interfaceName: string
32
+ ): string {
33
+ const columns = table.columns.map(renderColumn).join('\n');
34
+ return `export interface ${interfaceName} {\n${columns}\n}`;
35
+ }
36
+
37
+ /**
38
+ * Render a database interface containing all tables.
39
+ */
40
+ function renderDbInterface(
41
+ schema: VersionedSchema,
42
+ interfaceName: string,
43
+ extendsType?: string
44
+ ): string {
45
+ const extendsClause = extendsType ? ` extends ${extendsType}` : '';
46
+ const tableEntries = schema.tables
47
+ .map((t) => ` ${t.name}: ${toPascalCase(t.name)}Table;`)
48
+ .join('\n');
49
+
50
+ return `export interface ${interfaceName}${extendsClause} {\n${tableEntries}\n}`;
51
+ }
52
+
53
+ /**
54
+ * Options for rendering types.
55
+ */
56
+ export interface RenderOptions {
57
+ /** Schemas at each version (for version history) */
58
+ schemas: VersionedSchema[];
59
+ /** Whether to extend SyncClientDb */
60
+ extendsSyncClientDb?: boolean;
61
+ /** Generate versioned interfaces */
62
+ includeVersionHistory?: boolean;
63
+ /** Custom imports collected from resolver results */
64
+ customImports?: Array<{ name: string; from: string }>;
65
+ }
66
+
67
+ /**
68
+ * Render complete TypeScript type definitions.
69
+ */
70
+ export function renderTypes(options: RenderOptions): string {
71
+ const { schemas, extendsSyncClientDb, includeVersionHistory, customImports } =
72
+ options;
73
+ const lines: string[] = [];
74
+
75
+ // Header
76
+ lines.push('/**');
77
+ lines.push(' * Auto-generated database types from migrations.');
78
+ lines.push(' * DO NOT EDIT - regenerate with @syncular/typegen');
79
+ lines.push(' */');
80
+ lines.push('');
81
+
82
+ // Import SyncClientDb if extending
83
+ if (extendsSyncClientDb) {
84
+ lines.push("import type { SyncClientDb } from '@syncular/client';");
85
+ lines.push('');
86
+ }
87
+
88
+ // Render custom imports from resolver
89
+ if (customImports && customImports.length > 0) {
90
+ // Group imports by source module
91
+ const byModule = new Map<string, Set<string>>();
92
+ for (const imp of customImports) {
93
+ let names = byModule.get(imp.from);
94
+ if (!names) {
95
+ names = new Set();
96
+ byModule.set(imp.from, names);
97
+ }
98
+ names.add(imp.name);
99
+ }
100
+ for (const [from, names] of byModule) {
101
+ const sorted = [...names].sort();
102
+ lines.push(`import type { ${sorted.join(', ')} } from '${from}';`);
103
+ }
104
+ lines.push('');
105
+ }
106
+
107
+ // Get the latest schema
108
+ const latestSchema = schemas[schemas.length - 1];
109
+ if (!latestSchema) {
110
+ lines.push('// No migrations defined');
111
+ return lines.join('\n');
112
+ }
113
+
114
+ // Generate table interfaces for latest version
115
+ for (const table of latestSchema.tables) {
116
+ lines.push(renderTableInterface(table, `${toPascalCase(table.name)}Table`));
117
+ lines.push('');
118
+ }
119
+
120
+ // Generate versioned DB interfaces if requested
121
+ if (includeVersionHistory && schemas.length > 0) {
122
+ for (const schema of schemas) {
123
+ // For each version, generate table interfaces with version suffix
124
+ // if they differ from the latest
125
+ const versionSuffix = `V${schema.version}`;
126
+
127
+ // Generate versioned table interfaces
128
+ for (const table of schema.tables) {
129
+ const latestTable = latestSchema.tables.find(
130
+ (t) => t.name === table.name
131
+ );
132
+
133
+ // Only generate versioned interface if different from latest
134
+ if (latestTable && !tablesEqual(table, latestTable)) {
135
+ lines.push(
136
+ renderTableInterface(
137
+ table,
138
+ `${toPascalCase(table.name)}Table${versionSuffix}`
139
+ )
140
+ );
141
+ lines.push('');
142
+ }
143
+ }
144
+
145
+ // Generate versioned DB interface
146
+ const tableEntries = schema.tables
147
+ .map((t) => {
148
+ const latestTable = latestSchema.tables.find(
149
+ (lt) => lt.name === t.name
150
+ );
151
+ const useVersioned = latestTable && !tablesEqual(t, latestTable);
152
+ const typeName = useVersioned
153
+ ? `${toPascalCase(t.name)}Table${versionSuffix}`
154
+ : `${toPascalCase(t.name)}Table`;
155
+ return ` ${t.name}: ${typeName};`;
156
+ })
157
+ .join('\n');
158
+
159
+ lines.push(`export interface ClientDb${versionSuffix} {`);
160
+ lines.push(tableEntries);
161
+ lines.push('}');
162
+ lines.push('');
163
+ }
164
+ }
165
+
166
+ // Generate main DB interface (latest version)
167
+ const extendsType = extendsSyncClientDb ? 'SyncClientDb' : undefined;
168
+ lines.push(renderDbInterface(latestSchema, 'ClientDb', extendsType));
169
+ lines.push('');
170
+
171
+ return lines.join('\n');
172
+ }
173
+
174
+ /**
175
+ * Check if two table schemas are equal.
176
+ */
177
+ function tablesEqual(a: TableSchema, b: TableSchema): boolean {
178
+ if (a.columns.length !== b.columns.length) return false;
179
+
180
+ for (let i = 0; i < a.columns.length; i++) {
181
+ const colA = a.columns[i]!;
182
+ const colB = b.columns[i]!;
183
+
184
+ if (
185
+ colA.name !== colB.name ||
186
+ colA.tsType !== colB.tsType ||
187
+ colA.nullable !== colB.nullable ||
188
+ colA.hasDefault !== colB.hasDefault
189
+ ) {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ return true;
195
+ }
package/src/types.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @syncular/typegen - Type definitions
3
+ */
4
+
5
+ import type { DefinedMigrations } from '@syncular/migrations';
6
+
7
+ export type TypegenDialect = 'sqlite' | 'postgres';
8
+
9
+ /**
10
+ * Column information passed to the resolver function.
11
+ */
12
+ export interface ColumnInfo {
13
+ table: string;
14
+ column: string;
15
+ sqlType: string;
16
+ nullable: boolean;
17
+ isPrimaryKey: boolean;
18
+ hasDefault: boolean;
19
+ dialect: TypegenDialect;
20
+ }
21
+
22
+ /**
23
+ * Return type from a resolver function.
24
+ */
25
+ export type TypeOverride =
26
+ | string
27
+ | { type: string; import?: { name: string; from: string } };
28
+
29
+ /**
30
+ * User-provided function to override default type mapping.
31
+ */
32
+ export type ResolveTypeFn = (col: ColumnInfo) => TypeOverride | undefined;
33
+
34
+ /**
35
+ * Parsed table schema.
36
+ */
37
+ export interface TableSchema {
38
+ name: string;
39
+ columns: ColumnSchema[];
40
+ }
41
+
42
+ /**
43
+ * Parsed column schema.
44
+ */
45
+ export interface ColumnSchema {
46
+ name: string;
47
+ sqlType: string;
48
+ tsType: string;
49
+ nullable: boolean;
50
+ isPrimaryKey: boolean;
51
+ hasDefault: boolean;
52
+ }
53
+
54
+ /**
55
+ * Schema snapshot at a specific version.
56
+ */
57
+ export interface VersionedSchema {
58
+ version: number;
59
+ tables: TableSchema[];
60
+ }
61
+
62
+ /**
63
+ * Options for generateTypes().
64
+ */
65
+ export interface GenerateTypesOptions<DB = unknown> {
66
+ /** Defined migrations from defineMigrations() */
67
+ migrations: DefinedMigrations<DB>;
68
+ /** Output file path for generated types */
69
+ output: string;
70
+ /** Database dialect to use for introspection (default: 'sqlite') */
71
+ dialect?: TypegenDialect;
72
+ /** Whether to extend SyncClientDb interface (adds sync infrastructure types) */
73
+ extendsSyncClientDb?: boolean;
74
+ /** Generate versioned interfaces (ClientDbV1, ClientDbV2, etc.) */
75
+ includeVersionHistory?: boolean;
76
+ /** Only generate types for these tables (default: all tables) */
77
+ tables?: string[];
78
+ /** Custom type resolver for overriding default type mapping */
79
+ resolveType?: ResolveTypeFn;
80
+ }
81
+
82
+ /**
83
+ * Result of type generation.
84
+ */
85
+ export interface GenerateTypesResult {
86
+ /** Path to the generated file */
87
+ outputPath: string;
88
+ /** Current schema version */
89
+ currentVersion: number;
90
+ /** Number of tables generated */
91
+ tableCount: number;
92
+ /** Generated TypeScript code */
93
+ code: string;
94
+ }