Skip to content

Commit dd13da4

Browse files
[Feature] Table View Overrides (#45)
* flush table change updates on trailing edge. Prevents race conditions in watched queries * added viewName override for tables * update powersync-sqlite-core v0.1.6 * add Global sync locks in React Native
1 parent 7d4c6fe commit dd13da4

File tree

16 files changed

+208
-32
lines changed

16 files changed

+208
-32
lines changed

.changeset/chilled-poets-think.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@journeyapps/powersync-sdk-react-native': patch
3+
---
4+
5+
Added global locks for syncing connections. Added warning when creating multiple Powersync instances.

.changeset/curly-peas-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@journeyapps/powersync-sdk-common': minor
3+
---
4+
5+
Added `viewName` option to Schema Table definitions. This allows for overriding a table's view name.

.changeset/eight-squids-peel.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@journeyapps/powersync-sdk-react-native': minor
3+
---
4+
5+
Bumped powersync-sqlite-core to v0.1.6. dependant projects should:
6+
- Upgrade to `@journeyapps/react-native-quick-sqlite@1.1.1`
7+
- run `pod repo update && pod update` in the `ios` folder for updates to reflect.

.changeset/warm-foxes-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@journeyapps/powersync-sdk-common': patch
3+
---
4+
5+
Improved table change updates to be throttled on the trailing edge. This prevents unnecessary query on both the leading and rising edge.

packages/powersync-sdk-common/src/client/AbstractPowerSyncDatabase.ts

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { CrudEntry } from './sync/bucket/CrudEntry';
1818
import { mutexRunExclusive } from '../utils/mutex';
1919
import { BaseObserver } from '../utils/BaseObserver';
2020
import { EventIterator } from 'event-iterator';
21+
import { quoteIdentifier } from '../utils/strings';
22+
23+
export interface DisconnectAndClearOptions {
24+
clearLocal?: boolean;
25+
}
2126

2227
export interface PowerSyncDatabaseOptions {
2328
schema: Schema;
@@ -57,6 +62,10 @@ export interface PowerSyncDBListener extends StreamingSyncImplementationListener
5762

5863
const POWERSYNC_TABLE_MATCH = /(^ps_data__|^ps_data_local__)/;
5964

65+
const DEFAULT_DISCONNECT_CLEAR_OPTIONS: DisconnectAndClearOptions = {
66+
clearLocal: true
67+
};
68+
6069
export const DEFAULT_WATCH_THROTTLE_MS = 30;
6170

6271
export const DEFAULT_POWERSYNC_DB_OPTIONS = {
@@ -90,21 +99,23 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
9099
protected bucketStorageAdapter: BucketStorageAdapter;
91100
private syncStatusListenerDisposer?: () => void;
92101
protected _isReadyPromise: Promise<void>;
102+
protected _schema: Schema;
93103

94104
constructor(protected options: PowerSyncDatabaseOptions) {
95105
super();
96106
this.bucketStorageAdapter = this.generateBucketStorageAdapter();
97107
this.closed = true;
98108
this.currentStatus = null;
99109
this.options = { ...DEFAULT_POWERSYNC_DB_OPTIONS, ...options };
110+
this._schema = options.schema;
100111
this.ready = false;
101112
this.sdkVersion = '';
102113
// Start async init
103114
this._isReadyPromise = this.initialize();
104115
}
105116

106117
get schema() {
107-
return this.options.schema;
118+
return this._schema;
108119
}
109120

110121
protected get database() {
@@ -145,13 +156,32 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
145156
protected async initialize() {
146157
await this._initialize();
147158
await this.bucketStorageAdapter.init();
148-
await this.database.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]);
149159
const version = await this.options.database.execute('SELECT powersync_rs_version()');
150160
this.sdkVersion = version.rows?.item(0)['powersync_rs_version()'] ?? '';
161+
await this.updateSchema(this.options.schema);
151162
this.ready = true;
152163
this.iterateListeners((cb) => cb.initialized?.());
153164
}
154165

166+
async updateSchema(schema: Schema) {
167+
if (this.abortController) {
168+
throw new Error('Cannot update schema while connected');
169+
}
170+
171+
/**
172+
* TODO
173+
* Validations only show a warning for now.
174+
* The next major release should throw an exception.
175+
*/
176+
try {
177+
schema.validate();
178+
} catch (ex) {
179+
this.options.logger.warn('Schema validation failed. Unexpected behaviour could occur', ex);
180+
}
181+
this._schema = schema;
182+
await this.database.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]);
183+
}
184+
155185
/**
156186
* Queues a CRUD upload when internal CRUD tables have been updated
157187
*/
@@ -208,24 +238,31 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
208238
* The database can still be queried after this is called, but the tables
209239
* would be empty.
210240
*/
211-
async disconnectAndClear() {
241+
async disconnectAndClear(options = DEFAULT_DISCONNECT_CLEAR_OPTIONS) {
212242
await this.disconnect();
213243

244+
const { clearLocal } = options;
245+
214246
// TODO DB name, verify this is necessary with extension
215247
await this.database.writeTransaction(async (tx) => {
216-
await tx.execute(`DELETE FROM ${PSInternalTable.OPLOG} WHERE 1`);
217-
await tx.execute(`DELETE FROM ${PSInternalTable.CRUD} WHERE 1`);
218-
await tx.execute(`DELETE FROM ${PSInternalTable.BUCKETS} WHERE 1`);
248+
await tx.execute(`DELETE FROM ${PSInternalTable.OPLOG}`);
249+
await tx.execute(`DELETE FROM ${PSInternalTable.CRUD}`);
250+
await tx.execute(`DELETE FROM ${PSInternalTable.BUCKETS}`);
251+
252+
const tableGlob = clearLocal ? 'ps_data_*' : 'ps_data__*';
219253

220254
const existingTableRows = await tx.execute(
221-
"SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data_*'"
255+
`
256+
SELECT name FROM sqlite_master WHERE type='table' AND name GLOB ?
257+
`,
258+
[tableGlob]
222259
);
223260

224261
if (!existingTableRows.rows.length) {
225262
return;
226263
}
227264
for (const row of existingTableRows.rows._array) {
228-
await tx.execute(`DELETE FROM ${row.name} WHERE 1`);
265+
await tx.execute(`DELETE FROM ${quoteIdentifier(row.name)} WHERE 1`);
229266
}
230267
});
231268
}
@@ -499,15 +536,19 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
499536
const throttleMs = options.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS;
500537

501538
return new EventIterator<WatchOnChangeEvent>((eventOptions) => {
502-
const flushTableUpdates = _.throttle(async () => {
503-
const intersection = _.intersection(watchedTables, throttledTableUpdates);
504-
if (intersection.length) {
505-
eventOptions.push({
506-
changedTables: intersection
507-
});
508-
}
509-
throttledTableUpdates = [];
510-
}, throttleMs);
539+
const flushTableUpdates = _.throttle(
540+
async () => {
541+
const intersection = _.intersection(watchedTables, throttledTableUpdates);
542+
if (intersection.length) {
543+
eventOptions.push({
544+
changedTables: intersection
545+
});
546+
}
547+
throttledTableUpdates = [];
548+
},
549+
throttleMs,
550+
{ leading: false, trailing: true }
551+
);
511552

512553
const dispose = this.database.registerListener({
513554
tablesUpdated: async (update) => {

packages/powersync-sdk-common/src/client/AbstractPowerSyncOpenFactory.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Logger from 'js-logger';
12
import { DBAdapter } from '../db/DBAdapter';
23
import { Schema } from '../db/schema/Schema';
34
import { AbstractPowerSyncDatabase, PowerSyncDatabaseOptions } from './AbstractPowerSyncDatabase';
@@ -15,7 +16,9 @@ export interface PowerSyncOpenFactoryOptions extends Partial<PowerSyncDatabaseOp
1516
}
1617

1718
export abstract class AbstractPowerSyncDatabaseOpenFactory {
18-
constructor(protected options: PowerSyncOpenFactoryOptions) {}
19+
constructor(protected options: PowerSyncOpenFactoryOptions) {
20+
options.logger = options.logger ?? Logger.get(`PowerSync ${this.options.dbFilename}`);
21+
}
1922

2023
get schema() {
2124
return this.options.schema;

packages/powersync-sdk-common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,16 @@ export interface LockOptions<T> {
3333

3434
export interface AbstractStreamingSyncImplementationOptions {
3535
adapter: BucketStorageAdapter;
36-
remote: AbstractRemote;
3736
uploadCrud: () => Promise<void>;
37+
crudUploadThrottleMs?: number;
38+
/**
39+
* An identifier for which PowerSync DB this sync implementation is
40+
* linked to. Most commonly DB name, but not restricted to DB name.
41+
*/
42+
identifier?: string;
3843
logger?: ILogger;
44+
remote: AbstractRemote;
3945
retryDelayMs?: number;
40-
crudUploadThrottleMs?: number;
4146
}
4247

4348
export interface StreamingSyncImplementationListener extends BaseListener {

packages/powersync-sdk-common/src/db/schema/Schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import type { Table } from './Table';
33
export class Schema {
44
constructor(public tables: Table[]) {}
55

6+
validate() {
7+
for (const table of this.tables) {
8+
table.validate();
9+
}
10+
}
11+
612
toJSON() {
713
return {
814
tables: this.tables.map((t) => t.toJSON())

packages/powersync-sdk-common/src/db/schema/Table.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import _ from 'lodash';
12
import { Column } from '../Column';
23
import type { Index } from './Index';
34

45
export interface TableOptions {
6+
/**
7+
* The synced table name, matching sync rules
8+
*/
59
name: string;
610
columns: Column[];
711
indexes?: Index[];
812
localOnly?: boolean;
913
insertOnly?: boolean;
14+
viewName?: string;
1015
}
1116

1217
export const DEFAULT_TABLE_OPTIONS: Partial<TableOptions> = {
@@ -15,6 +20,8 @@ export const DEFAULT_TABLE_OPTIONS: Partial<TableOptions> = {
1520
localOnly: false
1621
};
1722

23+
export const InvalidSQLCharacters = /[\"\'%,\.#\s\[\]]/;
24+
1825
export class Table {
1926
protected options: TableOptions;
2027

@@ -34,6 +41,14 @@ export class Table {
3441
return this.options.name;
3542
}
3643

44+
get viewNameOverride() {
45+
return this.options.viewName;
46+
}
47+
48+
get viewName() {
49+
return this.viewNameOverride || this.name;
50+
}
51+
3752
get columns() {
3853
return this.options.columns;
3954
}
@@ -59,13 +74,57 @@ export class Table {
5974
}
6075

6176
get validName() {
62-
// TODO verify
63-
return !/[\"\'%,\.#\s\[\]]/.test(this.name);
77+
return _.chain([this.name, this.viewNameOverride])
78+
.compact()
79+
.every((name) => !InvalidSQLCharacters.test(name))
80+
.value();
81+
}
82+
83+
validate() {
84+
if (InvalidSQLCharacters.test(this.name)) {
85+
throw new Error(`Invalid characters in table name: ${this.name}`);
86+
} else if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride!)) {
87+
throw new Error(`
88+
Invalid characters in view name: ${this.viewNameOverride}`);
89+
}
90+
91+
const columnNames = new Set<string>();
92+
columnNames.add('id');
93+
for (const column of this.columns) {
94+
const { name: columnName } = column;
95+
if (column.name == 'id') {
96+
throw new Error(`${this.name}: id column is automatically added, custom id columns are not supported`);
97+
} else if (columnNames.has(columnName)) {
98+
throw new Error(`Duplicate column ${columnName}`);
99+
} else if (InvalidSQLCharacters.test(columnName)) {
100+
throw new Error(`Invalid characters in column name: $name.${column}`);
101+
}
102+
columnNames.add(columnName);
103+
}
104+
105+
const indexNames = new Set<string>();
106+
107+
for (const index of this.indexes) {
108+
if (indexNames.has(index.name)) {
109+
throw new Error(`Duplicate index $name.${index}`);
110+
} else if (InvalidSQLCharacters.test(index.name)) {
111+
throw new Error(`Invalid characters in index name: $name.${index}`);
112+
}
113+
114+
for (const column of index.columns) {
115+
if (!columnNames.has(column.name)) {
116+
throw new Error(`Column ${column.name} not found for index ${index.name}`);
117+
}
118+
}
119+
120+
indexNames.add(index.name);
121+
}
64122
}
65123

66124
toJSON() {
67125
return {
68126
name: this.name,
127+
view_name: this.viewName,
69128
local_only: this.localOnly,
70129
insert_only: this.insertOnly,
71130
columns: this.columns.map((c) => c.toJSON()),

0 commit comments

Comments
 (0)