/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { camelize, deepCopy, GeneratedClass, isObject } from "@matter/general"; import type { Schema } from "@matter/model"; import { ElementTag, FieldValue, MandatoryDefaultValue, Metatype, ValueModel } from "@matter/model"; import { AccessControl, PhantomReferenceError, SchemaImplementationError, Val } from "@matter/protocol"; import { FabricIndex } from "@matter/types"; import { RootSupervisor } from "../../../supervision/RootSupervisor.js"; import type { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; import { Instrumentation } from "../Instrumentation.js"; import { Internal } from "../Internal.js"; import { ManagedReference } from "../ManagedReference.js"; import { memberFallbackKeyFor, memberKeyFor, memberReadFallbackKeyFor, memberSlotOf, memberValueOf, } from "../MemberKeys.js"; import { NameResolver } from "../NameResolver.js"; import type { ValReference } from "../ValReference.js"; import { isFabricScopedList } from "./ListManager.js"; import { PrimitiveManager } from "./PrimitiveManager.js"; /** * Internal view of struct. */ interface Struct extends Val.Struct { [Internal.session]: ValueSupervisor.Session; [Internal.reference]: ValReference; } /** * For structs we generate a class with accessors for each property in the schema. */ export function StructManager(owner: RootSupervisor, schema: Schema): ValueSupervisor.Manage { /** * These install on the prototype. We use this for internal methods and ID methods as we don't want them to be * enumerable. */ const prototypeDescriptors = { // TODO - interferes with Chai deep equal. Best fix would probably be a custom deep equal assertion but // leaving out for now // [Symbol.toStringTag]: { // value: name, // }, // TODO - makes Mocha diffs pretty useless. Best fix is probably customized diff but leaving out for now // toString: { // value() { // return serialize(this); // } // }, } as PropertyDescriptorMap; /** * These we define on the instance so they appear as normal object properties. */ const instanceDescriptors = {} as PropertyDescriptorMap; let hasFabricIndex = false; const isCluster = schema.tag === ElementTag.Cluster; // Scan the schema and configure each member (field or attribute) as a property for (const member of owner.membersOf(schema)) { const name = member.propertyName; const { descriptor } = configureProperty(owner, member); instanceDescriptors[name] = descriptor; if (member.id !== undefined) { prototypeDescriptors[member.id] = { ...descriptor, enumerable: false }; } if (member.name === "FabricIndex") { hasFabricIndex = true; } } let name = schema.name; if (schema.tag === ElementTag.Cluster && !name.endsWith("$State")) { name = `${name}$State`; } const Wrapper = GeneratedClass({ name, initialize(this: Struct, ref: ValReference, session: ValueSupervisor.Session) { Object.defineProperties(this, { [Internal.reference]: { value: ref as ValReference, }, [Internal.session]: { value: session, }, }); // Only objects are acceptable if (!isObject(ref.value)) { throw new SchemaImplementationError( ref.location, `Cannot manage ${typeof ref.value} because it is not a struct`, ); } // If we have a fabric index, update the context if (hasFabricIndex) { const owningFabric = ref.value.fabricIndex as FabricIndex | undefined; ref.location = { ...ref.location, owningFabric }; } // Sadly we can't place instance descriptors on our prototype because then simple JS patterns that rely on // enumerating own properties (e.g. spread) won't work. At least we can reuse the accessors so they should // get JITed // // The "protocol" optimization for clusters omits these fields as the attribute protocol is ID only. // Currently outer layers do not support ID keys for fields if (!isCluster || !session.protocol) { Object.defineProperties(this, instanceDescriptors); } }, instanceDescriptors: prototypeDescriptors, }) as new (value: Val, session: AccessControl.Session) => Val.Struct; Instrumentation.instrumentStruct(Wrapper); return (reference, session) => { reference.owner = new Wrapper(reference, session); return reference.owner; }; } function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { const name = schema.propertyName; const id = schema.id; const { access, manage, validate } = supervisor.get(schema); const fabricScopedList = isFabricScopedList(schema) && schema.effectiveMetatype === Metatype.array; // We generally do not deal with default values. If the schema defines a default it is assigned before the manager // is created. The one exception is for field references. These we must look up dynamically at runtime because the // value should always track the referenced value let defaultReader: ((val: Val) => Val) | undefined; if (typeof FieldValue.referenced(schema.default) === "string") { defaultReader = NameResolver(supervisor, schema.parent, camelize(FieldValue.referenced(schema.default)!)); } // A member mandatory under the mirror's active features always reads a value even when the peer's // AttributeList omits it, and an optional member stays undefined even when the peer's AttributeList reports // it -- this mirrors the conformance-aware TypeScript typings, which know nothing about runtime AttributeList // data. The container itself never holds this value, so it can't masquerade as peer data in persistence, // change detection or external-change integration. const scope = supervisor.scope; const isMandatory = scope.isMandatory(schema); // The template is memoized but every value handed out is a fresh copy: an explicit schema default is // model-owned shared state. let template: Val | undefined; let templated = false; const synthesizeDefault = isMandatory ? () => { if (!templated) { template = MandatoryDefaultValue(scope, schema); templated = true; } return deepCopy(template); } : undefined; // Rebind so the closure below narrows: TS doesn't narrow a captured `let` across a nested function boundary. const referencedDefault = defaultReader; const readDefault: ((self: Struct) => Val | undefined) | undefined = synthesizeDefault ? referencedDefault ? (self: Struct) => referencedDefault(self) ?? synthesizeDefault() : () => synthesizeDefault() : undefined; const descriptor: PropertyDescriptor = { enumerable: true, set(this: Struct, value: Val) { access.authorizeWrite(this[Internal.session], this[Internal.reference].location); // We allow attribute/field name or id as key. If name is present id is ignored const pk = this[Internal.reference].primaryKey; let key = memberKeyFor(pk, name, id); let storedKey = memberSlotOf(this[Internal.reference].value, key, memberFallbackKeyFor(pk, name, id)); // Rollback and the fabric-scoped-list merge check must see stored truth — never the write-migration // slot above and never a synthesized default const oldValue = memberValueOf(this[Internal.reference].value, key, memberReadFallbackKeyFor(pk, name, id)); const self = this; this[Internal.reference].change(() => { const struct = this[Internal.reference].value; // Identify the target. Usually just "struct" except when struct supports Val.Dynamic let target; let previousValue = oldValue; if (Val.properties in struct) { const properties = (struct as Val.Dynamic)[Val.properties]( this[Internal.reference].rootOwner, this[Internal.session], ); if (name in properties) { key = storedKey = name; target = properties; if (fabricScopedList || validate) { // A dynamic member's previous value lives in the provider, not the container slot; // read it only when rollback or the fabric-scoped merge check may consume it, as the // provider's getter may be costly. Unmanage in case the provider serves a managed // value — rollback must not plant a proxy that expires with this session previousValue = Internal.unmanage(properties[name]); } } else { target = struct; } } else { target = struct; } // Unwrap if incoming value (or nested children) is managed value = Internal.unmanage(value); // Undo our change on error. Rollback will take care of this when transactional but this handles the // cases of 1.) no transaction, and 2.) error is caught within transaction. A previously absent member // must not leave a slot behind — consumers enumerate slot keys to discover which members hold values const undo = () => { if (previousValue === undefined) { delete target[key]; } else { target[key] = previousValue; } }; // Modify the value if (fabricScopedList && Array.isArray(value)) { // The merge must run through the proxy even for an absent member; it is what filters by fabric // and supplies the accessing fabric if (previousValue === undefined) { target[key] = []; if (storedKey !== undefined && storedKey !== key) { delete target[storedKey]; } } // In the case of fabric-scoped write to established list we use the managed proxy to perform update // as it will sort through values and only modify those with correct fabricIndex const proxy = self[memberKeyFor(pk, name, id)] as Val.List; try { for (let i = 0; i < value.length; i++) { proxy[i] = value[i]; } proxy.length = value.length; } catch (e) { undo(); throw e; } } else { // Direct assignment target[key] = value; if (storedKey !== undefined && storedKey !== key) { delete target[storedKey]; } } if (!this[Internal.session].acceptInvalid && validate) { // Note: We validate fully for nested structs but *not* for the current struct. This is because // choice conformance may be violated temporarily as individual fields change. // // Also, validating fully would require us to validate across all properties for every property // write. // // I think this is OK for now. If it becomes an issue we'll probably want to wire in a separate // validation step that is performed on commit when choice conformance is in play. try { validate(value, this[Internal.session], { path: this[Internal.reference].location.path, siblings: struct, owner: this[Internal.reference].rootOwner, }); } catch (e) { undo(); throw e; } } }, key); }, }; if (manage === PrimitiveManager) { // For primitives, we don't need a manager so just proxy reads directly descriptor.get = function (this: Struct) { if (access.mayRead(this[Internal.session], this[Internal.reference].location)) { const struct = this[Internal.reference].value as Val.Dynamic; if (struct === undefined) { throw new PhantomReferenceError(this[Internal.reference].location); } if (struct[Val.properties]) { const properties = struct[Val.properties]( this[Internal.reference].rootOwner, this[Internal.session], ); if (name in properties) { return properties[name]; } } const primaryKey = this[Internal.reference].primaryKey; const value = memberValueOf( struct, memberKeyFor(primaryKey, name, id), memberReadFallbackKeyFor(primaryKey, name, id), ); return value === undefined && primaryKey === "id" ? readDefault?.(this) : value; } }; } else { // For collections, we create a managed value let cloneContainer: (container: Val) => Val; switch (schema.effectiveMetatype) { case Metatype.array: cloneContainer = (container: Val) => [...(container as Val.List)]; break; case Metatype.bitmap: cloneContainer = (container: Val) => { // Special case for bitmaps -- this only occurs when the manager takes control and converts from // a number to an object if (typeof container === "number" || typeof container === "bigint") { return {}; } // Already an object return { ...(container as Val.Struct) }; }; break; default: cloneContainer = (container: Val) => ({ ...(container as Val.Struct) }); } descriptor.get = function (this: Struct) { let value; // Obtain the value. Normally just struct[key] except in the case of Val.Dynamic const struct = this[Internal.reference].value; const primaryKey = this[Internal.reference].primaryKey; const key = memberKeyFor(primaryKey, name, id); if ((struct as Val.Dynamic)[Val.properties]) { const properties = (struct as Val.Dynamic)[Val.properties]( this[Internal.reference].rootOwner, this[Internal.session], ); if (name in properties) { value = properties[name]; } else { value = memberValueOf(struct, key, memberReadFallbackKeyFor(primaryKey, name, id)); } } else { value = memberValueOf(struct, key, memberReadFallbackKeyFor(primaryKey, name, id)); } // Note that we only mask values that are unreadable. This is appropriate when the parent object is // visible. For direct access to a property we should throw an error but that must be implemented at a // higher level because we cannot differentiate here if (!access.mayRead(this[Internal.session], this[Internal.reference].location)) { return undefined; } if (value === undefined) { return primaryKey === "name" ? defaultReader?.(this) : readDefault?.(this); } // Value is null or a dynamic property, so just return it // TODO Consider to also use Management for dynamic properties if (value === null) { return value; } const managed = this[Internal.reference].subrefs?.[key]; if (managed) { return managed.owner; } const assertWriteOk = (value: Val) => { // Note - this needs to mirror behavior in the setter above access.authorizeWrite(this[Internal.session], this[Internal.reference].location); if (validate) { validate(value, this[Internal.session], { path: this[Internal.reference].location.path, siblings: this[Internal.reference].value, owner: this[Internal.reference].rootOwner, }); } }; // Clone the container before write const ref = new ManagedReference( this[Internal.reference], name, id, assertWriteOk, cloneContainer, this[Internal.session], ); ref.owner = manage(ref, this[Internal.session]); return ref.owner; }; } return { descriptor, access }; }