@tanstack/powersync-db-collection 0.1.67 → 0.2.1

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.
@@ -1,4 +1,4 @@
1
- import { DiffTriggerOperation, BaseColumnType, ExtractColumnValueType, Table } from '@powersync/common';
1
+ import { DiffTriggerOperation, ExtractColumnValueType, Table } from '@powersync/common';
2
2
  /**
3
3
  * All PowerSync table records include a UUID `id` column.
4
4
  */
@@ -20,9 +20,9 @@ type OptionalIfUndefined<T> = {
20
20
  /**
21
21
  * Provides the base column types for a table. This excludes the `id` column.
22
22
  */
23
- export type ExtractedTableColumns<TTable extends Table> = {
24
- [K in keyof TTable[`columnMap`]]: ExtractColumnValueType<TTable[`columnMap`][K]>;
25
- };
23
+ export type ExtractedTableColumns<TTable extends Table> = TTable extends Table<infer Columns> ? {
24
+ [K in keyof Columns]: ExtractColumnValueType<Columns[K]>;
25
+ } : never;
26
26
  /**
27
27
  * Utility type that extracts the typed structure of a table based on its column definitions.
28
28
  * Maps each column to its corresponding TypeScript type using ExtractColumnValueType.
@@ -42,7 +42,7 @@ export type ExtractedTable<TTable extends Table> = ExtractedTableColumns<TTable>
42
42
  id: string;
43
43
  };
44
44
  export type OptionalExtractedTable<TTable extends Table> = OptionalIfUndefined<{
45
- [K in keyof TTable[`columnMap`]]: WithUndefinedIfNull<ExtractColumnValueType<TTable[`columnMap`][K]>>;
45
+ [K in keyof ExtractedTableColumns<TTable>]: WithUndefinedIfNull<ExtractedTableColumns<TTable>[K]>;
46
46
  }> & {
47
47
  id: string;
48
48
  };
@@ -51,14 +51,11 @@ export type OptionalExtractedTable<TTable extends Table> = OptionalIfUndefined<{
51
51
  * requires the keys be equal, but the values can have any value type.
52
52
  */
53
53
  export type AnyTableColumnType<TTable extends Table> = {
54
- [K in keyof TTable[`columnMap`]]: any;
54
+ [K in keyof ExtractedTableColumns<TTable>]: unknown;
55
55
  } & {
56
56
  id: string;
57
57
  };
58
58
  export declare function asPowerSyncRecord(record: any): PowerSyncRecord;
59
- export type MapBaseColumnType<TOutput> = {
60
- [Key in keyof TOutput]: BaseColumnType<any>;
61
- };
62
59
  /**
63
60
  * Maps {@link DiffTriggerOperation} to TanstackDB operations
64
61
  */
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.js","sources":["../../src/helpers.ts"],"sourcesContent":["import { DiffTriggerOperation } from '@powersync/common'\nimport type {\n BaseColumnType,\n ExtractColumnValueType,\n Table,\n} from '@powersync/common'\n\n/**\n * All PowerSync table records include a UUID `id` column.\n */\nexport type PowerSyncRecord = {\n id: string\n [key: string]: unknown\n}\n\n/**\n * Utility type: If T includes null, also allow undefined (to support optional fields in insert/update operations).\n * PowerSync records are typically typed as `string | null`, where insert\n * and update operations may also allow not specifying a value at all (optional).\n */\ntype WithUndefinedIfNull<T> = null extends T ? T | undefined : T\ntype OptionalIfUndefined<T> = {\n [K in keyof T as undefined extends T[K] ? K : never]?: T[K]\n} & {\n [K in keyof T as undefined extends T[K] ? never : K]: T[K]\n}\n\n/**\n * Provides the base column types for a table. This excludes the `id` column.\n */\nexport type ExtractedTableColumns<TTable extends Table> = {\n [K in keyof TTable[`columnMap`]]: ExtractColumnValueType<\n TTable[`columnMap`][K]\n >\n}\n/**\n * Utility type that extracts the typed structure of a table based on its column definitions.\n * Maps each column to its corresponding TypeScript type using ExtractColumnValueType.\n *\n * @template TTable - The PowerSync table definition\n * @example\n * ```typescript\n * const table = new Table({\n * name: column.text,\n * age: column.integer\n * })\n * type TableType = ExtractedTable<typeof table>\n * // Results in: { id: string, name: string | null, age: number | null }\n * ```\n */\nexport type ExtractedTable<TTable extends Table> =\n ExtractedTableColumns<TTable> & {\n id: string\n }\n\nexport type OptionalExtractedTable<TTable extends Table> = OptionalIfUndefined<{\n [K in keyof TTable[`columnMap`]]: WithUndefinedIfNull<\n ExtractColumnValueType<TTable[`columnMap`][K]>\n >\n}> & {\n id: string\n}\n\n/**\n * Maps the schema of TTable to a type which\n * requires the keys be equal, but the values can have any value type.\n */\nexport type AnyTableColumnType<TTable extends Table> = {\n [K in keyof TTable[`columnMap`]]: any\n} & { id: string }\n\nexport function asPowerSyncRecord(record: any): PowerSyncRecord {\n if (typeof record.id !== `string`) {\n throw new Error(`Record must have a string id field`)\n }\n return record as PowerSyncRecord\n}\n\n// Helper type to ensure the keys of TOutput match the Table columns\nexport type MapBaseColumnType<TOutput> = {\n [Key in keyof TOutput]: BaseColumnType<any>\n}\n\n/**\n * Maps {@link DiffTriggerOperation} to TanstackDB operations\n */\nexport function mapOperation(operation: DiffTriggerOperation) {\n switch (operation) {\n case DiffTriggerOperation.INSERT:\n return `insert`\n case DiffTriggerOperation.UPDATE:\n return `update`\n case DiffTriggerOperation.DELETE:\n return `delete`\n }\n}\n\n/**\n * Maps TanstackDB operations to {@link DiffTriggerOperation}\n */\nexport function mapOperationToPowerSync(operation: string) {\n switch (operation) {\n case `insert`:\n return DiffTriggerOperation.INSERT\n case `update`:\n return DiffTriggerOperation.UPDATE\n case `delete`:\n return DiffTriggerOperation.DELETE\n default:\n throw new Error(`Unknown operation ${operation} received`)\n }\n}\n"],"names":[],"mappings":";AAuEO,SAAS,kBAAkB,QAA8B;AAC9D,MAAI,OAAO,OAAO,OAAO,UAAU;AACjC,UAAM,IAAI,MAAM,oCAAoC;AAAA,EACtD;AACA,SAAO;AACT;AAUO,SAAS,aAAa,WAAiC;AAC5D,UAAQ,WAAA;AAAA,IACN,KAAK,qBAAqB;AACxB,aAAO;AAAA,IACT,KAAK,qBAAqB;AACxB,aAAO;AAAA,IACT,KAAK,qBAAqB;AACxB,aAAO;AAAA,EAAA;AAEb;AAKO,SAAS,wBAAwB,WAAmB;AACzD,UAAQ,WAAA;AAAA,IACN,KAAK;AACH,aAAO,qBAAqB;AAAA,IAC9B,KAAK;AACH,aAAO,qBAAqB;AAAA,IAC9B,KAAK;AACH,aAAO,qBAAqB;AAAA,IAC9B;AACE,YAAM,IAAI,MAAM,qBAAqB,SAAS,WAAW;AAAA,EAAA;AAE/D;"}
1
+ {"version":3,"file":"helpers.js","sources":["../../src/helpers.ts"],"sourcesContent":["import { DiffTriggerOperation } from '@powersync/common'\nimport type { ExtractColumnValueType, Table } from '@powersync/common'\n\n/**\n * All PowerSync table records include a UUID `id` column.\n */\nexport type PowerSyncRecord = {\n id: string\n [key: string]: unknown\n}\n\n/**\n * Utility type: If T includes null, also allow undefined (to support optional fields in insert/update operations).\n * PowerSync records are typically typed as `string | null`, where insert\n * and update operations may also allow not specifying a value at all (optional).\n */\ntype WithUndefinedIfNull<T> = null extends T ? T | undefined : T\ntype OptionalIfUndefined<T> = {\n [K in keyof T as undefined extends T[K] ? K : never]?: T[K]\n} & {\n [K in keyof T as undefined extends T[K] ? never : K]: T[K]\n}\n\n/**\n * Provides the base column types for a table. This excludes the `id` column.\n */\nexport type ExtractedTableColumns<TTable extends Table> =\n TTable extends Table<infer Columns>\n ? {\n [K in keyof Columns]: ExtractColumnValueType<Columns[K]>\n }\n : never\n/**\n * Utility type that extracts the typed structure of a table based on its column definitions.\n * Maps each column to its corresponding TypeScript type using ExtractColumnValueType.\n *\n * @template TTable - The PowerSync table definition\n * @example\n * ```typescript\n * const table = new Table({\n * name: column.text,\n * age: column.integer\n * })\n * type TableType = ExtractedTable<typeof table>\n * // Results in: { id: string, name: string | null, age: number | null }\n * ```\n */\nexport type ExtractedTable<TTable extends Table> =\n ExtractedTableColumns<TTable> & {\n id: string\n }\n\nexport type OptionalExtractedTable<TTable extends Table> = OptionalIfUndefined<{\n [K in keyof ExtractedTableColumns<TTable>]: WithUndefinedIfNull<\n ExtractedTableColumns<TTable>[K]\n >\n}> & {\n id: string\n}\n\n/**\n * Maps the schema of TTable to a type which\n * requires the keys be equal, but the values can have any value type.\n */\nexport type AnyTableColumnType<TTable extends Table> = {\n [K in keyof ExtractedTableColumns<TTable>]: unknown\n} & { id: string }\n\nexport function asPowerSyncRecord(record: any): PowerSyncRecord {\n if (typeof record.id !== `string`) {\n throw new Error(`Record must have a string id field`)\n }\n return record as PowerSyncRecord\n}\n\n/**\n * Maps {@link DiffTriggerOperation} to TanstackDB operations\n */\nexport function mapOperation(operation: DiffTriggerOperation) {\n switch (operation) {\n case DiffTriggerOperation.INSERT:\n return `insert`\n case DiffTriggerOperation.UPDATE:\n return `update`\n case DiffTriggerOperation.DELETE:\n return `delete`\n }\n}\n\n/**\n * Maps TanstackDB operations to {@link DiffTriggerOperation}\n */\nexport function mapOperationToPowerSync(operation: string) {\n switch (operation) {\n case `insert`:\n return DiffTriggerOperation.INSERT\n case `update`:\n return DiffTriggerOperation.UPDATE\n case `delete`:\n return DiffTriggerOperation.DELETE\n default:\n throw new Error(`Unknown operation ${operation} received`)\n }\n}\n"],"names":[],"mappings":";AAoEO,SAAS,kBAAkB,QAA8B;AAC9D,MAAI,OAAO,OAAO,OAAO,UAAU;AACjC,UAAM,IAAI,MAAM,oCAAoC;AAAA,EACtD;AACA,SAAO;AACT;AAKO,SAAS,aAAa,WAAiC;AAC5D,UAAQ,WAAA;AAAA,IACN,KAAK,qBAAqB;AACxB,aAAO;AAAA,IACT,KAAK,qBAAqB;AACxB,aAAO;AAAA,IACT,KAAK,qBAAqB;AACxB,aAAO;AAAA,EAAA;AAEb;AAKO,SAAS,wBAAwB,WAAmB;AACzD,UAAQ,WAAA;AAAA,IACN,KAAK;AACH,aAAO,qBAAqB;AAAA,IAC9B,KAAK;AACH,aAAO,qBAAqB;AAAA,IAC9B,KAAK;AACH,aAAO,qBAAqB;AAAA,IAC9B;AACE,YAAM,IAAI,MAAM,qBAAqB,SAAS,WAAW;AAAA,EAAA;AAE/D;"}
@@ -2,3 +2,4 @@ export * from './definitions.js';
2
2
  export * from './powersync.js';
3
3
  export * from './PowerSyncTransactor.js';
4
4
  export * from './sqlite-compiler.js';
5
+ export type { OptionalExtractedTable } from './helpers.js';
@@ -1,4 +1,4 @@
1
- import { DiffTriggerOperation, sanitizeSQL } from "@powersync/common";
1
+ import { LogLevels, DiffTriggerOperation, sanitizeSQL } from "@powersync/common";
2
2
  import { withCollectionConfigFactory, or } from "@tanstack/db";
3
3
  import { compileSQLite } from "./sqlite-compiler.js";
4
4
  import { PendingOperationStore } from "./PendingOperationStore.js";
@@ -7,6 +7,9 @@ import { DEFAULT_BATCH_SIZE } from "./definitions.js";
7
7
  import { asPowerSyncRecord, mapOperation } from "./helpers.js";
8
8
  import { convertTableToSchema } from "./schema.js";
9
9
  import { serializeForSQLite } from "./serialization.js";
10
+ function isPromiseLike(value) {
11
+ return !!value && (typeof value === `object` || typeof value === `function`) && typeof value.then === `function`;
12
+ }
10
13
  function powerSyncCollectionOptions(config) {
11
14
  const outputConfig = createPowerSyncCollectionConfig(config);
12
15
  return withCollectionConfigFactory(
@@ -34,12 +37,15 @@ function createPowerSyncCollectionConfig(config) {
34
37
  return validation.value;
35
38
  } else if (`issues` in validation) {
36
39
  const issueMessage = `Failed to validate incoming data for ${viewName}. Issues: ${validation.issues.map((issue) => `${issue.path} - ${issue.message}`)}`;
37
- database.logger.error(issueMessage);
40
+ database.logger.log({ level: LogLevels.error, message: issueMessage });
38
41
  onDeserializationError(validation);
39
42
  throw new Error(issueMessage);
40
43
  } else {
41
44
  const unknownErrorMessage = `Unknown deserialization error for ${viewName}`;
42
- database.logger.error(unknownErrorMessage);
45
+ database.logger.log({
46
+ level: LogLevels.error,
47
+ message: unknownErrorMessage
48
+ });
43
49
  onDeserializationError({ issues: [{ message: unknownErrorMessage }] });
44
50
  throw new Error(unknownErrorMessage);
45
51
  }
@@ -58,6 +64,8 @@ function createPowerSyncCollectionConfig(config) {
58
64
  const abortController = new AbortController();
59
65
  let disposeTracking = null;
60
66
  let trackingSetup = null;
67
+ let activeTrackingDisposal = null;
68
+ let abortTrackingDisposal = null;
61
69
  if (syncMode === `eager`) {
62
70
  return runEagerSync();
63
71
  } else {
@@ -70,10 +78,29 @@ function createPowerSyncCollectionConfig(config) {
70
78
  }
71
79
  const dispose = disposeTracking;
72
80
  if (!dispose) {
81
+ await activeTrackingDisposal;
73
82
  return;
74
83
  }
75
84
  disposeTracking = null;
76
- await dispose(context ? { context } : void 0);
85
+ let disposal;
86
+ try {
87
+ disposal = Promise.resolve(dispose(context ? { context } : void 0));
88
+ } catch (error) {
89
+ disposal = Promise.reject(error);
90
+ }
91
+ activeTrackingDisposal = disposal;
92
+ try {
93
+ await disposal;
94
+ } finally {
95
+ if (activeTrackingDisposal === disposal) {
96
+ activeTrackingDisposal = null;
97
+ }
98
+ }
99
+ }
100
+ function disposeTrackingAfterAbort() {
101
+ const disposal = abortTrackingDisposal ??= safelyDisposeTracking();
102
+ void disposal.catch(() => void 0);
103
+ return disposal;
77
104
  }
78
105
  async function establishTracking(options, appliedReceipts) {
79
106
  const setup = (async () => {
@@ -94,6 +121,7 @@ function createPowerSyncCollectionConfig(config) {
94
121
  return await database.triggers.createDiffTrigger({
95
122
  source: viewName,
96
123
  destination: trackedTableName,
124
+ columns: table.columns.map((column) => column.name),
97
125
  setupContext,
98
126
  when,
99
127
  hooks: {
@@ -117,9 +145,10 @@ function createPowerSyncCollectionConfig(config) {
117
145
  }
118
146
  appliedReceipts.push(commit());
119
147
  }
120
- database.logger.info(
121
- `Sync is ready for ${viewName} into ${trackedTableName}`
122
- );
148
+ database.logger.log({
149
+ level: LogLevels.info,
150
+ message: `Sync is ready for ${viewName} into ${trackedTableName}`
151
+ });
123
152
  }
124
153
  }
125
154
  });
@@ -136,10 +165,11 @@ function createPowerSyncCollectionConfig(config) {
136
165
  await database.writeTransaction(async (context) => {
137
166
  await flushDiffRecordsWithContext(context, ignoredReceipts);
138
167
  }).catch((error) => {
139
- database.logger.error(
140
- `An error has been detected in the sync handler`,
168
+ database.logger.log({
169
+ level: LogLevels.error,
170
+ message: `An error has been detected in the sync handler`,
141
171
  error
142
- );
172
+ });
143
173
  });
144
174
  }
145
175
  async function flushDiffRecordsWithContext(context, appliedReceipts) {
@@ -179,16 +209,18 @@ function createPowerSyncCollectionConfig(config) {
179
209
  appliedReceipts.push(applied);
180
210
  pendingOperationStore.resolvePendingFor(pendingOperations);
181
211
  } catch (error) {
182
- database.logger.error(
183
- `An error has been detected in the sync handler`,
212
+ database.logger.log({
213
+ level: LogLevels.error,
214
+ message: `An error has been detected in the sync handler`,
184
215
  error
185
- );
216
+ });
186
217
  }
187
218
  }
188
219
  async function start(afterOnChangeRegistered) {
189
- database.logger.info(
190
- `Sync is starting for ${viewName} into ${trackedTableName}`
191
- );
220
+ database.logger.log({
221
+ level: LogLevels.info,
222
+ message: `Sync is starting for ${viewName} into ${trackedTableName}`
223
+ });
192
224
  database.onChangeWithCallback(
193
225
  {
194
226
  onChange: async () => {
@@ -203,12 +235,12 @@ function createPowerSyncCollectionConfig(config) {
203
235
  );
204
236
  await afterOnChangeRegistered?.();
205
237
  if (abortController.signal.aborted) {
206
- await safelyDisposeTracking();
238
+ await disposeTrackingAfterAbort();
207
239
  } else {
208
240
  abortController.signal.addEventListener(
209
241
  `abort`,
210
- async () => {
211
- await safelyDisposeTracking();
242
+ () => {
243
+ void disposeTrackingAfterAbort();
212
244
  },
213
245
  { once: true }
214
246
  );
@@ -216,13 +248,13 @@ function createPowerSyncCollectionConfig(config) {
216
248
  }
217
249
  function runEagerSync() {
218
250
  let onUnload = null;
219
- start(async () => {
251
+ let onUnloadStarted = false;
252
+ const startup = start(async () => {
220
253
  const cleanup = await restConfig.onLoad?.();
254
+ onUnload = cleanup;
221
255
  if (abortController.signal.aborted) {
222
- cleanup?.();
223
256
  return;
224
257
  }
225
- onUnload = cleanup;
226
258
  const appliedReceipts = [];
227
259
  await establishTracking(
228
260
  {
@@ -245,38 +277,77 @@ function createPowerSyncCollectionConfig(config) {
245
277
  await Promise.all(appliedReceipts);
246
278
  markReady();
247
279
  }).catch((error) => {
248
- database.logger.error(
249
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
280
+ database.logger.log({
281
+ level: LogLevels.error,
282
+ message: `Could not start syncing process for ${viewName} into ${trackedTableName}`,
250
283
  error
251
- );
284
+ });
252
285
  if (collection.status === `loading`) {
253
286
  markError(error);
254
287
  }
255
288
  });
256
- return () => {
257
- database.logger.info(
258
- `Sync has been stopped for ${viewName} into ${trackedTableName}`
259
- );
289
+ const invokeOnUnload = () => {
290
+ if (onUnloadStarted || !onUnload) return;
291
+ onUnloadStarted = true;
292
+ const cleanup = onUnload;
293
+ onUnload = null;
294
+ return cleanup();
295
+ };
296
+ return async () => {
297
+ database.logger.log({
298
+ level: LogLevels.info,
299
+ message: `Sync has been stopped for ${viewName} into ${trackedTableName}`
300
+ });
260
301
  abortController.abort();
261
- onUnload?.();
302
+ const trackingDisposal = disposeTrackingAfterAbort();
303
+ let onUnloadFailure;
304
+ const settleOnUnload = async () => {
305
+ try {
306
+ await invokeOnUnload();
307
+ } catch (error) {
308
+ onUnloadFailure ??= { error };
309
+ }
310
+ };
311
+ const settlements = await Promise.allSettled([
312
+ startup,
313
+ trackingDisposal,
314
+ settleOnUnload()
315
+ ]);
316
+ const disposalSettlement = settlements[1];
317
+ await settleOnUnload();
318
+ if (disposalSettlement.status === `rejected` && onUnloadFailure) {
319
+ throw new AggregateError(
320
+ [disposalSettlement.reason, onUnloadFailure.error],
321
+ `PowerSync tracking disposal and load-hook cleanup both failed`,
322
+ { cause: disposalSettlement.reason }
323
+ );
324
+ }
325
+ if (disposalSettlement.status === `rejected`) {
326
+ throw disposalSettlement.reason;
327
+ }
328
+ if (onUnloadFailure) throw onUnloadFailure.error;
262
329
  };
263
330
  }
264
331
  function runOnDemandSync() {
265
332
  const demands = /* @__PURE__ */ new Map();
266
333
  const releasedSubsets = /* @__PURE__ */ new WeakSet();
267
334
  const pendingReleases = [];
335
+ const pendingDemandLoads = /* @__PURE__ */ new Set();
336
+ const pendingDemandCleanups = /* @__PURE__ */ new Set();
268
337
  let stopped = false;
269
338
  let trackingRevision = 0;
270
339
  let reconciledTrackingRevision = 0;
271
340
  let rebuildPromise = null;
272
341
  let drainingReleases = false;
273
342
  let releaseRetryTimer;
343
+ let demandCleanupCollector = null;
274
344
  const startup = start();
275
345
  void startup.catch(
276
- (error) => database.logger.error(
277
- `Could not start syncing process for ${viewName} into ${trackedTableName}`,
346
+ (error) => database.logger.log({
347
+ level: LogLevels.error,
348
+ message: `Could not start syncing process for ${viewName} into ${trackedTableName}`,
278
349
  error
279
- )
350
+ })
280
351
  );
281
352
  const activeWhereExpressions = () => Array.from(demands.values()).filter((demand) => demand.active).map((demand) => demand.options.where);
282
353
  const reconcileTracking = async () => {
@@ -291,41 +362,52 @@ function createPowerSyncCollectionConfig(config) {
291
362
  await safelyDisposeTracking(ctx);
292
363
  if (!isCurrent()) return;
293
364
  const active = activeWhereExpressions();
294
- if (active.length === 0) return;
295
- const combinedWhere = active.length === 1 ? active[0] : or(active[0], active[1], ...active.slice(2));
296
- const compiledNewData = compileSQLite(
297
- { where: combinedWhere },
298
- { jsonColumn: "NEW.data" }
299
- );
300
- const compiledOldData = compileSQLite(
301
- { where: combinedWhere },
302
- { jsonColumn: "OLD.data" }
303
- );
304
- const compiledView = compileSQLite({ where: combinedWhere });
305
- const newDataWhenClause = toInlinedWhereClause(compiledNewData);
306
- const oldDataWhenClause = toInlinedWhereClause(compiledOldData);
307
- const viewWhereClause = toInlinedWhereClause(compiledView);
308
- await establishTracking(
309
- {
310
- setupContext: ctx,
311
- when: {
312
- [DiffTriggerOperation.INSERT]: newDataWhenClause,
313
- [DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
314
- [DiffTriggerOperation.DELETE]: oldDataWhenClause
365
+ const missing = collection.status === `error` ? new Set(collection.keys()) : void 0;
366
+ if (active.length > 0) {
367
+ const combinedWhere = active.length === 1 ? active[0] : or(active[0], active[1], ...active.slice(2));
368
+ const compiledNewData = compileSQLite(
369
+ { where: combinedWhere },
370
+ { jsonColumn: "NEW.data" }
371
+ );
372
+ const compiledOldData = compileSQLite(
373
+ { where: combinedWhere },
374
+ { jsonColumn: "OLD.data" }
375
+ );
376
+ const compiledView = compileSQLite({ where: combinedWhere });
377
+ const newDataWhenClause = toInlinedWhereClause(compiledNewData);
378
+ const oldDataWhenClause = toInlinedWhereClause(compiledOldData);
379
+ const viewWhereClause = toInlinedWhereClause(compiledView);
380
+ await establishTracking(
381
+ {
382
+ setupContext: ctx,
383
+ when: {
384
+ [DiffTriggerOperation.INSERT]: newDataWhenClause,
385
+ [DiffTriggerOperation.UPDATE]: `(${newDataWhenClause}) OR (${oldDataWhenClause})`,
386
+ [DiffTriggerOperation.DELETE]: oldDataWhenClause
387
+ },
388
+ writeType: (rowId) => collection.has(rowId) ? `update` : `insert`,
389
+ batchQuery: (lockContext, batchSize, cursor) => lockContext.getAll(
390
+ `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
391
+ [batchSize, cursor]
392
+ ).then((rows) => {
393
+ for (const row of rows) missing?.delete(row.id);
394
+ return rows;
395
+ })
315
396
  },
316
- writeType: (rowId) => collection.has(rowId) ? `update` : `insert`,
317
- batchQuery: (lockContext, batchSize, cursor) => lockContext.getAll(
318
- `SELECT * FROM ${viewName} WHERE ${viewWhereClause} LIMIT ? OFFSET ?`,
319
- [batchSize, cursor]
320
- )
321
- },
322
- appliedReceipts
323
- );
397
+ appliedReceipts
398
+ );
399
+ }
324
400
  if (!isCurrent()) await safelyDisposeTracking(ctx);
401
+ else if (missing?.size) {
402
+ begin();
403
+ for (const key of missing) write({ type: `delete`, key });
404
+ appliedReceipts.push(commit());
405
+ }
325
406
  });
326
407
  await Promise.all(appliedReceipts);
327
408
  if (isCurrent()) {
328
409
  reconciledTrackingRevision = revision;
410
+ if (collection.status === `error`) markReady();
329
411
  }
330
412
  }
331
413
  };
@@ -356,6 +438,13 @@ function createPowerSyncCollectionConfig(config) {
356
438
  if (cleanup) demand.cleanup = cleanup;
357
439
  } catch (error) {
358
440
  demands.delete(options);
441
+ const collector = demandCleanupCollector;
442
+ if (collector) {
443
+ collector.failures.push({
444
+ order: collector.nextOrder++,
445
+ error
446
+ });
447
+ }
359
448
  throw error;
360
449
  }
361
450
  if (
@@ -363,14 +452,20 @@ function createPowerSyncCollectionConfig(config) {
363
452
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
364
453
  stopped || releasedSubsets.has(options) || options.signal?.aborted || demands.get(options) !== demand
365
454
  ) {
366
- demands.delete(options);
367
- demand.cleanup?.();
455
+ cleanupDemand(demand);
368
456
  return;
369
457
  }
370
458
  demand.active = true;
371
459
  trackingRevision++;
372
460
  await rebuildTracking();
373
461
  };
462
+ const trackLoadSubset = (options) => {
463
+ const task = loadSubset(options);
464
+ pendingDemandLoads.add(task);
465
+ const finish = () => pendingDemandLoads.delete(task);
466
+ void task.then(finish, finish);
467
+ return task;
468
+ };
374
469
  const toInlinedWhereClause = (compiled) => {
375
470
  if (!compiled.where) return "TRUE";
376
471
  const sqlParts = compiled.where.split("?");
@@ -379,15 +474,55 @@ function createPowerSyncCollectionConfig(config) {
379
474
  ...compiled.params
380
475
  );
381
476
  };
477
+ const reportDemandCleanupFailure = (error) => {
478
+ database.logger.log({
479
+ level: LogLevels.error,
480
+ message: `Could not clean up subset hook for ${viewName}`,
481
+ error
482
+ });
483
+ };
382
484
  const cleanupDemand = (demand) => {
383
485
  demands.delete(demand.options);
486
+ const collector = demandCleanupCollector;
487
+ const order = collector ? collector.nextOrder++ : void 0;
384
488
  try {
385
- demand.cleanup?.();
489
+ const cleanup = demand.cleanup;
490
+ demand.cleanup = void 0;
491
+ const result = cleanup?.();
492
+ if (!isPromiseLike(result)) return;
493
+ const task = Promise.resolve(result);
494
+ pendingDemandCleanups.add(task);
495
+ const finish = () => pendingDemandCleanups.delete(task);
496
+ void task.then(finish, finish);
497
+ if (collector) {
498
+ collector.tasks.push({ order, promise: task });
499
+ collector.ownedTasks.add(task);
500
+ void task.catch(() => void 0);
501
+ } else {
502
+ void task.catch((error) => {
503
+ if (!demandCleanupCollector?.ownedTasks.has(task)) {
504
+ reportDemandCleanupFailure(error);
505
+ }
506
+ });
507
+ }
386
508
  } catch (error) {
387
- database.logger.error(
388
- `Could not clean up subset hook for ${viewName}`,
389
- error
390
- );
509
+ if (collector) {
510
+ collector.failures.push({ order, error });
511
+ } else {
512
+ reportDemandCleanupFailure(error);
513
+ }
514
+ }
515
+ };
516
+ const throwDemandCleanupFailures = (collector, primaryFailures = []) => {
517
+ const errors = [
518
+ ...primaryFailures,
519
+ ...collector.failures.slice().sort((left, right) => left.order - right.order).map(({ error }) => error)
520
+ ];
521
+ if (errors.length === 1) throw errors[0];
522
+ if (errors.length > 1) {
523
+ throw new AggregateError(errors, `PowerSync cleanup failed`, {
524
+ cause: errors[0]
525
+ });
391
526
  }
392
527
  };
393
528
  const performPhysicalRelease = async (options) => {
@@ -451,10 +586,11 @@ function createPowerSyncCollectionConfig(config) {
451
586
  3e4
452
587
  );
453
588
  retryDelay = retryDelay === 0 ? delay : Math.min(retryDelay, delay);
454
- database.logger.error(
455
- `Could not release subset tracking for ${viewName}; retrying`,
589
+ database.logger.log({
590
+ level: LogLevels.error,
591
+ message: `Could not release subset tracking for ${viewName}; retrying`,
456
592
  error
457
- );
593
+ });
458
594
  }
459
595
  }
460
596
  } finally {
@@ -482,16 +618,58 @@ function createPowerSyncCollectionConfig(config) {
482
618
  stopped = true;
483
619
  clearTimeout(releaseRetryTimer);
484
620
  releaseRetryTimer = void 0;
485
- database.logger.info(
486
- `Sync has been stopped for ${viewName} into ${trackedTableName}`
487
- );
621
+ database.logger.log({
622
+ level: LogLevels.info,
623
+ message: `Sync has been stopped for ${viewName} into ${trackedTableName}`
624
+ });
488
625
  abortController.abort();
626
+ const trackingDisposal = disposeTrackingAfterAbort();
627
+ const pendingLoads = [...pendingDemandLoads];
628
+ const pendingTasks = [...pendingDemandCleanups];
629
+ const collector = {
630
+ tasks: pendingTasks.map((promise, order) => ({ order, promise })),
631
+ ownedTasks: new Set(pendingTasks),
632
+ failures: [],
633
+ nextOrder: pendingTasks.length
634
+ };
635
+ demandCleanupCollector = collector;
489
636
  for (const demand of demands.values()) {
490
637
  cleanupDemand(demand);
491
638
  }
492
639
  pendingReleases.length = 0;
640
+ return (async () => {
641
+ const ownershipSettlements = await Promise.allSettled([
642
+ trackingDisposal,
643
+ ...pendingLoads
644
+ ]);
645
+ const disposalSettlement = ownershipSettlements[0];
646
+ let settledTasks = 0;
647
+ while (settledTasks < collector.tasks.length) {
648
+ const tasks = collector.tasks.slice(settledTasks);
649
+ settledTasks = collector.tasks.length;
650
+ const outcomes = await Promise.allSettled(
651
+ tasks.map(({ promise }) => promise)
652
+ );
653
+ for (const [index, outcome] of outcomes.entries()) {
654
+ if (outcome.status === `rejected`) {
655
+ collector.failures.push({
656
+ order: tasks[index].order,
657
+ error: outcome.reason
658
+ });
659
+ }
660
+ }
661
+ }
662
+ throwDemandCleanupFailures(
663
+ collector,
664
+ disposalSettlement.status === `rejected` ? [disposalSettlement.reason] : []
665
+ );
666
+ })().finally(() => {
667
+ if (demandCleanupCollector === collector) {
668
+ demandCleanupCollector = null;
669
+ }
670
+ });
493
671
  },
494
- loadSubset: (options) => loadSubset(options),
672
+ loadSubset: trackLoadSubset,
495
673
  unloadSubset
496
674
  };
497
675
  }
@@ -522,13 +700,7 @@ function createPowerSyncCollectionConfig(config) {
522
700
  tableName: viewName,
523
701
  trackedTableName,
524
702
  metadataIsTracked,
525
- serializeValue: (value) => serializeForSQLite(
526
- value,
527
- // This is required by the input generic
528
- table,
529
- // Coerce serializer to the shape that corresponds to the Table constructed from OutputType
530
- serializer
531
- )
703
+ serializeValue: (value) => serializeForSQLite(value, table, serializer)
532
704
  })
533
705
  }
534
706
  };