diff --git a/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts b/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts index 4e81fc46d3..3f029b6eac 100644 --- a/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts +++ b/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.test.ts @@ -61,3 +61,64 @@ 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('refuses a callback that is not callable', () => { + installPdfjsDomGlobals(); + const map = new Map(); + expect(() => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the guard exists for callers without types + map.getOrInsertComputed( + 'a', + undefined as unknown as (k: string) => number, + ), + ).toThrow(TypeError); + }); +}); diff --git a/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.ts b/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.ts index cfe9bcf11a..88af27e2fe 100644 --- a/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.ts +++ b/services/platform/backend/core/lib/knowledge/extraction/pdfjs_dom_polyfill.ts @@ -1,8 +1,8 @@ 'use node'; /** - * Pure-JS DOM-global polyfills for running pdfjs-dist in the Convex node - * action runtime. + * Pure-JS DOM-global polyfills, plus the ES2025 shims pdfjs expects, for + * running pdfjs-dist on the backend's Node worker. * * pdfjs 5.x's Node setup polyfills `DOMMatrix` / `ImageData` / `Path2D` by * `require("@napi-rs/canvas")` — a NATIVE module. Convex extracts a bundled @@ -324,6 +324,37 @@ function ensureEs2025Shims(): void { }); } + // 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 once per page, and that 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 when the key is present; otherwise + // compute, insert, and return. Presence, not truthiness — a callback that + // returns `undefined` still counts, so a second call does not recompute. + // `set` runs after the callback, as the proposal does: the callback may have + // inserted the key itself, 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, + }); + } + // Install when missing, or replace a process-global poison shim that // ignores `alphabet` (regression: always-`base64` loop). Native Node ≥24 // / Bun implementations honor options and win the probe below.