diff --git a/.changeset/thirty-bikes-move.md b/.changeset/thirty-bikes-move.md new file mode 100644 index 000000000..4451d3391 --- /dev/null +++ b/.changeset/thirty-bikes-move.md @@ -0,0 +1,5 @@ +--- +'@powersync/service-sync-rules': patch +--- + +Add TypeScript/TableV2 schema generator diff --git a/packages/sync-rules/src/JsSchemaGenerator.ts b/packages/sync-rules/src/JsLegacySchemaGenerator.ts similarity index 87% rename from packages/sync-rules/src/JsSchemaGenerator.ts rename to packages/sync-rules/src/JsLegacySchemaGenerator.ts index 2e22876d2..9315e3bc8 100644 --- a/packages/sync-rules/src/JsSchemaGenerator.ts +++ b/packages/sync-rules/src/JsLegacySchemaGenerator.ts @@ -3,10 +3,10 @@ import { SchemaGenerator } from './SchemaGenerator.js'; import { SqlSyncRules } from './SqlSyncRules.js'; import { SourceSchema } from './types.js'; -export class JsSchemaGenerator extends SchemaGenerator { - readonly key = 'js'; - readonly label = 'JavaScript'; - readonly mediaType = 'application/javascript'; +export class JsLegacySchemaGenerator extends SchemaGenerator { + readonly key = 'jsLegacy'; + readonly label = 'JavaScript (legacy syntax)'; + readonly mediaType = 'text/javascript'; readonly fileName = 'schema.js'; generate(source: SqlSyncRules, schema: SourceSchema): string { diff --git a/packages/sync-rules/src/TsSchemaGenerator.ts b/packages/sync-rules/src/TsSchemaGenerator.ts new file mode 100644 index 000000000..10cf12209 --- /dev/null +++ b/packages/sync-rules/src/TsSchemaGenerator.ts @@ -0,0 +1,106 @@ +import { ColumnDefinition, TYPE_INTEGER, TYPE_REAL, TYPE_TEXT } from './ExpressionType.js'; +import { SchemaGenerator } from './SchemaGenerator.js'; +import { SqlSyncRules } from './SqlSyncRules.js'; +import { SourceSchema } from './types.js'; + +export interface TsSchemaGeneratorOptions { + language?: TsSchemaLanguage; + imports?: TsSchemaImports; +} + +export enum TsSchemaLanguage { + ts = 'ts', + /** Excludes types from the generated schema. */ + js = 'js' +} + +export enum TsSchemaImports { + web = 'web', + reactNative = 'reactNative', + /** + * Emits imports for `@powersync/web`, with comments for `@powersync/react-native`. + */ + auto = 'auto' +} + +export class TsSchemaGenerator extends SchemaGenerator { + readonly key: string; + readonly fileName: string; + readonly mediaType: string; + readonly label: string; + + readonly language: TsSchemaLanguage; + + constructor(public readonly options: TsSchemaGeneratorOptions = {}) { + super(); + + this.language = options.language ?? TsSchemaLanguage.ts; + this.key = this.language; + if (this.language == TsSchemaLanguage.ts) { + this.fileName = 'schema.ts'; + this.mediaType = 'text/typescript'; + this.label = 'TypeScript'; + } else { + this.fileName = 'schema.js'; + this.mediaType = 'text/javascript'; + this.label = 'JavaScript'; + } + } + + generate(source: SqlSyncRules, schema: SourceSchema): string { + const tables = super.getAllTables(source, schema); + + return `${this.generateImports()} + +${tables.map((table) => this.generateTable(table.name, table.columns)).join('\n\n')} + +export const AppSchema = new Schema({ + ${tables.map((table) => table.name).join(',\n ')} +}); + +${this.generateTypeExports()}`; + } + + private generateTypeExports() { + if (this.language == TsSchemaLanguage.ts) { + return `export type Database = (typeof AppSchema)['types'];\n`; + } else { + return ``; + } + } + + private generateImports() { + const importStyle = this.options.imports ?? 'auto'; + if (importStyle == TsSchemaImports.web) { + return `import { column, Schema, TableV2 } from '@powersync/web';`; + } else if (importStyle == TsSchemaImports.reactNative) { + return `import { column, Schema, TableV2 } from '@powersync/react-native';`; + } else { + return `import { column, Schema, TableV2 } from '@powersync/web'; +// OR: import { column, Schema, TableV2 } from '@powersync/react-native';`; + } + } + + private generateTable(name: string, columns: ColumnDefinition[]): string { + return `const ${name} = new TableV2( + { + // id column (text) is automatically included + ${columns.map((c) => this.generateColumn(c)).join(',\n ')} + }, + { indexes: {} } +);`; + } + + private generateColumn(column: ColumnDefinition) { + const t = column.type; + if (t.typeFlags & TYPE_TEXT) { + return `${column.name}: column.text`; + } else if (t.typeFlags & TYPE_REAL) { + return `${column.name}: column.real`; + } else if (t.typeFlags & TYPE_INTEGER) { + return `${column.name}: column.integer`; + } else { + return `${column.name}: column.text`; + } + } +} diff --git a/packages/sync-rules/src/generators.ts b/packages/sync-rules/src/generators.ts index da3e06845..254f9f78c 100644 --- a/packages/sync-rules/src/generators.ts +++ b/packages/sync-rules/src/generators.ts @@ -1,7 +1,10 @@ import { DartSchemaGenerator } from './DartSchemaGenerator.js'; -import { JsSchemaGenerator } from './JsSchemaGenerator.js'; +import { JsLegacySchemaGenerator } from './JsLegacySchemaGenerator.js'; +import { TsSchemaGenerator, TsSchemaLanguage } from './TsSchemaGenerator.js'; export const schemaGenerators = { - js: new JsSchemaGenerator(), + ts: new TsSchemaGenerator(), + js: new TsSchemaGenerator({ language: TsSchemaLanguage.js }), + jsLegacy: new JsLegacySchemaGenerator(), dart: new DartSchemaGenerator() }; diff --git a/packages/sync-rules/src/index.ts b/packages/sync-rules/src/index.ts index 78ba6b593..d72a1e58c 100644 --- a/packages/sync-rules/src/index.ts +++ b/packages/sync-rules/src/index.ts @@ -13,7 +13,8 @@ export * from './StaticSchema.js'; export * from './ExpressionType.js'; export * from './SchemaGenerator.js'; export * from './DartSchemaGenerator.js'; -export * from './JsSchemaGenerator.js'; +export * from './JsLegacySchemaGenerator.js'; +export * from './TsSchemaGenerator.js'; export * from './generators.js'; export * from './SqlDataQuery.js'; export * from './request_functions.js'; diff --git a/packages/sync-rules/test/src/sync_rules.test.ts b/packages/sync-rules/test/src/sync_rules.test.ts index 3c4fc5f12..13abed5bb 100644 --- a/packages/sync-rules/test/src/sync_rules.test.ts +++ b/packages/sync-rules/test/src/sync_rules.test.ts @@ -3,10 +3,12 @@ import { DEFAULT_SCHEMA, DEFAULT_TAG, DartSchemaGenerator, - JsSchemaGenerator, + JsLegacySchemaGenerator, SqlSyncRules, - StaticSchema + StaticSchema, + TsSchemaGenerator } from '../../src/index.js'; + import { ASSETS, BASIC_SCHEMA, TestSourceTable, USERS, normalizeTokenParameters } from './util.js'; describe('sync rules', () => { @@ -761,7 +763,7 @@ bucket_definitions: ]); `); - expect(new JsSchemaGenerator().generate(rules, schema)).toEqual(`new Schema([ + expect(new JsLegacySchemaGenerator().generate(rules, schema)).toEqual(`new Schema([ new Table({ name: 'assets1', columns: [ @@ -781,5 +783,39 @@ bucket_definitions: }) ]) `); + + expect(new TsSchemaGenerator().generate(rules, schema)).toEqual( + `import { column, Schema, TableV2 } from '@powersync/web'; +// OR: import { column, Schema, TableV2 } from '@powersync/react-native'; + +const assets1 = new TableV2( + { + // id column (text) is automatically included + name: column.text, + count: column.integer, + owner_id: column.text + }, + { indexes: {} } +); + +const assets2 = new TableV2( + { + // id column (text) is automatically included + name: column.text, + count: column.integer, + other_id: column.text, + foo: column.text + }, + { indexes: {} } +); + +export const AppSchema = new Schema({ + assets1, + assets2 +}); + +export type Database = (typeof AppSchema)['types']; +` + ); }); });