UNPKG

@cloudflare/actors

Version:

An easier way to build with Cloudflare Durable Objects

501 lines 23.7 kB
// Symbol to store persisted property metadata export const PERSISTED_PROPERTIES = Symbol('PERSISTED_PROPERTIES'); // Symbol to store private values map export const PERSISTED_VALUES = Symbol('PERSISTED_VALUES'); // Symbol to mark an object as proxied export const IS_PROXIED = Symbol('IS_PROXIED'); /** * Creates a deep proxy for objects to track nested property changes * @param value The value to potentially proxy * @param instance The Actor instance * @param propertyKey The top-level property key * @param triggerPersist Function to trigger persistence * @returns Proxied object if value is an object, otherwise the original value */ function createDeepProxy(value, instance, propertyKey, triggerPersist) { // Don't proxy primitives, functions, or already proxied objects if (value === null || value === undefined || typeof value !== 'object' || typeof value === 'function') { return value; } // Check if already proxied using a safer approach try { if (value[IS_PROXIED] === true) { return value; } } catch (e) { // If accessing the symbol throws an error, proceed with creating a new proxy } // Handle special cases - don't proxy these types if (value instanceof Date || value instanceof RegExp || value instanceof Error || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { return value; } // Create a proxy to intercept property access and modification const proxy = new Proxy(value, { get(target, key) { // Handle special symbol for proxy detection if (key === IS_PROXIED) return true; // Handle special cases and built-in methods if (typeof key === 'symbol' || key === 'toString' || key === 'valueOf' || key === 'constructor' || key === 'toJSON') { return Reflect.get(target, key); } try { // Check if the property exists if (!Reflect.has(target, key)) { // For non-existent properties that are being accessed as objects, // automatically create the object structure // This handles cases like obj.a.b.c where a or b don't exist yet const newObj = {}; Reflect.set(target, key, newObj); return createDeepProxy(newObj, instance, propertyKey, triggerPersist); } const prop = Reflect.get(target, key); // If the property is null or undefined but is being accessed as an object, // automatically convert it to an object if ((prop === null || prop === undefined) && typeof key === 'string' && !key.startsWith('_') && key !== 'length') { const newObj = {}; Reflect.set(target, key, newObj); return createDeepProxy(newObj, instance, propertyKey, triggerPersist); } // Special handling for array methods that modify the array if (Array.isArray(target) && typeof prop === 'function') { const mutatingArrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill']; if (mutatingArrayMethods.includes(key)) { // Return a wrapped function that triggers persistence after the operation return function (...args) { const result = prop.apply(target, args); triggerPersist(); return result; }; } } // Special handling for Map and Set methods if ((target instanceof Map || target instanceof Set) && typeof prop === 'function') { const mutatingCollectionMethods = ['set', 'delete', 'clear', 'add']; if (mutatingCollectionMethods.includes(key)) { // Return a wrapped function that triggers persistence after the operation return function (...args) { const result = prop.apply(target, args); triggerPersist(); return result; }; } } // If the property is an object, create a proxy for it if (prop !== null && typeof prop === 'object' && !Object.isFrozen(prop)) { return createDeepProxy(prop, instance, propertyKey, triggerPersist); } return prop; } catch (e) { console.error(`Error accessing property ${String(key)}:`, e); // Return an empty object proxy for error recovery const newObj = {}; Reflect.set(target, key, newObj); return createDeepProxy(newObj, instance, propertyKey, triggerPersist); } }, set(target, key, newValue) { // Don't proxy special symbols if (typeof key === 'symbol') { Reflect.set(target, key, newValue); return true; } try { // Get the current value at this key const currentValue = Reflect.get(target, key); // Handle different type transition scenarios if (currentValue !== null && typeof currentValue === 'object' && newValue !== null && typeof newValue === 'object' && !Array.isArray(currentValue) && !Array.isArray(newValue)) { // Case 1: Both values are objects - merge them instead of replacing Object.assign(currentValue, newValue); } else if (newValue !== null && typeof newValue === 'object' && !Object.isFrozen(newValue)) { // Case 2: New value is an object but current value is not (or doesn't exist) // Create a new proxied object const proxiedValue = createDeepProxy(newValue, instance, propertyKey, triggerPersist); Reflect.set(target, key, proxiedValue); } else { // Case 3: New value is a primitive (or null) or a frozen object // Simply replace the current value Reflect.set(target, key, newValue); } // Trigger persistence for the entire object triggerPersist(); return true; } catch (e) { const error = e; console.error(`Error setting property ${String(key)}:`, error); // If setting the property failed, let's try to recover try { // If we're trying to set a property on a non-object, convert to an object first if (error.message && error.message.includes('Cannot create property')) { // Create an empty object and set it const emptyObj = {}; Reflect.set(target, key, createDeepProxy(emptyObj, instance, propertyKey, triggerPersist)); // Now try setting the property again return true; } } catch (recoveryErr) { console.error('Error during recovery attempt:', recoveryErr); } return false; } }, deleteProperty(target, key) { try { if (Reflect.has(target, key)) { Reflect.deleteProperty(target, key); triggerPersist(); } return true; } catch (e) { console.error(`Error deleting property ${String(key)}:`, e); return false; } } }); return proxy; } export function Persist(target, propertyKeyOrContext) { // Handle both decorator formats (legacy and new) if (typeof propertyKeyOrContext === 'string') { // Legacy decorator format (TS < 5.0) const propertyKey = propertyKeyOrContext; handleLegacyDecorator(target, propertyKey); } else { // New decorator format (TS 5.0+) const context = propertyKeyOrContext; context.addInitializer(function () { const instance = this; const constructor = Object.getPrototypeOf(instance).constructor; const propertyKey = context.name.toString(); // Get or initialize the list of persisted properties for this class if (!constructor[PERSISTED_PROPERTIES]) { constructor[PERSISTED_PROPERTIES] = new Set(); } // Add this property to the list of persisted properties constructor[PERSISTED_PROPERTIES].add(propertyKey); // Initialize the persisted values map if it doesn't exist if (!instance[PERSISTED_VALUES]) { instance[PERSISTED_VALUES] = new Map(); } // Store the initial value from the class definition let initialValue = instance[propertyKey]; // Create a function to trigger persistence const triggerPersist = () => { const currentValue = instance[PERSISTED_VALUES].get(propertyKey); // Only persist if the Actor is fully initialized with storage if (instance.storage?.raw) { // Store the value in the database instance._persistProperty(propertyKey, currentValue).catch((err) => { console.error(`Failed to persist property ${propertyKey}:`, err); }); } }; // If the initial value is an object, create a proxy for it if (initialValue !== null && typeof initialValue === 'object' && !Array.isArray(initialValue)) { initialValue = createDeepProxy(initialValue, instance, propertyKey, triggerPersist); } instance[PERSISTED_VALUES].set(propertyKey, initialValue); // Define the property with getter and setter Object.defineProperty(instance, propertyKey, { get() { return this[PERSISTED_VALUES].get(propertyKey); }, set(value) { // If the new value is an object, create a proxy for it const proxiedValue = (value !== null && typeof value === 'object') ? createDeepProxy(value, instance, propertyKey, triggerPersist) : value; this[PERSISTED_VALUES].set(propertyKey, proxiedValue); // Only persist if the Actor is fully initialized with storage if (this.storage?.raw) { // Store the value in the database this._persistProperty(propertyKey, proxiedValue).catch((err) => { console.error(`Failed to persist property ${propertyKey}:`, err); }); } }, enumerable: true, configurable: true }); }); } } // Helper function for legacy decorator format function handleLegacyDecorator(target, propertyKey) { // Get or initialize the list of persisted properties for this class const constructor = target.constructor; if (!constructor[PERSISTED_PROPERTIES]) { constructor[PERSISTED_PROPERTIES] = new Set(); } // Add this property to the list of persisted properties constructor[PERSISTED_PROPERTIES].add(propertyKey); // Store the original descriptor const originalDescriptor = Object.getOwnPropertyDescriptor(target, propertyKey); Object.defineProperty(target, propertyKey, { get() { // Initialize the persisted values map if it doesn't exist if (!this[PERSISTED_VALUES]) { this[PERSISTED_VALUES] = new Map(); } return this[PERSISTED_VALUES].get(propertyKey); }, set(value) { // Initialize the persisted values map if it doesn't exist if (!this[PERSISTED_VALUES]) { this[PERSISTED_VALUES] = new Map(); } // Create a function to trigger persistence const triggerPersist = () => { const currentValue = this[PERSISTED_VALUES].get(propertyKey); // Only persist if the Actor is fully initialized with storage if (this.storage?.raw) { // Store the value in the database this._persistProperty(propertyKey, currentValue).catch((err) => { console.error(`Failed to persist property ${propertyKey}:`, err); }); } }; // If the value is an object, create a proxy for it const proxiedValue = (value !== null && typeof value === 'object') ? createDeepProxy(value, this, propertyKey, triggerPersist) : value; this[PERSISTED_VALUES].set(propertyKey, proxiedValue); // Only persist if the Actor is fully initialized with storage if (this.storage?.raw) { // Store the value in the database this._persistProperty(propertyKey, proxiedValue).catch((err) => { console.error(`Failed to persist property ${propertyKey}:`, err); }); } }, enumerable: true, configurable: true }); } /** * Helper function to initialize persisted properties from storage. * This is called during Actor construction. */ export async function initializePersistedProperties(instance) { if (!instance.storage?.raw) return; try { // Create the persist table if it doesn't exist await instance.storage.__studio({ type: 'query', statement: 'CREATE TABLE IF NOT EXISTS _actor_persist (property TEXT PRIMARY KEY, value TEXT)' }); // Get the list of persisted properties for this class const constructor = instance.constructor; const persistedProps = constructor[PERSISTED_PROPERTIES]; if (!persistedProps || persistedProps.size === 0) return; // Initialize the persisted values map if it doesn't exist if (!instance[PERSISTED_VALUES]) { instance[PERSISTED_VALUES] = new Map(); } // Load all persisted properties from storage const results = await instance.storage.__studio({ type: 'query', statement: 'SELECT property, value FROM _actor_persist' }); // Set the properties on the instance for (const row of results) { if (persistedProps.has(row.property)) { try { // Parse the stored value using our safe parser let parsedValue; try { parsedValue = safeParse(row.value); // Handle error objects that were serialized if (parsedValue && parsedValue.__error === 'Serialization failed') { console.warn(`Property ${row.property} had serialization issues: ${parsedValue.value}`); // Use an empty object as fallback for previously failed serializations parsedValue = typeof parsedValue.value === 'string' ? parsedValue.value : {}; } // Generic handling for type transitions during initialization // Check if we have an initial value defined on the class const initialValue = instance[row.property]; if (initialValue !== undefined && typeof initialValue === 'object' && initialValue !== null) { // If the initial value is an object but the parsed value is not // or if the parsed value is missing expected nested properties if (typeof parsedValue !== 'object' || parsedValue === null) { console.warn(`Property ${row.property} type mismatch: expected object, got ${typeof parsedValue}. Resetting to initial structure.`); // Reset to the initial structure parsedValue = structuredClone(initialValue); } else { // Ensure all expected nested properties exist ensureObjectStructure(parsedValue, initialValue); } } } catch (parseErr) { console.error(`Failed to parse persisted value for ${row.property}:`, parseErr); // Use an empty object as fallback parsedValue = {}; } // Helper function to ensure object structure matches expected structure function ensureObjectStructure(target, template) { if (target === null || typeof target !== 'object' || template === null || typeof template !== 'object') { return; } // For each property in the template for (const key in template) { // If the template has a nested object if (template[key] !== null && typeof template[key] === 'object') { // If the target doesn't have this property or it's not an object if (!target[key] || typeof target[key] !== 'object') { // Create the object structure target[key] = Array.isArray(template[key]) ? [] : {}; } // Recursively ensure structure ensureObjectStructure(target[key], template[key]); } } } // Create a function to trigger persistence for this property const triggerPersist = () => { const currentValue = instance[PERSISTED_VALUES].get(row.property); // Only persist if the Actor is fully initialized with storage if (instance.storage?.raw) { // Store the value in the database instance._persistProperty(row.property, currentValue).catch((err) => { console.error(`Failed to persist property ${row.property}:`, err); }); } }; // If the value is an object, create a proxy for it const proxiedValue = (parsedValue !== null && typeof parsedValue === 'object') ? createDeepProxy(parsedValue, instance, row.property, triggerPersist) : parsedValue; // Store in the values map without triggering the setter instance[PERSISTED_VALUES].set(row.property, proxiedValue); } catch (err) { console.error(`Failed to process persisted value for ${row.property}:`, err); // Set a default value to prevent further errors instance[PERSISTED_VALUES].set(row.property, {}); } } } } catch (err) { console.error('Error initializing persisted properties:', err); } } /** * Helper function for safe JSON serialization that handles circular references */ function safeStringify(obj) { const seen = new WeakSet(); return JSON.stringify(obj, (key, value) => { // Handle special types that don't serialize well if (value instanceof Date) { return { __type: 'Date', value: value.toISOString() }; } if (value instanceof RegExp) { return { __type: 'RegExp', source: value.source, flags: value.flags }; } if (value instanceof Error) { return { __type: 'Error', message: value.message, stack: value.stack }; } // Handle circular references if (typeof value === 'object' && value !== null) { if (seen.has(value)) { return '[Circular]'; } seen.add(value); } return value; }); } /** * Helper function to parse JSON that might contain special type markers */ function safeParse(json) { return JSON.parse(json, (key, value) => { if (value && typeof value === 'object' && value.__type) { switch (value.__type) { case 'Date': return new Date(value.value); case 'RegExp': return new RegExp(value.source, value.flags); case 'Error': const error = new Error(value.message); error.stack = value.stack; return error; } } return value; }); } /** * Helper function to persist a property value to storage. */ export async function persistProperty(instance, propertyKey, value) { if (!instance.storage?.raw) return; try { // Get the raw value (unwrap from proxy if needed) let rawValue = value; // Serialize the value to JSON with circular reference handling let serializedValue; try { serializedValue = safeStringify(rawValue); } catch (jsonErr) { console.error(`Failed to serialize property ${propertyKey}:`, jsonErr); // Fallback to a simpler representation serializedValue = JSON.stringify({ __error: 'Serialization failed', value: String(rawValue) }); } // Store in the database with UPSERT semantics await instance.storage.__studio({ type: 'query', statement: 'INSERT INTO _actor_persist (property, value) VALUES (?, ?) ON CONFLICT(property) DO UPDATE SET value = ?', params: [propertyKey, serializedValue, serializedValue] }); // Call the onPersist hook if it exists if (typeof instance.onPersist === 'function') { try { await instance.onPersist(propertyKey, value); } catch (hookErr) { console.error(`Error in onPersist hook for property ${propertyKey}:`, hookErr); } } } catch (err) { console.error(`Error persisting property ${propertyKey}:`, err); throw err; } } //# sourceMappingURL=persist.js.map