From cad67978411a99bf2422d4926a660e11f94f90a4 Mon Sep 17 00:00:00 2001 From: Ben Senescu Date: Fri, 31 Oct 2025 11:43:56 -0400 Subject: [PATCH 1/4] Cloudflare Proxy --- .changeset/cloudflare-workers-support.md | 13 + packages/db/CLOUDFLARE_WORKERS.md | 115 ++++++++ packages/db/src/collection/index.ts | 38 ++- packages/db/src/index.ts | 1 + packages/db/src/utils/lazy-init.ts | 88 ++++++ packages/db/tests/cloudflare-workers.test.ts | 269 +++++++++++++++++++ 6 files changed, 515 insertions(+), 9 deletions(-) create mode 100644 .changeset/cloudflare-workers-support.md create mode 100644 packages/db/CLOUDFLARE_WORKERS.md create mode 100644 packages/db/src/utils/lazy-init.ts create mode 100644 packages/db/tests/cloudflare-workers.test.ts diff --git a/.changeset/cloudflare-workers-support.md b/.changeset/cloudflare-workers-support.md new file mode 100644 index 000000000..492ba83c4 --- /dev/null +++ b/.changeset/cloudflare-workers-support.md @@ -0,0 +1,13 @@ +--- +"@tanstack/db": minor +--- + +Add automatic Cloudflare Workers runtime detection and lazy initialization + +Collections created with `createCollection` now automatically detect when running in Cloudflare Workers environment and defer initialization to prevent "Disallowed operation called within global scope" errors. This is done using a transparent proxy that initializes the collection on first access. + +The detection uses `navigator.userAgent === 'Cloudflare-Workers'` to identify the runtime environment. + +Also exports `lazyInitForWorkers` utility function for advanced use cases where users need to apply lazy loading to other resources. + +This change is backwards compatible and requires no code changes for existing users. diff --git a/packages/db/CLOUDFLARE_WORKERS.md b/packages/db/CLOUDFLARE_WORKERS.md new file mode 100644 index 000000000..9995b2972 --- /dev/null +++ b/packages/db/CLOUDFLARE_WORKERS.md @@ -0,0 +1,115 @@ +# Cloudflare Workers Compatibility + +TanStack DB automatically detects when running in Cloudflare Workers runtime and defers collection initialization to prevent "Disallowed operation called within global scope" errors. + +## Automatic Detection + +Starting from version 0.5.0, `createCollection` automatically detects Cloudflare Workers runtime using `navigator.userAgent === 'Cloudflare-Workers'` and applies lazy initialization when needed. + +```typescript +import { createCollection } from "@tanstack/db" +import { queryCollectionOptions } from "@tanstack/query-db-collection" + +// This works in Cloudflare Workers without any changes! +export const missionsCollection = createCollection( + queryCollectionOptions({ + queryKey: orpc.missions.list.queryKey(), + queryFn: () => orpc.missions.list.call(), + queryClient, + getKey: (item) => item.id, + }) +) +``` + +## How It Works + +In Cloudflare Workers, certain operations (like creating Promises or using the crypto API) are not allowed in the global scope. TanStack DB solves this by: + +1. Detecting the Cloudflare Workers runtime environment +2. Wrapping the collection in a transparent lazy-loading proxy +3. Deferring initialization until the first property access (which happens during request handling, not at module load time) + +The proxy is completely transparent - you use the collection exactly as you would in any other environment. + +## Manual Lazy Loading (Advanced) + +If you need to apply lazy loading to other resources, you can use the `lazyInitForWorkers` utility function: + +```typescript +import { lazyInitForWorkers } from "@tanstack/db" + +export const myResource = lazyInitForWorkers(() => { + // This code only runs when myResource is first accessed + return createExpensiveResource() +}) +``` + +## Performance Considerations + +The lazy initialization approach has minimal performance impact: + +- **Singleton behavior**: The collection is only initialized once, on first access +- **Transparent proxy**: All operations are passed directly to the underlying instance +- **No overhead after initialization**: Once initialized, there's no proxying overhead + +## Query Collection Synchronization + +When using query collections (TanStack Query integration), the synchronization works correctly with lazy initialization: + +- The query client is used as the data source +- Multiple lazy collections can share the same query client +- Each collection maintains its own subscription to the query client +- Changes in the query client are propagated to all collections + +## Live Queries + +Live queries work seamlessly with lazy initialization: + +- Collections subscribe to other collections on first access +- Reactivity is maintained through the query client +- No additional configuration needed + +## Testing + +When testing code that uses collections in a simulated Cloudflare Workers environment: + +```typescript +// Mock Cloudflare Workers environment +Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "Cloudflare-Workers" }, + writable: true, + configurable: true, +}) + +// Your collections will automatically use lazy initialization +const collection = createCollection(...) +``` + +## Troubleshooting + +### Collection is undefined or null + +Make sure you're accessing the collection during request handling, not at module load time: + +```typescript +// ❌ BAD: Don't do this at module level +const data = collection.toArray // This runs at module load + +// ✅ GOOD: Access during request handling +export default { + async fetch(request: Request) { + const data = collection.toArray // This runs during request + return Response.json(data) + }, +} +``` + +### Type errors with lazy collections + +The lazy proxy should be completely transparent to TypeScript, but if you encounter type issues, you can cast to `any` for specific operations: + +```typescript +const collection = createCollection(...) as any +``` + +However, this should rarely be necessary - please report any type issues you encounter! diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 5a4ab3c07..a89abeee7 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -2,6 +2,7 @@ import { CollectionRequiresConfigError, CollectionRequiresSyncConfigError, } from "../errors" +import { lazyInitForWorkers } from "../utils/lazy-init" import { currentStateAsChanges } from "./change-events" import { CollectionStateManager } from "./state" @@ -185,18 +186,37 @@ export function createCollection( utils?: UtilsRecord } ): Collection { - const collection = new CollectionImpl( - options - ) + function _createCollection() { + const collection = new CollectionImpl( + options + ) + + // Copy utils to both top level and .utils namespace + if (options.utils) { + collection.utils = { ...options.utils } + } else { + collection.utils = {} + } + + return collection + } + + // Check if we're running in Cloudflare Workers runtime + // https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent + const isCloudflareWorkers = + typeof navigator !== `undefined` && + navigator.userAgent === `Cloudflare-Workers` - // Copy utils to both top level and .utils namespace - if (options.utils) { - collection.utils = { ...options.utils } - } else { - collection.utils = {} + // Workers runtime limitation + // Without this, initializing a collection causes this error: + // Disallowed operation called within global scope. + if (isCloudflareWorkers) { + return lazyInitForWorkers(() => { + return _createCollection() + }) } - return collection + return _createCollection() } export class CollectionImpl< diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a41cf51aa..c909c42b7 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -13,6 +13,7 @@ export * from "./optimistic-action" export * from "./local-only" export * from "./local-storage" export * from "./errors" +export { lazyInitForWorkers } from "./utils/lazy-init" // Index system exports export * from "./indexes/base-index.js" diff --git a/packages/db/src/utils/lazy-init.ts b/packages/db/src/utils/lazy-init.ts new file mode 100644 index 000000000..f167a1a30 --- /dev/null +++ b/packages/db/src/utils/lazy-init.ts @@ -0,0 +1,88 @@ +/** + * Wraps a factory function in a Proxy to defer initialization until first access. + * This prevents async operations (like creating Tanstack DB Collections) from running + * in Cloudflare Workers' global scope. + * + * @param factory - A function that creates and returns the resource. + * Must be a callback to defer execution; passing the value directly + * would evaluate it at module load time, triggering the Cloudflare error. + * @returns A Proxy that lazily initializes the resource on first property access + * + * @example + * ```ts + * export const myCollection = lazyInitForWorkers(() => + * createCollection(queryCollectionOptions({ + * queryKey: ["myData"], + * queryFn: async () => fetchData(), + * // ... other options + * })) + * ); + * ``` + */ +export function lazyInitForWorkers(factory: () => T): T { + // Closure: This variable is captured by getInstance() and the Proxy traps below. + // It remains in memory as long as the returned Proxy is referenced, enabling singleton behavior. + let instance: T | null = null + + function getInstance() { + if (!instance) { + instance = factory() + } + return instance + } + + return new Proxy({} as T, { + get(_target, prop, _receiver) { + const inst = getInstance() + return Reflect.get(inst, prop, inst) + }, + set(_target, prop, value, _receiver) { + const inst = getInstance() + return Reflect.set(inst, prop, value, inst) + }, + deleteProperty(_target, prop) { + const inst = getInstance() + return Reflect.deleteProperty(inst, prop) + }, + has(_target, prop) { + const inst = getInstance() + return Reflect.has(inst, prop) + }, + ownKeys(_target) { + const inst = getInstance() + return Reflect.ownKeys(inst) + }, + getOwnPropertyDescriptor(_target, prop) { + const inst = getInstance() + return Reflect.getOwnPropertyDescriptor(inst, prop) + }, + getPrototypeOf(_target) { + const inst = getInstance() + return Reflect.getPrototypeOf(inst) + }, + setPrototypeOf(_target, proto) { + const inst = getInstance() + return Reflect.setPrototypeOf(inst, proto) + }, + isExtensible(_target) { + const inst = getInstance() + return Reflect.isExtensible(inst) + }, + preventExtensions(_target) { + const inst = getInstance() + return Reflect.preventExtensions(inst) + }, + defineProperty(_target, prop, descriptor) { + const inst = getInstance() + return Reflect.defineProperty(inst, prop, descriptor) + }, + apply(_target, _thisArg, argumentsList) { + const inst = getInstance() + return Reflect.apply(inst as any, inst, argumentsList) + }, + construct(_target, argumentsList, _newTarget) { + const inst = getInstance() + return Reflect.construct(inst as any, argumentsList, inst as any) + }, + }) +} diff --git a/packages/db/tests/cloudflare-workers.test.ts b/packages/db/tests/cloudflare-workers.test.ts new file mode 100644 index 000000000..76904a16a --- /dev/null +++ b/packages/db/tests/cloudflare-workers.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createCollection, lazyInitForWorkers } from "../src" +import { localOnlyCollectionOptions } from "../src/local-only" + +describe(`Cloudflare Workers compatibility`, () => { + let originalNavigator: typeof globalThis.navigator + + beforeEach(() => { + // Save original navigator + originalNavigator = globalThis.navigator + }) + + afterEach(() => { + // Restore original navigator + Object.defineProperty(globalThis, `navigator`, { + value: originalNavigator, + writable: true, + configurable: true, + }) + }) + + describe(`lazyInitForWorkers`, () => { + it(`should defer initialization until first access`, () => { + const factorySpy = vi.fn(() => ({ value: 42, getValue: () => 42 })) + + const lazy = lazyInitForWorkers(factorySpy) + + // Factory should not be called yet + expect(factorySpy).not.toHaveBeenCalled() + + // Access a property + expect(lazy.value).toBe(42) + + // Factory should be called once + expect(factorySpy).toHaveBeenCalledOnce() + }) + + it(`should only initialize once (singleton behavior)`, () => { + const factorySpy = vi.fn(() => ({ value: 42, increment: () => {} })) + + const lazy = lazyInitForWorkers(factorySpy) + + // Multiple accesses + lazy.value + lazy.increment() + lazy.value + + // Factory should still only be called once + expect(factorySpy).toHaveBeenCalledOnce() + }) + + it(`should preserve method binding`, () => { + class Counter { + count = 0 + increment() { + this.count++ + } + getCount() { + return this.count + } + } + + const lazy = lazyInitForWorkers(() => new Counter()) + + // Call methods + lazy.increment() + lazy.increment() + + // Should work correctly with proper `this` binding + expect(lazy.getCount()).toBe(2) + expect(lazy.count).toBe(2) + }) + + it(`should support property writes`, () => { + const lazy = lazyInitForWorkers(() => ({ value: 0 })) + + lazy.value = 10 + expect(lazy.value).toBe(10) + }) + + it(`should support delete operations`, () => { + const lazy = lazyInitForWorkers(() => ({ + value: 0, + extra: 123 as number | undefined, + })) + + delete (lazy as any).extra + expect(`extra` in lazy).toBe(false) + }) + + it(`should support has operator`, () => { + const lazy = lazyInitForWorkers(() => ({ value: 0 })) + + expect(`value` in lazy).toBe(true) + expect(`nonexistent` in lazy).toBe(false) + }) + + it(`should support ownKeys`, () => { + const lazy = lazyInitForWorkers(() => ({ a: 1, b: 2 })) + + expect(Object.keys(lazy)).toEqual([`a`, `b`]) + }) + + it(`should support getOwnPropertyDescriptor`, () => { + const lazy = lazyInitForWorkers(() => ({ value: 42 })) + + const descriptor = Object.getOwnPropertyDescriptor(lazy, `value`) + expect(descriptor).toBeDefined() + expect(descriptor?.value).toBe(42) + }) + + it(`should support getPrototypeOf`, () => { + class MyClass { + value = 42 + } + + const lazy = lazyInitForWorkers(() => new MyClass()) + + expect(Object.getPrototypeOf(lazy)).toBe(MyClass.prototype) + }) + + it(`should support getters`, () => { + class MyClass { + _value = 42 + get value() { + return this._value + } + } + + const lazy = lazyInitForWorkers(() => new MyClass()) + + expect(lazy.value).toBe(42) + }) + + it(`should support getters that call other methods`, () => { + class MyClass { + values = [1, 2, 3] + getValues() { + return this.values + } + get asArray() { + return Array.from(this.getValues()) + } + } + + const lazy = lazyInitForWorkers(() => new MyClass()) + + expect(lazy.asArray).toEqual([1, 2, 3]) + }) + }) + + describe(`createCollection with Cloudflare Workers detection`, () => { + it(`should use lazy initialization when in Cloudflare Workers`, () => { + // Mock Cloudflare Workers environment + Object.defineProperty(globalThis, `navigator`, { + value: { userAgent: `Cloudflare-Workers` }, + writable: true, + configurable: true, + }) + + const insertSpy = vi.fn() + + // Create collection + const collection = createCollection( + localOnlyCollectionOptions({ + getKey: (item: { id: number }) => item.id, + onInsert: insertSpy, + }) + ) + + // onInsert should not be called yet (collection not initialized) + expect(insertSpy).not.toHaveBeenCalled() + + // Access a property to trigger initialization + const id = collection.id + + // Should now have an ID + expect(typeof id).toBe(`string`) + }) + + it(`should use normal initialization in non-Cloudflare environments`, () => { + // Mock non-Cloudflare environment + Object.defineProperty(globalThis, `navigator`, { + value: { userAgent: `Mozilla/5.0` }, + writable: true, + configurable: true, + }) + + // Create collection + const collection = createCollection( + localOnlyCollectionOptions({ + getKey: (item: { id: number }) => item.id, + }) + ) + + // Should be initialized immediately + expect(typeof collection.id).toBe(`string`) + }) + + it(`should work when navigator is undefined`, () => { + // Mock environment without navigator (e.g., Node.js) + Object.defineProperty(globalThis, `navigator`, { + value: undefined, + writable: true, + configurable: true, + }) + + // Should not throw + const collection = createCollection( + localOnlyCollectionOptions({ + getKey: (item: { id: number }) => item.id, + }) + ) + + expect(typeof collection.id).toBe(`string`) + }) + + it(`should allow normal collection operations with lazy initialization`, async () => { + // Mock Cloudflare Workers environment + Object.defineProperty(globalThis, `navigator`, { + value: { userAgent: `Cloudflare-Workers` }, + writable: true, + configurable: true, + }) + + type Todo = { id: number; title: string; completed: boolean } + + const collection = createCollection( + localOnlyCollectionOptions({ + getKey: (item) => item.id, + }) + ) + + // Insert items + const tx1 = collection.insert({ + id: 1, + title: `Task 1`, + completed: false, + }) + const tx2 = collection.insert({ id: 2, title: `Task 2`, completed: true }) + + await tx1 + await tx2 + + // Query items + const all = collection.toArray + expect(all).toHaveLength(2) + + const item = collection.get(1) + expect(item?.title).toBe(`Task 1`) + + // Update item + const updateTx = collection.update(1, (draft) => { + draft.completed = true + }) + await updateTx + + const updated = collection.get(1) + expect(updated?.completed).toBe(true) + + // Delete item + const deleteTx = collection.delete(1) + await deleteTx + + expect(collection.has(1)).toBe(false) + expect(collection.size).toBe(1) + }) + }) +}) From eb4d26409d9824390c1c769d4939f045234e4890 Mon Sep 17 00:00:00 2001 From: Ben Senescu Date: Thu, 6 Nov 2025 17:22:00 -0500 Subject: [PATCH 2/4] Cleaned up PR --- .changeset/cloudflare-workers-support.md | 13 - packages/db/CLOUDFLARE_WORKERS.md | 115 -------- packages/db/tests/cloudflare-workers.test.ts | 269 ------------------- 3 files changed, 397 deletions(-) delete mode 100644 .changeset/cloudflare-workers-support.md delete mode 100644 packages/db/CLOUDFLARE_WORKERS.md delete mode 100644 packages/db/tests/cloudflare-workers.test.ts diff --git a/.changeset/cloudflare-workers-support.md b/.changeset/cloudflare-workers-support.md deleted file mode 100644 index 492ba83c4..000000000 --- a/.changeset/cloudflare-workers-support.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@tanstack/db": minor ---- - -Add automatic Cloudflare Workers runtime detection and lazy initialization - -Collections created with `createCollection` now automatically detect when running in Cloudflare Workers environment and defer initialization to prevent "Disallowed operation called within global scope" errors. This is done using a transparent proxy that initializes the collection on first access. - -The detection uses `navigator.userAgent === 'Cloudflare-Workers'` to identify the runtime environment. - -Also exports `lazyInitForWorkers` utility function for advanced use cases where users need to apply lazy loading to other resources. - -This change is backwards compatible and requires no code changes for existing users. diff --git a/packages/db/CLOUDFLARE_WORKERS.md b/packages/db/CLOUDFLARE_WORKERS.md deleted file mode 100644 index 9995b2972..000000000 --- a/packages/db/CLOUDFLARE_WORKERS.md +++ /dev/null @@ -1,115 +0,0 @@ -# Cloudflare Workers Compatibility - -TanStack DB automatically detects when running in Cloudflare Workers runtime and defers collection initialization to prevent "Disallowed operation called within global scope" errors. - -## Automatic Detection - -Starting from version 0.5.0, `createCollection` automatically detects Cloudflare Workers runtime using `navigator.userAgent === 'Cloudflare-Workers'` and applies lazy initialization when needed. - -```typescript -import { createCollection } from "@tanstack/db" -import { queryCollectionOptions } from "@tanstack/query-db-collection" - -// This works in Cloudflare Workers without any changes! -export const missionsCollection = createCollection( - queryCollectionOptions({ - queryKey: orpc.missions.list.queryKey(), - queryFn: () => orpc.missions.list.call(), - queryClient, - getKey: (item) => item.id, - }) -) -``` - -## How It Works - -In Cloudflare Workers, certain operations (like creating Promises or using the crypto API) are not allowed in the global scope. TanStack DB solves this by: - -1. Detecting the Cloudflare Workers runtime environment -2. Wrapping the collection in a transparent lazy-loading proxy -3. Deferring initialization until the first property access (which happens during request handling, not at module load time) - -The proxy is completely transparent - you use the collection exactly as you would in any other environment. - -## Manual Lazy Loading (Advanced) - -If you need to apply lazy loading to other resources, you can use the `lazyInitForWorkers` utility function: - -```typescript -import { lazyInitForWorkers } from "@tanstack/db" - -export const myResource = lazyInitForWorkers(() => { - // This code only runs when myResource is first accessed - return createExpensiveResource() -}) -``` - -## Performance Considerations - -The lazy initialization approach has minimal performance impact: - -- **Singleton behavior**: The collection is only initialized once, on first access -- **Transparent proxy**: All operations are passed directly to the underlying instance -- **No overhead after initialization**: Once initialized, there's no proxying overhead - -## Query Collection Synchronization - -When using query collections (TanStack Query integration), the synchronization works correctly with lazy initialization: - -- The query client is used as the data source -- Multiple lazy collections can share the same query client -- Each collection maintains its own subscription to the query client -- Changes in the query client are propagated to all collections - -## Live Queries - -Live queries work seamlessly with lazy initialization: - -- Collections subscribe to other collections on first access -- Reactivity is maintained through the query client -- No additional configuration needed - -## Testing - -When testing code that uses collections in a simulated Cloudflare Workers environment: - -```typescript -// Mock Cloudflare Workers environment -Object.defineProperty(globalThis, "navigator", { - value: { userAgent: "Cloudflare-Workers" }, - writable: true, - configurable: true, -}) - -// Your collections will automatically use lazy initialization -const collection = createCollection(...) -``` - -## Troubleshooting - -### Collection is undefined or null - -Make sure you're accessing the collection during request handling, not at module load time: - -```typescript -// ❌ BAD: Don't do this at module level -const data = collection.toArray // This runs at module load - -// ✅ GOOD: Access during request handling -export default { - async fetch(request: Request) { - const data = collection.toArray // This runs during request - return Response.json(data) - }, -} -``` - -### Type errors with lazy collections - -The lazy proxy should be completely transparent to TypeScript, but if you encounter type issues, you can cast to `any` for specific operations: - -```typescript -const collection = createCollection(...) as any -``` - -However, this should rarely be necessary - please report any type issues you encounter! diff --git a/packages/db/tests/cloudflare-workers.test.ts b/packages/db/tests/cloudflare-workers.test.ts deleted file mode 100644 index 76904a16a..000000000 --- a/packages/db/tests/cloudflare-workers.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { createCollection, lazyInitForWorkers } from "../src" -import { localOnlyCollectionOptions } from "../src/local-only" - -describe(`Cloudflare Workers compatibility`, () => { - let originalNavigator: typeof globalThis.navigator - - beforeEach(() => { - // Save original navigator - originalNavigator = globalThis.navigator - }) - - afterEach(() => { - // Restore original navigator - Object.defineProperty(globalThis, `navigator`, { - value: originalNavigator, - writable: true, - configurable: true, - }) - }) - - describe(`lazyInitForWorkers`, () => { - it(`should defer initialization until first access`, () => { - const factorySpy = vi.fn(() => ({ value: 42, getValue: () => 42 })) - - const lazy = lazyInitForWorkers(factorySpy) - - // Factory should not be called yet - expect(factorySpy).not.toHaveBeenCalled() - - // Access a property - expect(lazy.value).toBe(42) - - // Factory should be called once - expect(factorySpy).toHaveBeenCalledOnce() - }) - - it(`should only initialize once (singleton behavior)`, () => { - const factorySpy = vi.fn(() => ({ value: 42, increment: () => {} })) - - const lazy = lazyInitForWorkers(factorySpy) - - // Multiple accesses - lazy.value - lazy.increment() - lazy.value - - // Factory should still only be called once - expect(factorySpy).toHaveBeenCalledOnce() - }) - - it(`should preserve method binding`, () => { - class Counter { - count = 0 - increment() { - this.count++ - } - getCount() { - return this.count - } - } - - const lazy = lazyInitForWorkers(() => new Counter()) - - // Call methods - lazy.increment() - lazy.increment() - - // Should work correctly with proper `this` binding - expect(lazy.getCount()).toBe(2) - expect(lazy.count).toBe(2) - }) - - it(`should support property writes`, () => { - const lazy = lazyInitForWorkers(() => ({ value: 0 })) - - lazy.value = 10 - expect(lazy.value).toBe(10) - }) - - it(`should support delete operations`, () => { - const lazy = lazyInitForWorkers(() => ({ - value: 0, - extra: 123 as number | undefined, - })) - - delete (lazy as any).extra - expect(`extra` in lazy).toBe(false) - }) - - it(`should support has operator`, () => { - const lazy = lazyInitForWorkers(() => ({ value: 0 })) - - expect(`value` in lazy).toBe(true) - expect(`nonexistent` in lazy).toBe(false) - }) - - it(`should support ownKeys`, () => { - const lazy = lazyInitForWorkers(() => ({ a: 1, b: 2 })) - - expect(Object.keys(lazy)).toEqual([`a`, `b`]) - }) - - it(`should support getOwnPropertyDescriptor`, () => { - const lazy = lazyInitForWorkers(() => ({ value: 42 })) - - const descriptor = Object.getOwnPropertyDescriptor(lazy, `value`) - expect(descriptor).toBeDefined() - expect(descriptor?.value).toBe(42) - }) - - it(`should support getPrototypeOf`, () => { - class MyClass { - value = 42 - } - - const lazy = lazyInitForWorkers(() => new MyClass()) - - expect(Object.getPrototypeOf(lazy)).toBe(MyClass.prototype) - }) - - it(`should support getters`, () => { - class MyClass { - _value = 42 - get value() { - return this._value - } - } - - const lazy = lazyInitForWorkers(() => new MyClass()) - - expect(lazy.value).toBe(42) - }) - - it(`should support getters that call other methods`, () => { - class MyClass { - values = [1, 2, 3] - getValues() { - return this.values - } - get asArray() { - return Array.from(this.getValues()) - } - } - - const lazy = lazyInitForWorkers(() => new MyClass()) - - expect(lazy.asArray).toEqual([1, 2, 3]) - }) - }) - - describe(`createCollection with Cloudflare Workers detection`, () => { - it(`should use lazy initialization when in Cloudflare Workers`, () => { - // Mock Cloudflare Workers environment - Object.defineProperty(globalThis, `navigator`, { - value: { userAgent: `Cloudflare-Workers` }, - writable: true, - configurable: true, - }) - - const insertSpy = vi.fn() - - // Create collection - const collection = createCollection( - localOnlyCollectionOptions({ - getKey: (item: { id: number }) => item.id, - onInsert: insertSpy, - }) - ) - - // onInsert should not be called yet (collection not initialized) - expect(insertSpy).not.toHaveBeenCalled() - - // Access a property to trigger initialization - const id = collection.id - - // Should now have an ID - expect(typeof id).toBe(`string`) - }) - - it(`should use normal initialization in non-Cloudflare environments`, () => { - // Mock non-Cloudflare environment - Object.defineProperty(globalThis, `navigator`, { - value: { userAgent: `Mozilla/5.0` }, - writable: true, - configurable: true, - }) - - // Create collection - const collection = createCollection( - localOnlyCollectionOptions({ - getKey: (item: { id: number }) => item.id, - }) - ) - - // Should be initialized immediately - expect(typeof collection.id).toBe(`string`) - }) - - it(`should work when navigator is undefined`, () => { - // Mock environment without navigator (e.g., Node.js) - Object.defineProperty(globalThis, `navigator`, { - value: undefined, - writable: true, - configurable: true, - }) - - // Should not throw - const collection = createCollection( - localOnlyCollectionOptions({ - getKey: (item: { id: number }) => item.id, - }) - ) - - expect(typeof collection.id).toBe(`string`) - }) - - it(`should allow normal collection operations with lazy initialization`, async () => { - // Mock Cloudflare Workers environment - Object.defineProperty(globalThis, `navigator`, { - value: { userAgent: `Cloudflare-Workers` }, - writable: true, - configurable: true, - }) - - type Todo = { id: number; title: string; completed: boolean } - - const collection = createCollection( - localOnlyCollectionOptions({ - getKey: (item) => item.id, - }) - ) - - // Insert items - const tx1 = collection.insert({ - id: 1, - title: `Task 1`, - completed: false, - }) - const tx2 = collection.insert({ id: 2, title: `Task 2`, completed: true }) - - await tx1 - await tx2 - - // Query items - const all = collection.toArray - expect(all).toHaveLength(2) - - const item = collection.get(1) - expect(item?.title).toBe(`Task 1`) - - // Update item - const updateTx = collection.update(1, (draft) => { - draft.completed = true - }) - await updateTx - - const updated = collection.get(1) - expect(updated?.completed).toBe(true) - - // Delete item - const deleteTx = collection.delete(1) - await deleteTx - - expect(collection.has(1)).toBe(false) - expect(collection.size).toBe(1) - }) - }) -}) From 62adfbba4c4cf0cefe198fd20a15c838924c77bc Mon Sep 17 00:00:00 2001 From: Ben Senescu Date: Thu, 6 Nov 2025 17:25:31 -0500 Subject: [PATCH 3/4] Improve naming --- packages/db/src/collection/index.ts | 4 ++-- packages/db/src/index.ts | 1 - .../src/utils/{lazy-init.ts => lazy-init-for-cf-workers.ts} | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) rename packages/db/src/utils/{lazy-init.ts => lazy-init-for-cf-workers.ts} (95%) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index a89abeee7..335d72473 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -2,7 +2,7 @@ import { CollectionRequiresConfigError, CollectionRequiresSyncConfigError, } from "../errors" -import { lazyInitForWorkers } from "../utils/lazy-init" +import { lazyInitForCFWorkers } from "../utils/lazy-init-for-cf-workers" import { currentStateAsChanges } from "./change-events" import { CollectionStateManager } from "./state" @@ -211,7 +211,7 @@ export function createCollection( // Without this, initializing a collection causes this error: // Disallowed operation called within global scope. if (isCloudflareWorkers) { - return lazyInitForWorkers(() => { + return lazyInitForCFWorkers(() => { return _createCollection() }) } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index c909c42b7..a41cf51aa 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -13,7 +13,6 @@ export * from "./optimistic-action" export * from "./local-only" export * from "./local-storage" export * from "./errors" -export { lazyInitForWorkers } from "./utils/lazy-init" // Index system exports export * from "./indexes/base-index.js" diff --git a/packages/db/src/utils/lazy-init.ts b/packages/db/src/utils/lazy-init-for-cf-workers.ts similarity index 95% rename from packages/db/src/utils/lazy-init.ts rename to packages/db/src/utils/lazy-init-for-cf-workers.ts index f167a1a30..e5a2c3a50 100644 --- a/packages/db/src/utils/lazy-init.ts +++ b/packages/db/src/utils/lazy-init-for-cf-workers.ts @@ -10,7 +10,7 @@ * * @example * ```ts - * export const myCollection = lazyInitForWorkers(() => + * export const myCollection = lazyInitForCFWorkers(() => * createCollection(queryCollectionOptions({ * queryKey: ["myData"], * queryFn: async () => fetchData(), @@ -19,7 +19,7 @@ * ); * ``` */ -export function lazyInitForWorkers(factory: () => T): T { +export function lazyInitForCFWorkers(factory: () => T): T { // Closure: This variable is captured by getInstance() and the Proxy traps below. // It remains in memory as long as the returned Proxy is referenced, enabling singleton behavior. let instance: T | null = null From d47953cb183082163af17eb9c53897a7adea7c17 Mon Sep 17 00:00:00 2001 From: Ben Senescu Date: Thu, 6 Nov 2025 17:37:36 -0500 Subject: [PATCH 4/4] Tweak implementation --- .../db/src/utils/lazy-init-for-cf-workers.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/db/src/utils/lazy-init-for-cf-workers.ts b/packages/db/src/utils/lazy-init-for-cf-workers.ts index e5a2c3a50..fe8336c3d 100644 --- a/packages/db/src/utils/lazy-init-for-cf-workers.ts +++ b/packages/db/src/utils/lazy-init-for-cf-workers.ts @@ -1,7 +1,6 @@ /** * Wraps a factory function in a Proxy to defer initialization until first access. - * This prevents async operations (like creating Tanstack DB Collections) from running - * in Cloudflare Workers' global scope. + * This prevents async operations (Like creating Tanstack DB Collections) from running in Cloudflare Workers' global scope. * * @param factory - A function that creates and returns the resource. * Must be a callback to defer execution; passing the value directly @@ -32,13 +31,13 @@ export function lazyInitForCFWorkers(factory: () => T): T { } return new Proxy({} as T, { - get(_target, prop, _receiver) { + get(_target, prop, receiver) { const inst = getInstance() - return Reflect.get(inst, prop, inst) + return Reflect.get(inst, prop, receiver) }, - set(_target, prop, value, _receiver) { + set(_target, prop, value, receiver) { const inst = getInstance() - return Reflect.set(inst, prop, value, inst) + return Reflect.set(inst, prop, value, receiver) }, deleteProperty(_target, prop) { const inst = getInstance() @@ -56,6 +55,10 @@ export function lazyInitForCFWorkers(factory: () => T): T { const inst = getInstance() return Reflect.getOwnPropertyDescriptor(inst, prop) }, + defineProperty(_target, prop, descriptor) { + const inst = getInstance() + return Reflect.defineProperty(inst, prop, descriptor) + }, getPrototypeOf(_target) { const inst = getInstance() return Reflect.getPrototypeOf(inst) @@ -72,17 +75,13 @@ export function lazyInitForCFWorkers(factory: () => T): T { const inst = getInstance() return Reflect.preventExtensions(inst) }, - defineProperty(_target, prop, descriptor) { - const inst = getInstance() - return Reflect.defineProperty(inst, prop, descriptor) - }, - apply(_target, _thisArg, argumentsList) { + apply(_target, thisArg, args) { const inst = getInstance() - return Reflect.apply(inst as any, inst, argumentsList) + return Reflect.apply(inst as any, thisArg, args) }, - construct(_target, argumentsList, _newTarget) { + construct(_target, args, newTarget) { const inst = getInstance() - return Reflect.construct(inst as any, argumentsList, inst as any) + return Reflect.construct(inst as any, args, newTarget) }, }) }