*/ public function bindingsFor(FormField|FormFieldLibrary $owner): Collection { $type = $this->ownerTypeFor($owner); return FormFieldBinding::query() ->where('owner_type', $type) ->where('owner_id', $owner->getKey()) ->get(); } /** * Replace the set of bindings on an owner transactionally. Callers pass * an array of binding specs; each spec is validated against the entity- * column registry before anything is written. An empty array clears all * bindings for the owner (Pattern B). * * @param list $bindingData */ public function replaceBindings(FormField|FormFieldLibrary $owner, array $bindingData): void { foreach ($bindingData as $spec) { $this->assertSpecValid($spec); } $ownerType = $this->ownerTypeFor($owner); DB::transaction(function () use ($owner, $ownerType, $bindingData): void { FormFieldBinding::query() ->withoutGlobalScopes() ->where('owner_type', $ownerType) ->where('owner_id', $owner->getKey()) ->delete(); foreach ($bindingData as $spec) { FormFieldBinding::query()->withoutGlobalScopes()->create([ 'owner_type' => $ownerType, 'owner_id' => $owner->getKey(), 'target_entity' => $spec['target_entity'], 'target_attribute' => $spec['target_attribute'], 'mode' => $spec['mode'], 'sync_direction' => $spec['sync_direction'] ?? null, 'merge_strategy' => $spec['merge_strategy'] ?? FormFieldBindingMergeStrategy::Overwrite->value, 'trust_level' => $spec['trust_level'] ?? 50, 'is_identity_key' => $spec['is_identity_key'] ?? false, ]); } if ($owner instanceof FormField) { $owner->logFieldChange('field.bindings_replaced', [ 'count' => count($bindingData), ]); } }); } /** * Row-copy from a library entry to a freshly-inserted field (ARCH §6.7, * addendum Q3). Preserves every binding column; only owner_type / * owner_id are rewritten. */ public function copyBindings(FormFieldLibrary $from, FormField $to): void { $bindings = $this->bindingsFor($from); if ($bindings->isEmpty()) { return; } DB::transaction(function () use ($bindings, $to): void { foreach ($bindings as $binding) { FormFieldBinding::query()->withoutGlobalScopes()->create([ 'owner_type' => 'form_field', 'owner_id' => $to->id, 'target_entity' => $binding->target_entity, 'target_attribute' => $binding->target_attribute, 'mode' => $binding->mode instanceof FormFieldBindingMode ? $binding->mode->value : (string) $binding->mode, 'sync_direction' => $binding->sync_direction, 'merge_strategy' => $binding->merge_strategy instanceof FormFieldBindingMergeStrategy ? $binding->merge_strategy->value : (string) $binding->merge_strategy, 'trust_level' => (int) $binding->trust_level, 'is_identity_key' => (bool) $binding->is_identity_key, ]); } }); } /** * Serialise a binding row into the ARCH §6.3 JSON shape. Returned null * if no binding is given — callers can pipe directly into snapshot / * resource output (Pattern B = null). * * @return array{mode:string,entity:string,column:string,sync_direction?:string}|null */ public function toJsonShape(?FormFieldBinding $binding): ?array { if ($binding === null) { return null; } $mode = $binding->mode instanceof FormFieldBindingMode ? $binding->mode->value : (string) $binding->mode; $shape = [ 'mode' => $mode, 'entity' => $binding->target_entity, 'column' => $binding->target_attribute, ]; if ($binding->sync_direction !== null && $binding->sync_direction !== '') { $shape['sync_direction'] = $binding->sync_direction; } return $shape; } /** * Richer snapshot shape for the WS-6 binding pipeline. Captures * applicator-relevant metadata (binding id, merge_strategy, * trust_level, is_identity_key) on top of the legacy * mode/entity/column triple. * * RFC-WS-6.md §3 (Q6): the applicator reads from snapshot, not from * the live form_field_bindings table — this shape carries everything * needed for conflict resolution and person provisioning. * * @return array{ * id:string, * mode:string, * entity:string, * column:string, * sync_direction?:string, * merge_strategy:string, * trust_level:int, * is_identity_key:bool, * } */ public function toApplicatorShape(FormFieldBinding $binding): array { // FormFieldBinding casts mode/merge_strategy to enum already; access // the value directly without redundant instanceof guards. $shape = [ 'id' => (string) $binding->id, 'mode' => $binding->mode->value, 'entity' => (string) $binding->target_entity, 'column' => (string) $binding->target_attribute, 'merge_strategy' => ($binding->merge_strategy ?? FormFieldBindingMergeStrategy::Overwrite)->value, 'trust_level' => (int) $binding->trust_level, 'is_identity_key' => (bool) $binding->is_identity_key, ]; if ($binding->sync_direction !== null && $binding->sync_direction !== '') { $shape['sync_direction'] = (string) $binding->sync_direction; } return $shape; } /** * Build the snapshot fragment for the WS-6 `bindings` (plural) key. * Session 2.5 dropped the legacy `binding` (singular) — see RFC v1.1 * + WS-6 session 2 deviation #9. With no production data and dev * seeders re-running every cycle, dual-key state had no upside. * * @param iterable $bindings * @return array{bindings: list>} */ public function snapshotShapesFor(iterable $bindings): array { // ULID id sort = insertion-order semantics, deterministic across DB // engines. Without it, MySQL returns rows in unspecified order and // schema_snapshot bytes drift across re-emits — breaks audit replay. $sorted = collect($bindings)->sortBy(fn (FormFieldBinding $b) => (string) $b->id); $all = []; foreach ($sorted as $binding) { $all[] = $this->toApplicatorShape($binding); } return ['bindings' => $all]; } private function ownerTypeFor(FormField|FormFieldLibrary $owner): string { return $owner instanceof FormField ? 'form_field' : 'form_field_library'; } /** @param array $spec */ private function assertSpecValid(array $spec): void { $entity = (string) ($spec['target_entity'] ?? ''); $attribute = (string) ($spec['target_attribute'] ?? ''); $mode = (string) ($spec['mode'] ?? ''); if ($entity === '' || $attribute === '') { throw new \InvalidArgumentException( 'Binding spec requires target_entity and target_attribute.', ); } if (FormFieldBindingMode::tryFrom($mode) === null) { throw new \InvalidArgumentException( "Binding spec mode '{$mode}' is not a valid FormFieldBindingMode.", ); } if (array_key_exists('merge_strategy', $spec)) { $strategy = (string) $spec['merge_strategy']; if (FormFieldBindingMergeStrategy::tryFrom($strategy) === null) { throw new \InvalidArgumentException( "Binding spec merge_strategy '{$strategy}' is not a valid FormFieldBindingMergeStrategy.", ); } } $registry = (array) config('form_binding.'.$entity); if ($registry === []) { throw new \InvalidArgumentException( "Binding target_entity '{$entity}' is not registered in config/form_binding.php.", ); } if (! array_key_exists($attribute, $registry)) { throw new \InvalidArgumentException( "Binding target_attribute '{$entity}.{$attribute}' is not registered in config/form_binding.php.", ); } } }