Skip to content

Commit 09753eb

Browse files
Merge branch 'main' into triggers
2 parents f19eb64 + 8decd49 commit 09753eb

File tree

14 files changed

+257
-75
lines changed

14 files changed

+257
-75
lines changed

.changeset/nice-dragons-smile.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@powersync/common': minor
3+
'@powersync/node': minor
4+
'@powersync/react-native': minor
5+
'@powersync/web': minor
6+
---
7+
8+
Add `getCrudTransactions()`, returning an async iterator of transactions. This can be used to batch transactions when uploading CRUD data.

.changeset/slow-melons-raise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/react': patch
3+
---
4+
5+
Refactor useQuery hook to avoid calling internal hooks conditionally.

.github/workflows/test-simulators.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ jobs:
138138
with:
139139
persist-credentials: false
140140

141+
- name: Set up XCode
142+
uses: maxim-lobanov/setup-xcode@v1
143+
with:
144+
xcode-version: latest-stable
145+
141146
- name: CocoaPods Cache
142147
uses: actions/cache@v3
143148
id: cocoapods-cache

packages/adapter-sql-js/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
A development package for PowerSync which uses [SQL.js](https://sql.js.org/#/) to provide a pure JavaScript SQLite implementation.
44
This eliminates the need for native dependencies and enables seamless development with Expo Go and other JavaScript-only environments.
55

6-
This adapter is specifically intended to streamline the development workflow and will be much slower than DB adapters that use native dependencies.
6+
This adapter is specifically intended to streamline the **development workflow** and will be much slower than DB adapters that use native dependencies.
77
Every write operation triggers a complete rewrite of the entire database file to persistent storage, not just the changed data.
8-
In addition to the perfomance overheads, this adapter doesn't provide any of the SQLite consistency guarantees - you may end up with missing data or a corrupted database file if the app is killed while writing to the database file.
8+
In addition to the performance overheads, this adapter doesn't provide any of the SQLite consistency guarantees - you may end up with missing data or a corrupted database file if the app is killed while writing to the database file.
99

1010
For production use, when building React Native apps we recommend switching to our [react-native-quick-sqlite](https://www.npmjs.com/package/@journeyapps/react-native-quick-sqlite) or [OP-SQLite](https://www.npmjs.com/package/@powersync/op-sqlite) adapters when making production builds as they give substantially better performance.
1111

packages/common/src/client/AbstractPowerSyncDatabase.ts

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js';
1515
import { Schema } from '../db/schema/Schema.js';
1616
import { BaseObserver } from '../utils/BaseObserver.js';
1717
import { ControlledExecutor } from '../utils/ControlledExecutor.js';
18-
import { throttleTrailing } from '../utils/async.js';
18+
import { symbolAsyncIterator, throttleTrailing } from '../utils/async.js';
1919
import { ConnectionManager } from './ConnectionManager.js';
2020
import { CustomQuery } from './CustomQuery.js';
2121
import { ArrayQueryDefinition, Query } from './Query.js';
@@ -647,35 +647,80 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
647647
* @returns A transaction of CRUD operations to upload, or null if there are none
648648
*/
649649
async getNextCrudTransaction(): Promise<CrudTransaction | null> {
650-
return await this.readTransaction(async (tx) => {
651-
const first = await tx.getOptional<CrudEntryJSON>(
652-
`SELECT id, tx_id, data FROM ${PSInternalTable.CRUD} ORDER BY id ASC LIMIT 1`
653-
);
650+
const iterator = this.getCrudTransactions()[symbolAsyncIterator]();
651+
return (await iterator.next()).value;
652+
}
654653

655-
if (!first) {
656-
return null;
657-
}
658-
const txId = first.tx_id;
654+
/**
655+
* Returns an async iterator of completed transactions with local writes against the database.
656+
*
657+
* This is typically used from the {@link PowerSyncBackendConnector.uploadData} callback. Each entry emitted by the
658+
* returned iterator is a full transaction containing all local writes made while that transaction was active.
659+
*
660+
* Unlike {@link getNextCrudTransaction}, which always returns the oldest transaction that hasn't been
661+
* {@link CrudTransaction.complete}d yet, this iterator can be used to receive multiple transactions. Calling
662+
* {@link CrudTransaction.complete} will mark that and all prior transactions emitted by the iterator as completed.
663+
*
664+
* This can be used to upload multiple transactions in a single batch, e.g with:
665+
*
666+
* ```JavaScript
667+
* let lastTransaction = null;
668+
* let batch = [];
669+
*
670+
* for await (const transaction of database.getCrudTransactions()) {
671+
* batch.push(...transaction.crud);
672+
* lastTransaction = transaction;
673+
*
674+
* if (batch.length > 10) {
675+
* break;
676+
* }
677+
* }
678+
* ```
679+
*
680+
* If there is no local data to upload, the async iterator complete without emitting any items.
681+
*
682+
* Note that iterating over async iterables requires a [polyfill](https://github.com/powersync-ja/powersync-js/tree/main/packages/react-native#babel-plugins-watched-queries)
683+
* for React Native.
684+
*/
685+
getCrudTransactions(): AsyncIterable<CrudTransaction, null> {
686+
return {
687+
[symbolAsyncIterator]: () => {
688+
let lastCrudItemId = -1;
689+
const sql = `
690+
WITH RECURSIVE crud_entries AS (
691+
SELECT id, tx_id, data FROM ps_crud WHERE id = (SELECT min(id) FROM ps_crud WHERE id > ?)
692+
UNION ALL
693+
SELECT ps_crud.id, ps_crud.tx_id, ps_crud.data FROM ps_crud
694+
INNER JOIN crud_entries ON crud_entries.id + 1 = rowid
695+
WHERE crud_entries.tx_id = ps_crud.tx_id
696+
)
697+
SELECT * FROM crud_entries;
698+
`;
699+
700+
return {
701+
next: async () => {
702+
const nextTransaction = await this.database.getAll<CrudEntryJSON>(sql, [lastCrudItemId]);
703+
if (nextTransaction.length == 0) {
704+
return { done: true, value: null };
705+
}
659706

660-
let all: CrudEntry[];
661-
if (!txId) {
662-
all = [CrudEntry.fromRow(first)];
663-
} else {
664-
const result = await tx.getAll<CrudEntryJSON>(
665-
`SELECT id, tx_id, data FROM ${PSInternalTable.CRUD} WHERE tx_id = ? ORDER BY id ASC`,
666-
[txId]
667-
);
668-
all = result.map((row) => CrudEntry.fromRow(row));
707+
const items = nextTransaction.map((row) => CrudEntry.fromRow(row));
708+
const last = items[items.length - 1];
709+
const txId = last.transactionId;
710+
lastCrudItemId = last.clientId;
711+
712+
return {
713+
done: false,
714+
value: new CrudTransaction(
715+
items,
716+
async (writeCheckpoint?: string) => this.handleCrudCheckpoint(last.clientId, writeCheckpoint),
717+
txId
718+
)
719+
};
720+
}
721+
};
669722
}
670-
671-
const last = all[all.length - 1];
672-
673-
return new CrudTransaction(
674-
all,
675-
async (writeCheckpoint?: string) => this.handleCrudCheckpoint(last.clientId, writeCheckpoint),
676-
txId
677-
);
678-
});
723+
};
679724
}
680725

681726
/**

packages/common/src/utils/async.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
/**
2+
* A ponyfill for `Symbol.asyncIterator` that is compatible with the
3+
* [recommended polyfill](https://github.com/Azure/azure-sdk-for-js/blob/%40azure/core-asynciterator-polyfill_1.0.2/sdk/core/core-asynciterator-polyfill/src/index.ts#L4-L6)
4+
* we recommend for React Native.
5+
*
6+
* As long as we use this symbol (instead of `for await` and `async *`) in this package, we can be compatible with async
7+
* iterators without requiring them.
8+
*/
9+
export const symbolAsyncIterator: typeof Symbol.asyncIterator =
10+
Symbol.asyncIterator ?? Symbol.for('Symbol.asyncIterator');
11+
112
/**
213
* Throttle a function to be called at most once every "wait" milliseconds,
314
* on the trailing edge.

packages/node/tests/PowerSyncDatabase.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Worker } from 'node:worker_threads';
33

44
import { vi, expect, test } from 'vitest';
55
import { AppSchema, databaseTest, tempDirectoryTest } from './utils';
6-
import { PowerSyncDatabase } from '../lib';
6+
import { CrudEntry, CrudTransaction, PowerSyncDatabase } from '../lib';
77
import { WorkerOpener } from '../lib/db/options';
88

99
test('validates options', async () => {
@@ -131,3 +131,39 @@ databaseTest.skip('can watch queries', async ({ database }) => {
131131
await database.execute('INSERT INTO todos (id, content) VALUES (uuid(), ?)', ['fourth']);
132132
expect((await query.next()).value.rows).toHaveLength(4);
133133
});
134+
135+
databaseTest('getCrudTransactions', async ({ database }) => {
136+
async function createTransaction(amount: number) {
137+
await database.writeTransaction(async (tx) => {
138+
for (let i = 0; i < amount; i++) {
139+
await tx.execute('insert into todos (id) values (uuid())');
140+
}
141+
});
142+
}
143+
144+
let iterator = database.getCrudTransactions()[Symbol.asyncIterator]();
145+
expect(await iterator.next()).toMatchObject({ done: true });
146+
147+
await createTransaction(5);
148+
await createTransaction(10);
149+
await createTransaction(15);
150+
151+
let lastTransaction: CrudTransaction | null = null;
152+
let batch: CrudEntry[] = [];
153+
154+
// Take the first two transactions via the async generator.
155+
for await (const transaction of database.getCrudTransactions()) {
156+
batch.push(...transaction.crud);
157+
lastTransaction = transaction;
158+
159+
if (batch.length > 10) {
160+
break;
161+
}
162+
}
163+
164+
expect(batch).toHaveLength(15);
165+
await lastTransaction!.complete();
166+
167+
const remainingTransaction = await database.getNextCrudTransaction();
168+
expect(remainingTransaction?.crud).toHaveLength(15);
169+
});

packages/react-native/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ npx expo install @journeyapps/react-native-quick-sqlite
3737
Watched queries can be used with either a callback response or Async Iterator response.
3838

3939
Watched queries using the Async Iterator response format require support for Async Iterators.
40+
`PowerSyncDatabase.getCrudTransactions()` also returns an Async Iterator and requires this workaround.
4041

4142
Expo apps currently require polyfill and Babel plugins in order to use this functionality.
4243

packages/react/src/hooks/watched/useQuery.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,26 +61,26 @@ export function useQuery<RowType = any>(
6161
return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') };
6262
}
6363
const { parsedQuery, queryChanged } = constructCompatibleQuery(query, parameters, options);
64+
const runOnce = options?.runQueryOnce == true;
65+
const single = useSingleQuery<RowType>({
66+
query: parsedQuery,
67+
powerSync,
68+
queryChanged,
69+
active: runOnce
70+
});
71+
const watched = useWatchedQuery<RowType>({
72+
query: parsedQuery,
73+
powerSync,
74+
queryChanged,
75+
options: {
76+
reportFetching: options.reportFetching,
77+
// Maintains backwards compatibility with previous versions
78+
// Differentiation is opt-in by default
79+
// We emit new data for each table change by default.
80+
rowComparator: options.rowComparator
81+
},
82+
active: !runOnce
83+
});
6484

65-
switch (options?.runQueryOnce) {
66-
case true:
67-
return useSingleQuery<RowType>({
68-
query: parsedQuery,
69-
powerSync,
70-
queryChanged
71-
});
72-
default:
73-
return useWatchedQuery<RowType>({
74-
query: parsedQuery,
75-
powerSync,
76-
queryChanged,
77-
options: {
78-
reportFetching: options.reportFetching,
79-
// Maintains backwards compatibility with previous versions
80-
// Differentiation is opt-in by default
81-
// We emit new data for each table change by default.
82-
rowComparator: options.rowComparator
83-
}
84-
});
85-
}
85+
return runOnce ? single : watched;
8686
}

packages/react/src/hooks/watched/useSingleQuery.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import React from 'react';
22
import { QueryResult } from './watch-types.js';
33
import { InternalHookOptions } from './watch-utils.js';
44

5+
/**
6+
* @internal not exported from `index.ts`
7+
*/
58
export const useSingleQuery = <RowType = any>(options: InternalHookOptions<RowType[]>): QueryResult<RowType> => {
6-
const { query, powerSync, queryChanged } = options;
9+
const { query, powerSync, queryChanged, active } = options;
710

811
const [output, setOutputState] = React.useState<QueryResult<RowType>>({
912
isLoading: true,
@@ -46,13 +49,16 @@ export const useSingleQuery = <RowType = any>(options: InternalHookOptions<RowTy
4649
);
4750

4851
// Trigger initial query execution
52+
// @ts-ignore: Complains about not all code paths returning a value
4953
React.useEffect(() => {
50-
const abortController = new AbortController();
51-
runQuery(abortController.signal);
52-
return () => {
53-
abortController.abort();
54-
};
55-
}, [powerSync, queryChanged]);
54+
if (active) {
55+
const abortController = new AbortController();
56+
runQuery(abortController.signal);
57+
return () => {
58+
abortController.abort();
59+
};
60+
}
61+
}, [powerSync, active, queryChanged]);
5662

5763
return {
5864
...output,

0 commit comments

Comments
 (0)