diff --git a/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts b/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts index 4e81fc46d3..9252b79b06 100644 --- a/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts +++ b/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts @@ -61,3 +61,112 @@ describe('installPdfjsDomGlobals base64 codecs', () => { expect(new TextDecoder().decode(plaintext)).toBe('integration-secret'); }); }); + +describe('installPdfjsDomGlobals Map.getOrInsertComputed', () => { + it('installs the method pdfjs calls on its Maps', () => { + // Its absence is what broke the operator-list read per page (#3018). + installPdfjsDomGlobals(); + expect(typeof Map.prototype.getOrInsertComputed).toBe('function'); + }); + + it('computes and inserts when the key is absent', () => { + installPdfjsDomGlobals(); + const map = new Map(); + const calls: string[] = []; + const value = map.getOrInsertComputed('a', (key) => { + calls.push(key); + return 1; + }); + expect(value).toBe(1); + expect(map.get('a')).toBe(1); + expect(calls).toEqual(['a']); + }); + + it('returns the existing value without calling the callback', () => { + // pdfjs relies on this: the callback builds an intent state, and calling + // it twice would discard the one already in flight. + installPdfjsDomGlobals(); + const map = new Map([['a', 1]]); + let called = false; + const value = map.getOrInsertComputed('a', () => { + called = true; + return 2; + }); + expect(value).toBe(1); + expect(called).toBe(false); + }); + + it('stores a value the callback returns even when it is undefined', () => { + // Presence, not truthiness: a second call must not recompute. + installPdfjsDomGlobals(); + const map = new Map(); + map.getOrInsertComputed('a', () => undefined); + expect(map.has('a')).toBe(true); + let recomputed = false; + map.getOrInsertComputed('a', () => { + recomputed = true; + return undefined; + }); + expect(recomputed).toBe(false); + }); + + it('lets the computed value win when the callback inserted the key itself', () => { + // The proposal re-checks after the callback and overwrites, so a callback + // with a side effect cannot leave the map disagreeing with the return. + installPdfjsDomGlobals(); + const map = new Map(); + const value = map.getOrInsertComputed('a', () => { + map.set('a', 99); + return 1; + }); + expect(value).toBe(1); + expect(map.get('a')).toBe(1); + }); + + it('rejects a callback that is not callable, even when the key exists', () => { + // Spec order: the callable check comes BEFORE the lookup. Without it, a + // present key would return happily and a bad callback would go unnoticed + // until some later call happened to miss. + installPdfjsDomGlobals(); + const present = new Map([['a', 1]]); + expect(() => + ( + present as unknown as { + getOrInsertComputed: (k: string, f: unknown) => void; + } + ).getOrInsertComputed('a', 'not a function'), + ).toThrow(TypeError); + + const absent = new Map(); + expect(() => + ( + absent as unknown as { + getOrInsertComputed: (k: string, f: unknown) => void; + } + ).getOrInsertComputed('a', 'not a function'), + ).toThrow(TypeError); + }); + + it('leaves a real runtime implementation alone', () => { + // Guarded like every other shim here, so Node ≥24 wins. + const marker = function (this: Map) { + return 'native'; + }; + // oxlint-disable-next-line no-extend-native -- test stands in for a native implementation + Object.defineProperty(Map.prototype, 'getOrInsertComputed', { + value: marker, + writable: true, + configurable: true, + }); + try { + installPdfjsDomGlobals(); + expect(Map.prototype.getOrInsertComputed).toBe(marker); + } finally { + // The guard will not replace a function, so the stand-in has to be + // removed here or every later suite in this worker inherits it. + delete (Map.prototype as { getOrInsertComputed?: unknown }) + .getOrInsertComputed; + installPdfjsDomGlobals(); + } + }); +}); diff --git a/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.ts b/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.ts index cfe9bcf11a..39c1180080 100644 --- a/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.ts +++ b/services/platform/convex/lib/knowledge/extraction/pdfjs_dom_polyfill.ts @@ -344,6 +344,36 @@ function ensureEs2025Shims(): void { configurable: true, }); } + // pdfjs 5.x calls this on plain Maps in both the main and worker builds + // (`_intentStates`, `methodPromises`, `objs`, …). Node 22 lacks it, and the + // failure is quiet: the operator-list read throws per page, which is the + // path that finds embedded images and decides "scanned page → OCR". Text + // extraction still runs, so a digital PDF looks fine while a scanned one + // indexes thin or empty with only a warning to say so (#3018). + // + // Spec shape: return the existing value if the key is present; otherwise + // compute, insert, and return. `set` after the callback is what the proposal + // does too — the callback may itself have inserted the key, and the computed + // value wins. + if (typeof Map.prototype.getOrInsertComputed !== 'function') { + // oxlint-disable-next-line no-extend-native -- deliberate spec-shaped polyfill of an ES2025 method Node 22 lacks; guarded so a real runtime implementation wins + Object.defineProperty(Map.prototype, 'getOrInsertComputed', { + value: function (this: Map, key: K, callbackfn: (k: K) => V) { + if (typeof callbackfn !== 'function') { + throw new TypeError( + 'Map.prototype.getOrInsertComputed: callback is not a function', + ); + } + if (this.has(key)) return this.get(key); + const value = callbackfn(key); + this.set(key, value); + return value; + }, + writable: true, + configurable: true, + }); + } + if (typeof Uint8Array.prototype.toHex !== 'function') { // oxlint-disable-next-line no-extend-native -- deliberate spec-shaped polyfill of an ES2025 method Node 22 lacks; guarded so a real runtime implementation wins Object.defineProperty(Uint8Array.prototype, 'toHex', {