@powersync/common 0.0.0-dev-20251106124255 → 0.0.0-dev-20251201150812

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/bundle.cjs +4560 -184
  2. package/dist/bundle.cjs.map +1 -1
  3. package/dist/bundle.mjs +4560 -184
  4. package/dist/bundle.mjs.map +1 -1
  5. package/dist/bundle.node.cjs +42 -17
  6. package/dist/bundle.node.cjs.map +1 -1
  7. package/dist/bundle.node.mjs +42 -17
  8. package/dist/bundle.node.mjs.map +1 -1
  9. package/dist/index.d.cts +226 -162
  10. package/lib/client/ConnectionManager.d.ts +1 -1
  11. package/lib/client/ConnectionManager.js.map +1 -1
  12. package/lib/client/sync/bucket/SqliteBucketStorage.js +2 -2
  13. package/lib/client/sync/bucket/SqliteBucketStorage.js.map +1 -1
  14. package/lib/client/sync/stream/AbstractRemote.js +10 -4
  15. package/lib/client/sync/stream/AbstractRemote.js.map +1 -1
  16. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +4 -4
  17. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js.map +1 -1
  18. package/lib/client/triggers/TriggerManager.d.ts +71 -12
  19. package/lib/client/triggers/TriggerManagerImpl.js +10 -5
  20. package/lib/client/triggers/TriggerManagerImpl.js.map +1 -1
  21. package/lib/db/crud/SyncStatus.d.ts +7 -2
  22. package/lib/db/crud/SyncStatus.js +15 -1
  23. package/lib/db/crud/SyncStatus.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/client/ConnectionManager.ts +1 -1
  26. package/src/client/sync/bucket/SqliteBucketStorage.ts +2 -2
  27. package/src/client/sync/stream/AbstractRemote.ts +15 -5
  28. package/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +5 -6
  29. package/src/client/triggers/TriggerManager.ts +79 -12
  30. package/src/client/triggers/TriggerManagerImpl.ts +12 -6
  31. package/src/db/crud/SyncStatus.ts +18 -3
@@ -1,4 +1,5 @@
1
1
  import { ILogger } from 'js-logger';
2
+ import { SyncStatus } from '../db/crud/SyncStatus.js';
2
3
  import { BaseListener, BaseObserver } from '../utils/BaseObserver.js';
3
4
  import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js';
4
5
  import {
@@ -13,7 +14,6 @@ import {
13
14
  SyncStreamSubscribeOptions,
14
15
  SyncStreamSubscription
15
16
  } from './sync/sync-streams.js';
16
- import { SyncStatus } from '../db/crud/SyncStatus.js';
17
17
 
18
18
  /**
19
19
  * @internal
@@ -255,7 +255,7 @@ export class SqliteBucketStorage extends BaseObserver<BucketStorageListener> imp
255
255
  // Nothing to update
256
256
  return false;
257
257
  }
258
- const rs = await this.db.getAll<{ seq: number }>("SELECT seq FROM sqlite_sequence WHERE name = 'ps_crud'");
258
+ const rs = await this.db.getAll<{ seq: number }>("SELECT seq FROM main.sqlite_sequence WHERE name = 'ps_crud'");
259
259
  if (!rs.length) {
260
260
  // Nothing to update
261
261
  return false;
@@ -273,7 +273,7 @@ export class SqliteBucketStorage extends BaseObserver<BucketStorageListener> imp
273
273
  return false;
274
274
  }
275
275
 
276
- const rs = await tx.execute("SELECT seq FROM sqlite_sequence WHERE name = 'ps_crud'");
276
+ const rs = await tx.execute("SELECT seq FROM main.sqlite_sequence WHERE name = 'ps_crud'");
277
277
  if (!rs.rows?.length) {
278
278
  // assert isNotEmpty
279
279
  throw new Error('SQLite Sequence should not be empty');
@@ -6,8 +6,9 @@ import PACKAGE from '../../../../package.json' with { type: 'json' };
6
6
  import { AbortOperation } from '../../../utils/AbortOperation.js';
7
7
  import { DataStream } from '../../../utils/DataStream.js';
8
8
  import { PowerSyncCredentials } from '../../connection/PowerSyncCredentials.js';
9
- import { StreamingSyncRequest } from './streaming-sync-types.js';
10
9
  import { WebsocketClientTransport } from './WebsocketClientTransport.js';
10
+ import { StreamingSyncRequest } from './streaming-sync-types.js';
11
+
11
12
 
12
13
  export type BSONImplementation = typeof BSON;
13
14
 
@@ -557,9 +558,11 @@ export abstract class AbstractRemote {
557
558
  // Create a new stream splitting the response at line endings while also handling cancellations
558
559
  // by closing the reader.
559
560
  const reader = res.body.getReader();
561
+ let readerReleased = false;
560
562
  // This will close the network request and read stream
561
563
  const closeReader = async () => {
562
564
  try {
565
+ readerReleased = true;
563
566
  await reader.cancel();
564
567
  } catch (ex) {
565
568
  // an error will throw if the reader hasn't been used yet
@@ -567,20 +570,27 @@ export abstract class AbstractRemote {
567
570
  reader.releaseLock();
568
571
  };
569
572
 
573
+
574
+ const stream = new DataStream<T, string>({
575
+ logger: this.logger,
576
+ mapLine: mapLine
577
+ });
578
+
570
579
  abortSignal?.addEventListener('abort', () => {
571
580
  closeReader();
581
+ stream.close();
572
582
  });
573
583
 
574
584
  const decoder = this.createTextDecoder();
575
585
  let buffer = '';
576
586
 
577
- const stream = new DataStream<T, string>({
578
- logger: this.logger,
579
- mapLine: mapLine
580
- });
587
+
581
588
 
582
589
  const l = stream.registerListener({
583
590
  lowWater: async () => {
591
+ if (stream.closed || abortSignal?.aborted || readerReleased) {
592
+ return
593
+ }
584
594
  try {
585
595
  let didCompleteLine = false;
586
596
  while (!didCompleteLine) {
@@ -16,7 +16,7 @@ import {
16
16
  import { CrudEntry } from '../bucket/CrudEntry.js';
17
17
  import { SyncDataBucket } from '../bucket/SyncDataBucket.js';
18
18
  import { AbstractRemote, FetchStrategy, SyncStreamOptions } from './AbstractRemote.js';
19
- import { coreStatusToJs, EstablishSyncStream, Instruction, SyncPriorityStatus } from './core-instruction.js';
19
+ import { EstablishSyncStream, Instruction, coreStatusToJs } from './core-instruction.js';
20
20
  import {
21
21
  BucketRequest,
22
22
  CrudUploadNotification,
@@ -429,7 +429,7 @@ The next upload iteration will be delayed.`);
429
429
  uploadError: ex
430
430
  }
431
431
  });
432
- await this.delayRetry(controller.signal);
432
+ await this.delayRetry(controller.signal, this.options.crudUploadThrottleMs);
433
433
  if (!this.isConnected) {
434
434
  // Exit the upload loop if the sync stream is no longer connected
435
435
  break;
@@ -1216,15 +1216,14 @@ The next upload iteration will be delayed.`);
1216
1216
  this.iterateListeners((cb) => cb.statusUpdated?.(options));
1217
1217
  }
1218
1218
 
1219
- private async delayRetry(signal?: AbortSignal): Promise<void> {
1219
+ private async delayRetry(signal?: AbortSignal, delayMs?: number): Promise<void> {
1220
1220
  return new Promise((resolve) => {
1221
1221
  if (signal?.aborted) {
1222
1222
  // If the signal is already aborted, resolve immediately
1223
1223
  resolve();
1224
1224
  return;
1225
1225
  }
1226
-
1227
- const { retryDelayMs } = this.options;
1226
+ const delay = delayMs ?? this.options.retryDelayMs;
1228
1227
 
1229
1228
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
1230
1229
 
@@ -1238,7 +1237,7 @@ The next upload iteration will be delayed.`);
1238
1237
  };
1239
1238
 
1240
1239
  signal?.addEventListener('abort', endDelay, { once: true });
1241
- timeoutId = setTimeout(endDelay, retryDelayMs);
1240
+ timeoutId = setTimeout(endDelay, delay);
1242
1241
  });
1243
1242
  }
1244
1243
 
@@ -14,8 +14,11 @@ export enum DiffTriggerOperation {
14
14
  * @experimental
15
15
  * Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
16
16
  * This is the base record structure for all diff records.
17
+ *
18
+ * @template TOperationId - The type for `operation_id`. Defaults to `number` as returned by default SQLite database queries.
19
+ * Use `string` for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
17
20
  */
18
- export interface BaseTriggerDiffRecord {
21
+ export interface BaseTriggerDiffRecord<TOperationId extends string | number = number> {
19
22
  /**
20
23
  * The modified row's `id` column value.
21
24
  */
@@ -24,6 +27,13 @@ export interface BaseTriggerDiffRecord {
24
27
  * The operation performed which created this record.
25
28
  */
26
29
  operation: DiffTriggerOperation;
30
+ /**
31
+ * Auto-incrementing primary key for the operation.
32
+ * Defaults to number as returned by database queries (wa-sqlite returns lower 32 bits).
33
+ * Can be string for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
34
+ */
35
+ operation_id: TOperationId;
36
+
27
37
  /**
28
38
  * Time the change operation was recorded.
29
39
  * This is in ISO 8601 format, e.g. `2023-10-01T12:00:00.000Z`.
@@ -37,7 +47,8 @@ export interface BaseTriggerDiffRecord {
37
47
  * This record contains the new value and optionally the previous value.
38
48
  * Values are stored as JSON strings.
39
49
  */
40
- export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
50
+ export interface TriggerDiffUpdateRecord<TOperationId extends string | number = number>
51
+ extends BaseTriggerDiffRecord<TOperationId> {
41
52
  operation: DiffTriggerOperation.UPDATE;
42
53
  /**
43
54
  * The updated state of the row in JSON string format.
@@ -54,7 +65,8 @@ export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
54
65
  * Represents a diff record for a SQLite INSERT operation.
55
66
  * This record contains the new value represented as a JSON string.
56
67
  */
57
- export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
68
+ export interface TriggerDiffInsertRecord<TOperationId extends string | number = number>
69
+ extends BaseTriggerDiffRecord<TOperationId> {
58
70
  operation: DiffTriggerOperation.INSERT;
59
71
  /**
60
72
  * The value of the row, at the time of INSERT, in JSON string format.
@@ -67,7 +79,8 @@ export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
67
79
  * Represents a diff record for a SQLite DELETE operation.
68
80
  * This record contains the new value represented as a JSON string.
69
81
  */
70
- export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
82
+ export interface TriggerDiffDeleteRecord<TOperationId extends string | number = number>
83
+ extends BaseTriggerDiffRecord<TOperationId> {
71
84
  operation: DiffTriggerOperation.DELETE;
72
85
  /**
73
86
  * The value of the row, before the DELETE operation, in JSON string format.
@@ -82,27 +95,53 @@ export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
82
95
  *
83
96
  * Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withDiff} will return records
84
97
  * with the structure of this type.
98
+ *
99
+ * @template TOperationId - The type for `operation_id`. Defaults to `number` as returned by database queries.
100
+ * Use `string` for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
101
+ *
85
102
  * @example
86
103
  * ```typescript
104
+ * // Default: operation_id is number
87
105
  * const diffs = await context.withDiff<TriggerDiffRecord>('SELECT * FROM DIFF');
88
- * diff.forEach(diff => console.log(diff.operation, diff.timestamp, JSON.parse(diff.value)))
106
+ *
107
+ * // With string operation_id for full precision
108
+ * const diffsWithString = await context.withDiff<TriggerDiffRecord<string>>(
109
+ * 'SELECT * FROM DIFF',
110
+ * undefined,
111
+ * { castOperationIdAsText: true }
112
+ * );
89
113
  * ```
90
114
  */
91
- export type TriggerDiffRecord = TriggerDiffUpdateRecord | TriggerDiffInsertRecord | TriggerDiffDeleteRecord;
115
+ export type TriggerDiffRecord<TOperationId extends string | number = number> =
116
+ | TriggerDiffUpdateRecord<TOperationId>
117
+ | TriggerDiffInsertRecord<TOperationId>
118
+ | TriggerDiffDeleteRecord<TOperationId>;
92
119
 
93
120
  /**
94
121
  * @experimental
95
122
  * Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withExtractedDiff} will return records
96
123
  * with the tracked columns extracted from the JSON value.
97
124
  * This type represents the structure of such records.
125
+ *
126
+ * @template T - The type for the extracted columns from the tracked JSON value.
127
+ * @template TOperationId - The type for `operation_id`. Defaults to `number` as returned by database queries.
128
+ * Use `string` for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
129
+ *
98
130
  * @example
99
131
  * ```typescript
132
+ * // Default: operation_id is number
100
133
  * const diffs = await context.withExtractedDiff<ExtractedTriggerDiffRecord<{id: string, name: string}>>('SELECT * FROM DIFF');
101
- * diff.forEach(diff => console.log(diff.__operation, diff.__timestamp, diff.columnName))
134
+ *
135
+ * // With string operation_id for full precision
136
+ * const diffsWithString = await context.withExtractedDiff<ExtractedTriggerDiffRecord<{id: string, name: string}, string>>(
137
+ * 'SELECT * FROM DIFF',
138
+ * undefined,
139
+ * { castOperationIdAsText: true }
140
+ * );
102
141
  * ```
103
142
  */
104
- export type ExtractedTriggerDiffRecord<T> = T & {
105
- [K in keyof Omit<BaseTriggerDiffRecord, 'id'> as `__${string & K}`]: TriggerDiffRecord[K];
143
+ export type ExtractedTriggerDiffRecord<T, TOperationId extends string | number = number> = T & {
144
+ [K in keyof Omit<BaseTriggerDiffRecord<TOperationId>, 'id'> as `__${string & K}`]: TriggerDiffRecord<TOperationId>[K];
106
145
  } & {
107
146
  __previous_value?: string;
108
147
  };
@@ -183,6 +222,21 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions {
183
222
  */
184
223
  export type TriggerRemoveCallback = () => Promise<void>;
185
224
 
225
+ /**
226
+ * @experimental
227
+ * Options for {@link TriggerDiffHandlerContext#withDiff}.
228
+ */
229
+ export interface WithDiffOptions {
230
+ /**
231
+ * If true, casts `operation_id` as TEXT in the internal CTE to preserve full 64-bit precision.
232
+ * Use this when you need to ensure `operation_id` is treated as a string to avoid precision loss
233
+ * for values exceeding JavaScript's Number.MAX_SAFE_INTEGER.
234
+ *
235
+ * When enabled, use {@link TriggerDiffRecord}<string> to type the result correctly.
236
+ */
237
+ castOperationIdAsText?: boolean;
238
+ }
239
+
186
240
  /**
187
241
  * @experimental
188
242
  * Context for the `onChange` handler provided to {@link TriggerManager#trackTableDiff}.
@@ -200,9 +254,10 @@ export interface TriggerDiffHandlerContext extends LockContext {
200
254
  * The `DIFF` table is of the form described in {@link TriggerManager#createDiffTrigger}
201
255
  * ```sql
202
256
  * CREATE TEMP DIFF (
257
+ * operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
203
258
  * id TEXT,
204
259
  * operation TEXT,
205
- * timestamp TEXT
260
+ * timestamp TEXT,
206
261
  * value TEXT,
207
262
  * previous_value TEXT
208
263
  * );
@@ -222,8 +277,19 @@ export interface TriggerDiffHandlerContext extends LockContext {
222
277
  * JOIN todos ON DIFF.id = todos.id
223
278
  * WHERE json_extract(DIFF.value, '$.status') = 'active'
224
279
  * ```
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * // With operation_id cast as TEXT for full precision
284
+ * const diffs = await context.withDiff<TriggerDiffRecord<string>>(
285
+ * 'SELECT * FROM DIFF',
286
+ * undefined,
287
+ * { castOperationIdAsText: true }
288
+ * );
289
+ * // diffs[0].operation_id is now typed as string
290
+ * ```
225
291
  */
226
- withDiff: <T = any>(query: string, params?: ReadonlyArray<Readonly<any>>) => Promise<T[]>;
292
+ withDiff: <T = any>(query: string, params?: ReadonlyArray<Readonly<any>>, options?: WithDiffOptions) => Promise<T[]>;
227
293
 
228
294
  /**
229
295
  * Allows querying the database with access to the table containing diff records.
@@ -292,9 +358,10 @@ export interface TriggerManager {
292
358
  *
293
359
  * ```sql
294
360
  * CREATE TEMP TABLE ${destination} (
361
+ * operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
295
362
  * id TEXT,
296
363
  * operation TEXT,
297
- * timestamp TEXT
364
+ * timestamp TEXT,
298
365
  * value TEXT,
299
366
  * previous_value TEXT
300
367
  * );
@@ -7,7 +7,8 @@ import {
7
7
  DiffTriggerOperation,
8
8
  TrackDiffOptions,
9
9
  TriggerManager,
10
- TriggerRemoveCallback
10
+ TriggerRemoveCallback,
11
+ WithDiffOptions
11
12
  } from './TriggerManager.js';
12
13
 
13
14
  export type TriggerManagerImplOptions = {
@@ -117,6 +118,7 @@ export class TriggerManagerImpl implements TriggerManager {
117
118
  await hooks?.beforeCreate?.(tx);
118
119
  await tx.execute(/* sql */ `
119
120
  CREATE TEMP TABLE ${destination} (
121
+ operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
120
122
  id TEXT,
121
123
  operation TEXT,
122
124
  timestamp TEXT,
@@ -243,17 +245,20 @@ export class TriggerManagerImpl implements TriggerManager {
243
245
  const callbackResult = await options.onChange({
244
246
  ...tx,
245
247
  destinationTable: destination,
246
- withDiff: async <T>(query, params) => {
248
+ withDiff: async <T>(query, params, options?: WithDiffOptions) => {
247
249
  // Wrap the query to expose the destination table
250
+ const operationIdSelect = options?.castOperationIdAsText
251
+ ? 'id, operation, CAST(operation_id AS TEXT) as operation_id, timestamp, value, previous_value'
252
+ : '*';
248
253
  const wrappedQuery = /* sql */ `
249
254
  WITH
250
255
  DIFF AS (
251
256
  SELECT
252
- *
257
+ ${operationIdSelect}
253
258
  FROM
254
259
  ${destination}
255
260
  ORDER BY
256
- timestamp ASC
261
+ operation_id ASC
257
262
  ) ${query}
258
263
  `;
259
264
  return tx.getAll<T>(wrappedQuery, params);
@@ -267,13 +272,14 @@ export class TriggerManagerImpl implements TriggerManager {
267
272
  id,
268
273
  ${contextColumns.length > 0
269
274
  ? `${contextColumns.map((col) => `json_extract(value, '$.${col}') as ${col}`).join(', ')},`
270
- : ''} operation as __operation,
275
+ : ''} operation_id as __operation_id,
276
+ operation as __operation,
271
277
  timestamp as __timestamp,
272
278
  previous_value as __previous_value
273
279
  FROM
274
280
  ${destination}
275
281
  ORDER BY
276
- __timestamp ASC
282
+ __operation_id ASC
277
283
  ) ${query}
278
284
  `;
279
285
  return tx.getAll<T>(wrappedQuery, params);
@@ -1,7 +1,7 @@
1
- import { CoreStreamSubscription } from '../../client/sync/stream/core-instruction.js';
2
1
  import { SyncClientImplementation } from '../../client/sync/stream/AbstractStreamingSyncImplementation.js';
3
- import { InternalProgressInformation, ProgressWithOperations, SyncProgress } from './SyncProgress.js';
2
+ import { CoreStreamSubscription } from '../../client/sync/stream/core-instruction.js';
4
3
  import { SyncStreamDescription, SyncSubscriptionDescription } from '../../client/sync/sync-streams.js';
4
+ import { InternalProgressInformation, ProgressWithOperations, SyncProgress } from './SyncProgress.js';
5
5
 
6
6
  export type SyncDataFlowStatus = Partial<{
7
7
  downloading: boolean;
@@ -250,13 +250,28 @@ export class SyncStatus {
250
250
  return {
251
251
  connected: this.connected,
252
252
  connecting: this.connecting,
253
- dataFlow: this.dataFlowStatus,
253
+ dataFlow: {
254
+ ...this.dataFlowStatus,
255
+ uploadError: this.serializeError(this.dataFlowStatus.uploadError),
256
+ downloadError: this.serializeError(this.dataFlowStatus.downloadError)
257
+ },
254
258
  lastSyncedAt: this.lastSyncedAt,
255
259
  hasSynced: this.hasSynced,
256
260
  priorityStatusEntries: this.priorityStatusEntries
257
261
  };
258
262
  }
259
263
 
264
+ protected serializeError(error?: Error) {
265
+ if (typeof error == 'undefined') {
266
+ return undefined;
267
+ }
268
+ return {
269
+ name: error.name,
270
+ message: error.message,
271
+ stack: error.stack
272
+ };
273
+ }
274
+
260
275
  private static comparePriorities(a: SyncPriorityStatus, b: SyncPriorityStatus) {
261
276
  return b.priority - a.priority; // Reverse because higher priorities have lower numbers
262
277
  }