Skip to content

Commit 6c855cd

Browse files
authored
Raw table improvements (#855)
1 parent cc6cacc commit 6c855cd

9 files changed

Lines changed: 288 additions & 54 deletions

File tree

.changeset/rare-doors-repeat.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@powersync/common': minor
3+
'@powersync/node': minor
4+
'@powersync/capacitor': minor
5+
'@powersync/react-native': minor
6+
'@powersync/web': minor
7+
---
8+
9+
Improve raw tables by making `put` and `delete` statements optional if a local name is given.
Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
import { TableOrRawTableOptions } from './Table.js';
2+
13
/**
2-
* A pending variant of a {@link RawTable} that doesn't have a name (because it would be inferred when creating the
3-
* schema).
4+
* Instructs PowerSync to sync data into a "raw" table.
5+
*
6+
* Since raw tables are not backed by JSON, running complex queries on them may be more efficient. Further, they allow
7+
* using client-side table and column constraints.
8+
*
9+
* To collect local writes to raw tables with PowerSync, custom triggers are required. See
10+
* {@link https://docs.powersync.com/usage/use-case-examples/raw-tables the documentation} for details and an example on
11+
* using raw tables.
12+
*
13+
* Note that raw tables are only supported when using the new `SyncClientImplementation.rust` sync client.
14+
*
15+
* @experimental Please note that this feature is experimental at the moment, and not covered by PowerSync semver or
16+
* stability guarantees.
417
*/
5-
export type RawTableType = {
18+
export type RawTableType = RawTableTypeWithStatements | InferredRawTableType;
19+
20+
interface RawTableTypeWithStatements {
621
/**
722
* The statement to run when PowerSync detects that a row needs to be inserted or updated.
823
*/
@@ -11,7 +26,44 @@ export type RawTableType = {
1126
* The statement to run when PowerSync detects that a row needs to be deleted.
1227
*/
1328
delete: PendingStatement;
14-
};
29+
30+
/**
31+
* An optional statement to run when `disconnectAndClear()` is called on a PowerSync database.
32+
*/
33+
clear?: string;
34+
}
35+
36+
/**
37+
* The schema of a {@link RawTableType} in the local database.
38+
*
39+
* This information is optional when declaring raw tables. However, providing it allows the sync client to infer `put`
40+
* and `delete` statements automatically.
41+
*/
42+
interface RawTableSchema extends TableOrRawTableOptions {
43+
/**
44+
* The actual name of the raw table in the local schema.
45+
*
46+
* Unlike {@link RawTable.name}, which describes the name of synced tables to match, this reflects the SQLite table
47+
* name. This is used to infer {@link RawTableType.put} and {@link RawTableType.delete} statements for the sync
48+
* client. It can also be used to auto-generate triggers forwarding writes on raw tables into the CRUD upload queue
49+
* (using the `powersync_create_raw_table_crud_trigger` SQL function).
50+
*
51+
* When absent, defaults to {@link RawTable.name}.
52+
*/
53+
tableName?: string;
54+
55+
/**
56+
* An optional filter of columns that should be synced.
57+
*
58+
* By default, all columns in a raw table are considered for sync. If a filter is specified, PowerSync treats
59+
* unmatched columns as local-only and will not attempt to sync them.
60+
*/
61+
syncedColumns?: string[];
62+
}
63+
64+
interface InferredRawTableType extends Partial<RawTableTypeWithStatements> {
65+
schema: RawTableSchema;
66+
}
1567

1668
/**
1769
* A parameter to use as part of {@link PendingStatement}.
@@ -21,8 +73,10 @@ export type RawTableType = {
2173
*
2274
* For insert and replace operations, the values of columns in the table are available as parameters through
2375
* `{Column: 'name'}`.
76+
* The `"Rest"` parameter gets resolved to a JSON object covering all values from the synced row that haven't been
77+
* covered by a `Column` parameter.
2478
*/
25-
export type PendingStatementParameter = 'Id' | { Column: string };
79+
export type PendingStatementParameter = 'Id' | { Column: string } | 'Rest';
2680

2781
/**
2882
* A statement that the PowerSync client should use to insert or delete data into a table managed by the user.
@@ -33,35 +87,16 @@ export type PendingStatement = {
3387
};
3488

3589
/**
36-
* Instructs PowerSync to sync data into a "raw" table.
37-
*
38-
* Since raw tables are not backed by JSON, running complex queries on them may be more efficient. Further, they allow
39-
* using client-side table and column constraints.
40-
*
41-
* To collect local writes to raw tables with PowerSync, custom triggers are required. See
42-
* {@link https://docs.powersync.com/usage/use-case-examples/raw-tables the documentation} for details and an example on
43-
* using raw tables.
44-
*
45-
* Note that raw tables are only supported when using the new `SyncClientImplementation.rust` sync client.
46-
*
47-
* @experimental Please note that this feature is experimental at the moment, and not covered by PowerSync semver or
48-
* stability guarantees.
90+
* @internal
4991
*/
50-
export class RawTable implements RawTableType {
92+
export type RawTable<T extends RawTableType = RawTableType> = T & {
5193
/**
5294
* The name of the table.
5395
*
54-
* This does not have to match the actual table name in the schema - {@link put} and {@link delete} are free to use
55-
* another table. Instead, this name is used by the sync client to recognize that operations on this table (as it
56-
* appears in the source / backend database) are to be handled specially.
96+
* This does not have to match the actual table name in the schema - {@link RawTableType.put} and
97+
* {@link RawTableType.delete} are free to use another table. Instead, this name is used by the sync client to
98+
* recognize that operations on this table (as it appears in the source / backend database) are to be handled
99+
* specially.
57100
*/
58101
name: string;
59-
put: PendingStatement;
60-
delete: PendingStatement;
61-
62-
constructor(name: string, type: RawTableType) {
63-
this.name = name;
64-
this.put = type.put;
65-
this.delete = type.delete;
66-
}
67-
}
102+
};

packages/common/src/db/schema/Schema.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { encodeTableOptions } from './internal.js';
12
import { RawTable, RawTableType } from './RawTable.js';
23
import { RowType, Table } from './Table.js';
34

@@ -57,7 +58,7 @@ export class Schema<S extends SchemaType = SchemaType> {
5758
*/
5859
withRawTables(tables: Record<string, RawTableType>) {
5960
for (const [name, rawTableDefinition] of Object.entries(tables)) {
60-
this.rawTables.push(new RawTable(name, rawTableDefinition));
61+
this.rawTables.push({ name, ...rawTableDefinition });
6162
}
6263
}
6364

@@ -70,7 +71,31 @@ export class Schema<S extends SchemaType = SchemaType> {
7071
toJSON() {
7172
return {
7273
tables: this.tables.map((t) => t.toJSON()),
73-
raw_tables: this.rawTables
74+
raw_tables: this.rawTables.map(Schema.rawTableToJson)
7475
};
7576
}
77+
78+
/**
79+
* Returns a representation of the raw table that is understood by the PowerSync SQLite core extension.
80+
*
81+
* The output of this can be passed through `JSON.serialize` and then used in `powersync_create_raw_table_crud_trigger`
82+
* to define triggers for this table.
83+
*/
84+
static rawTableToJson(table: RawTable): unknown {
85+
const serialized: any = {
86+
name: table.name,
87+
put: table.put,
88+
delete: table.delete,
89+
clear: table.clear
90+
};
91+
if ('schema' in table) {
92+
// We have schema options, those are flattened into the outer JSON object for the core extension.
93+
const schema = table.schema;
94+
serialized.table_name = schema.tableName ?? table.name;
95+
serialized.synced_columns = schema.syncedColumns;
96+
Object.assign(serialized, encodeTableOptions(table.schema));
97+
}
98+
99+
return serialized;
100+
}
76101
}

packages/common/src/db/schema/Table.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@ import {
88
} from './Column.js';
99
import { Index } from './Index.js';
1010
import { IndexedColumn } from './IndexedColumn.js';
11+
import { encodeTableOptions } from './internal.js';
1112
import { TableV2 } from './TableV2.js';
1213

13-
interface SharedTableOptions {
14+
/**
15+
* Options that apply both to JSON-based tables and raw tables.
16+
*/
17+
export interface TableOrRawTableOptions {
1418
localOnly?: boolean;
1519
insertOnly?: boolean;
16-
viewName?: string;
1720
trackPrevious?: boolean | TrackPreviousOptions;
1821
trackMetadata?: boolean;
1922
ignoreEmptyUpdates?: boolean;
2023
}
2124

25+
interface SharedTableOptions extends TableOrRawTableOptions {
26+
viewName?: string;
27+
}
28+
2229
/** Whether to include previous column values when PowerSync tracks local changes.
2330
*
2431
* Including old values may be helpful for some backend connector implementations, which is
@@ -341,19 +348,12 @@ export class Table<Columns extends ColumnsType = ColumnsType> {
341348
}
342349

343350
toJSON() {
344-
const trackPrevious = this.trackPrevious;
345-
346351
return {
347352
name: this.name,
348353
view_name: this.viewName,
349-
local_only: this.localOnly,
350-
insert_only: this.insertOnly,
351-
include_old: trackPrevious && ((trackPrevious as any).columns ?? true),
352-
include_old_only_when_changed: typeof trackPrevious == 'object' && trackPrevious.onlyWhenChanged == true,
353-
include_metadata: this.trackMetadata,
354-
ignore_empty_update: this.ignoreEmptyUpdates,
355354
columns: this.columns.map((c) => c.toJSON()),
356-
indexes: this.indexes.map((e) => e.toJSON(this))
355+
indexes: this.indexes.map((e) => e.toJSON(this)),
356+
...encodeTableOptions(this)
357357
};
358358
}
359359
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { TableOrRawTableOptions } from './Table.js';
2+
3+
/**
4+
* @internal Not exported from `index.ts`.
5+
*/
6+
export function encodeTableOptions(options: TableOrRawTableOptions) {
7+
const trackPrevious = options.trackPrevious;
8+
9+
return {
10+
local_only: options.localOnly,
11+
insert_only: options.insertOnly,
12+
include_old: trackPrevious && ((trackPrevious as any).columns ?? true),
13+
include_old_only_when_changed: typeof trackPrevious == 'object' && trackPrevious.onlyWhenChanged == true,
14+
include_metadata: options.trackMetadata,
15+
ignore_empty_update: options.ignoreEmptyUpdates
16+
};
17+
}

packages/common/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export * from './db/DBAdapter.js';
3939
export * from './db/schema/Column.js';
4040
export * from './db/schema/Index.js';
4141
export * from './db/schema/IndexedColumn.js';
42-
export * from './db/schema/RawTable.js';
42+
export { RawTableType, PendingStatementParameter, PendingStatement } from './db/schema/RawTable.js';
4343
export * from './db/schema/Schema.js';
4444
export * from './db/schema/Table.js';
4545
export * from './db/schema/TableV2.js';

packages/node/tests/PowerSyncDatabase.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from 'node:path';
22
import { Worker } from 'node:worker_threads';
33

4-
import { LockContext } from '@powersync/common';
4+
import { LockContext, Schema } from '@powersync/common';
55
import { randomUUID } from 'node:crypto';
66
import { expect, test, vi } from 'vitest';
77
import { CrudEntry, CrudTransaction, PowerSyncDatabase } from '../lib';
@@ -227,3 +227,21 @@ tempDirectoryTest.skipIf(process.versions.node < '22.5.0')(
227227
}
228228
}
229229
);
230+
231+
databaseTest('clear raw tables', async ({ database }) => {
232+
await database.init();
233+
const schema = new Schema({});
234+
schema.withRawTables({
235+
users: {
236+
table_name: 'lists',
237+
clear: 'DELETE FROM lists'
238+
}
239+
});
240+
await database.updateSchema(schema);
241+
await database.execute('CREATE TABLE lists (id TEXT NOT NULL PRIMARY KEY, name TEXT)');
242+
await database.execute('INSERT INTO lists (id, name) VALUES (uuid(), ?)', ['list']);
243+
244+
expect(await database.getAll('SELECT * FROM lists')).toHaveLength(1);
245+
await database.disconnectAndClear();
246+
expect(await database.getAll('SELECT * FROM lists')).toHaveLength(0);
247+
});

packages/node/tests/crud.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { expect } from 'vitest';
2-
import { column, Schema, Table } from '@powersync/common';
1+
import { expect, describe } from 'vitest';
2+
import { column, Schema, Table, RawTable } from '@powersync/common';
33
import { databaseTest } from './utils';
4+
import { PowerSyncDatabase } from '../src';
45

56
databaseTest('include metadata', async ({ database }) => {
67
await database.init();
@@ -103,3 +104,60 @@ databaseTest('ignore empty update', async ({ database }) => {
103104
const batch = await database.getNextCrudTransaction();
104105
expect(batch).toBeNull();
105106
});
107+
108+
describe('raw table', () => {
109+
async function createTrigger(db: PowerSyncDatabase, table: RawTable, write: string) {
110+
await db.execute('SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)', [
111+
JSON.stringify(Schema.rawTableToJson(table)),
112+
`users_${write}`,
113+
write
114+
]);
115+
}
116+
117+
databaseTest('inferred crud trigger', async ({ database }) => {
118+
const table: RawTable = { name: 'users', schema: {} };
119+
await database.execute('CREATE TABLE users (id TEXT, name TEXT);');
120+
await createTrigger(database, table, 'INSERT');
121+
122+
await database.execute('INSERT INTO users (id, name) VALUES (?, ?);', ['id', 'user']);
123+
const tx = await database.getNextCrudTransaction()!!;
124+
expect(tx.crud).toHaveLength(1);
125+
const write = tx.crud[0];
126+
expect(write.op).toStrictEqual('PUT');
127+
expect(write.table).toStrictEqual('users');
128+
expect(write.id).toStrictEqual('id');
129+
expect(write.opData).toStrictEqual({
130+
name: 'user'
131+
});
132+
});
133+
134+
databaseTest('with options', async ({ database }) => {
135+
const table: RawTable = {
136+
name: 'custom_sync_name',
137+
schema: {
138+
tableName: 'users',
139+
syncedColumns: ['name'],
140+
ignoreEmptyUpdates: true,
141+
trackPrevious: true
142+
}
143+
};
144+
await database.execute('CREATE TABLE users (id TEXT, name TEXT, local TEXT);');
145+
await database.execute('INSERT INTO users (id, name, local) VALUES (?, ?, ?);', ['id', 'name', 'local']);
146+
await createTrigger(database, table, 'UPDATE');
147+
148+
await database.execute('UPDATE users SET name = ?, local = ?;', ['updated_name', 'updated_local']);
149+
// This should not generate a CRUD entry because the only synced column is not affected.
150+
await database.execute('UPDATE users SET name = ?, local = ?;', ['name', 'updated_local_2']);
151+
152+
const tx = await database.getNextCrudTransaction()!!;
153+
expect(tx.crud).toHaveLength(1);
154+
const write = tx.crud[0];
155+
expect(write.op).toStrictEqual('PATCH');
156+
expect(write.table).toStrictEqual('custom_sync_name');
157+
expect(write.id).toStrictEqual('id');
158+
expect(write.opData).toStrictEqual({ name: 'updated_name' });
159+
expect(write.previousValues).toStrictEqual({
160+
name: 'name'
161+
});
162+
});
163+
});

0 commit comments

Comments
 (0)