@cloudflare/actors
Version:
An easier way to build with Cloudflare Durable Objects
501 lines • 23.7 kB
JavaScript
// 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