diff --git a/.prettierignore b/.prettierignore index b992e6cb..32571663 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,6 @@ **/android/** **/assets/** **/bin/** -**/ios/** \ No newline at end of file +**/ios/** + +pnpm-lock.yaml diff --git a/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts b/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts index 6a4feff5..96f25600 100644 --- a/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts +++ b/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts @@ -103,9 +103,7 @@ export class SqliteBucketStorage extends BaseObserver imp */ private async deleteBucket(bucket: string) { await this.writeTransaction(async (tx) => { - await tx.execute( - 'INSERT INTO powersync_operations(op, data) VALUES(?, ?)', - ['delete_bucket', bucket]); + await tx.execute('INSERT INTO powersync_operations(op, data) VALUES(?, ?)', ['delete_bucket', bucket]); }); this.logger.debug('done deleting bucket'); diff --git a/packages/common/src/db/Column.ts b/packages/common/src/db/schema/Column.ts similarity index 96% rename from packages/common/src/db/Column.ts rename to packages/common/src/db/schema/Column.ts index 923ea071..b3ce9860 100644 --- a/packages/common/src/db/Column.ts +++ b/packages/common/src/db/schema/Column.ts @@ -32,7 +32,7 @@ const real: BaseColumnType = { // There is maximum of 127 arguments for any function in SQLite. Currently we use json_object which uses 1 arg per key (column name) // and one per value, which limits it to 63 arguments. -const MAX_AMOUNT_OF_COLUMNS = 63; +export const MAX_AMOUNT_OF_COLUMNS = 63; export const column = { text, diff --git a/packages/common/src/db/schema/IndexedColumn.ts b/packages/common/src/db/schema/IndexedColumn.ts index 32c1fb40..8b92ee43 100644 --- a/packages/common/src/db/schema/IndexedColumn.ts +++ b/packages/common/src/db/schema/IndexedColumn.ts @@ -1,4 +1,4 @@ -import { ColumnType } from '../Column'; +import { ColumnType } from './Column'; import { Table } from './Table'; export interface IndexColumnOptions { diff --git a/packages/common/src/db/schema/Table.ts b/packages/common/src/db/schema/Table.ts index c09b0b4e..1286e71a 100644 --- a/packages/common/src/db/schema/Table.ts +++ b/packages/common/src/db/schema/Table.ts @@ -1,4 +1,11 @@ -import { BaseColumnType, column, Column, ColumnsType, ColumnType, ExtractColumnValueType } from '../Column'; +import { + BaseColumnType, + Column, + ColumnsType, + ColumnType, + ExtractColumnValueType, + MAX_AMOUNT_OF_COLUMNS +} from './Column'; import { Index } from './Index'; import { IndexedColumn } from './IndexedColumn'; import { TableV2 } from './TableV2'; @@ -36,8 +43,6 @@ export const DEFAULT_TABLE_OPTIONS = { localOnly: false }; -const MAX_AMOUNT_OF_COLUMNS = 63; - export const InvalidSQLCharacters = /["'%,.#\s[\]]/; export class Table { @@ -67,55 +72,55 @@ export class Table { constructor(columns: Columns, options?: TableV2Options); constructor(options: TableOptions); constructor(optionsOrColumns: Columns | TableOptions, v2Options?: TableV2Options) { - if (!Array.isArray(optionsOrColumns.columns)) { - this._mappedColumns = optionsOrColumns as Columns; + if (this.isTableV1(optionsOrColumns)) { + this.initTableV1(optionsOrColumns); + } else { + this.initTableV2(optionsOrColumns, v2Options); } + } + + private isTableV1(arg: TableOptions | Columns): arg is TableOptions { + return 'columns' in arg && Array.isArray(arg.columns); + } + + private initTableV1(options: TableOptions) { + this.options = { + ...options, + indexes: options.indexes || [], + insertOnly: options.insertOnly ?? DEFAULT_TABLE_OPTIONS.insertOnly, + localOnly: options.localOnly ?? DEFAULT_TABLE_OPTIONS.localOnly + }; + } + + private initTableV2(columns: Columns, options?: TableV2Options) { + const convertedColumns = Object.entries(columns).map( + ([name, columnInfo]) => new Column({ name, type: columnInfo.type }) + ); - // This is a WIP, might cleanup - - // Convert mappings to base columns and indexes - const columns = Array.isArray(optionsOrColumns.columns) - ? optionsOrColumns.columns - : Object.entries(optionsOrColumns).map( - ([name, columnInfo]) => - new Column({ - name, - type: columnInfo.type - }) - ); - - const indexes = Array.isArray(optionsOrColumns.indexes) - ? optionsOrColumns.indexes - : Object.entries(v2Options?.indexes ?? {}).map( - ([name, columnNames]) => - new Index({ - name: name, - columns: columnNames.map( - (name) => - new IndexedColumn({ - name: name.replace(/^-/, ''), - ascending: !name.startsWith('-') - }) - ) - }) - ); - - const insertOnly = - typeof optionsOrColumns.insertOnly == 'boolean' - ? optionsOrColumns.insertOnly - : (v2Options?.insertOnly ?? DEFAULT_TABLE_OPTIONS.insertOnly); - const viewName = typeof optionsOrColumns.viewName == 'string' ? optionsOrColumns.viewName : v2Options?.viewName; - const localOnly = - typeof optionsOrColumns.localOnly == 'boolean' ? optionsOrColumns.localOnly : v2Options?.localOnly; + const convertedIndexes = Object.entries(options?.indexes ?? {}).map( + ([name, columnNames]) => + new Index({ + name, + columns: columnNames.map( + (name) => + new IndexedColumn({ + name: name.replace(/^-/, ''), + ascending: !name.startsWith('-') + }) + ) + }) + ); this.options = { - columns, - name: typeof optionsOrColumns.name == 'string' ? optionsOrColumns.name : 'missing', - indexes, - insertOnly, - localOnly, - viewName + name: '', + columns: convertedColumns, + indexes: convertedIndexes, + insertOnly: options?.insertOnly ?? DEFAULT_TABLE_OPTIONS.insertOnly, + localOnly: options?.localOnly ?? DEFAULT_TABLE_OPTIONS.localOnly, + viewName: options?.viewName }; + + this._mappedColumns = columns; } get name() { @@ -176,7 +181,7 @@ export class Table { validate() { if (InvalidSQLCharacters.test(this.name)) { - throw new Error(`Invalid characters in table name: ${this.name}`); + throw new Error(`Invalid characters in table name ${this.name ?? ""}`); } if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride!)) { @@ -185,7 +190,7 @@ export class Table { if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) { throw new Error( - `Table ${this.name} has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.` + `Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.` ); } @@ -194,25 +199,24 @@ export class Table { for (const column of this.columns) { const { name: columnName } = column; if (column.name === 'id') { - throw new Error(`${this.name}: id column is automatically added, custom id columns are not supported`); + throw new Error(`An id column is automatically added, custom id columns are not supported`); } if (columnNames.has(columnName)) { throw new Error(`Duplicate column ${columnName}`); } if (InvalidSQLCharacters.test(columnName)) { - throw new Error(`Invalid characters in column name: $name.${column}`); + throw new Error(`Invalid characters in column name: ${column.name}`); } columnNames.add(columnName); } const indexNames = new Set(); - for (const index of this.indexes) { if (indexNames.has(index.name)) { - throw new Error(`Duplicate index $name.${index}`); + throw new Error(`Duplicate index ${index}`); } if (InvalidSQLCharacters.test(index.name)) { - throw new Error(`Invalid characters in index name: $name.${index}`); + throw new Error(`Invalid characters in index name: ${index}`); } for (const column of index.columns) { @@ -236,18 +240,3 @@ export class Table { }; } } - -const test = new Table( - { - list_id: column.text, - created_at: column.text, - completed_at: column.text, - description: column.text, - created_by: column.text, - completed_by: column.text, - completed: column.integer - }, - { indexes: { list: ['list_id'] } } -); - -const r = test.columnMap; diff --git a/packages/common/src/db/schema/TableV2.ts b/packages/common/src/db/schema/TableV2.ts index 4a4c0fe9..c65fa5e2 100644 --- a/packages/common/src/db/schema/TableV2.ts +++ b/packages/common/src/db/schema/TableV2.ts @@ -1,4 +1,4 @@ -import { ColumnsType } from '../Column'; +import { ColumnsType } from './Column'; import { Table } from './Table'; /* diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d6b93809..fb76bcef 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -15,7 +15,7 @@ export * from './client/sync/bucket/OplogEntry'; export * from './client/sync/stream/AbstractRemote'; export * from './client/sync/stream/AbstractStreamingSyncImplementation'; export * from './client/sync/stream/streaming-sync-types'; -export { MAX_OP_ID } from './client/constants' +export { MAX_OP_ID } from './client/constants'; export * from './db/crud/SyncStatus'; export * from './db/crud/UploadQueueStatus'; @@ -23,11 +23,11 @@ export * from './db/schema/Schema'; export * from './db/schema/Table'; export * from './db/schema/Index'; export * from './db/schema/IndexedColumn'; +export * from './db/schema/Column'; +export * from './db/schema/TableV2'; export * from './db/crud/SyncStatus'; export * from './db/crud/UploadQueueStatus'; export * from './db/DBAdapter'; -export * from './db/Column'; -export * from './db/schema/TableV2'; export * from './utils/AbortOperation'; export * from './utils/BaseObserver'; diff --git a/packages/common/tests/db/schema/Table.test.ts b/packages/common/tests/db/schema/Table.test.ts new file mode 100644 index 00000000..2a4b6105 --- /dev/null +++ b/packages/common/tests/db/schema/Table.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest'; +import { Table } from '../../../src/db/schema/Table'; +import { column, Column, ColumnType } from '../../../src/db/schema/Column'; +import { Index } from '../../../src/db/schema/Index'; +import { IndexedColumn } from '../../../src/db/schema/IndexedColumn'; + +describe('Table', () => { + it('should create a table with V1 syntax', () => { + const table = new Table({ + name: 'users', + columns: [ + new Column({ name: 'name', type: ColumnType.TEXT }), + new Column({ name: 'age', type: ColumnType.INTEGER }) + ], + indexes: [ + new Index({ + name: 'profile_id', + columns: [new IndexedColumn({ name: 'age' })] + }) + ] + }); + + expect(table.name).toBe('users'); + expect(table.columns.length).toBe(2); + expect(table.columns[0].name).toBe('name'); + expect(table.columns[1].name).toBe('age'); + expect(table.indexes[0].name).toBe('profile_id'); + }); + + it('should create a table with V2 syntax', () => { + const table = new Table( + { + name: column.text, + age: column.integer + }, + { indexes: { nameIndex: ['name'] } } + ); + + expect(table.columns.length).toBe(2); + expect(table.columns[0].name).toBe('name'); + expect(table.columns[1].name).toBe('age'); + expect(table.indexes.length).toBe(1); + expect(table.indexes[0].name).toBe('nameIndex'); + }); + + it('should create a local-only table', () => { + const table = new Table( + { + data: column.text + }, + { localOnly: true } + ); + + expect(table.localOnly).toBe(true); + expect(table.insertOnly).toBe(false); + }); + + it('should create an insert-only table', () => { + const table = new Table( + { + data: column.text + }, + { insertOnly: true } + ); + + expect(table.localOnly).toBe(false); + expect(table.insertOnly).toBe(true); + }); + + it('should create correct internal name', () => { + const normalTable = new Table({ + data: column.text + }); + + expect(normalTable.internalName).toBe('ps_data__'); + + const localTable = new Table( + { + data: column.text + }, + { localOnly: true } + ); + + expect(localTable.internalName).toBe('ps_data_local__'); + }); + + it('should generate correct JSON representation', () => { + const table = new Table( + { + name: column.text, + age: column.integer + }, + { + indexes: { nameIndex: ['name'] }, + viewName: 'customView' + } + ); + + const json = table.toJSON(); + + expect(json).toEqual({ + name: '', + view_name: 'customView', + local_only: false, + insert_only: false, + columns: [ + { name: 'name', type: 'TEXT' }, + { name: 'age', type: 'INTEGER' } + ], + indexes: [{ name: 'nameIndex', columns: [{ ascending: true, name: 'name', type: 'TEXT' },] }] + }); + }); + + it('should handle descending index', () => { + const table = new Table( + { + name: column.text, + age: column.integer + }, + { + indexes: { ageIndex: ['-age'] } + } + ); + + expect(table.indexes[0].columns[0].name).toBe('age'); + expect(table.indexes[0].columns[0].ascending).toBe(false); + }); + + describe("validate", () => { + it('should throw an error for invalid view names', () => { + expect(() => { + new Table( + { + data: column.text + }, + { viewName: 'invalid view' } + ).validate(); + }).toThrowError('Invalid characters in view name'); + }); + + it('should throw an error for custom id columns', () => { + expect(() => { + new Table({ + id: column.text + }).validate(); + }).toThrow('id column is automatically added, custom id columns are not supported'); + }); + + it('should throw an error if more than 63 columns are provided', () => { + const columns = {}; + for (let i = 0; i < 64; i++) { + columns[`column${i}`] = column.text; + } + + expect(() => new Table(columns).validate()).toThrowError('Table has too many columns. The maximum number of columns is 63.'); + }); + + it('should throw an error if an id column is provided', () => { + expect( + () => + new Table({ + id: column.text, + name: column.text + }).validate() + ).toThrowError('An id column is automatically added, custom id columns are not supported'); + }); + + it('should throw an error if a column name contains invalid SQL characters', () => { + expect( + () => + new Table({ + '#invalid-name': column.text + }).validate() + ).toThrowError('Invalid characters in column name: #invalid-name'); + }); + }); +}); diff --git a/packages/common/tests/db/schema/TableV2.test.ts b/packages/common/tests/db/schema/TableV2.test.ts deleted file mode 100644 index 3d708a48..00000000 --- a/packages/common/tests/db/schema/TableV2.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { TableV2, column } from '../../../src/db/schema/TableV2'; // Adjust the import path as needed -import { ColumnType } from '../../../src/db/Column'; - -describe('TableV2', () => { - it('should create a table with valid columns', () => { - const table = new TableV2({ - name: column.text, - age: column.integer, - height: column.real - }); - - expect(table.columns).toEqual({ - name: { type: ColumnType.TEXT }, - age: { type: ColumnType.INTEGER }, - height: { type: ColumnType.REAL } - }); - }); - - it('should throw an error if more than 63 columns are provided', () => { - const columns = {}; - for (let i = 0; i < 64; i++) { - columns[`column${i}`] = column.text; - } - - expect(() => new TableV2(columns)).toThrowError('TableV2 cannot have more than 63 columns'); - }); - - it('should throw an error if an id column is provided', () => { - expect(() => new TableV2({ - id: column.text, - name: column.text - })).toThrowError('An id column is automatically added, custom id columns are not supported'); - }); - - it('should throw an error if a column name contains invalid SQL characters', () => { - expect(() => new TableV2({ - '#invalid-name': column.text - })).toThrowError('Invalid characters in column name: #invalid-name'); - }); - - it('should create indexes correctly', () => { - const table = new TableV2( - { - name: column.text, - age: column.integer - }, - { - indexes: { - nameIndex: ['name'], - '-ageIndex': ['age'] - } - } - ); - - expect(table.indexes).toHaveLength(2); - expect(table.indexes[0].name).toBe('nameIndex'); - expect(table.indexes[0].columns[0].ascending).toBe(true); - expect(table.indexes[1].name).toBe('ageIndex'); - expect(table.indexes[1].columns[0].ascending).toBe(false); - }); - - it('should allow creating a table with exactly 63 columns', () => { - const columns = {}; - for (let i = 0; i < 63; i++) { - columns[`column${i}`] = column.text; - } - - expect(() => new TableV2(columns)).not.toThrow(); - }); - - it('should allow creating a table with no columns', () => { - expect(() => new TableV2({})).not.toThrow(); - }); - - it('should allow creating a table with no options', () => { - const table = new TableV2({ name: column.text }); - expect(table.options).toEqual({}); - }); - - it('should correctly set options', () => { - const options = { localOnly: true, insertOnly: false, viewName: 'TestView' }; - const table = new TableV2({ name: column.text }, options); - expect(table.options).toEqual(options); - }); -}); diff --git a/packages/kysely-driver/README.md b/packages/kysely-driver/README.md index 846eb69d..82cc7ea5 100644 --- a/packages/kysely-driver/README.md +++ b/packages/kysely-driver/README.md @@ -21,7 +21,7 @@ export const powerSyncDb = new PowerSyncDatabase({ database: { dbFilename: 'test.sqlite' }, - schema: appSchema, + schema: appSchema }); export const db = wrapPowerSyncWithKysely(powerSyncDb); diff --git a/tools/diagnostics-app/README.md b/tools/diagnostics-app/README.md index 73aef496..2a7d9216 100644 --- a/tools/diagnostics-app/README.md +++ b/tools/diagnostics-app/README.md @@ -32,7 +32,7 @@ The app is now available on [http://localhost:5173/](http://localhost:5173/). Signing in as a user requires a PowerSync Token (JWT) and Endpoint. -**PowerSync Token**: +**PowerSync Token**: Generate a [development token](https://docs.powersync.com/usage/installation/authentication-setup/development-tokens) for the user.