@tanstack/db 0.4.5 → 0.4.6

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 (70) hide show
  1. package/dist/cjs/collection/change-events.cjs +1 -1
  2. package/dist/cjs/collection/change-events.cjs.map +1 -1
  3. package/dist/cjs/collection/change-events.d.cts +1 -1
  4. package/dist/cjs/collection/index.cjs +11 -0
  5. package/dist/cjs/collection/index.cjs.map +1 -1
  6. package/dist/cjs/collection/index.d.cts +8 -1
  7. package/dist/cjs/collection/lifecycle.cjs +4 -1
  8. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  9. package/dist/cjs/collection/subscription.cjs +21 -1
  10. package/dist/cjs/collection/subscription.cjs.map +1 -1
  11. package/dist/cjs/collection/subscription.d.cts +4 -3
  12. package/dist/cjs/collection/sync.cjs +94 -71
  13. package/dist/cjs/collection/sync.cjs.map +1 -1
  14. package/dist/cjs/collection/sync.d.cts +9 -1
  15. package/dist/cjs/index.cjs +2 -0
  16. package/dist/cjs/index.cjs.map +1 -1
  17. package/dist/cjs/index.d.cts +2 -0
  18. package/dist/cjs/indexes/auto-index.cjs +4 -1
  19. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  20. package/dist/cjs/query/compiler/expressions.cjs +19 -0
  21. package/dist/cjs/query/compiler/expressions.cjs.map +1 -1
  22. package/dist/cjs/query/compiler/expressions.d.cts +2 -1
  23. package/dist/cjs/query/compiler/order-by.cjs +2 -1
  24. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  25. package/dist/cjs/query/compiler/order-by.d.cts +2 -1
  26. package/dist/cjs/query/live/collection-subscriber.cjs +18 -8
  27. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  28. package/dist/cjs/query/live/collection-subscriber.d.cts +1 -0
  29. package/dist/cjs/types.d.cts +11 -1
  30. package/dist/esm/collection/change-events.d.ts +1 -1
  31. package/dist/esm/collection/change-events.js +1 -1
  32. package/dist/esm/collection/change-events.js.map +1 -1
  33. package/dist/esm/collection/index.d.ts +8 -1
  34. package/dist/esm/collection/index.js +11 -0
  35. package/dist/esm/collection/index.js.map +1 -1
  36. package/dist/esm/collection/lifecycle.js +4 -1
  37. package/dist/esm/collection/lifecycle.js.map +1 -1
  38. package/dist/esm/collection/subscription.d.ts +4 -3
  39. package/dist/esm/collection/subscription.js +22 -2
  40. package/dist/esm/collection/subscription.js.map +1 -1
  41. package/dist/esm/collection/sync.d.ts +9 -1
  42. package/dist/esm/collection/sync.js +94 -71
  43. package/dist/esm/collection/sync.js.map +1 -1
  44. package/dist/esm/index.d.ts +2 -0
  45. package/dist/esm/index.js +2 -0
  46. package/dist/esm/index.js.map +1 -1
  47. package/dist/esm/indexes/auto-index.js +4 -1
  48. package/dist/esm/indexes/auto-index.js.map +1 -1
  49. package/dist/esm/query/compiler/expressions.d.ts +2 -1
  50. package/dist/esm/query/compiler/expressions.js +19 -0
  51. package/dist/esm/query/compiler/expressions.js.map +1 -1
  52. package/dist/esm/query/compiler/order-by.d.ts +2 -1
  53. package/dist/esm/query/compiler/order-by.js +2 -1
  54. package/dist/esm/query/compiler/order-by.js.map +1 -1
  55. package/dist/esm/query/live/collection-subscriber.d.ts +1 -0
  56. package/dist/esm/query/live/collection-subscriber.js +19 -9
  57. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  58. package/dist/esm/types.d.ts +11 -1
  59. package/package.json +1 -1
  60. package/src/collection/change-events.ts +5 -2
  61. package/src/collection/index.ts +13 -0
  62. package/src/collection/lifecycle.ts +4 -1
  63. package/src/collection/subscription.ts +34 -4
  64. package/src/collection/sync.ts +147 -110
  65. package/src/index.ts +5 -0
  66. package/src/indexes/auto-index.ts +4 -1
  67. package/src/query/compiler/expressions.ts +26 -1
  68. package/src/query/compiler/order-by.ts +3 -1
  69. package/src/query/live/collection-subscriber.ts +31 -10
  70. package/src/types.ts +13 -1
@@ -1,5 +1,5 @@
1
1
  import { MultiSet } from "@tanstack/db-ivm";
2
- import { convertToBasicExpression } from "../compiler/expressions.js";
2
+ import { convertToBasicExpression, convertOrderByToBasicExpression } from "../compiler/expressions.js";
3
3
  class CollectionSubscriber {
4
4
  constructor(collectionId, collection, config, syncState, collectionConfigBuilder) {
5
5
  this.collectionId = collectionId;
@@ -8,17 +8,17 @@ class CollectionSubscriber {
8
8
  this.syncState = syncState;
9
9
  this.collectionConfigBuilder = collectionConfigBuilder;
10
10
  this.biggest = void 0;
11
- }
12
- subscribe() {
13
- const collectionAlias = findCollectionAlias(
11
+ this.collectionAlias = findCollectionAlias(
14
12
  this.collectionId,
15
13
  this.collectionConfigBuilder.query
16
14
  );
17
- const whereClause = this.getWhereClauseFromAlias(collectionAlias);
15
+ }
16
+ subscribe() {
17
+ const whereClause = this.getWhereClauseFromAlias(this.collectionAlias);
18
18
  if (whereClause) {
19
19
  const whereExpression = convertToBasicExpression(
20
20
  whereClause,
21
- collectionAlias
21
+ this.collectionAlias
22
22
  );
23
23
  if (whereExpression) {
24
24
  return this.subscribeToChanges(whereExpression);
@@ -76,7 +76,7 @@ class CollectionSubscriber {
76
76
  return subscription;
77
77
  }
78
78
  subscribeToOrderedChanges(whereExpression) {
79
- const { offset, limit, comparator, dataNeeded, index } = this.collectionConfigBuilder.optimizableOrderByCollections[this.collectionId];
79
+ const { orderBy, offset, limit, comparator, dataNeeded, index } = this.collectionConfigBuilder.optimizableOrderByCollections[this.collectionId];
80
80
  const sendChangesInRange = (changes) => {
81
81
  const splittedChanges = splitUpdates(changes);
82
82
  let filteredChanges = splittedChanges;
@@ -93,8 +93,13 @@ class CollectionSubscriber {
93
93
  whereExpression
94
94
  });
95
95
  subscription.setOrderByIndex(index);
96
+ const normalizedOrderBy = convertOrderByToBasicExpression(
97
+ orderBy,
98
+ this.collectionAlias
99
+ );
96
100
  subscription.requestLimitedSnapshot({
97
- limit: offset + limit
101
+ limit: offset + limit,
102
+ orderBy: normalizedOrderBy
98
103
  });
99
104
  return subscription;
100
105
  }
@@ -129,10 +134,15 @@ class CollectionSubscriber {
129
134
  // Loads the next `n` items from the collection
130
135
  // starting from the biggest item it has sent
131
136
  loadNextItems(n, subscription) {
132
- const { valueExtractorForRawRow } = this.collectionConfigBuilder.optimizableOrderByCollections[this.collectionId];
137
+ const { orderBy, valueExtractorForRawRow } = this.collectionConfigBuilder.optimizableOrderByCollections[this.collectionId];
133
138
  const biggestSentRow = this.biggest;
134
139
  const biggestSentValue = biggestSentRow ? valueExtractorForRawRow(biggestSentRow) : biggestSentRow;
140
+ const normalizedOrderBy = convertOrderByToBasicExpression(
141
+ orderBy,
142
+ this.collectionAlias
143
+ );
135
144
  subscription.requestLimitedSnapshot({
145
+ orderBy: normalizedOrderBy,
136
146
  limit: n,
137
147
  minValue: biggestSentValue
138
148
  });
@@ -1 +1 @@
1
- {"version":3,"file":"collection-subscriber.js","sources":["../../../../src/query/live/collection-subscriber.ts"],"sourcesContent":["import { MultiSet } from \"@tanstack/db-ivm\"\nimport { convertToBasicExpression } from \"../compiler/expressions.js\"\nimport type { FullSyncState } from \"./types.js\"\nimport type { MultiSetArray, RootStreamBuilder } from \"@tanstack/db-ivm\"\nimport type { Collection } from \"../../collection/index.js\"\nimport type { ChangeMessage, SyncConfig } from \"../../types.js\"\nimport type { Context, GetResult } from \"../builder/types.js\"\nimport type { BasicExpression } from \"../ir.js\"\nimport type { CollectionConfigBuilder } from \"./collection-config-builder.js\"\nimport type { CollectionSubscription } from \"../../collection/subscription.js\"\n\nexport class CollectionSubscriber<\n TContext extends Context,\n TResult extends object = GetResult<TContext>,\n> {\n // Keep track of the biggest value we've sent so far (needed for orderBy optimization)\n private biggest: any = undefined\n\n constructor(\n private collectionId: string,\n private collection: Collection,\n private config: Parameters<SyncConfig<TResult>[`sync`]>[0],\n private syncState: FullSyncState,\n private collectionConfigBuilder: CollectionConfigBuilder<TContext, TResult>\n ) {}\n\n subscribe(): CollectionSubscription {\n const collectionAlias = findCollectionAlias(\n this.collectionId,\n this.collectionConfigBuilder.query\n )\n const whereClause = this.getWhereClauseFromAlias(collectionAlias)\n\n if (whereClause) {\n // Convert WHERE clause to BasicExpression format for collection subscription\n const whereExpression = convertToBasicExpression(\n whereClause,\n collectionAlias!\n )\n\n if (whereExpression) {\n // Use index optimization for this collection\n return this.subscribeToChanges(whereExpression)\n } else {\n // This should not happen - if we have a whereClause but can't create whereExpression,\n // it indicates a bug in our optimization logic\n throw new Error(\n `Failed to convert WHERE clause to collection filter for collection '${this.collectionId}'. ` +\n `This indicates a bug in the query optimization logic.`\n )\n }\n } else {\n // No WHERE clause for this collection, use regular subscription\n return this.subscribeToChanges()\n }\n }\n\n private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {\n let subscription: CollectionSubscription\n if (\n Object.hasOwn(\n this.collectionConfigBuilder.optimizableOrderByCollections,\n this.collectionId\n )\n ) {\n subscription = this.subscribeToOrderedChanges(whereExpression)\n } else {\n // If the collection is lazy then we should not include the initial state\n const includeInitialState =\n !this.collectionConfigBuilder.lazyCollections.has(this.collectionId)\n\n subscription = this.subscribeToMatchingChanges(\n whereExpression,\n includeInitialState\n )\n }\n const unsubscribe = () => {\n subscription.unsubscribe()\n }\n this.syncState.unsubscribeCallbacks.add(unsubscribe)\n return subscription\n }\n\n private sendChangesToPipeline(\n changes: Iterable<ChangeMessage<any, string | number>>,\n callback?: () => boolean\n ) {\n const input = this.syncState.inputs[this.collectionId]!\n const sentChanges = sendChangesToInput(\n input,\n changes,\n this.collection.config.getKey\n )\n\n // Do not provide the callback that loads more data\n // if there's no more data to load\n // otherwise we end up in an infinite loop trying to load more data\n const dataLoader = sentChanges > 0 ? callback : undefined\n\n // We need to call `maybeRunGraph` even if there's no data to load\n // because we need to mark the collection as ready if it's not already\n // and that's only done in `maybeRunGraph`\n this.collectionConfigBuilder.maybeRunGraph(\n this.config,\n this.syncState,\n dataLoader\n )\n }\n\n private subscribeToMatchingChanges(\n whereExpression: BasicExpression<boolean> | undefined,\n includeInitialState: boolean = false\n ) {\n const sendChanges = (\n changes: Array<ChangeMessage<any, string | number>>\n ) => {\n this.sendChangesToPipeline(changes)\n }\n\n const subscription = this.collection.subscribeChanges(sendChanges, {\n includeInitialState,\n whereExpression,\n })\n\n return subscription\n }\n\n private subscribeToOrderedChanges(\n whereExpression: BasicExpression<boolean> | undefined\n ) {\n const { offset, limit, comparator, dataNeeded, index } =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]!\n\n const sendChangesInRange = (\n changes: Iterable<ChangeMessage<any, string | number>>\n ) => {\n // Split live updates into a delete of the old value and an insert of the new value\n // and filter out changes that are bigger than the biggest value we've sent so far\n // because they can't affect the topK (and if later we need more data, we will dynamically load more data)\n const splittedChanges = splitUpdates(changes)\n let filteredChanges = splittedChanges\n if (dataNeeded!() === 0) {\n // If the topK is full [..., maxSentValue] then we do not need to send changes > maxSentValue\n // because they can never make it into the topK.\n // However, if the topK isn't full yet, we need to also send changes > maxSentValue\n // because they will make it into the topK\n filteredChanges = filterChangesSmallerOrEqualToMax(\n splittedChanges,\n comparator,\n this.biggest\n )\n }\n\n this.sendChangesToPipelineWithTracking(filteredChanges, subscription)\n }\n\n // Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far\n // values that are bigger don't need to be sent because they can't affect the topK\n const subscription = this.collection.subscribeChanges(sendChangesInRange, {\n whereExpression,\n })\n\n subscription.setOrderByIndex(index)\n\n // Load the first `offset + limit` values from the index\n // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[\n subscription.requestLimitedSnapshot({\n limit: offset + limit,\n })\n\n return subscription\n }\n\n // This function is called by maybeRunGraph\n // after each iteration of the query pipeline\n // to ensure that the orderBy operator has enough data to work with\n loadMoreIfNeeded(subscription: CollectionSubscription) {\n const orderByInfo =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]\n\n if (!orderByInfo) {\n // This query has no orderBy operator\n // so there's no data to load\n return true\n }\n\n const { dataNeeded } = orderByInfo\n\n if (!dataNeeded) {\n // This should never happen because the topK operator should always set the size callback\n // which in turn should lead to the orderBy operator setting the dataNeeded callback\n throw new Error(\n `Missing dataNeeded callback for collection ${this.collectionId}`\n )\n }\n\n // `dataNeeded` probes the orderBy operator to see if it needs more data\n // if it needs more data, it returns the number of items it needs\n const n = dataNeeded()\n if (n > 0) {\n this.loadNextItems(n, subscription)\n }\n return true\n }\n\n private sendChangesToPipelineWithTracking(\n changes: Iterable<ChangeMessage<any, string | number>>,\n subscription: CollectionSubscription\n ) {\n const { comparator } =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]!\n const trackedChanges = this.trackSentValues(changes, comparator)\n this.sendChangesToPipeline(\n trackedChanges,\n this.loadMoreIfNeeded.bind(this, subscription)\n )\n }\n\n // Loads the next `n` items from the collection\n // starting from the biggest item it has sent\n private loadNextItems(n: number, subscription: CollectionSubscription) {\n const { valueExtractorForRawRow } =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]!\n const biggestSentRow = this.biggest\n const biggestSentValue = biggestSentRow\n ? valueExtractorForRawRow(biggestSentRow)\n : biggestSentRow\n // Take the `n` items after the biggest sent value\n subscription.requestLimitedSnapshot({\n limit: n,\n minValue: biggestSentValue,\n })\n }\n\n private getWhereClauseFromAlias(\n collectionAlias: string | undefined\n ): BasicExpression<boolean> | undefined {\n const collectionWhereClausesCache =\n this.collectionConfigBuilder.collectionWhereClausesCache\n if (collectionAlias && collectionWhereClausesCache) {\n return collectionWhereClausesCache.get(collectionAlias)\n }\n return undefined\n }\n\n private *trackSentValues(\n changes: Iterable<ChangeMessage<any, string | number>>,\n comparator: (a: any, b: any) => number\n ) {\n for (const change of changes) {\n if (!this.biggest) {\n this.biggest = change.value\n } else if (comparator(this.biggest, change.value) < 0) {\n this.biggest = change.value\n }\n\n yield change\n }\n }\n}\n\n/**\n * Finds the alias for a collection ID in the query\n */\nfunction findCollectionAlias(\n collectionId: string,\n query: any\n): string | undefined {\n // Check FROM clause\n if (\n query.from?.type === `collectionRef` &&\n query.from.collection?.id === collectionId\n ) {\n return query.from.alias\n }\n\n // Check JOIN clauses\n if (query.join) {\n for (const joinClause of query.join) {\n if (\n joinClause.from?.type === `collectionRef` &&\n joinClause.from.collection?.id === collectionId\n ) {\n return joinClause.from.alias\n }\n }\n }\n\n return undefined\n}\n\n/**\n * Helper function to send changes to a D2 input stream\n */\nfunction sendChangesToInput(\n input: RootStreamBuilder<unknown>,\n changes: Iterable<ChangeMessage>,\n getKey: (item: ChangeMessage[`value`]) => any\n): number {\n const multiSetArray: MultiSetArray<unknown> = []\n for (const change of changes) {\n const key = getKey(change.value)\n if (change.type === `insert`) {\n multiSetArray.push([[key, change.value], 1])\n } else if (change.type === `update`) {\n multiSetArray.push([[key, change.previousValue], -1])\n multiSetArray.push([[key, change.value], 1])\n } else {\n // change.type === `delete`\n multiSetArray.push([[key, change.value], -1])\n }\n }\n\n if (multiSetArray.length !== 0) {\n input.sendData(new MultiSet(multiSetArray))\n }\n\n return multiSetArray.length\n}\n\n/** Splits updates into a delete of the old value and an insert of the new value */\nfunction* splitUpdates<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n>(\n changes: Iterable<ChangeMessage<T, TKey>>\n): Generator<ChangeMessage<T, TKey>> {\n for (const change of changes) {\n if (change.type === `update`) {\n yield { type: `delete`, key: change.key, value: change.previousValue! }\n yield { type: `insert`, key: change.key, value: change.value }\n } else {\n yield change\n }\n }\n}\n\nfunction* filterChanges<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n>(\n changes: Iterable<ChangeMessage<T, TKey>>,\n f: (change: ChangeMessage<T, TKey>) => boolean\n): Generator<ChangeMessage<T, TKey>> {\n for (const change of changes) {\n if (f(change)) {\n yield change\n }\n }\n}\n\n/**\n * Filters changes to only include those that are smaller or equal to the provided max value\n * @param changes - Iterable of changes to filter\n * @param comparator - Comparator function to use for filtering\n * @param maxValue - Range to filter changes within (range boundaries are exclusive)\n * @returns Iterable of changes that fall within the range\n */\nfunction* filterChangesSmallerOrEqualToMax<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n>(\n changes: Iterable<ChangeMessage<T, TKey>>,\n comparator: (a: any, b: any) => number,\n maxValue: any\n): Generator<ChangeMessage<T, TKey>> {\n yield* filterChanges(changes, (change) => {\n return !maxValue || comparator(change.value, maxValue) <= 0\n })\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"collection-subscriber.js","sources":["../../../../src/query/live/collection-subscriber.ts"],"sourcesContent":["import { MultiSet } from \"@tanstack/db-ivm\"\nimport {\n convertOrderByToBasicExpression,\n convertToBasicExpression,\n} from \"../compiler/expressions.js\"\nimport type { FullSyncState } from \"./types.js\"\nimport type { MultiSetArray, RootStreamBuilder } from \"@tanstack/db-ivm\"\nimport type { Collection } from \"../../collection/index.js\"\nimport type { ChangeMessage, SyncConfig } from \"../../types.js\"\nimport type { Context, GetResult } from \"../builder/types.js\"\nimport type { BasicExpression } from \"../ir.js\"\nimport type { CollectionConfigBuilder } from \"./collection-config-builder.js\"\nimport type { CollectionSubscription } from \"../../collection/subscription.js\"\n\nexport class CollectionSubscriber<\n TContext extends Context,\n TResult extends object = GetResult<TContext>,\n> {\n // Keep track of the biggest value we've sent so far (needed for orderBy optimization)\n private biggest: any = undefined\n\n private collectionAlias: string\n\n constructor(\n private collectionId: string,\n private collection: Collection,\n private config: Parameters<SyncConfig<TResult>[`sync`]>[0],\n private syncState: FullSyncState,\n private collectionConfigBuilder: CollectionConfigBuilder<TContext, TResult>\n ) {\n this.collectionAlias = findCollectionAlias(\n this.collectionId,\n this.collectionConfigBuilder.query\n )!\n }\n\n subscribe(): CollectionSubscription {\n const whereClause = this.getWhereClauseFromAlias(this.collectionAlias)\n\n if (whereClause) {\n // Convert WHERE clause to BasicExpression format for collection subscription\n const whereExpression = convertToBasicExpression(\n whereClause,\n this.collectionAlias\n )\n\n if (whereExpression) {\n // Use index optimization for this collection\n return this.subscribeToChanges(whereExpression)\n } else {\n // This should not happen - if we have a whereClause but can't create whereExpression,\n // it indicates a bug in our optimization logic\n throw new Error(\n `Failed to convert WHERE clause to collection filter for collection '${this.collectionId}'. ` +\n `This indicates a bug in the query optimization logic.`\n )\n }\n } else {\n // No WHERE clause for this collection, use regular subscription\n return this.subscribeToChanges()\n }\n }\n\n private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {\n let subscription: CollectionSubscription\n if (\n Object.hasOwn(\n this.collectionConfigBuilder.optimizableOrderByCollections,\n this.collectionId\n )\n ) {\n subscription = this.subscribeToOrderedChanges(whereExpression)\n } else {\n // If the collection is lazy then we should not include the initial state\n const includeInitialState =\n !this.collectionConfigBuilder.lazyCollections.has(this.collectionId)\n\n subscription = this.subscribeToMatchingChanges(\n whereExpression,\n includeInitialState\n )\n }\n const unsubscribe = () => {\n subscription.unsubscribe()\n }\n this.syncState.unsubscribeCallbacks.add(unsubscribe)\n return subscription\n }\n\n private sendChangesToPipeline(\n changes: Iterable<ChangeMessage<any, string | number>>,\n callback?: () => boolean\n ) {\n const input = this.syncState.inputs[this.collectionId]!\n const sentChanges = sendChangesToInput(\n input,\n changes,\n this.collection.config.getKey\n )\n\n // Do not provide the callback that loads more data\n // if there's no more data to load\n // otherwise we end up in an infinite loop trying to load more data\n const dataLoader = sentChanges > 0 ? callback : undefined\n\n // We need to call `maybeRunGraph` even if there's no data to load\n // because we need to mark the collection as ready if it's not already\n // and that's only done in `maybeRunGraph`\n this.collectionConfigBuilder.maybeRunGraph(\n this.config,\n this.syncState,\n dataLoader\n )\n }\n\n private subscribeToMatchingChanges(\n whereExpression: BasicExpression<boolean> | undefined,\n includeInitialState: boolean = false\n ) {\n const sendChanges = (\n changes: Array<ChangeMessage<any, string | number>>\n ) => {\n this.sendChangesToPipeline(changes)\n }\n\n const subscription = this.collection.subscribeChanges(sendChanges, {\n includeInitialState,\n whereExpression,\n })\n\n return subscription\n }\n\n private subscribeToOrderedChanges(\n whereExpression: BasicExpression<boolean> | undefined\n ) {\n const { orderBy, offset, limit, comparator, dataNeeded, index } =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]!\n\n const sendChangesInRange = (\n changes: Iterable<ChangeMessage<any, string | number>>\n ) => {\n // Split live updates into a delete of the old value and an insert of the new value\n // and filter out changes that are bigger than the biggest value we've sent so far\n // because they can't affect the topK (and if later we need more data, we will dynamically load more data)\n const splittedChanges = splitUpdates(changes)\n let filteredChanges = splittedChanges\n if (dataNeeded!() === 0) {\n // If the topK is full [..., maxSentValue] then we do not need to send changes > maxSentValue\n // because they can never make it into the topK.\n // However, if the topK isn't full yet, we need to also send changes > maxSentValue\n // because they will make it into the topK\n filteredChanges = filterChangesSmallerOrEqualToMax(\n splittedChanges,\n comparator,\n this.biggest\n )\n }\n\n this.sendChangesToPipelineWithTracking(filteredChanges, subscription)\n }\n\n // Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far\n // values that are bigger don't need to be sent because they can't affect the topK\n const subscription = this.collection.subscribeChanges(sendChangesInRange, {\n whereExpression,\n })\n\n subscription.setOrderByIndex(index)\n\n // Normalize the orderBy clauses such that the references are relative to the collection\n const normalizedOrderBy = convertOrderByToBasicExpression(\n orderBy,\n this.collectionAlias\n )\n\n // Load the first `offset + limit` values from the index\n // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[\n subscription.requestLimitedSnapshot({\n limit: offset + limit,\n orderBy: normalizedOrderBy,\n })\n\n return subscription\n }\n\n // This function is called by maybeRunGraph\n // after each iteration of the query pipeline\n // to ensure that the orderBy operator has enough data to work with\n loadMoreIfNeeded(subscription: CollectionSubscription) {\n const orderByInfo =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]\n\n if (!orderByInfo) {\n // This query has no orderBy operator\n // so there's no data to load\n return true\n }\n\n const { dataNeeded } = orderByInfo\n\n if (!dataNeeded) {\n // This should never happen because the topK operator should always set the size callback\n // which in turn should lead to the orderBy operator setting the dataNeeded callback\n throw new Error(\n `Missing dataNeeded callback for collection ${this.collectionId}`\n )\n }\n\n // `dataNeeded` probes the orderBy operator to see if it needs more data\n // if it needs more data, it returns the number of items it needs\n const n = dataNeeded()\n if (n > 0) {\n this.loadNextItems(n, subscription)\n }\n return true\n }\n\n private sendChangesToPipelineWithTracking(\n changes: Iterable<ChangeMessage<any, string | number>>,\n subscription: CollectionSubscription\n ) {\n const { comparator } =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]!\n const trackedChanges = this.trackSentValues(changes, comparator)\n this.sendChangesToPipeline(\n trackedChanges,\n this.loadMoreIfNeeded.bind(this, subscription)\n )\n }\n\n // Loads the next `n` items from the collection\n // starting from the biggest item it has sent\n private loadNextItems(n: number, subscription: CollectionSubscription) {\n const { orderBy, valueExtractorForRawRow } =\n this.collectionConfigBuilder.optimizableOrderByCollections[\n this.collectionId\n ]!\n const biggestSentRow = this.biggest\n const biggestSentValue = biggestSentRow\n ? valueExtractorForRawRow(biggestSentRow)\n : biggestSentRow\n\n // Normalize the orderBy clauses such that the references are relative to the collection\n const normalizedOrderBy = convertOrderByToBasicExpression(\n orderBy,\n this.collectionAlias\n )\n\n // Take the `n` items after the biggest sent value\n subscription.requestLimitedSnapshot({\n orderBy: normalizedOrderBy,\n limit: n,\n minValue: biggestSentValue,\n })\n }\n\n private getWhereClauseFromAlias(\n collectionAlias: string | undefined\n ): BasicExpression<boolean> | undefined {\n const collectionWhereClausesCache =\n this.collectionConfigBuilder.collectionWhereClausesCache\n if (collectionAlias && collectionWhereClausesCache) {\n return collectionWhereClausesCache.get(collectionAlias)\n }\n return undefined\n }\n\n private *trackSentValues(\n changes: Iterable<ChangeMessage<any, string | number>>,\n comparator: (a: any, b: any) => number\n ) {\n for (const change of changes) {\n if (!this.biggest) {\n this.biggest = change.value\n } else if (comparator(this.biggest, change.value) < 0) {\n this.biggest = change.value\n }\n\n yield change\n }\n }\n}\n\n/**\n * Finds the alias for a collection ID in the query\n */\nfunction findCollectionAlias(\n collectionId: string,\n query: any\n): string | undefined {\n // Check FROM clause\n if (\n query.from?.type === `collectionRef` &&\n query.from.collection?.id === collectionId\n ) {\n return query.from.alias\n }\n\n // Check JOIN clauses\n if (query.join) {\n for (const joinClause of query.join) {\n if (\n joinClause.from?.type === `collectionRef` &&\n joinClause.from.collection?.id === collectionId\n ) {\n return joinClause.from.alias\n }\n }\n }\n\n return undefined\n}\n\n/**\n * Helper function to send changes to a D2 input stream\n */\nfunction sendChangesToInput(\n input: RootStreamBuilder<unknown>,\n changes: Iterable<ChangeMessage>,\n getKey: (item: ChangeMessage[`value`]) => any\n): number {\n const multiSetArray: MultiSetArray<unknown> = []\n for (const change of changes) {\n const key = getKey(change.value)\n if (change.type === `insert`) {\n multiSetArray.push([[key, change.value], 1])\n } else if (change.type === `update`) {\n multiSetArray.push([[key, change.previousValue], -1])\n multiSetArray.push([[key, change.value], 1])\n } else {\n // change.type === `delete`\n multiSetArray.push([[key, change.value], -1])\n }\n }\n\n if (multiSetArray.length !== 0) {\n input.sendData(new MultiSet(multiSetArray))\n }\n\n return multiSetArray.length\n}\n\n/** Splits updates into a delete of the old value and an insert of the new value */\nfunction* splitUpdates<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n>(\n changes: Iterable<ChangeMessage<T, TKey>>\n): Generator<ChangeMessage<T, TKey>> {\n for (const change of changes) {\n if (change.type === `update`) {\n yield { type: `delete`, key: change.key, value: change.previousValue! }\n yield { type: `insert`, key: change.key, value: change.value }\n } else {\n yield change\n }\n }\n}\n\nfunction* filterChanges<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n>(\n changes: Iterable<ChangeMessage<T, TKey>>,\n f: (change: ChangeMessage<T, TKey>) => boolean\n): Generator<ChangeMessage<T, TKey>> {\n for (const change of changes) {\n if (f(change)) {\n yield change\n }\n }\n}\n\n/**\n * Filters changes to only include those that are smaller or equal to the provided max value\n * @param changes - Iterable of changes to filter\n * @param comparator - Comparator function to use for filtering\n * @param maxValue - Range to filter changes within (range boundaries are exclusive)\n * @returns Iterable of changes that fall within the range\n */\nfunction* filterChangesSmallerOrEqualToMax<\n T extends object = Record<string, unknown>,\n TKey extends string | number = string | number,\n>(\n changes: Iterable<ChangeMessage<T, TKey>>,\n comparator: (a: any, b: any) => number,\n maxValue: any\n): Generator<ChangeMessage<T, TKey>> {\n yield* filterChanges(changes, (change) => {\n return !maxValue || comparator(change.value, maxValue) <= 0\n })\n}\n"],"names":[],"mappings":"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"}
@@ -92,6 +92,16 @@ type Value<TExtensions = never> = string | number | boolean | bigint | null | TE
92
92
  };
93
93
  export type Row<TExtensions = never> = Record<string, Value<TExtensions>>;
94
94
  export type OperationType = `insert` | `update` | `delete`;
95
+ export type OnLoadMoreOptions = {
96
+ where?: BasicExpression<boolean>;
97
+ orderBy?: OrderBy;
98
+ limit?: number;
99
+ };
100
+ export type CleanupFn = () => void;
101
+ export type SyncConfigRes = {
102
+ cleanup?: CleanupFn;
103
+ onLoadMore?: (options: OnLoadMoreOptions) => void | Promise<void>;
104
+ };
95
105
  export interface SyncConfig<T extends object = Record<string, unknown>, TKey extends string | number = string | number> {
96
106
  sync: (params: {
97
107
  collection: Collection<T, TKey, any, any, any>;
@@ -100,7 +110,7 @@ export interface SyncConfig<T extends object = Record<string, unknown>, TKey ext
100
110
  commit: () => void;
101
111
  markReady: () => void;
102
112
  truncate: () => void;
103
- }) => void;
113
+ }) => void | CleanupFn | SyncConfigRes;
104
114
  /**
105
115
  * Get the sync metadata for insert operations
106
116
  * @returns Record containing relation information
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
3
  "description": "A reactive client store for building super fast apps on sync",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "dependencies": {
6
6
  "@standard-schema/spec": "^1.0.0",
7
7
  "@tanstack/db-ivm": "0.1.9"
@@ -20,7 +20,10 @@ import type { BasicExpression } from "../query/ir.js"
20
20
  export interface CollectionLike<
21
21
  T extends object = Record<string, unknown>,
22
22
  TKey extends string | number = string | number,
23
- > extends Pick<Collection<T, TKey>, `get` | `has` | `entries` | `indexes`> {}
23
+ > extends Pick<
24
+ Collection<T, TKey>,
25
+ `get` | `has` | `entries` | `indexes` | `id`
26
+ > {}
24
27
 
25
28
  /**
26
29
  * Returns the current state of the collection as an array of changes
@@ -109,7 +112,7 @@ export function currentStateAsChanges<
109
112
  } catch (error) {
110
113
  // If anything goes wrong with the where clause, fall back to full scan
111
114
  console.warn(
112
- `Error processing where clause, falling back to full scan:`,
115
+ `${collection.id ? `[${collection.id}] ` : ``}Error processing where clause, falling back to full scan:`,
113
116
  error
114
117
  )
115
118
 
@@ -25,6 +25,7 @@ import type {
25
25
  InferSchemaOutput,
26
26
  InsertConfig,
27
27
  NonSingleResult,
28
+ OnLoadMoreOptions,
28
29
  OperationConfig,
29
30
  SingleResult,
30
31
  SubscribeChangesOptions,
@@ -362,6 +363,18 @@ export class CollectionImpl<
362
363
  this._sync.startSync()
363
364
  }
364
365
 
366
+ /**
367
+ * Requests the sync layer to load more data.
368
+ * @param options Options to control what data is being loaded
369
+ * @returns If data loading is asynchronous, this method returns a promise that resolves when the data is loaded.
370
+ * If data loading is synchronous, the data is loaded when the method returns.
371
+ */
372
+ public syncMore(options: OnLoadMoreOptions): void | Promise<void> {
373
+ if (this._sync.syncOnLoadMoreFn) {
374
+ return this._sync.syncOnLoadMoreFn(options)
375
+ }
376
+ }
377
+
365
378
  /**
366
379
  * Preload the collection data by starting sync if not already started
367
380
  * Multiple concurrent calls will share the same promise
@@ -111,7 +111,10 @@ export class CollectionLifecycleManager<
111
111
  if (newStatus === `ready` && !this.indexes.isIndexesResolved) {
112
112
  // Resolve indexes asynchronously without blocking
113
113
  this.indexes.resolveAllIndexes().catch((error) => {
114
- console.warn(`Failed to resolve indexes:`, error)
114
+ console.warn(
115
+ `${this.config.id ? `[${this.config.id}] ` : ``}Failed to resolve indexes:`,
116
+ error
117
+ )
115
118
  })
116
119
  }
117
120
 
@@ -1,10 +1,11 @@
1
1
  import { ensureIndexForExpression } from "../indexes/auto-index.js"
2
- import { and } from "../query/builder/functions.js"
2
+ import { and, gt, lt } from "../query/builder/functions.js"
3
+ import { Value } from "../query/ir.js"
3
4
  import {
4
5
  createFilterFunctionFromExpression,
5
6
  createFilteredCallback,
6
7
  } from "./change-events.js"
7
- import type { BasicExpression } from "../query/ir.js"
8
+ import type { BasicExpression, OrderBy } from "../query/ir.js"
8
9
  import type { IndexInterface } from "../indexes/base-index.js"
9
10
  import type { ChangeMessage } from "../types.js"
10
11
  import type { CollectionImpl } from "./index.js"
@@ -15,8 +16,9 @@ type RequestSnapshotOptions = {
15
16
  }
16
17
 
17
18
  type RequestLimitedSnapshotOptions = {
18
- minValue?: any
19
+ orderBy: OrderBy
19
20
  limit: number
21
+ minValue?: any
20
22
  }
21
23
 
22
24
  type CollectionSubscriptionOptions = {
@@ -117,6 +119,13 @@ export class CollectionSubscription {
117
119
  this.loadedInitialState = true
118
120
  }
119
121
 
122
+ // Request the sync layer to load more data
123
+ // don't await it, we will load the data into the collection when it comes in
124
+ this.collection.syncMore({
125
+ where: stateOpts.where,
126
+ })
127
+
128
+ // Also load data immediately from the collection
120
129
  const snapshot = this.collection.currentStateAsChanges(stateOpts)
121
130
 
122
131
  if (snapshot === undefined) {
@@ -140,7 +149,11 @@ export class CollectionSubscription {
140
149
  * It uses that range index to load the items in the order of the index.
141
150
  * Note: it does not send keys that have already been sent before.
142
151
  */
143
- requestLimitedSnapshot({ limit, minValue }: RequestLimitedSnapshotOptions) {
152
+ requestLimitedSnapshot({
153
+ orderBy,
154
+ limit,
155
+ minValue,
156
+ }: RequestLimitedSnapshotOptions) {
144
157
  if (!limit) throw new Error(`limit is required`)
145
158
 
146
159
  if (!this.orderByIndex) {
@@ -190,6 +203,23 @@ export class CollectionSubscription {
190
203
  }
191
204
 
192
205
  this.callback(changes)
206
+
207
+ let whereWithValueFilter = where
208
+ if (typeof minValue !== `undefined`) {
209
+ // Only request data that we haven't seen yet (i.e. is bigger than the minValue)
210
+ const { expression, compareOptions } = orderBy[0]!
211
+ const operator = compareOptions.direction === `asc` ? gt : lt
212
+ const valueFilter = operator(expression, new Value(minValue))
213
+ whereWithValueFilter = where ? and(where, valueFilter) : valueFilter
214
+ }
215
+
216
+ // Request the sync layer to load more data
217
+ // don't await it, we will load the data into the collection when it comes in
218
+ this.collection.syncMore({
219
+ where: whereWithValueFilter,
220
+ limit,
221
+ orderBy,
222
+ })
193
223
  }
194
224
 
195
225
  /**
@@ -9,7 +9,13 @@ import {
9
9
  } from "../errors"
10
10
  import { deepEquals } from "../utils"
11
11
  import type { StandardSchemaV1 } from "@standard-schema/spec"
12
- import type { ChangeMessage, CollectionConfig } from "../types"
12
+ import type {
13
+ ChangeMessage,
14
+ CleanupFn,
15
+ CollectionConfig,
16
+ OnLoadMoreOptions,
17
+ SyncConfigRes,
18
+ } from "../types"
13
19
  import type { CollectionImpl } from "./index.js"
14
20
  import type { CollectionStateManager } from "./state"
15
21
  import type { CollectionLifecycleManager } from "./lifecycle"
@@ -28,6 +34,9 @@ export class CollectionSyncManager<
28
34
 
29
35
  public preloadPromise: Promise<void> | null = null
30
36
  public syncCleanupFn: (() => void) | null = null
37
+ public syncOnLoadMoreFn:
38
+ | ((options: OnLoadMoreOptions) => void | Promise<void>)
39
+ | null = null
31
40
 
32
41
  /**
33
42
  * Creates a new CollectionSyncManager instance
@@ -52,7 +61,6 @@ export class CollectionSyncManager<
52
61
  * This is called when the collection is first accessed or preloaded
53
62
  */
54
63
  public startSync(): void {
55
- const state = this.state
56
64
  if (
57
65
  this.lifecycle.status !== `idle` &&
58
66
  this.lifecycle.status !== `cleaned-up`
@@ -63,120 +71,125 @@ export class CollectionSyncManager<
63
71
  this.lifecycle.setStatus(`loading`)
64
72
 
65
73
  try {
66
- const cleanupFn = this.config.sync.sync({
67
- collection: this.collection,
68
- begin: () => {
69
- state.pendingSyncedTransactions.push({
70
- committed: false,
71
- operations: [],
72
- deletedKeys: new Set(),
73
- })
74
- },
75
- write: (messageWithoutKey: Omit<ChangeMessage<TOutput>, `key`>) => {
76
- const pendingTransaction =
77
- state.pendingSyncedTransactions[
78
- state.pendingSyncedTransactions.length - 1
79
- ]
80
- if (!pendingTransaction) {
81
- throw new NoPendingSyncTransactionWriteError()
82
- }
83
- if (pendingTransaction.committed) {
84
- throw new SyncTransactionAlreadyCommittedWriteError()
85
- }
86
- const key = this.config.getKey(messageWithoutKey.value)
87
-
88
- let messageType = messageWithoutKey.type
89
-
90
- // Check if an item with this key already exists when inserting
91
- if (messageWithoutKey.type === `insert`) {
92
- const insertingIntoExistingSynced = state.syncedData.has(key)
93
- const hasPendingDeleteForKey =
94
- pendingTransaction.deletedKeys.has(key)
95
- const isTruncateTransaction = pendingTransaction.truncate === true
96
- // Allow insert after truncate in the same transaction even if it existed in syncedData
97
- if (
98
- insertingIntoExistingSynced &&
99
- !hasPendingDeleteForKey &&
100
- !isTruncateTransaction
101
- ) {
102
- const existingValue = state.syncedData.get(key)
74
+ const syncRes = normalizeSyncFnResult(
75
+ this.config.sync.sync({
76
+ collection: this.collection,
77
+ begin: () => {
78
+ this.state.pendingSyncedTransactions.push({
79
+ committed: false,
80
+ operations: [],
81
+ deletedKeys: new Set(),
82
+ })
83
+ },
84
+ write: (messageWithoutKey: Omit<ChangeMessage<TOutput>, `key`>) => {
85
+ const pendingTransaction =
86
+ this.state.pendingSyncedTransactions[
87
+ this.state.pendingSyncedTransactions.length - 1
88
+ ]
89
+ if (!pendingTransaction) {
90
+ throw new NoPendingSyncTransactionWriteError()
91
+ }
92
+ if (pendingTransaction.committed) {
93
+ throw new SyncTransactionAlreadyCommittedWriteError()
94
+ }
95
+ const key = this.config.getKey(messageWithoutKey.value)
96
+
97
+ let messageType = messageWithoutKey.type
98
+
99
+ // Check if an item with this key already exists when inserting
100
+ if (messageWithoutKey.type === `insert`) {
101
+ const insertingIntoExistingSynced = this.state.syncedData.has(key)
102
+ const hasPendingDeleteForKey =
103
+ pendingTransaction.deletedKeys.has(key)
104
+ const isTruncateTransaction = pendingTransaction.truncate === true
105
+ // Allow insert after truncate in the same transaction even if it existed in syncedData
103
106
  if (
104
- existingValue !== undefined &&
105
- deepEquals(existingValue, messageWithoutKey.value)
107
+ insertingIntoExistingSynced &&
108
+ !hasPendingDeleteForKey &&
109
+ !isTruncateTransaction
106
110
  ) {
107
- // The "insert" is an echo of a value we already have locally.
108
- // Treat it as an update so we preserve optimistic intent without
109
- // throwing a duplicate-key error during reconciliation.
110
- messageType = `update`
111
- } else {
112
- throw new DuplicateKeySyncError(key, this.id)
111
+ const existingValue = this.state.syncedData.get(key)
112
+ if (
113
+ existingValue !== undefined &&
114
+ deepEquals(existingValue, messageWithoutKey.value)
115
+ ) {
116
+ // The "insert" is an echo of a value we already have locally.
117
+ // Treat it as an update so we preserve optimistic intent without
118
+ // throwing a duplicate-key error during reconciliation.
119
+ messageType = `update`
120
+ } else {
121
+ throw new DuplicateKeySyncError(key, this.id)
122
+ }
113
123
  }
114
124
  }
115
- }
116
-
117
- const message: ChangeMessage<TOutput> = {
118
- ...messageWithoutKey,
119
- type: messageType,
120
- key,
121
- }
122
- pendingTransaction.operations.push(message)
123
-
124
- if (messageType === `delete`) {
125
- pendingTransaction.deletedKeys.add(key)
126
- }
127
- },
128
- commit: () => {
129
- const pendingTransaction =
130
- state.pendingSyncedTransactions[
131
- state.pendingSyncedTransactions.length - 1
132
- ]
133
- if (!pendingTransaction) {
134
- throw new NoPendingSyncTransactionCommitError()
135
- }
136
- if (pendingTransaction.committed) {
137
- throw new SyncTransactionAlreadyCommittedError()
138
- }
139
-
140
- pendingTransaction.committed = true
141
-
142
- // Update status to initialCommit when transitioning from loading
143
- // This indicates we're in the process of committing the first transaction
144
- if (this.lifecycle.status === `loading`) {
145
- this.lifecycle.setStatus(`initialCommit`)
146
- }
147
-
148
- state.commitPendingTransactions()
149
- },
150
- markReady: () => {
151
- this.lifecycle.markReady()
152
- },
153
- truncate: () => {
154
- const pendingTransaction =
155
- state.pendingSyncedTransactions[
156
- state.pendingSyncedTransactions.length - 1
157
- ]
158
- if (!pendingTransaction) {
159
- throw new NoPendingSyncTransactionWriteError()
160
- }
161
- if (pendingTransaction.committed) {
162
- throw new SyncTransactionAlreadyCommittedWriteError()
163
- }
164
-
165
- // Clear all operations from the current transaction
166
- pendingTransaction.operations = []
167
- pendingTransaction.deletedKeys.clear()
168
-
169
- // Mark the transaction as a truncate operation. During commit, this triggers:
170
- // - Delete events for all previously synced keys (excluding optimistic-deleted keys)
171
- // - Clearing of syncedData/syncedMetadata
172
- // - Subsequent synced ops applied on the fresh base
173
- // - Finally, optimistic mutations re-applied on top (single batch)
174
- pendingTransaction.truncate = true
175
- },
176
- })
125
+
126
+ const message: ChangeMessage<TOutput> = {
127
+ ...messageWithoutKey,
128
+ type: messageType,
129
+ key,
130
+ }
131
+ pendingTransaction.operations.push(message)
132
+
133
+ if (messageType === `delete`) {
134
+ pendingTransaction.deletedKeys.add(key)
135
+ }
136
+ },
137
+ commit: () => {
138
+ const pendingTransaction =
139
+ this.state.pendingSyncedTransactions[
140
+ this.state.pendingSyncedTransactions.length - 1
141
+ ]
142
+ if (!pendingTransaction) {
143
+ throw new NoPendingSyncTransactionCommitError()
144
+ }
145
+ if (pendingTransaction.committed) {
146
+ throw new SyncTransactionAlreadyCommittedError()
147
+ }
148
+
149
+ pendingTransaction.committed = true
150
+
151
+ // Update status to initialCommit when transitioning from loading
152
+ // This indicates we're in the process of committing the first transaction
153
+ if (this.lifecycle.status === `loading`) {
154
+ this.lifecycle.setStatus(`initialCommit`)
155
+ }
156
+
157
+ this.state.commitPendingTransactions()
158
+ },
159
+ markReady: () => {
160
+ this.lifecycle.markReady()
161
+ },
162
+ truncate: () => {
163
+ const pendingTransaction =
164
+ this.state.pendingSyncedTransactions[
165
+ this.state.pendingSyncedTransactions.length - 1
166
+ ]
167
+ if (!pendingTransaction) {
168
+ throw new NoPendingSyncTransactionWriteError()
169
+ }
170
+ if (pendingTransaction.committed) {
171
+ throw new SyncTransactionAlreadyCommittedWriteError()
172
+ }
173
+
174
+ // Clear all operations from the current transaction
175
+ pendingTransaction.operations = []
176
+ pendingTransaction.deletedKeys.clear()
177
+
178
+ // Mark the transaction as a truncate operation. During commit, this triggers:
179
+ // - Delete events for all previously synced keys (excluding optimistic-deleted keys)
180
+ // - Clearing of syncedData/syncedMetadata
181
+ // - Subsequent synced ops applied on the fresh base
182
+ // - Finally, optimistic mutations re-applied on top (single batch)
183
+ pendingTransaction.truncate = true
184
+ },
185
+ })
186
+ )
177
187
 
178
188
  // Store cleanup function if provided
179
- this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
189
+ this.syncCleanupFn = syncRes?.cleanup ?? null
190
+
191
+ // Store onLoadMore function if provided
192
+ this.syncOnLoadMoreFn = syncRes?.onLoadMore ?? null
180
193
  } catch (error) {
181
194
  this.lifecycle.setStatus(`error`)
182
195
  throw error
@@ -225,6 +238,18 @@ export class CollectionSyncManager<
225
238
  return this.preloadPromise
226
239
  }
227
240
 
241
+ /**
242
+ * Requests the sync layer to load more data.
243
+ * @param options Options to control what data is being loaded
244
+ * @returns If data loading is asynchronous, this method returns a promise that resolves when the data is loaded.
245
+ * If data loading is synchronous, the data is loaded when the method returns.
246
+ */
247
+ public syncMore(options: OnLoadMoreOptions): void | Promise<void> {
248
+ if (this.syncOnLoadMoreFn) {
249
+ return this.syncOnLoadMoreFn(options)
250
+ }
251
+ }
252
+
228
253
  public cleanup(): void {
229
254
  try {
230
255
  if (this.syncCleanupFn) {
@@ -248,3 +273,15 @@ export class CollectionSyncManager<
248
273
  this.preloadPromise = null
249
274
  }
250
275
  }
276
+
277
+ function normalizeSyncFnResult(result: void | CleanupFn | SyncConfigRes) {
278
+ if (typeof result === `function`) {
279
+ return { cleanup: result }
280
+ }
281
+
282
+ if (typeof result === `object`) {
283
+ return result
284
+ }
285
+
286
+ return undefined
287
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  // Re-export all public APIs
2
+ // Re-export IR types under their own namespace
3
+ // because custom collections need access to the IR types
4
+ import * as IR from "./query/ir.js"
5
+
2
6
  export * from "./collection/index.js"
3
7
  export * from "./SortedMap"
4
8
  export * from "./transactions"
@@ -18,3 +22,4 @@ export { type IndexOptions } from "./indexes/index-options.js"
18
22
 
19
23
  // Re-export some stuff explicitly to ensure the type & value is exported
20
24
  export type { Collection } from "./collection/index.js"
25
+ export { IR }