@tanstack/db-ivm 0.1.14 → 0.1.15

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 (58) hide show
  1. package/dist/cjs/index.cjs +4 -3
  2. package/dist/cjs/index.cjs.map +1 -1
  3. package/dist/cjs/index.d.cts +1 -1
  4. package/dist/cjs/indexes.cjs +1 -1
  5. package/dist/cjs/indexes.cjs.map +1 -1
  6. package/dist/cjs/operators/groupBy.cjs +2 -1
  7. package/dist/cjs/operators/groupBy.cjs.map +1 -1
  8. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs +116 -0
  9. package/dist/cjs/operators/groupedTopKWithFractionalIndex.cjs.map +1 -0
  10. package/dist/cjs/operators/groupedTopKWithFractionalIndex.d.cts +50 -0
  11. package/dist/cjs/operators/index.d.cts +1 -0
  12. package/dist/cjs/operators/topKArray.cjs +137 -0
  13. package/dist/cjs/operators/topKArray.cjs.map +1 -0
  14. package/dist/cjs/operators/topKArray.d.cts +59 -0
  15. package/dist/cjs/operators/topKState.cjs +73 -0
  16. package/dist/cjs/operators/topKState.cjs.map +1 -0
  17. package/dist/cjs/operators/topKState.d.cts +35 -0
  18. package/dist/cjs/operators/topKWithFractionalIndex.cjs +14 -190
  19. package/dist/cjs/operators/topKWithFractionalIndex.cjs.map +1 -1
  20. package/dist/cjs/operators/topKWithFractionalIndex.d.cts +1 -32
  21. package/dist/cjs/operators/topKWithFractionalIndexBTree.d.cts +2 -1
  22. package/dist/cjs/utils.cjs +12 -0
  23. package/dist/cjs/utils.cjs.map +1 -1
  24. package/dist/cjs/utils.d.cts +6 -0
  25. package/dist/esm/index.d.ts +1 -1
  26. package/dist/esm/index.js +6 -5
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/indexes.js +1 -1
  29. package/dist/esm/indexes.js.map +1 -1
  30. package/dist/esm/operators/groupBy.js +2 -1
  31. package/dist/esm/operators/groupBy.js.map +1 -1
  32. package/dist/esm/operators/groupedTopKWithFractionalIndex.d.ts +50 -0
  33. package/dist/esm/operators/groupedTopKWithFractionalIndex.js +116 -0
  34. package/dist/esm/operators/groupedTopKWithFractionalIndex.js.map +1 -0
  35. package/dist/esm/operators/index.d.ts +1 -0
  36. package/dist/esm/operators/topKArray.d.ts +59 -0
  37. package/dist/esm/operators/topKArray.js +137 -0
  38. package/dist/esm/operators/topKArray.js.map +1 -0
  39. package/dist/esm/operators/topKState.d.ts +35 -0
  40. package/dist/esm/operators/topKState.js +73 -0
  41. package/dist/esm/operators/topKState.js.map +1 -0
  42. package/dist/esm/operators/topKWithFractionalIndex.d.ts +1 -32
  43. package/dist/esm/operators/topKWithFractionalIndex.js +12 -188
  44. package/dist/esm/operators/topKWithFractionalIndex.js.map +1 -1
  45. package/dist/esm/operators/topKWithFractionalIndexBTree.d.ts +2 -1
  46. package/dist/esm/utils.d.ts +6 -0
  47. package/dist/esm/utils.js +13 -1
  48. package/dist/esm/utils.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/index.ts +1 -1
  51. package/src/operators/groupBy.ts +2 -1
  52. package/src/operators/groupedTopKWithFractionalIndex.ts +192 -0
  53. package/src/operators/index.ts +1 -0
  54. package/src/operators/topKArray.ts +255 -0
  55. package/src/operators/topKState.ts +111 -0
  56. package/src/operators/topKWithFractionalIndex.ts +13 -337
  57. package/src/operators/topKWithFractionalIndexBTree.ts +4 -12
  58. package/src/utils.ts +17 -0
@@ -57,3 +57,9 @@ export declare function diffHalfOpen(a: HRange, b: HRange): {
57
57
  * Handles mixed types by ordering strings before numbers.
58
58
  */
59
59
  export declare function compareKeys(a: string | number, b: string | number): number;
60
+ /**
61
+ * Serializes a value for use as a key, handling BigInt and Date values that JSON.stringify cannot handle.
62
+ * Uses JSON.stringify with a replacer function to convert BigInt values to strings and Date values to ISO strings.
63
+ * This is used for creating string keys in groupBy operations.
64
+ */
65
+ export declare function serializeValue(value: unknown): string;
package/dist/esm/utils.js CHANGED
@@ -111,6 +111,17 @@ function compareKeys(a, b) {
111
111
  }
112
112
  return typeof a === `string` ? -1 : 1;
113
113
  }
114
+ function serializeValue(value) {
115
+ return JSON.stringify(value, (_, val) => {
116
+ if (typeof val === "bigint") {
117
+ return val.toString();
118
+ }
119
+ if (val instanceof Date) {
120
+ return val.toISOString();
121
+ }
122
+ return val;
123
+ });
124
+ }
114
125
  export {
115
126
  DefaultMap,
116
127
  ObjectIdGenerator,
@@ -118,6 +129,7 @@ export {
118
129
  chunkedArrayPush,
119
130
  compareKeys,
120
131
  diffHalfOpen,
121
- globalObjectIdGenerator
132
+ globalObjectIdGenerator,
133
+ serializeValue
122
134
  };
123
135
  //# sourceMappingURL=utils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sources":["../../src/utils.ts"],"sourcesContent":["/**\n * Simple assertion function for runtime checks.\n * Throws an error if the condition is false.\n */\nexport function assert(\n condition: unknown,\n message?: string,\n): asserts condition {\n if (!condition) {\n throw new Error(message || `Assertion failed`)\n }\n}\n\n/**\n * A map that returns a default value for keys that are not present.\n */\nexport class DefaultMap<K, V> extends Map<K, V> {\n constructor(\n private defaultValue: () => V,\n entries?: Iterable<[K, V]>,\n ) {\n super(entries)\n }\n\n get(key: K): V {\n if (!this.has(key)) {\n // this.set(key, this.defaultValue())\n return this.defaultValue()\n }\n return super.get(key)!\n }\n\n /**\n * Update the value for a key using a function.\n */\n update(key: K, updater: (value: V) => V): V {\n const value = this.get(key)\n const newValue = updater(value)\n this.set(key, newValue)\n return newValue\n }\n}\n\n// JS engines have various limits on how many args can be passed to a function\n// with a spread operator, so we need to split the operation into chunks\n// 32767 is the max for Chrome 14, all others are higher\n// TODO: investigate the performance of this and other approaches\nconst chunkSize = 30000\nexport function chunkedArrayPush(array: Array<unknown>, other: Array<unknown>) {\n if (other.length <= chunkSize) {\n array.push(...other)\n } else {\n for (let i = 0; i < other.length; i += chunkSize) {\n const chunk = other.slice(i, i + chunkSize)\n array.push(...chunk)\n }\n }\n}\n\nexport function binarySearch<T>(\n array: Array<T>,\n value: T,\n comparator: (a: T, b: T) => number,\n): number {\n let low = 0\n let high = array.length\n while (low < high) {\n const mid = Math.floor((low + high) / 2)\n const comparison = comparator(array[mid]!, value)\n if (comparison < 0) {\n low = mid + 1\n } else if (comparison > 0) {\n high = mid\n } else {\n return mid\n }\n }\n return low\n}\n\n/**\n * Utility for generating unique IDs for objects and values.\n * Uses WeakMap for object reference tracking and consistent hashing for primitives.\n */\nexport class ObjectIdGenerator {\n private objectIds = new WeakMap<object, number>()\n private nextId = 0\n\n /**\n * Get a unique identifier for any value.\n * - Objects: Uses WeakMap for reference-based identity\n * - Primitives: Uses consistent string-based hashing\n */\n getId(value: any): number {\n // For primitives, use a simple hash of their string representation\n if (typeof value !== `object` || value === null) {\n const str = String(value)\n let hashValue = 0\n for (let i = 0; i < str.length; i++) {\n const char = str.charCodeAt(i)\n hashValue = (hashValue << 5) - hashValue + char\n hashValue = hashValue & hashValue // Convert to 32-bit integer\n }\n return hashValue\n }\n\n // For objects, use WeakMap to assign unique IDs\n if (!this.objectIds.has(value)) {\n this.objectIds.set(value, this.nextId++)\n }\n return this.objectIds.get(value)!\n }\n\n /**\n * Get a string representation of the ID for use in composite keys.\n */\n getStringId(value: any): string {\n if (value === null) return `null`\n if (value === undefined) return `undefined`\n if (typeof value !== `object`) return `str_${String(value)}`\n\n return `obj_${this.getId(value)}`\n }\n}\n\n/**\n * Global instance for cases where a shared object ID space is needed.\n */\nexport const globalObjectIdGenerator = new ObjectIdGenerator()\n\nexport function* concatIterable<T>(\n ...iterables: Array<Iterable<T>>\n): Iterable<T> {\n for (const iterable of iterables) {\n yield* iterable\n }\n}\n\nexport function* mapIterable<T, U>(\n it: Iterable<T>,\n fn: (t: T) => U,\n): Iterable<U> {\n for (const t of it) {\n yield fn(t)\n }\n}\n\nexport type HRange = [number, number] // half-open [start, end[ i.e. end is exclusive\n\n/**\n * Computes the difference between two half-open ranges.\n * @param a - The first half-open range\n * @param b - The second half-open range\n * @returns The difference between the two ranges\n */\nexport function diffHalfOpen(a: HRange, b: HRange) {\n const [a1, a2] = a\n const [b1, b2] = b\n\n // A \\ B can be up to two segments (left and right of the overlap)\n const onlyInA: Array<number> = [\n ...range(a1, Math.min(a2, b1)), // left side of A outside B\n ...range(Math.max(a1, b2), a2), // right side of A outside B\n ]\n\n // B \\ A similarly\n const onlyInB: Array<number> = [\n ...range(b1, Math.min(b2, a1)),\n ...range(Math.max(b1, a2), b2),\n ]\n\n return { onlyInA, onlyInB }\n}\n\nfunction range(start: number, end: number): Array<number> {\n const out: Array<number> = []\n for (let i = start; i < end; i++) out.push(i)\n return out\n}\n\n/**\n * Compares two keys (string | number) in a consistent, deterministic way.\n * Handles mixed types by ordering strings before numbers.\n */\nexport function compareKeys(a: string | number, b: string | number): number {\n // Same type: compare directly\n if (typeof a === typeof b) {\n if (a < b) return -1\n if (a > b) return 1\n return 0\n }\n // Different types: strings come before numbers\n return typeof a === `string` ? -1 : 1\n}\n"],"names":[],"mappings":"AAgBO,MAAM,mBAAyB,IAAU;AAAA,EAC9C,YACU,cACR,SACA;AACA,UAAM,OAAO;AAHL,SAAA,eAAA;AAAA,EAIV;AAAA,EAEA,IAAI,KAAW;AACb,QAAI,CAAC,KAAK,IAAI,GAAG,GAAG;AAElB,aAAO,KAAK,aAAA;AAAA,IACd;AACA,WAAO,MAAM,IAAI,GAAG;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA,EAKA,OAAO,KAAQ,SAA6B;AAC1C,UAAM,QAAQ,KAAK,IAAI,GAAG;AAC1B,UAAM,WAAW,QAAQ,KAAK;AAC9B,SAAK,IAAI,KAAK,QAAQ;AACtB,WAAO;AAAA,EACT;AACF;AAMA,MAAM,YAAY;AACX,SAAS,iBAAiB,OAAuB,OAAuB;AAC7E,MAAI,MAAM,UAAU,WAAW;AAC7B,UAAM,KAAK,GAAG,KAAK;AAAA,EACrB,OAAO;AACL,aAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,WAAW;AAChD,YAAM,QAAQ,MAAM,MAAM,GAAG,IAAI,SAAS;AAC1C,YAAM,KAAK,GAAG,KAAK;AAAA,IACrB;AAAA,EACF;AACF;AAEO,SAAS,aACd,OACA,OACA,YACQ;AACR,MAAI,MAAM;AACV,MAAI,OAAO,MAAM;AACjB,SAAO,MAAM,MAAM;AACjB,UAAM,MAAM,KAAK,OAAO,MAAM,QAAQ,CAAC;AACvC,UAAM,aAAa,WAAW,MAAM,GAAG,GAAI,KAAK;AAChD,QAAI,aAAa,GAAG;AAClB,YAAM,MAAM;AAAA,IACd,WAAW,aAAa,GAAG;AACzB,aAAO;AAAA,IACT,OAAO;AACL,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAMO,MAAM,kBAAkB;AAAA,EAAxB,cAAA;AACL,SAAQ,gCAAgB,QAAA;AACxB,SAAQ,SAAS;AAAA,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOjB,MAAM,OAAoB;AAExB,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,YAAM,MAAM,OAAO,KAAK;AACxB,UAAI,YAAY;AAChB,eAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;AACnC,cAAM,OAAO,IAAI,WAAW,CAAC;AAC7B,qBAAa,aAAa,KAAK,YAAY;AAC3C,oBAAY,YAAY;AAAA,MAC1B;AACA,aAAO;AAAA,IACT;AAGA,QAAI,CAAC,KAAK,UAAU,IAAI,KAAK,GAAG;AAC9B,WAAK,UAAU,IAAI,OAAO,KAAK,QAAQ;AAAA,IACzC;AACA,WAAO,KAAK,UAAU,IAAI,KAAK;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,YAAY,OAAoB;AAC9B,QAAI,UAAU,KAAM,QAAO;AAC3B,QAAI,UAAU,OAAW,QAAO;AAChC,QAAI,OAAO,UAAU,iBAAiB,OAAO,OAAO,KAAK,CAAC;AAE1D,WAAO,OAAO,KAAK,MAAM,KAAK,CAAC;AAAA,EACjC;AACF;AAKO,MAAM,0BAA0B,IAAI,kBAAA;AA2BpC,SAAS,aAAa,GAAW,GAAW;AACjD,QAAM,CAAC,IAAI,EAAE,IAAI;AACjB,QAAM,CAAC,IAAI,EAAE,IAAI;AAGjB,QAAM,UAAyB;AAAA,IAC7B,GAAG,MAAM,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;AAAA;AAAA,IAC7B,GAAG,MAAM,KAAK,IAAI,IAAI,EAAE,GAAG,EAAE;AAAA;AAAA,EAAA;AAI/B,QAAM,UAAyB;AAAA,IAC7B,GAAG,MAAM,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;AAAA,IAC7B,GAAG,MAAM,KAAK,IAAI,IAAI,EAAE,GAAG,EAAE;AAAA,EAAA;AAG/B,SAAO,EAAE,SAAS,QAAA;AACpB;AAEA,SAAS,MAAM,OAAe,KAA4B;AACxD,QAAM,MAAqB,CAAA;AAC3B,WAAS,IAAI,OAAO,IAAI,KAAK,IAAK,KAAI,KAAK,CAAC;AAC5C,SAAO;AACT;AAMO,SAAS,YAAY,GAAoB,GAA4B;AAE1E,MAAI,OAAO,MAAM,OAAO,GAAG;AACzB,QAAI,IAAI,EAAG,QAAO;AAClB,QAAI,IAAI,EAAG,QAAO;AAClB,WAAO;AAAA,EACT;AAEA,SAAO,OAAO,MAAM,WAAW,KAAK;AACtC;"}
1
+ {"version":3,"file":"utils.js","sources":["../../src/utils.ts"],"sourcesContent":["/**\n * Simple assertion function for runtime checks.\n * Throws an error if the condition is false.\n */\nexport function assert(\n condition: unknown,\n message?: string,\n): asserts condition {\n if (!condition) {\n throw new Error(message || `Assertion failed`)\n }\n}\n\n/**\n * A map that returns a default value for keys that are not present.\n */\nexport class DefaultMap<K, V> extends Map<K, V> {\n constructor(\n private defaultValue: () => V,\n entries?: Iterable<[K, V]>,\n ) {\n super(entries)\n }\n\n get(key: K): V {\n if (!this.has(key)) {\n // this.set(key, this.defaultValue())\n return this.defaultValue()\n }\n return super.get(key)!\n }\n\n /**\n * Update the value for a key using a function.\n */\n update(key: K, updater: (value: V) => V): V {\n const value = this.get(key)\n const newValue = updater(value)\n this.set(key, newValue)\n return newValue\n }\n}\n\n// JS engines have various limits on how many args can be passed to a function\n// with a spread operator, so we need to split the operation into chunks\n// 32767 is the max for Chrome 14, all others are higher\n// TODO: investigate the performance of this and other approaches\nconst chunkSize = 30000\nexport function chunkedArrayPush(array: Array<unknown>, other: Array<unknown>) {\n if (other.length <= chunkSize) {\n array.push(...other)\n } else {\n for (let i = 0; i < other.length; i += chunkSize) {\n const chunk = other.slice(i, i + chunkSize)\n array.push(...chunk)\n }\n }\n}\n\nexport function binarySearch<T>(\n array: Array<T>,\n value: T,\n comparator: (a: T, b: T) => number,\n): number {\n let low = 0\n let high = array.length\n while (low < high) {\n const mid = Math.floor((low + high) / 2)\n const comparison = comparator(array[mid]!, value)\n if (comparison < 0) {\n low = mid + 1\n } else if (comparison > 0) {\n high = mid\n } else {\n return mid\n }\n }\n return low\n}\n\n/**\n * Utility for generating unique IDs for objects and values.\n * Uses WeakMap for object reference tracking and consistent hashing for primitives.\n */\nexport class ObjectIdGenerator {\n private objectIds = new WeakMap<object, number>()\n private nextId = 0\n\n /**\n * Get a unique identifier for any value.\n * - Objects: Uses WeakMap for reference-based identity\n * - Primitives: Uses consistent string-based hashing\n */\n getId(value: any): number {\n // For primitives, use a simple hash of their string representation\n if (typeof value !== `object` || value === null) {\n const str = String(value)\n let hashValue = 0\n for (let i = 0; i < str.length; i++) {\n const char = str.charCodeAt(i)\n hashValue = (hashValue << 5) - hashValue + char\n hashValue = hashValue & hashValue // Convert to 32-bit integer\n }\n return hashValue\n }\n\n // For objects, use WeakMap to assign unique IDs\n if (!this.objectIds.has(value)) {\n this.objectIds.set(value, this.nextId++)\n }\n return this.objectIds.get(value)!\n }\n\n /**\n * Get a string representation of the ID for use in composite keys.\n */\n getStringId(value: any): string {\n if (value === null) return `null`\n if (value === undefined) return `undefined`\n if (typeof value !== `object`) return `str_${String(value)}`\n\n return `obj_${this.getId(value)}`\n }\n}\n\n/**\n * Global instance for cases where a shared object ID space is needed.\n */\nexport const globalObjectIdGenerator = new ObjectIdGenerator()\n\nexport function* concatIterable<T>(\n ...iterables: Array<Iterable<T>>\n): Iterable<T> {\n for (const iterable of iterables) {\n yield* iterable\n }\n}\n\nexport function* mapIterable<T, U>(\n it: Iterable<T>,\n fn: (t: T) => U,\n): Iterable<U> {\n for (const t of it) {\n yield fn(t)\n }\n}\n\nexport type HRange = [number, number] // half-open [start, end[ i.e. end is exclusive\n\n/**\n * Computes the difference between two half-open ranges.\n * @param a - The first half-open range\n * @param b - The second half-open range\n * @returns The difference between the two ranges\n */\nexport function diffHalfOpen(a: HRange, b: HRange) {\n const [a1, a2] = a\n const [b1, b2] = b\n\n // A \\ B can be up to two segments (left and right of the overlap)\n const onlyInA: Array<number> = [\n ...range(a1, Math.min(a2, b1)), // left side of A outside B\n ...range(Math.max(a1, b2), a2), // right side of A outside B\n ]\n\n // B \\ A similarly\n const onlyInB: Array<number> = [\n ...range(b1, Math.min(b2, a1)),\n ...range(Math.max(b1, a2), b2),\n ]\n\n return { onlyInA, onlyInB }\n}\n\nfunction range(start: number, end: number): Array<number> {\n const out: Array<number> = []\n for (let i = start; i < end; i++) out.push(i)\n return out\n}\n\n/**\n * Compares two keys (string | number) in a consistent, deterministic way.\n * Handles mixed types by ordering strings before numbers.\n */\nexport function compareKeys(a: string | number, b: string | number): number {\n // Same type: compare directly\n if (typeof a === typeof b) {\n if (a < b) return -1\n if (a > b) return 1\n return 0\n }\n // Different types: strings come before numbers\n return typeof a === `string` ? -1 : 1\n}\n\n/**\n * Serializes a value for use as a key, handling BigInt and Date values that JSON.stringify cannot handle.\n * Uses JSON.stringify with a replacer function to convert BigInt values to strings and Date values to ISO strings.\n * This is used for creating string keys in groupBy operations.\n */\nexport function serializeValue(value: unknown): string {\n return JSON.stringify(value, (_, val) => {\n if (typeof val === 'bigint') {\n return val.toString()\n }\n if (val instanceof Date) {\n return val.toISOString()\n }\n return val\n })\n}\n"],"names":[],"mappings":"AAgBO,MAAM,mBAAyB,IAAU;AAAA,EAC9C,YACU,cACR,SACA;AACA,UAAM,OAAO;AAHL,SAAA,eAAA;AAAA,EAIV;AAAA,EAEA,IAAI,KAAW;AACb,QAAI,CAAC,KAAK,IAAI,GAAG,GAAG;AAElB,aAAO,KAAK,aAAA;AAAA,IACd;AACA,WAAO,MAAM,IAAI,GAAG;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA,EAKA,OAAO,KAAQ,SAA6B;AAC1C,UAAM,QAAQ,KAAK,IAAI,GAAG;AAC1B,UAAM,WAAW,QAAQ,KAAK;AAC9B,SAAK,IAAI,KAAK,QAAQ;AACtB,WAAO;AAAA,EACT;AACF;AAMA,MAAM,YAAY;AACX,SAAS,iBAAiB,OAAuB,OAAuB;AAC7E,MAAI,MAAM,UAAU,WAAW;AAC7B,UAAM,KAAK,GAAG,KAAK;AAAA,EACrB,OAAO;AACL,aAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,WAAW;AAChD,YAAM,QAAQ,MAAM,MAAM,GAAG,IAAI,SAAS;AAC1C,YAAM,KAAK,GAAG,KAAK;AAAA,IACrB;AAAA,EACF;AACF;AAEO,SAAS,aACd,OACA,OACA,YACQ;AACR,MAAI,MAAM;AACV,MAAI,OAAO,MAAM;AACjB,SAAO,MAAM,MAAM;AACjB,UAAM,MAAM,KAAK,OAAO,MAAM,QAAQ,CAAC;AACvC,UAAM,aAAa,WAAW,MAAM,GAAG,GAAI,KAAK;AAChD,QAAI,aAAa,GAAG;AAClB,YAAM,MAAM;AAAA,IACd,WAAW,aAAa,GAAG;AACzB,aAAO;AAAA,IACT,OAAO;AACL,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAMO,MAAM,kBAAkB;AAAA,EAAxB,cAAA;AACL,SAAQ,gCAAgB,QAAA;AACxB,SAAQ,SAAS;AAAA,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOjB,MAAM,OAAoB;AAExB,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,YAAM,MAAM,OAAO,KAAK;AACxB,UAAI,YAAY;AAChB,eAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;AACnC,cAAM,OAAO,IAAI,WAAW,CAAC;AAC7B,qBAAa,aAAa,KAAK,YAAY;AAC3C,oBAAY,YAAY;AAAA,MAC1B;AACA,aAAO;AAAA,IACT;AAGA,QAAI,CAAC,KAAK,UAAU,IAAI,KAAK,GAAG;AAC9B,WAAK,UAAU,IAAI,OAAO,KAAK,QAAQ;AAAA,IACzC;AACA,WAAO,KAAK,UAAU,IAAI,KAAK;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,YAAY,OAAoB;AAC9B,QAAI,UAAU,KAAM,QAAO;AAC3B,QAAI,UAAU,OAAW,QAAO;AAChC,QAAI,OAAO,UAAU,iBAAiB,OAAO,OAAO,KAAK,CAAC;AAE1D,WAAO,OAAO,KAAK,MAAM,KAAK,CAAC;AAAA,EACjC;AACF;AAKO,MAAM,0BAA0B,IAAI,kBAAA;AA2BpC,SAAS,aAAa,GAAW,GAAW;AACjD,QAAM,CAAC,IAAI,EAAE,IAAI;AACjB,QAAM,CAAC,IAAI,EAAE,IAAI;AAGjB,QAAM,UAAyB;AAAA,IAC7B,GAAG,MAAM,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;AAAA;AAAA,IAC7B,GAAG,MAAM,KAAK,IAAI,IAAI,EAAE,GAAG,EAAE;AAAA;AAAA,EAAA;AAI/B,QAAM,UAAyB;AAAA,IAC7B,GAAG,MAAM,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;AAAA,IAC7B,GAAG,MAAM,KAAK,IAAI,IAAI,EAAE,GAAG,EAAE;AAAA,EAAA;AAG/B,SAAO,EAAE,SAAS,QAAA;AACpB;AAEA,SAAS,MAAM,OAAe,KAA4B;AACxD,QAAM,MAAqB,CAAA;AAC3B,WAAS,IAAI,OAAO,IAAI,KAAK,IAAK,KAAI,KAAK,CAAC;AAC5C,SAAO;AACT;AAMO,SAAS,YAAY,GAAoB,GAA4B;AAE1E,MAAI,OAAO,MAAM,OAAO,GAAG;AACzB,QAAI,IAAI,EAAG,QAAO;AAClB,QAAI,IAAI,EAAG,QAAO;AAClB,WAAO;AAAA,EACT;AAEA,SAAO,OAAO,MAAM,WAAW,KAAK;AACtC;AAOO,SAAS,eAAe,OAAwB;AACrD,SAAO,KAAK,UAAU,OAAO,CAAC,GAAG,QAAQ;AACvC,QAAI,OAAO,QAAQ,UAAU;AAC3B,aAAO,IAAI,SAAA;AAAA,IACb;AACA,QAAI,eAAe,MAAM;AACvB,aAAO,IAAI,YAAA;AAAA,IACb;AACA,WAAO;AAAA,EACT,CAAC;AACH;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/db-ivm",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Incremental View Maintenance for TanStack DB based on Differential Dataflow",
5
5
  "author": "Sam Willis",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -2,4 +2,4 @@ export * from './d2.js'
2
2
  export * from './multiset.js'
3
3
  export * from './operators/index.js'
4
4
  export * from './types.js'
5
- export { compareKeys } from './utils.js'
5
+ export { compareKeys, serializeValue } from './utils.js'
@@ -1,3 +1,4 @@
1
+ import { serializeValue } from '../utils.js'
1
2
  import { map } from './map.js'
2
3
  import { reduce } from './reduce.js'
3
4
  import type { IStreamBuilder, KeyValue } from '../types.js'
@@ -67,7 +68,7 @@ export function groupBy<
67
68
  const withKeysAndValues = stream.pipe(
68
69
  map((data) => {
69
70
  const key = keyExtractor(data)
70
- const keyString = JSON.stringify(key)
71
+ const keyString = serializeValue(key)
71
72
 
72
73
  // Create values object with pre-aggregated values
73
74
  const values: Record<string, unknown> = {}
@@ -0,0 +1,192 @@
1
+ import { DifferenceStreamWriter, UnaryOperator } from '../graph.js'
2
+ import { StreamBuilder } from '../d2.js'
3
+ import { MultiSet } from '../multiset.js'
4
+ import { TopKState, handleMoveIn, handleMoveOut } from './topKState.js'
5
+ import { TopKArray, createKeyedComparator } from './topKArray.js'
6
+ import type { IndexedValue, TopK } from './topKArray.js'
7
+ import type { DifferenceStreamReader } from '../graph.js'
8
+ import type { IStreamBuilder, PipedOperator } from '../types.js'
9
+
10
+ export interface GroupedTopKWithFractionalIndexOptions<K, T> {
11
+ limit?: number
12
+ offset?: number
13
+ setSizeCallback?: (getSize: () => number) => void
14
+ setWindowFn?: (
15
+ windowFn: (options: { offset?: number; limit?: number }) => void,
16
+ ) => void
17
+ /**
18
+ * Function to extract a group key from the element's key and value.
19
+ * Elements with the same group key will be sorted and limited together.
20
+ */
21
+ groupKeyFn: (key: K, value: T) => unknown
22
+ }
23
+
24
+ /**
25
+ * Operator for grouped fractional indexed topK operations.
26
+ * This operator maintains separate topK windows for each group,
27
+ * allowing per-group limits and ordering.
28
+ *
29
+ * The input is a keyed stream [K, T] and outputs [K, IndexedValue<T>].
30
+ * Elements are grouped by the groupKeyFn, and each group maintains
31
+ * its own sorted collection with independent limit/offset.
32
+ */
33
+ export class GroupedTopKWithFractionalIndexOperator<
34
+ K extends string | number,
35
+ T,
36
+ > extends UnaryOperator<[K, T], [K, IndexedValue<T>]> {
37
+ #groupStates: Map<unknown, TopKState<K, T>> = new Map()
38
+ #groupKeyFn: (key: K, value: T) => unknown
39
+ #comparator: (a: [K, T], b: [K, T]) => number
40
+ #offset: number
41
+ #limit: number
42
+
43
+ constructor(
44
+ id: number,
45
+ inputA: DifferenceStreamReader<[K, T]>,
46
+ output: DifferenceStreamWriter<[K, IndexedValue<T>]>,
47
+ comparator: (a: T, b: T) => number,
48
+ options: GroupedTopKWithFractionalIndexOptions<K, T>,
49
+ ) {
50
+ super(id, inputA, output)
51
+ this.#groupKeyFn = options.groupKeyFn
52
+ this.#limit = options.limit ?? Infinity
53
+ this.#offset = options.offset ?? 0
54
+ this.#comparator = createKeyedComparator(comparator)
55
+ options.setSizeCallback?.(() => this.#getTotalSize())
56
+ options.setWindowFn?.(this.#moveTopK.bind(this))
57
+ }
58
+
59
+ /**
60
+ * Creates a new TopK data structure for a group.
61
+ * Can be overridden in subclasses to use different implementations (e.g., B+ tree).
62
+ */
63
+ protected createTopK(
64
+ offset: number,
65
+ limit: number,
66
+ comparator: (a: [K, T], b: [K, T]) => number,
67
+ ): TopK<[K, T]> {
68
+ return new TopKArray(offset, limit, comparator)
69
+ }
70
+
71
+ #getTotalSize(): number {
72
+ let size = 0
73
+ for (const state of this.#groupStates.values()) {
74
+ size += state.size
75
+ }
76
+ return size
77
+ }
78
+
79
+ #getOrCreateGroupState(groupKey: unknown): TopKState<K, T> {
80
+ let state = this.#groupStates.get(groupKey)
81
+ if (!state) {
82
+ const topK = this.createTopK(this.#offset, this.#limit, this.#comparator)
83
+ state = new TopKState(topK)
84
+ this.#groupStates.set(groupKey, state)
85
+ }
86
+ return state
87
+ }
88
+
89
+ #cleanupGroupIfEmpty(groupKey: unknown, state: TopKState<K, T>): void {
90
+ if (state.isEmpty) {
91
+ this.#groupStates.delete(groupKey)
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Moves the topK window for all groups based on the provided offset and limit.
97
+ * Any changes to the topK are sent to the output.
98
+ */
99
+ #moveTopK({ offset, limit }: { offset?: number; limit?: number }): void {
100
+ if (offset !== undefined) {
101
+ this.#offset = offset
102
+ }
103
+ if (limit !== undefined) {
104
+ this.#limit = limit
105
+ }
106
+
107
+ const result: Array<[[K, IndexedValue<T>], number]> = []
108
+ let hasChanges = false
109
+
110
+ for (const state of this.#groupStates.values()) {
111
+ const diff = state.move({ offset: this.#offset, limit: this.#limit }) // TODO: think we should just pass offset and limit
112
+
113
+ diff.moveIns.forEach((moveIn) => handleMoveIn(moveIn, result))
114
+ diff.moveOuts.forEach((moveOut) => handleMoveOut(moveOut, result))
115
+
116
+ if (diff.changes) {
117
+ hasChanges = true
118
+ }
119
+ }
120
+
121
+ if (hasChanges) {
122
+ this.output.sendData(new MultiSet(result))
123
+ }
124
+ }
125
+
126
+ run(): void {
127
+ const result: Array<[[K, IndexedValue<T>], number]> = []
128
+ for (const message of this.inputMessages()) {
129
+ for (const [item, multiplicity] of message.getInner()) {
130
+ const [key, value] = item
131
+ this.#processElement(key, value, multiplicity, result)
132
+ }
133
+ }
134
+
135
+ if (result.length > 0) {
136
+ this.output.sendData(new MultiSet(result))
137
+ }
138
+ }
139
+
140
+ #processElement(
141
+ key: K,
142
+ value: T,
143
+ multiplicity: number,
144
+ result: Array<[[K, IndexedValue<T>], number]>,
145
+ ): void {
146
+ const groupKey = this.#groupKeyFn(key, value)
147
+ const state = this.#getOrCreateGroupState(groupKey)
148
+
149
+ const changes = state.processElement(key, value, multiplicity)
150
+ handleMoveIn(changes.moveIn, result)
151
+ handleMoveOut(changes.moveOut, result)
152
+
153
+ // Cleanup empty groups to prevent memory leaks
154
+ this.#cleanupGroupIfEmpty(groupKey, state)
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Limits the number of results per group based on a comparator, with optional offset.
160
+ * Uses fractional indexing to minimize the number of changes when elements move positions.
161
+ * Each element is assigned a fractional index that is lexicographically sortable.
162
+ * When elements move, only the indices of the moved elements are updated, not all elements.
163
+ *
164
+ * This operator groups elements by the provided groupKeyFn and applies the limit/offset
165
+ * independently to each group.
166
+ *
167
+ * @param comparator - A function that compares two elements for ordering
168
+ * @param options - Configuration including groupKeyFn, limit, and offset
169
+ * @returns A piped operator that orders elements per group and limits results per group
170
+ */
171
+ export function groupedTopKWithFractionalIndex<K extends string | number, T>(
172
+ comparator: (a: T, b: T) => number,
173
+ options: GroupedTopKWithFractionalIndexOptions<K, T>,
174
+ ): PipedOperator<[K, T], [K, IndexedValue<T>]> {
175
+ return (
176
+ stream: IStreamBuilder<[K, T]>,
177
+ ): IStreamBuilder<[K, IndexedValue<T>]> => {
178
+ const output = new StreamBuilder<[K, IndexedValue<T>]>(
179
+ stream.graph,
180
+ new DifferenceStreamWriter<[K, IndexedValue<T>]>(),
181
+ )
182
+ const operator = new GroupedTopKWithFractionalIndexOperator<K, T>(
183
+ stream.graph.getNextOperatorId(),
184
+ stream.connectReader(),
185
+ output.writer,
186
+ comparator,
187
+ options,
188
+ )
189
+ stream.graph.addOperator(operator)
190
+ return output
191
+ }
192
+ }
@@ -14,6 +14,7 @@ export * from './distinct.js'
14
14
  export * from './keying.js'
15
15
  export * from './topK.js'
16
16
  export * from './topKWithFractionalIndex.js'
17
+ export * from './groupedTopKWithFractionalIndex.js'
17
18
  export * from './orderBy.js'
18
19
  export * from './filterBy.js'
19
20
  export { groupBy, groupByOperators } from './groupBy.js'
@@ -0,0 +1,255 @@
1
+ import { generateKeyBetween } from 'fractional-indexing'
2
+ import { binarySearch, compareKeys, diffHalfOpen } from '../utils.js'
3
+ import type { HRange } from '../utils.js'
4
+
5
+ // Abstraction for fractionally indexed values
6
+ export type FractionalIndex = string
7
+ export type IndexedValue<V> = [V, FractionalIndex]
8
+
9
+ export function indexedValue<V>(
10
+ value: V,
11
+ index: FractionalIndex,
12
+ ): IndexedValue<V> {
13
+ return [value, index]
14
+ }
15
+
16
+ export function getValue<V>(indexedVal: IndexedValue<V>): V {
17
+ return indexedVal[0]
18
+ }
19
+
20
+ export function getIndex<V>(indexedVal: IndexedValue<V>): FractionalIndex {
21
+ return indexedVal[1]
22
+ }
23
+
24
+ /**
25
+ * Creates a comparator for [key, value] tuples that first compares values,
26
+ * then uses the row key as a stable tie-breaker.
27
+ */
28
+ export function createKeyedComparator<K extends string | number, T>(
29
+ comparator: (a: T, b: T) => number,
30
+ ): (a: [K, T], b: [K, T]) => number {
31
+ return ([aKey, aVal], [bKey, bVal]) => {
32
+ // First compare on the value
33
+ const valueComparison = comparator(aVal, bVal)
34
+ if (valueComparison !== 0) {
35
+ return valueComparison
36
+ }
37
+ // If the values are equal, use the row key as tie-breaker
38
+ // This provides stable, deterministic ordering since keys are string | number
39
+ return compareKeys(aKey, bKey)
40
+ }
41
+ }
42
+
43
+ export type TopKChanges<V> = {
44
+ /** Indicates which element moves into the topK (if any) */
45
+ moveIn: IndexedValue<V> | null
46
+ /** Indicates which element moves out of the topK (if any) */
47
+ moveOut: IndexedValue<V> | null
48
+ }
49
+
50
+ export type TopKMoveChanges<V> = {
51
+ /** Flag that marks whether there were any changes to the topK */
52
+ changes: boolean
53
+ /** Indicates which elements move into the topK (if any) */
54
+ moveIns: Array<IndexedValue<V>>
55
+ /** Indicates which elements move out of the topK (if any) */
56
+ moveOuts: Array<IndexedValue<V>>
57
+ }
58
+
59
+ /**
60
+ * A topK data structure that supports insertions and deletions
61
+ * and returns changes to the topK.
62
+ */
63
+ export interface TopK<V> {
64
+ size: number
65
+ insert: (value: V) => TopKChanges<V>
66
+ delete: (value: V) => TopKChanges<V>
67
+ }
68
+
69
+ /**
70
+ * Implementation of a topK data structure.
71
+ * Uses a sorted array internally to store the values and keeps a topK window over that array.
72
+ * Inserts and deletes are O(n) operations because worst case an element is inserted/deleted
73
+ * at the start of the array which causes all the elements to shift to the right/left.
74
+ */
75
+ export class TopKArray<V> implements TopK<V> {
76
+ #sortedValues: Array<IndexedValue<V>> = []
77
+ #comparator: (a: V, b: V) => number
78
+ #topKStart: number
79
+ #topKEnd: number
80
+
81
+ constructor(
82
+ offset: number,
83
+ limit: number,
84
+ comparator: (a: V, b: V) => number,
85
+ ) {
86
+ this.#topKStart = offset
87
+ this.#topKEnd = offset + limit
88
+ this.#comparator = comparator
89
+ }
90
+
91
+ get size(): number {
92
+ const offset = this.#topKStart
93
+ const limit = this.#topKEnd - this.#topKStart
94
+ const available = this.#sortedValues.length - offset
95
+ return Math.max(0, Math.min(limit, available))
96
+ }
97
+
98
+ /**
99
+ * Moves the topK window
100
+ */
101
+ move({
102
+ offset,
103
+ limit,
104
+ }: {
105
+ offset?: number
106
+ limit?: number
107
+ }): TopKMoveChanges<V> {
108
+ const oldOffset = this.#topKStart
109
+ const oldLimit = this.#topKEnd - this.#topKStart
110
+
111
+ // `this.#topKEnd` can be `Infinity` if it has no limit
112
+ // but `diffHalfOpen` expects a finite range
113
+ // so we restrict it to the size of the topK if topKEnd is infinite
114
+ const oldRange: HRange = [
115
+ this.#topKStart,
116
+ this.#topKEnd === Infinity ? this.#topKStart + this.size : this.#topKEnd,
117
+ ]
118
+
119
+ this.#topKStart = offset ?? oldOffset
120
+ this.#topKEnd = this.#topKStart + (limit ?? oldLimit) // can be `Infinity` if limit is `Infinity`
121
+
122
+ // Also handle `Infinity` in the newRange
123
+ const newRange: HRange = [
124
+ this.#topKStart,
125
+ this.#topKEnd === Infinity
126
+ ? Math.max(this.#topKStart + this.size, oldRange[1]) // since the new limit is Infinity we need to take everything (so we need to take the biggest (finite) topKEnd)
127
+ : this.#topKEnd,
128
+ ]
129
+ const { onlyInA, onlyInB } = diffHalfOpen(oldRange, newRange)
130
+
131
+ const moveIns: Array<IndexedValue<V>> = []
132
+ onlyInB.forEach((index) => {
133
+ const value = this.#sortedValues[index]
134
+ if (value) {
135
+ moveIns.push(value)
136
+ }
137
+ })
138
+
139
+ const moveOuts: Array<IndexedValue<V>> = []
140
+ onlyInA.forEach((index) => {
141
+ const value = this.#sortedValues[index]
142
+ if (value) {
143
+ moveOuts.push(value)
144
+ }
145
+ })
146
+
147
+ // It could be that there are changes (i.e. moveIns or moveOuts)
148
+ // but that the collection is lazy so we don't have the data yet that needs to move in/out
149
+ // so `moveIns` and `moveOuts` will be empty but `changes` will be true
150
+ // this will tell the caller that it needs to run the graph to load more data
151
+ return { moveIns, moveOuts, changes: onlyInA.length + onlyInB.length > 0 }
152
+ }
153
+
154
+ insert(value: V): TopKChanges<V> {
155
+ const result: TopKChanges<V> = { moveIn: null, moveOut: null }
156
+
157
+ // Lookup insert position
158
+ const index = this.#findIndex(value)
159
+ // Generate fractional index based on the fractional indices of the elements before and after it
160
+ const indexBefore =
161
+ index === 0 ? null : getIndex(this.#sortedValues[index - 1]!)
162
+ const indexAfter =
163
+ index === this.#sortedValues.length
164
+ ? null
165
+ : getIndex(this.#sortedValues[index]!)
166
+ const fractionalIndex = generateKeyBetween(indexBefore, indexAfter)
167
+
168
+ // Insert the value at the correct position
169
+ const val = indexedValue(value, fractionalIndex)
170
+ // Splice is O(n) where n = all elements in the collection (i.e. n >= k) !
171
+ this.#sortedValues.splice(index, 0, val)
172
+
173
+ // Check if the topK changed
174
+ if (index < this.#topKEnd) {
175
+ // The inserted element is either before the top K or within the top K
176
+ // If it is before the top K then it moves the element that was right before the topK into the topK
177
+ // If it is within the top K then the inserted element moves into the top K
178
+ // In both cases the last element of the old top K now moves out of the top K
179
+ const moveInIndex = Math.max(index, this.#topKStart)
180
+ if (moveInIndex < this.#sortedValues.length) {
181
+ // We actually have a topK
182
+ // because in some cases there may not be enough elements in the array to reach the start of the topK
183
+ // e.g. [1, 2, 3] with K = 2 and offset = 3 does not have a topK
184
+ result.moveIn = this.#sortedValues[moveInIndex]!
185
+
186
+ // We need to remove the element that falls out of the top K
187
+ // The element that falls out of the top K has shifted one to the right
188
+ // because of the element we inserted, so we find it at index topKEnd
189
+ if (this.#topKEnd < this.#sortedValues.length) {
190
+ result.moveOut = this.#sortedValues[this.#topKEnd]!
191
+ }
192
+ }
193
+ }
194
+
195
+ return result
196
+ }
197
+
198
+ /**
199
+ * Deletes a value that may or may not be in the topK.
200
+ * IMPORTANT: this assumes that the value is present in the collection
201
+ * if it's not the case it will remove the element
202
+ * that is on the position where the provided `value` would be.
203
+ */
204
+ delete(value: V): TopKChanges<V> {
205
+ const result: TopKChanges<V> = { moveIn: null, moveOut: null }
206
+
207
+ // Lookup delete position
208
+ const index = this.#findIndex(value)
209
+ // Remove the value at that position
210
+ const [removedElem] = this.#sortedValues.splice(index, 1)
211
+
212
+ // Check if the topK changed
213
+ if (index < this.#topKEnd) {
214
+ // The removed element is either before the top K or within the top K
215
+ // If it is before the top K then the first element of the topK moves out of the topK
216
+ // If it is within the top K then the removed element moves out of the topK
217
+ result.moveOut = removedElem!
218
+ if (index < this.#topKStart) {
219
+ // The removed element is before the topK
220
+ // so actually, the first element of the topK moves out of the topK
221
+ // and not the element that we removed
222
+ // The first element of the topK is now at index topKStart - 1
223
+ // since we removed an element before the topK
224
+ const moveOutIndex = this.#topKStart - 1
225
+ if (moveOutIndex < this.#sortedValues.length) {
226
+ result.moveOut = this.#sortedValues[moveOutIndex]!
227
+ } else {
228
+ // No value is moving out of the topK
229
+ // because there are no elements in the topK
230
+ result.moveOut = null
231
+ }
232
+ }
233
+
234
+ // Since we removed an element that was before or in the topK
235
+ // the first element after the topK moved one position to the left
236
+ // and thus falls into the topK now
237
+ const moveInIndex = this.#topKEnd - 1
238
+ if (moveInIndex < this.#sortedValues.length) {
239
+ result.moveIn = this.#sortedValues[moveInIndex]!
240
+ }
241
+ }
242
+
243
+ return result
244
+ }
245
+
246
+ // TODO: see if there is a way to refactor the code for insert and delete in the topK above
247
+ // because they are very similar, one is shifting the topK window to the left and the other is shifting it to the right
248
+ // so i have the feeling there is a common pattern here and we can implement both cases using that pattern
249
+
250
+ #findIndex(value: V): number {
251
+ return binarySearch(this.#sortedValues, indexedValue(value, ``), (a, b) =>
252
+ this.#comparator(getValue(a), getValue(b)),
253
+ )
254
+ }
255
+ }
@@ -0,0 +1,111 @@
1
+ import { TopKArray } from './topKArray.js'
2
+ import type {
3
+ IndexedValue,
4
+ TopK,
5
+ TopKChanges,
6
+ TopKMoveChanges,
7
+ } from './topKArray.js'
8
+
9
+ /**
10
+ * Helper class that manages the state for a single topK window.
11
+ * Encapsulates the multiplicity tracking and topK data structure,
12
+ * providing a clean interface for processing elements and moving the window.
13
+ *
14
+ * This class is used by both TopKWithFractionalIndexOperator (single instance)
15
+ * and GroupedTopKWithFractionalIndexOperator (one instance per group).
16
+ */
17
+ export class TopKState<K extends string | number, T> {
18
+ #multiplicities: Map<K, number> = new Map()
19
+ #topK: TopK<[K, T]>
20
+
21
+ constructor(topK: TopK<[K, T]>) {
22
+ this.#topK = topK
23
+ }
24
+
25
+ get size(): number {
26
+ return this.#topK.size
27
+ }
28
+
29
+ get isEmpty(): boolean {
30
+ return this.#multiplicities.size === 0 && this.#topK.size === 0
31
+ }
32
+
33
+ /**
34
+ * Process an element update (insert or delete based on multiplicity change).
35
+ * Returns the changes to the topK window.
36
+ */
37
+ processElement(key: K, value: T, multiplicity: number): TopKChanges<[K, T]> {
38
+ const { oldMultiplicity, newMultiplicity } = this.#updateMultiplicity(
39
+ key,
40
+ multiplicity,
41
+ )
42
+
43
+ if (oldMultiplicity <= 0 && newMultiplicity > 0) {
44
+ // The value was invisible but should now be visible
45
+ return this.#topK.insert([key, value])
46
+ } else if (oldMultiplicity > 0 && newMultiplicity <= 0) {
47
+ // The value was visible but should now be invisible
48
+ return this.#topK.delete([key, value])
49
+ }
50
+ // The value was invisible and remains invisible,
51
+ // or was visible and remains visible - no topK change
52
+ return { moveIn: null, moveOut: null }
53
+ }
54
+
55
+ /**
56
+ * Move the topK window. Only works with TopKArray implementation.
57
+ */
58
+ move(options: { offset?: number; limit?: number }): TopKMoveChanges<[K, T]> {
59
+ if (!(this.#topK instanceof TopKArray)) {
60
+ throw new Error(
61
+ `Cannot move B+-tree implementation of TopK with fractional index`,
62
+ )
63
+ }
64
+ return this.#topK.move(options)
65
+ }
66
+
67
+ #updateMultiplicity(
68
+ key: K,
69
+ multiplicity: number,
70
+ ): { oldMultiplicity: number; newMultiplicity: number } {
71
+ if (multiplicity === 0) {
72
+ const current = this.#multiplicities.get(key) ?? 0
73
+ return { oldMultiplicity: current, newMultiplicity: current }
74
+ }
75
+
76
+ const oldMultiplicity = this.#multiplicities.get(key) ?? 0
77
+ const newMultiplicity = oldMultiplicity + multiplicity
78
+ if (newMultiplicity === 0) {
79
+ this.#multiplicities.delete(key)
80
+ } else {
81
+ this.#multiplicities.set(key, newMultiplicity)
82
+ }
83
+ return { oldMultiplicity, newMultiplicity }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Handles a moveIn change by adding it to the result array.
89
+ */
90
+ export function handleMoveIn<K extends string | number, T>(
91
+ moveIn: IndexedValue<[K, T]> | null,
92
+ result: Array<[[K, IndexedValue<T>], number]>,
93
+ ): void {
94
+ if (moveIn) {
95
+ const [[key, value], index] = moveIn
96
+ result.push([[key, [value, index]], 1])
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Handles a moveOut change by adding it to the result array.
102
+ */
103
+ export function handleMoveOut<K extends string | number, T>(
104
+ moveOut: IndexedValue<[K, T]> | null,
105
+ result: Array<[[K, IndexedValue<T>], number]>,
106
+ ): void {
107
+ if (moveOut) {
108
+ const [[key, value], index] = moveOut
109
+ result.push([[key, [value, index]], -1])
110
+ }
111
+ }