Skip to content
5 changes: 5 additions & 0 deletions .changeset/sharp-donuts-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/common': minor
---

Merge `Table` and `TableV2` but kept `TableV2` to avoid making this a breaking change.
4 changes: 3 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@
**/android/**
**/assets/**
**/bin/**
**/ios/**
**/ios/**

pnpm-lock.yaml
2 changes: 1 addition & 1 deletion demos/react-multi-client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ yarn-error.log*
.vercel

# typescript
*.tsbuildinfo
*.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { column, Schema, TableV2 } from '@powersync/web';
import { column, Schema, Table } from '@powersync/web';

export const LISTS_TABLE = 'lists';
export const TODOS_TABLE = 'todos';

const todos = new TableV2(
const todos = new Table(
{
list_id: column.text,
created_at: column.text,
Expand All @@ -16,7 +16,7 @@ const todos = new TableV2(
{ indexes: { list: ['list_id'] } }
);

const lists = new TableV2({
const lists = new Table({
created_at: column.text,
name: column.text,
owner_id: column.text
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,7 @@ export class SqliteBucketStorage extends BaseObserver<BucketStorageListener> 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');
Expand Down
30 changes: 0 additions & 30 deletions packages/common/src/db/Column.ts

This file was deleted.

60 changes: 60 additions & 0 deletions packages/common/src/db/schema/Column.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// https://www.sqlite.org/lang_expr.html#castexpr
export enum ColumnType {
TEXT = 'TEXT',
INTEGER = 'INTEGER',
REAL = 'REAL'
}

export interface ColumnOptions {
name: string;
type?: ColumnType;
}

export type BaseColumnType<T extends number | string | null> = {
type: ColumnType;
};

export type ColumnsType = Record<string, BaseColumnType<any>>;

export type ExtractColumnValueType<T extends BaseColumnType<any>> = T extends BaseColumnType<infer R> ? R : unknown;

const text: BaseColumnType<string | null> = {
type: ColumnType.TEXT
};

const integer: BaseColumnType<number | null> = {
type: ColumnType.INTEGER
};

const real: BaseColumnType<number | null> = {
type: ColumnType.REAL
};

// 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.
export const MAX_AMOUNT_OF_COLUMNS = 63;

export const column = {
text,
integer,
real
};

export class Column {
constructor(protected options: ColumnOptions) {}

get name() {
return this.options.name;
}

get type() {
return this.options.type;
}

toJSON() {
return {
name: this.name,
type: this.type
};
}
}
2 changes: 1 addition & 1 deletion packages/common/src/db/schema/IndexedColumn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ColumnType } from '../Column';
import { ColumnType } from './Column';
import { Table } from './Table';

export interface IndexColumnOptions {
Expand Down
15 changes: 7 additions & 8 deletions packages/common/src/db/schema/Schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Table as ClassicTable } from './Table';
import { RowType, TableV2 } from './TableV2';
import { RowType, Table } from './Table';

type SchemaType = Record<string, TableV2<any>>;
type SchemaType = Record<string, Table<any>>;

type SchemaTableType<S extends SchemaType> = {
[K in keyof S]: RowType<S[K]>;
Expand All @@ -16,9 +15,9 @@ export class Schema<S extends SchemaType = SchemaType> {
*/
readonly types: SchemaTableType<S>;
readonly props: S;
readonly tables: ClassicTable[];
readonly tables: Table[];

constructor(tables: ClassicTable[] | S) {
constructor(tables: Table[] | S) {
if (Array.isArray(tables)) {
this.tables = tables;
} else {
Expand All @@ -28,20 +27,20 @@ export class Schema<S extends SchemaType = SchemaType> {
}

validate() {
for (const table of this.tables as ClassicTable[]) {
for (const table of this.tables) {
table.validate();
}
}

toJSON() {
return {
tables: (this.tables as ClassicTable[]).map((t) => t.toJSON())
tables: this.tables.map((t) => t.toJSON())
};
}

private convertToClassicTables(props: S) {
return Object.entries(props).map(([name, table]) => {
return ClassicTable.createTable(name, table);
return Table.createTable(name, table);
});
}
}
118 changes: 101 additions & 17 deletions packages/common/src/db/schema/Table.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { Column } from '../Column';
import type { Index } from './Index';
import {
BaseColumnType,
Column,
ColumnsType,
ColumnType,
ExtractColumnValueType,
MAX_AMOUNT_OF_COLUMNS
} from './Column';
import { Index } from './Index';
import { IndexedColumn } from './IndexedColumn';
import { TableV2 } from './TableV2';

export interface TableOptions {
Expand All @@ -14,19 +22,34 @@ export interface TableOptions {
viewName?: string;
}

export const DEFAULT_TABLE_OPTIONS: Partial<TableOptions> = {
export type RowType<T extends TableV2<any>> = {
[K in keyof T['columnMap']]: ExtractColumnValueType<T['columnMap'][K]>;
} & {
id: string;
};

export type IndexShorthand = Record<string, string[]>;

export interface TableV2Options {
indexes?: IndexShorthand;
localOnly?: boolean;
insertOnly?: boolean;
viewName?: string;
}

export const DEFAULT_TABLE_OPTIONS = {
indexes: [],
insertOnly: false,
localOnly: false
};

const MAX_AMOUNT_OF_COLUMNS = 63

export const InvalidSQLCharacters = /["'%,.#\s[\]]/;

export class Table {
export class Table<Columns extends ColumnsType = ColumnsType> {
protected options: TableOptions;

protected _mappedColumns: Columns;

static createLocalOnly(options: TableOptions) {
return new Table({ ...options, localOnly: true, insertOnly: false });
}
Expand All @@ -35,7 +58,7 @@ export class Table {
return new Table({ ...options, localOnly: false, insertOnly: true });
}

static createTable(name: string, table: TableV2) {
static createTable(name: string, table: Table) {
return new Table({
name,
columns: Object.entries(table.columns).map(([name, col]) => new Column({ name, type: col.type })),
Expand All @@ -46,8 +69,58 @@ export class Table {
});
}

constructor(options: TableOptions) {
this.options = { ...DEFAULT_TABLE_OPTIONS, ...options };
constructor(columns: Columns, options?: TableV2Options);
constructor(options: TableOptions);
constructor(optionsOrColumns: Columns | TableOptions, v2Options?: TableV2Options) {
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 })
);

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 = {
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() {
Expand All @@ -66,6 +139,16 @@ export class Table {
return this.options.columns;
}

get columnMap(): Columns {
return (
this._mappedColumns ??
this.columns.reduce((hash: Record<string, BaseColumnType<any>>, column) => {
hash[column.name] = { type: column.type ?? ColumnType.TEXT };
return hash;
}, {} as Columns)
);
}

get indexes() {
return this.options.indexes ?? [];
}
Expand Down Expand Up @@ -98,41 +181,42 @@ 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`);
}

if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride!)) {
throw new Error(`Invalid characters in view name: ${this.viewNameOverride}`);
}

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}.`);
if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) {
throw new Error(
`Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.`
);
}

const columnNames = new Set<string>();
columnNames.add('id');
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<string>();

for (const index of this.indexes) {
if (indexNames.has(index.name)) {
throw new Error(`Duplicate index $name.${index}`);
throw new Error(`Duplicate index ${index.name}`);
}
if (InvalidSQLCharacters.test(index.name)) {
throw new Error(`Invalid characters in index name: $name.${index}`);
throw new Error(`Invalid characters in index name: ${index.name}`);
}

for (const column of index.columns) {
Expand Down
Loading