@syncular/typegen 0.0.6-96 → 0.1.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 (45) hide show
  1. package/README.md +70 -1
  2. package/dist/app-contract.d.ts +154 -0
  3. package/dist/app-contract.d.ts.map +1 -0
  4. package/dist/app-contract.js +250 -0
  5. package/dist/app-contract.js.map +1 -0
  6. package/dist/checksums.d.ts +6 -0
  7. package/dist/checksums.d.ts.map +1 -0
  8. package/dist/checksums.js +173 -0
  9. package/dist/checksums.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +88 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/generate.d.ts +0 -1
  15. package/dist/generate.d.ts.map +1 -1
  16. package/dist/generate.js +4 -7
  17. package/dist/generate.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/introspect-postgres.js +7 -5
  23. package/dist/introspect-postgres.js.map +1 -1
  24. package/dist/introspect-sqlite.d.ts.map +1 -1
  25. package/dist/introspect-sqlite.js +2 -1
  26. package/dist/introspect-sqlite.js.map +1 -1
  27. package/dist/introspect.js +2 -2
  28. package/dist/introspect.js.map +1 -1
  29. package/dist/map-types.js.map +1 -1
  30. package/dist/render.d.ts +1 -5
  31. package/dist/render.d.ts.map +1 -1
  32. package/dist/render.js +2 -21
  33. package/dist/render.js.map +1 -1
  34. package/dist/types.d.ts +18 -13
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +16 -6
  37. package/src/app-contract.ts +531 -0
  38. package/src/checksums.ts +257 -0
  39. package/src/cli.ts +104 -0
  40. package/src/generate.ts +0 -5
  41. package/src/index.ts +2 -0
  42. package/src/introspect-postgres.ts +7 -7
  43. package/src/introspect-sqlite.ts +6 -1
  44. package/src/render.ts +3 -43
  45. package/src/types.ts +20 -17
@@ -0,0 +1,257 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { PGlite } from '@electric-sql/pglite';
4
+ import type {
5
+ DefinedMigrations,
6
+ MigrationChecksums,
7
+ ParsedMigration,
8
+ } from '@syncular/migrations';
9
+ import { Kysely, SqliteDialect } from 'kysely';
10
+ import { PGliteDialect } from 'kysely-pglite-dialect';
11
+ import type {
12
+ GenerateMigrationChecksumsOptions,
13
+ GenerateMigrationChecksumsResult,
14
+ TypegenDialect,
15
+ } from './types';
16
+
17
+ interface TraceableQuery {
18
+ sql: string;
19
+ parameters: readonly unknown[];
20
+ }
21
+
22
+ interface SqliteDb {
23
+ close(): void;
24
+ }
25
+
26
+ type BunAwareGlobals = typeof globalThis & {
27
+ Bun?: object;
28
+ };
29
+
30
+ const runtimeGlobals = globalThis as BunAwareGlobals;
31
+ const isBun = typeof runtimeGlobals.Bun !== 'undefined';
32
+
33
+ function hashString(value: string): string {
34
+ let hash = 0;
35
+
36
+ for (let index = 0; index < value.length; index += 1) {
37
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
38
+ }
39
+
40
+ return hash.toString(16).padStart(8, '0');
41
+ }
42
+
43
+ function normalizeParameterValue(value: unknown): unknown {
44
+ if (typeof value === 'bigint') {
45
+ return { type: 'bigint', value: value.toString() };
46
+ }
47
+ if (value instanceof Date) {
48
+ return { type: 'date', value: value.toISOString() };
49
+ }
50
+ if (value instanceof Uint8Array) {
51
+ return { type: 'bytes', value: Array.from(value) };
52
+ }
53
+ if (Array.isArray(value)) {
54
+ return value.map((entry) => normalizeParameterValue(entry));
55
+ }
56
+ if (value && typeof value === 'object') {
57
+ return Object.fromEntries(
58
+ Object.entries(value)
59
+ .sort(([left], [right]) => left.localeCompare(right))
60
+ .map(([key, entry]) => [key, normalizeParameterValue(entry)])
61
+ );
62
+ }
63
+ return value;
64
+ }
65
+
66
+ function serializeQuery(query: TraceableQuery): string {
67
+ return JSON.stringify({
68
+ sql: query.sql,
69
+ parameters: query.parameters.map((value) => normalizeParameterValue(value)),
70
+ });
71
+ }
72
+
73
+ function hashTrace(entries: string[]): string {
74
+ return hashString(entries.join('\n'));
75
+ }
76
+
77
+ function quoteTsString(value: string): string {
78
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
79
+ }
80
+
81
+ async function createSqliteTraceDb<DB>(traceEntries: string[]): Promise<{
82
+ db: Kysely<DB>;
83
+ sqliteDb: SqliteDb;
84
+ }> {
85
+ if (isBun) {
86
+ const bunSqliteSpecifier = 'bun:sqlite';
87
+ const sqliteModule = await import(bunSqliteSpecifier);
88
+ const dialectModule = await import('kysely-bun-sqlite');
89
+ const sqliteDb = new sqliteModule.Database(':memory:');
90
+ const db = new Kysely<DB>({
91
+ dialect: new dialectModule.BunSqliteDialect({
92
+ database: sqliteDb as never,
93
+ }),
94
+ log(event) {
95
+ if (event.level === 'query') {
96
+ traceEntries.push(serializeQuery(event.query));
97
+ }
98
+ },
99
+ });
100
+
101
+ return { db, sqliteDb };
102
+ }
103
+
104
+ const { default: Database } = await import('better-sqlite3');
105
+ const sqliteDb = new Database(':memory:');
106
+ const db = new Kysely<DB>({
107
+ dialect: new SqliteDialect({
108
+ database: sqliteDb as never,
109
+ }),
110
+ log(event) {
111
+ if (event.level === 'query') {
112
+ traceEntries.push(serializeQuery(event.query));
113
+ }
114
+ },
115
+ });
116
+
117
+ return { db, sqliteDb };
118
+ }
119
+
120
+ async function createPostgresTraceDb<DB>(traceEntries: string[]): Promise<{
121
+ db: Kysely<DB>;
122
+ dispose: () => Promise<void>;
123
+ }> {
124
+ const pglite = await PGlite.create();
125
+ const db = new Kysely<DB>({
126
+ dialect: new PGliteDialect(pglite),
127
+ log(event) {
128
+ if (event.level === 'query') {
129
+ traceEntries.push(serializeQuery(event.query));
130
+ }
131
+ },
132
+ });
133
+
134
+ return {
135
+ db,
136
+ dispose: async () => {
137
+ if (!pglite.closed) {
138
+ await pglite.close();
139
+ }
140
+ },
141
+ };
142
+ }
143
+
144
+ async function createTraceDb<DB>(
145
+ dialect: TypegenDialect,
146
+ traceEntries: string[]
147
+ ): Promise<{
148
+ db: Kysely<DB>;
149
+ dispose: () => Promise<void>;
150
+ }> {
151
+ if (dialect === 'postgres') {
152
+ return createPostgresTraceDb<DB>(traceEntries);
153
+ }
154
+
155
+ const { db, sqliteDb } = await createSqliteTraceDb<DB>(traceEntries);
156
+ return {
157
+ db,
158
+ dispose: async () => {
159
+ sqliteDb.close();
160
+ },
161
+ };
162
+ }
163
+
164
+ async function computeMigrationChecksum<DB>(
165
+ migrations: DefinedMigrations<DB>,
166
+ targetMigration: ParsedMigration<DB>,
167
+ dialect: TypegenDialect
168
+ ): Promise<string> {
169
+ const traceEntries: string[] = [];
170
+ const { db, dispose } = await createTraceDb<DB>(dialect, traceEntries);
171
+
172
+ try {
173
+ for (const migration of migrations.migrations) {
174
+ if (migration.version > targetMigration.version) {
175
+ break;
176
+ }
177
+
178
+ if (migration.version === targetMigration.version) {
179
+ traceEntries.length = 0;
180
+ }
181
+
182
+ await migration.up(db);
183
+
184
+ if (migration.version === targetMigration.version) {
185
+ break;
186
+ }
187
+ }
188
+
189
+ return hashTrace(traceEntries);
190
+ } finally {
191
+ await db.destroy();
192
+ await dispose();
193
+ }
194
+ }
195
+
196
+ export async function createMigrationChecksums<DB>(
197
+ migrations: DefinedMigrations<DB>,
198
+ dialect: TypegenDialect = 'sqlite'
199
+ ): Promise<MigrationChecksums> {
200
+ const checksums: Record<string, string> = {};
201
+
202
+ for (const migration of migrations.migrations) {
203
+ if (migration.checksum === 'disabled') {
204
+ continue;
205
+ }
206
+
207
+ checksums[String(migration.version)] = await computeMigrationChecksum(
208
+ migrations,
209
+ migration,
210
+ dialect
211
+ );
212
+ }
213
+
214
+ return checksums;
215
+ }
216
+
217
+ export function renderMigrationChecksums(
218
+ checksums: MigrationChecksums
219
+ ): string {
220
+ const entries = Object.entries(checksums)
221
+ .sort(([left], [right]) => Number(left) - Number(right))
222
+ .map(
223
+ ([version, checksum]) =>
224
+ ` ${quoteTsString(version)}: ${quoteTsString(checksum)},`
225
+ )
226
+ .join('\n');
227
+
228
+ return [
229
+ '/**',
230
+ ' * Generated by @syncular/typegen.',
231
+ ' * Do not edit by hand.',
232
+ ' */',
233
+ '',
234
+ 'export const migrationChecksums = {',
235
+ entries,
236
+ '} as const;',
237
+ '',
238
+ ].join('\n');
239
+ }
240
+
241
+ export async function generateMigrationChecksums<DB>(
242
+ options: GenerateMigrationChecksumsOptions<DB>
243
+ ): Promise<GenerateMigrationChecksumsResult> {
244
+ const { migrations, output, dialect = 'sqlite' } = options;
245
+ const checksums = await createMigrationChecksums(migrations, dialect);
246
+ const code = renderMigrationChecksums(checksums);
247
+
248
+ await mkdir(dirname(output), { recursive: true });
249
+ await writeFile(output, code, 'utf-8');
250
+
251
+ return {
252
+ outputPath: output,
253
+ currentVersion: migrations.currentVersion,
254
+ checksumCount: Object.keys(checksums).length,
255
+ code,
256
+ };
257
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ loadSyncularClientContract,
8
+ toSyncularCodegenJson,
9
+ writeSyncularCodegenJsonFromModule,
10
+ } from './app-contract';
11
+
12
+ interface CodegenConfigCommand {
13
+ app: string;
14
+ out: string;
15
+ exportName?: string;
16
+ check: boolean;
17
+ }
18
+
19
+ async function main(argv: string[]): Promise<void> {
20
+ const command = argv[0];
21
+ if (command !== 'codegen-config') {
22
+ printUsageAndExit(command ? `Unknown command ${command}` : undefined);
23
+ }
24
+ const options = parseCodegenConfigArgs(argv.slice(1));
25
+ if (options.check) {
26
+ const contract = await loadSyncularClientContract({
27
+ modulePath: options.app,
28
+ exportName: options.exportName,
29
+ });
30
+ const expected = toSyncularCodegenJson(contract);
31
+ const actual = await readFile(options.out, 'utf8').catch((error) => {
32
+ throw new Error(
33
+ `Cannot read ${options.out}; run without --check to generate it first: ${error.message}`
34
+ );
35
+ });
36
+ if (actual !== expected) {
37
+ throw new Error(
38
+ `${options.out} does not match ${options.app}; run syncular generate --app ${options.app}`
39
+ );
40
+ }
41
+ return;
42
+ }
43
+ await writeSyncularCodegenJsonFromModule({
44
+ modulePath: options.app,
45
+ exportName: options.exportName,
46
+ outputPath: options.out,
47
+ });
48
+ }
49
+
50
+ function parseCodegenConfigArgs(args: string[]): CodegenConfigCommand {
51
+ let app: string | undefined;
52
+ let out: string | undefined;
53
+ let exportName: string | undefined;
54
+ let check = false;
55
+ for (let index = 0; index < args.length; index += 1) {
56
+ const arg = args[index];
57
+ if (arg === '--app') {
58
+ app = requireValue(args, (index += 1), '--app');
59
+ } else if (arg === '--out') {
60
+ out = requireValue(args, (index += 1), '--out');
61
+ } else if (arg === '--export') {
62
+ exportName = requireValue(args, (index += 1), '--export');
63
+ } else if (arg === '--check') {
64
+ check = true;
65
+ } else if (arg === '--help' || arg === '-h') {
66
+ printUsageAndExit();
67
+ } else {
68
+ printUsageAndExit(`Unknown option ${arg}`);
69
+ }
70
+ }
71
+ if (!app) printUsageAndExit('Missing --app');
72
+ return {
73
+ app,
74
+ out: out ?? defaultCodegenConfigPath(app),
75
+ exportName,
76
+ check,
77
+ };
78
+ }
79
+
80
+ function defaultCodegenConfigPath(app: string): string {
81
+ const appPath = app.startsWith('file:') ? fileURLToPath(app) : app;
82
+ return join(dirname(appPath), 'generated', 'syncular.codegen.json');
83
+ }
84
+
85
+ function requireValue(args: string[], index: number, option: string): string {
86
+ const value = args[index];
87
+ if (!value || value.startsWith('--')) {
88
+ printUsageAndExit(`${option} requires a value`);
89
+ }
90
+ return value;
91
+ }
92
+
93
+ function printUsageAndExit(message?: string): never {
94
+ if (message) console.error(message);
95
+ console.error(
96
+ [
97
+ 'Usage:',
98
+ ' syncular-typegen codegen-config --app ./syncular.app.ts [--out ./generated/syncular.codegen.json] [--export app] [--check]',
99
+ ].join('\n')
100
+ );
101
+ process.exit(message ? 1 : 0);
102
+ }
103
+
104
+ await main(process.argv.slice(2));
package/src/generate.ts CHANGED
@@ -68,7 +68,6 @@ function applyTypeMappings(
68
68
  * await generateTypes({
69
69
  * migrations,
70
70
  * output: './src/db.generated.ts',
71
- * extendsSyncClientDb: true,
72
71
  * });
73
72
  * ```
74
73
  */
@@ -78,8 +77,6 @@ export async function generateTypes<DB>(
78
77
  const {
79
78
  migrations,
80
79
  output,
81
- extendsSyncClientDb,
82
- syncularImportType,
83
80
  includeVersionHistory,
84
81
  tables,
85
82
  dialect = 'sqlite',
@@ -105,8 +102,6 @@ export async function generateTypes<DB>(
105
102
  // Render TypeScript code
106
103
  const code = renderTypes({
107
104
  schemas,
108
- extendsSyncClientDb,
109
- syncularImportType,
110
105
  includeVersionHistory,
111
106
  customImports,
112
107
  });
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * 3. Generating TypeScript interfaces
8
8
  */
9
9
 
10
+ export * from './app-contract';
11
+ export * from './checksums';
10
12
  export * from './generate';
11
13
  export * from './introspect';
12
14
  export * from './map-types';
@@ -90,19 +90,16 @@ async function introspectAtVersion<DB = unknown>(
90
90
  filterTables?: string[]
91
91
  ): Promise<VersionedSchema> {
92
92
  const pglite = await PGlite.create();
93
+ const db = new Kysely<DB>({
94
+ dialect: new PGliteDialect(pglite),
95
+ });
93
96
 
94
97
  try {
95
- const db = new Kysely<DB>({
96
- dialect: new PGliteDialect(pglite),
97
- });
98
-
99
98
  for (const migration of migrations.migrations) {
100
99
  if (migration.version > targetVersion) break;
101
100
  await migration.up(db);
102
101
  }
103
102
 
104
- await db.destroy();
105
-
106
103
  let tables = await introspectPg(pglite);
107
104
 
108
105
  if (filterTables && filterTables.length > 0) {
@@ -115,7 +112,10 @@ async function introspectAtVersion<DB = unknown>(
115
112
  tables,
116
113
  };
117
114
  } finally {
118
- await pglite.close();
115
+ await db.destroy();
116
+ if (!pglite.closed) {
117
+ await pglite.close();
118
+ }
119
119
  }
120
120
  }
121
121
 
@@ -23,7 +23,12 @@ interface SqliteDb {
23
23
  close(): void;
24
24
  }
25
25
 
26
- const isBun = typeof globalThis.Bun !== 'undefined';
26
+ type BunAwareGlobals = typeof globalThis & {
27
+ Bun?: object;
28
+ };
29
+
30
+ const runtimeGlobals = globalThis as BunAwareGlobals;
31
+ const isBun = typeof runtimeGlobals.Bun !== 'undefined';
27
32
 
28
33
  async function createSqliteDb(): Promise<SqliteDb> {
29
34
  if (isBun) {
package/src/render.ts CHANGED
@@ -2,12 +2,7 @@
2
2
  * @syncular/typegen - TypeScript code generation
3
3
  */
4
4
 
5
- import type {
6
- ColumnSchema,
7
- SyncularImportType,
8
- TableSchema,
9
- VersionedSchema,
10
- } from './types';
5
+ import type { ColumnSchema, TableSchema, VersionedSchema } from './types';
11
6
 
12
7
  /**
13
8
  * Convert a snake_case table/column name to PascalCase.
@@ -62,43 +57,17 @@ function renderDbInterface(
62
57
  export interface RenderOptions {
63
58
  /** Schemas at each version (for version history) */
64
59
  schemas: VersionedSchema[];
65
- /** Whether to extend SyncClientDb */
66
- extendsSyncClientDb?: boolean;
67
- /** Controls package import style for SyncClientDb (default: 'scoped') */
68
- syncularImportType?: SyncularImportType;
69
60
  /** Generate versioned interfaces */
70
61
  includeVersionHistory?: boolean;
71
62
  /** Custom imports collected from resolver results */
72
63
  customImports?: Array<{ name: string; from: string }>;
73
64
  }
74
65
 
75
- function resolveSyncClientImportPath(importType: SyncularImportType): string {
76
- if (importType === 'umbrella') {
77
- return 'syncular/client';
78
- }
79
- if (importType === 'scoped') {
80
- return '@syncular/client';
81
- }
82
- const clientImportPath = importType.client.trim();
83
- if (clientImportPath.length === 0) {
84
- throw new Error(
85
- 'syncularImportType.client must be a non-empty package import path'
86
- );
87
- }
88
- return clientImportPath;
89
- }
90
-
91
66
  /**
92
67
  * Render complete TypeScript type definitions.
93
68
  */
94
69
  export function renderTypes(options: RenderOptions): string {
95
- const {
96
- schemas,
97
- extendsSyncClientDb,
98
- syncularImportType = 'scoped',
99
- includeVersionHistory,
100
- customImports,
101
- } = options;
70
+ const { schemas, includeVersionHistory, customImports } = options;
102
71
  const lines: string[] = [];
103
72
 
104
73
  // Header
@@ -108,14 +77,6 @@ export function renderTypes(options: RenderOptions): string {
108
77
  lines.push(' */');
109
78
  lines.push('');
110
79
 
111
- // Import SyncClientDb if extending
112
- if (extendsSyncClientDb) {
113
- lines.push(
114
- `import type { SyncClientDb } from '${resolveSyncClientImportPath(syncularImportType)}';`
115
- );
116
- lines.push('');
117
- }
118
-
119
80
  const usesGenerated = schemas.some((schema) =>
120
81
  schema.tables.some((table) =>
121
82
  table.columns.some((column) => column.hasDefault)
@@ -205,8 +166,7 @@ export function renderTypes(options: RenderOptions): string {
205
166
  }
206
167
 
207
168
  // Generate main DB interface (latest version)
208
- const extendsType = extendsSyncClientDb ? 'SyncClientDb' : undefined;
209
- lines.push(renderDbInterface(latestSchema, 'ClientDb', extendsType));
169
+ lines.push(renderDbInterface(latestSchema, 'ClientDb'));
210
170
  lines.push('');
211
171
 
212
172
  return lines.join('\n');
package/src/types.ts CHANGED
@@ -6,14 +6,6 @@ import type { DefinedMigrations } from '@syncular/migrations';
6
6
 
7
7
  export type TypegenDialect = 'sqlite' | 'postgres';
8
8
 
9
- export type SyncularImportType =
10
- | 'scoped'
11
- | 'umbrella'
12
- | {
13
- client: string;
14
- [packageName: string]: string;
15
- };
16
-
17
9
  /**
18
10
  * Column information for a schema column.
19
11
  */
@@ -99,15 +91,6 @@ export interface GenerateTypesOptions<DB = unknown> {
99
91
  output: string;
100
92
  /** Database dialect to use for introspection (default: 'sqlite') */
101
93
  dialect?: TypegenDialect;
102
- /** Whether to extend SyncClientDb interface (adds sync infrastructure types) */
103
- extendsSyncClientDb?: boolean;
104
- /**
105
- * Controls how syncular package imports are rendered in generated output.
106
- * - 'scoped' (default): '@syncular/client'
107
- * - 'umbrella': 'syncular/client'
108
- * - object: explicit package mapping (must include `client`)
109
- */
110
- syncularImportType?: SyncularImportType;
111
94
  /** Generate versioned interfaces (ClientDbV1, ClientDbV2, etc.) */
112
95
  includeVersionHistory?: boolean;
113
96
  /** Only generate types for these tables (default: all tables) */
@@ -132,3 +115,23 @@ export interface GenerateTypesResult {
132
115
  /** Generated TypeScript code */
133
116
  code: string;
134
117
  }
118
+
119
+ export interface GenerateMigrationChecksumsOptions<DB = unknown> {
120
+ /** Defined migrations from defineMigrations() */
121
+ migrations: DefinedMigrations<DB>;
122
+ /** Output file path for generated checksums */
123
+ output: string;
124
+ /** Database dialect to use for replay (default: 'sqlite') */
125
+ dialect?: TypegenDialect;
126
+ }
127
+
128
+ export interface GenerateMigrationChecksumsResult {
129
+ /** Path to the generated file */
130
+ outputPath: string;
131
+ /** Current schema version */
132
+ currentVersion: number;
133
+ /** Number of checksums generated */
134
+ checksumCount: number;
135
+ /** Generated TypeScript code */
136
+ code: string;
137
+ }