diff --git a/warp-drive-packages/core/src/reactive/-private/default-mode.ts b/warp-drive-packages/core/src/reactive/-private/default-mode.ts index 8bce6ee355c..e8298aeb5fe 100644 --- a/warp-drive-packages/core/src/reactive/-private/default-mode.ts +++ b/warp-drive-packages/core/src/reactive/-private/default-mode.ts @@ -53,6 +53,7 @@ export interface ObjectContext extends BaseContext { value: string; } export interface KindContext extends BaseContext { + reactivityMode: 'field' | 'category' | 'relationships' | 'resource' | 'immutable'; path: string[]; field: T; value: unknown; @@ -99,6 +100,8 @@ type Mode = { [Field in FieldSchema | IdentityField | HashField as Field['kind']]: KindImpl; }; +function signalForMode() {} + export const DefaultMode: Mode = { '@hash': { get: getHashField, diff --git a/warp-drive-packages/core/src/reactive/-private/record.ts b/warp-drive-packages/core/src/reactive/-private/record.ts index 953ac7fb9f6..cf7bbd99814 100644 --- a/warp-drive-packages/core/src/reactive/-private/record.ts +++ b/warp-drive-packages/core/src/reactive/-private/record.ts @@ -15,7 +15,7 @@ import { recordIdentifierFor, setRecordIdentifier } from '../../store/-private.t import { removeRecordIdentifier } from '../../store/-private/caches/instance-cache.ts'; import type { ResourceKey } from '../../types/identifier.ts'; import { STRUCTURED } from '../../types/request.ts'; -import type { FieldSchema, GenericField, IdentityField } from '../../types/schema/fields.ts'; +import type { FieldSchema, GenericField, IdentityField, ResourceSchema } from '../../types/schema/fields.ts'; import { RecordStore } from '../../types/symbols.ts'; import type { ObjectContext, ResourceContext } from './default-mode.ts'; import { DefaultMode } from './default-mode.ts'; @@ -119,8 +119,8 @@ export class ReactiveResource { const isEmbedded = context.path !== null; const schema = context.store.schema as unknown as SchemaService; const objectType = isEmbedded ? context.value : resourceKey.type; - const ResourceSchema = schema.resource(isEmbedded ? { type: objectType } : resourceKey); - const identityField = ResourceSchema.identity; + const resourceSchema = schema.resource(isEmbedded ? { type: objectType } : resourceKey); + const identityField = resourceSchema.identity; const BoundFns = new Map(); // prettier-ignore @@ -135,125 +135,142 @@ export class ReactiveResource { const fields = isEmbedded ? schema.fields({ type: objectType }) : schema.fields(resourceKey); const method = typeof schema.cacheFields === 'function' ? 'cacheFields' : 'fields'; const cacheFields = isEmbedded ? schema[method]({ type: objectType }) : schema[method](resourceKey); - + const reactivityMode = (schema.resource(resourceKey) as ResourceSchema).reactivityMode ?? 'field'; const signals = withSignalStore(this); - this.___notifications = context.store.notifications.subscribe( - resourceKey, - (_: ResourceKey, type: NotificationType, key?: string | string[]) => { - switch (type) { - case 'identity': { - if (isEmbedded || !identityField) return; // base paths never apply to embedded records - - if (identityField.name && identityField.kind === '@id') { - const signal = signals.get('@identity'); - if (signal) { - notifyInternalSignal(signal); - } - } - break; - } - case 'attributes': - if (key) { - if (Array.isArray(key)) { - if (context.path === null) return; // deep paths will be handled by embedded records - // TODO we should have the notification manager - // ensure it is safe for each callback to mutate this array - if (isPathMatch(context.path, key)) { - // handle the notification - // TODO we should likely handle this notification here - // also we should add a LOGGING flag - // eslint-disable-next-line no-console - console.warn(`Notification unhandled for ${key.join(',')} on ${resourceKey.type}`, proxy); - return; - } - - // TODO we should add a LOGGING flag - // console.log(`Deep notification skipped for ${key.join('.')} on ${identifier.type}`, proxy); - // deep notify the key path - } else { - if (isEmbedded) return; // base paths never apply to embedded records - - // TODO determine what LOGGING flag to wrap this in if any - // console.log(`Notification for ${key} on ${identifier.type}`, proxy); - const signal = signals.get(key); + if (reactivityMode !== 'immutable') { + this.___notifications = context.store.notifications.subscribe( + resourceKey, + (_: ResourceKey, type: NotificationType, key?: string | string[]) => { + switch (type) { + case 'identity': { + if (isEmbedded || !identityField) return; // base paths never apply to embedded records + + if (identityField.name && identityField.kind === '@id') { + const signal = signals.get('@identity'); if (signal) { notifyInternalSignal(signal); } - const field = cacheFields.get(key); - if (field?.kind === 'array' || field?.kind === 'schema-array') { - const peeked = signal?.value as ManagedArray | undefined; - if (peeked) { - assert( - `Expected the peekManagedArray for ${field.kind} to return a ManagedArray`, - ARRAY_SIGNAL in peeked - ); - const arrSignal = peeked[ARRAY_SIGNAL]; - notifyInternalSignal(arrSignal); - } - } - if (field?.kind === 'object') { - const peeked = peekManagedObject(proxy, field); - if (peeked) { - const objSignal = peeked[OBJECT_SIGNAL]; - notifyInternalSignal(objSignal); - } - } } + break; } - break; - case 'relationships': - if (key) { - if (Array.isArray(key)) { - // FIXME - } else { - if (isEmbedded) return; // base paths never apply to embedded records - - const field = cacheFields.get(key); - assert(`Expected relationship ${key} to be the name of a field`, field); - if (field.kind === 'belongsTo') { + case 'attributes': + if (key) { + if (Array.isArray(key)) { + if (context.path === null) return; // deep paths will be handled by embedded records + // TODO we should have the notification manager + // ensure it is safe for each callback to mutate this array + if (isPathMatch(context.path, key)) { + // handle the notification + // TODO we should likely handle this notification here + // also we should add a LOGGING flag + // eslint-disable-next-line no-console + console.warn(`Notification unhandled for ${key.join(',')} on ${resourceKey.type}`, proxy); + return; + } + + // TODO we should add a LOGGING flag + // console.log(`Deep notification skipped for ${key.join('.')} on ${identifier.type}`, proxy); + // deep notify the key path + } else { + if (isEmbedded) return; // base paths never apply to embedded records + // TODO determine what LOGGING flag to wrap this in if any // console.log(`Notification for ${key} on ${identifier.type}`, proxy); const signal = signals.get(key); if (signal) { notifyInternalSignal(signal); } + const field = cacheFields.get(key); + if (field?.kind === 'array' || field?.kind === 'schema-array') { + const peeked = signal?.value as ManagedArray | undefined; + if (peeked) { + assert( + `Expected the peekManagedArray for ${field.kind} to return a ManagedArray`, + ARRAY_SIGNAL in peeked + ); + const arrSignal = peeked[ARRAY_SIGNAL]; + notifyInternalSignal(arrSignal); + } + } + if (field?.kind === 'object') { + const peeked = peekManagedObject(proxy, field); + if (peeked) { + const objSignal = peeked[OBJECT_SIGNAL]; + notifyInternalSignal(objSignal); + } + } + } + } + break; + case 'relationships': + if (key) { + if (Array.isArray(key)) { // FIXME - } else if (field.kind === 'resource') { - // FIXME - } else if (field.kind === 'hasMany') { - if (field.options.linksMode) { + } else { + if (isEmbedded) return; // base paths never apply to embedded records + + const field = cacheFields.get(key); + assert(`Expected relationship ${key} to be the name of a field`, field); + if (field.kind === 'belongsTo') { + // TODO determine what LOGGING flag to wrap this in if any + // console.log(`Notification for ${key} on ${identifier.type}`, proxy); const signal = signals.get(key); if (signal) { - const peeked = signal.value as LegacyManyArray | undefined; - if (peeked) { - notifyInternalSignal(peeked[Context].signal); + notifyInternalSignal(signal); + } + // FIXME + } else if (field.kind === 'resource') { + // FIXME + } else if (field.kind === 'hasMany') { + if (field.options.linksMode) { + const signal = signals.get(key); + if (signal) { + const peeked = signal.value as LegacyManyArray | undefined; + if (peeked) { + notifyInternalSignal(peeked[Context].signal); + } } + return; } - return; - } - assert(`Can only use hasMany fields when the resource is in legacy mode`, context.legacy); + assert(`Can only use hasMany fields when the resource is in legacy mode`, context.legacy); - if (schema._kind('@legacy', 'hasMany').notify(context.store, proxy, resourceKey, field)) { - assert(`Expected options to exist on relationship meta`, field.options); - assert(`Expected async to exist on relationship meta options`, 'async' in field.options); - if (field.options.async) { - const signal = signals.get(key); - if (signal) { - notifyInternalSignal(signal); + if (schema._kind('@legacy', 'hasMany').notify(context.store, proxy, resourceKey, field)) { + assert(`Expected options to exist on relationship meta`, field.options); + assert(`Expected async to exist on relationship meta options`, 'async' in field.options); + if (field.options.async) { + const signal = signals.get(key); + if (signal) { + notifyInternalSignal(signal); + } } } + } else if (field.kind === 'collection') { + // FIXME } - } else if (field.kind === 'collection') { - // FIXME } } - } - break; + break; + } } - } - ); + ); + } else if (DEBUG) { + // In dev, we error if we receive any updated state notifications + this.___notifications = context.store.notifications.subscribe( + resourceKey, + (_: ResourceKey, type: NotificationType, key?: string | string[]) => { + switch (type) { + case 'identity': + case 'attributes': + case 'relationships': + throw new Error( + `Received unexpected ${type} update for ResourceType "${resourceKey.type}" id "${resourceKey.id}". The ResourceSchema for this resource defines it to use the 'immutable' reactivityMode` + ); + } + } + ); + } const proxy = new Proxy(this, { ownKeys() { @@ -497,10 +514,11 @@ export class ReactiveResource { case 'collection': return DefaultMode[field.kind as 'field'].get({ store: context.store, - resourceKey: resourceKey, + reactivityMode, modeName: context.modeName, legacy: context.legacy, editable: context.editable, + resourceKey: resourceKey, path: propArray, field: field as GenericField, record: receiver as unknown as ReactiveResource, @@ -595,10 +613,11 @@ export class ReactiveResource { case 'collection': return DefaultMode[field.kind as '@id'].set({ store: context.store, - resourceKey: resourceKey, + reactivityMode, modeName: context.modeName, legacy: context.legacy, editable: context.editable, + resourceKey: resourceKey, path: propArray, field: field as IdentityField, record: receiver as unknown as ReactiveResource, diff --git a/warp-drive-packages/core/src/types/schema/fields.ts b/warp-drive-packages/core/src/types/schema/fields.ts index 87174611187..c93f361c96e 100644 --- a/warp-drive-packages/core/src/types/schema/fields.ts +++ b/warp-drive-packages/core/src/types/schema/fields.ts @@ -2155,6 +2155,29 @@ export type ObjectFieldSchema = export interface PolarisResourceSchema { legacy?: false; + /** + * WarpDrive's internal logic for notifying signals of changes + * has the ability to operate on a granular per-field basis, + * or per-category (attributes, relationships, etc.), per-resource + * or even per-type. + * + * Historically, WarpDrive has always operated in 'field' mode, + * which continues to be the recommended default. + * + * However, there are cases where an application may want to + * fine-tune this behavior for performance reasons. For these + * cases the following reactivity modes are available: + * + * - 'field' - (default) signals are notified when individual fields change + * - 'category' - fields in a category (relationships, attributes, etc.) share a common signal. This means when any field in a category changes, all fields in that category notify updates. + * - 'relationships' - relationships will be in field mode, everything else will be in category mode + * - 'resource' - all fields and all categories share a single common signal + * - 'immutable' - the resource has no signal and is treated as permanently immutable, and any change in any field value will raise an error in dev. + * + * @default 'field' + */ + reactivityMode?: 'field' | 'category' | 'relationships' | 'resource' | 'immutable'; + /** * For primary resources, this should be an IdentityField * @@ -2224,6 +2247,29 @@ export interface LegacyResourceSchema { */ legacy: true; + /** + * WarpDrive's internal logic for notifying signals of changes + * has the ability to operate on a granular per-field basis, + * or per-category (attributes, relationships, etc.), per-resource + * or even per-type. + * + * Historically, WarpDrive has always operated in 'field' mode, + * which continues to be the recommended default. + * + * However, there are cases where an application may want to + * fine-tune this behavior for performance reasons. For these + * cases the following reactivity modes are available: + * + * - 'field' - (default) signals are notified when individual fields change + * - 'category' - fields in a category (relationships, attributes, etc.) share a common signal. This means when any field in a category changes, all fields in that category notify updates. + * - 'relationships' - relationships will be in field mode, everything else will be in category mode + * - 'resource' - all fields and all categories share a single common signal + * - 'immutable' - the resource has no signal and is treated as permanently immutable, and any change in any field value will raise an error in dev. + * + * @default 'field' + */ + reactivityMode?: 'field' | 'category' | 'relationships' | 'resource' | 'immutable'; + /** * This should be an IdentityField. *