@ai-matrx/associations 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog — @ai-matrx/associations
2
2
 
3
+ ## 0.7.0 — 2026-08-31 (role-safe pin and detach)
4
+
5
+ - `useContainerLinks` now exposes `setPinned`, which updates the exact
6
+ role-qualified edge, preserves every sibling metadata field, and refreshes
7
+ both endpoint caches through the canonical `assoc_add` chokepoint.
8
+ - `AssociationCard` attached-item windows now expose pin/unpin and pass the
9
+ edge role through both pin and detach. Previously, a role-bearing row was
10
+ visible but its detach control targeted only the role-less edge and could
11
+ silently leave the displayed row attached.
12
+ - The default `AssociationList` adapter now derives pin state from edge
13
+ metadata and supplies the same role-safe pin operation. Failed pin writes
14
+ announce themselves through the notifier instead of disappearing.
15
+ - Hook and face regressions prove metadata preservation and exact-role routing
16
+ for both pin and detach. Full package suite: 189 tests.
17
+
3
18
  ## 0.6.2 — 2026-08-31 (vocabulary catch-up: commerce_certified_printer)
4
19
 
5
20
  Regenerated `src/entity-types.generated.ts` from live `platform.entity_types`:
package/README.md CHANGED
@@ -4,16 +4,16 @@ THE one way to relate two entities on the AI Matrx platform: association edges,
4
4
  categories, favorites/recents, and the generated entity-type vocabulary — shipped
5
5
  as one package a Matrx client or enterprise app installs.
6
6
 
7
- **v0.3 ships the types root, the headless `/core`, and the React binding
8
- `/react`** the service chokepoint, guards, the never-throw result funnel,
9
- the registry merge engine, the subscribable cache store, the seven hooks, and
10
- the association faces (cards, list, pickers, attached-items sheet, capture
11
- toolbar). Everything below about the schema is binding — it is the package's
12
- product.
7
+ The package ships the types root, the headless `/core`, the React binding
8
+ `/react`, and the package-owned lazy React entry `/react/lazy`: the service
9
+ chokepoint, guards, never-throw result funnel, registry merge engine,
10
+ subscribable cache store, hooks, and the association faces (cards, list,
11
+ pickers, attached-items sheet, capture toolbar). Everything below about the
12
+ schema is binding — it is the package's product.
13
13
 
14
14
  ```ts
15
15
  import {
16
- type EntityTypeToken, // 651-token union generated from platform.entity_types
16
+ type EntityTypeToken, // generated from platform.entity_types
17
17
  ENTITY_TYPE_METADATA, // per-token schema/table/label/titleColumn/contentRole
18
18
  isEntityTypeToken, // runtime guard, narrows to the union
19
19
  type AssociationEdge, // one assoc_for_entity row (both directions)
@@ -48,6 +48,12 @@ await store.favorites.setFavorite("note", noteId, true);
48
48
  await assertDemandedSchema(supabase, { selfTest: true });
49
49
  ```
50
50
 
51
+ `useContainerLinks({ containerType, containerId, orgId })` is the canonical
52
+ container-side React API. Its `attach`, `detach`, and `setPinned` operations all
53
+ target the exact role-qualified edge; `setPinned` preserves the edge's other
54
+ metadata fields. `AssociationCardGrid` and the default `AssociationList`
55
+ adapter expose those same operations without host wiring.
56
+
51
57
  ```tsx
52
58
  // /react — the banner'd React binding (peer: react >=18;
53
59
  // @ai-matrx/design-system is a real dependency — the faces' chrome
@@ -1483,6 +1483,11 @@ interface UseContainerLinksReturn {
1483
1483
  }) => Promise<AssociationWriteResult>;
1484
1484
  /** Detach a resource from this container. */
1485
1485
  detach: (token: EntityTypeToken, resourceId: string, role?: string) => Promise<AssociationWriteResult>;
1486
+ /**
1487
+ * Toggle the edge's agent-context pin while preserving every other metadata
1488
+ * field and the exact semantic role.
1489
+ */
1490
+ setPinned: (token: EntityTypeToken, resourceId: string, pinned: boolean, role?: string) => Promise<AssociationWriteResult>;
1486
1491
  }
1487
1492
  declare function useContainerLinks(args: UseContainerLinksArgs): UseContainerLinksReturn;
1488
1493
 
@@ -1604,7 +1609,12 @@ interface AttachedItemsSheetProps {
1604
1609
  links: ContainerLink[];
1605
1610
  /** Opens the attach picker. Omitted when the token cannot list candidates. */
1606
1611
  onAdd?: () => void;
1607
- onDetach: (resourceId: string) => Promise<{
1612
+ onDetach: (resourceId: string, role?: string | null) => Promise<{
1613
+ ok: boolean;
1614
+ error?: string;
1615
+ }>;
1616
+ /** Toggle metadata.pinned on this exact role-qualified edge. */
1617
+ onSetPinned?: (resourceId: string, pinned: boolean, role?: string | null) => Promise<{
1608
1618
  ok: boolean;
1609
1619
  error?: string;
1610
1620
  }>;
@@ -1675,7 +1685,7 @@ interface ContainerResourcesAdapter {
1675
1685
  * Optional pin capability (edge `metadata.pinned`) — pinned resources stay
1676
1686
  * inline in agent context at every tier. Rows show a pin toggle when set.
1677
1687
  */
1678
- setPinned?: (token: EntityTypeToken, resourceId: string, pinned: boolean) => Promise<{
1688
+ setPinned?: (token: EntityTypeToken, resourceId: string, pinned: boolean, role?: string) => Promise<{
1679
1689
  ok: boolean;
1680
1690
  error?: string;
1681
1691
  }>;
@@ -1483,6 +1483,11 @@ interface UseContainerLinksReturn {
1483
1483
  }) => Promise<AssociationWriteResult>;
1484
1484
  /** Detach a resource from this container. */
1485
1485
  detach: (token: EntityTypeToken, resourceId: string, role?: string) => Promise<AssociationWriteResult>;
1486
+ /**
1487
+ * Toggle the edge's agent-context pin while preserving every other metadata
1488
+ * field and the exact semantic role.
1489
+ */
1490
+ setPinned: (token: EntityTypeToken, resourceId: string, pinned: boolean, role?: string) => Promise<AssociationWriteResult>;
1486
1491
  }
1487
1492
  declare function useContainerLinks(args: UseContainerLinksArgs): UseContainerLinksReturn;
1488
1493
 
@@ -1604,7 +1609,12 @@ interface AttachedItemsSheetProps {
1604
1609
  links: ContainerLink[];
1605
1610
  /** Opens the attach picker. Omitted when the token cannot list candidates. */
1606
1611
  onAdd?: () => void;
1607
- onDetach: (resourceId: string) => Promise<{
1612
+ onDetach: (resourceId: string, role?: string | null) => Promise<{
1613
+ ok: boolean;
1614
+ error?: string;
1615
+ }>;
1616
+ /** Toggle metadata.pinned on this exact role-qualified edge. */
1617
+ onSetPinned?: (resourceId: string, pinned: boolean, role?: string | null) => Promise<{
1608
1618
  ok: boolean;
1609
1619
  error?: string;
1610
1620
  }>;
@@ -1675,7 +1685,7 @@ interface ContainerResourcesAdapter {
1675
1685
  * Optional pin capability (edge `metadata.pinned`) — pinned resources stay
1676
1686
  * inline in agent context at every tier. Rows show a pin toggle when set.
1677
1687
  */
1678
- setPinned?: (token: EntityTypeToken, resourceId: string, pinned: boolean) => Promise<{
1688
+ setPinned?: (token: EntityTypeToken, resourceId: string, pinned: boolean, role?: string) => Promise<{
1679
1689
  ok: boolean;
1680
1690
  error?: string;
1681
1691
  }>;
@@ -1109,6 +1109,12 @@ var useEntityRelationships = useAssociations;
1109
1109
 
1110
1110
  // src/react/hooks/useContainerLinks.ts
1111
1111
  var import_react3 = require("react");
1112
+ function metadataRecord(metadata) {
1113
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
1114
+ return metadata == null ? {} : null;
1115
+ }
1116
+ return Object.fromEntries(Object.entries(metadata));
1117
+ }
1112
1118
  function useContainerLinks(args) {
1113
1119
  const { containerType, containerId, orgId } = args;
1114
1120
  const store = useAssociationsStore();
@@ -1230,6 +1236,31 @@ function useContainerLinks(args) {
1230
1236
  }
1231
1237
  return result;
1232
1238
  };
1239
+ const setPinned = async (token, resourceId, pinned, role) => {
1240
+ if (!containerId) return { ok: false, error: "Missing container id" };
1241
+ const exactRole = role ?? null;
1242
+ const link = incoming.find(
1243
+ (candidate) => candidate.token === token && candidate.resourceId === resourceId && candidate.role === exactRole
1244
+ );
1245
+ if (!link) return { ok: false, error: "Association edge not found" };
1246
+ const currentMetadata = metadataRecord(link.metadata);
1247
+ if (!currentMetadata) {
1248
+ return {
1249
+ ok: false,
1250
+ error: "Association metadata is not an object and cannot be updated safely"
1251
+ };
1252
+ }
1253
+ return store.add({
1254
+ sourceType: token,
1255
+ sourceId: resourceId,
1256
+ targetType: containerType,
1257
+ targetId: containerId,
1258
+ ...orgId != null ? { orgId } : {},
1259
+ ...link.label != null ? { label: link.label } : {},
1260
+ ...role !== void 0 ? { role } : {},
1261
+ metadata: { ...currentMetadata, pinned }
1262
+ });
1263
+ };
1233
1264
  const reload = async () => {
1234
1265
  await Promise.all([
1235
1266
  reloadGeneric(),
@@ -1247,7 +1278,8 @@ function useContainerLinks(args) {
1247
1278
  attachedIdsFor,
1248
1279
  linksFor,
1249
1280
  attach,
1250
- detach
1281
+ detach,
1282
+ setPinned
1251
1283
  };
1252
1284
  }
1253
1285
 
@@ -2628,6 +2660,9 @@ function UniversalAssociationPicker(props) {
2628
2660
  var import_react15 = require("react");
2629
2661
  var import_design_system5 = require("@ai-matrx/design-system");
2630
2662
  var import_jsx_runtime9 = require("react/jsx-runtime");
2663
+ function metadataIsPinned(metadata) {
2664
+ return metadata !== null && typeof metadata === "object" && !Array.isArray(metadata) && Reflect.get(metadata, "pinned") === true;
2665
+ }
2631
2666
  function AttachedItemsSheet(props) {
2632
2667
  const store = useAssociationsStore();
2633
2668
  const info = store.registry.getEntityInfo(props.token);
@@ -2663,7 +2698,8 @@ function AttachedItemsSheet(props) {
2663
2698
  enabled: props.open,
2664
2699
  links: props.links,
2665
2700
  ...props.onAdd ? { onAdd: props.onAdd } : {},
2666
- onDetach: props.onDetach
2701
+ onDetach: props.onDetach,
2702
+ ...props.onSetPinned ? { onSetPinned: props.onSetPinned } : {}
2667
2703
  }
2668
2704
  )
2669
2705
  }
@@ -2674,7 +2710,8 @@ function AttachedItemsBody({
2674
2710
  enabled,
2675
2711
  links,
2676
2712
  onAdd,
2677
- onDetach
2713
+ onDetach,
2714
+ onSetPinned
2678
2715
  }) {
2679
2716
  const store = useAssociationsStore();
2680
2717
  const notifier = useNotifier();
@@ -2716,11 +2753,12 @@ function AttachedItemsBody({
2716
2753
  cancelled = true;
2717
2754
  };
2718
2755
  }, [token, idKey, enabled, store]);
2719
- const detach = async (resourceId) => {
2756
+ const detach = async (link) => {
2757
+ const resourceId = link.resourceId;
2720
2758
  if (busyId) return;
2721
2759
  setBusyId(resourceId);
2722
2760
  try {
2723
- const res = await onDetach(resourceId);
2761
+ const res = await onDetach(resourceId, link.role);
2724
2762
  if (!res.ok) {
2725
2763
  notifier.error(`Couldn't detach${res.error ? `: ${res.error}` : ""}`);
2726
2764
  }
@@ -2728,6 +2766,20 @@ function AttachedItemsBody({
2728
2766
  setBusyId(null);
2729
2767
  }
2730
2768
  };
2769
+ const setPinned = async (link, pinned) => {
2770
+ if (!onSetPinned || busyId) return;
2771
+ setBusyId(link.resourceId);
2772
+ try {
2773
+ const res = await onSetPinned(link.resourceId, pinned, link.role);
2774
+ if (!res.ok) {
2775
+ notifier.error(
2776
+ `Couldn't ${pinned ? "pin" : "unpin"}${res.error ? `: ${res.error}` : ""}`
2777
+ );
2778
+ }
2779
+ } finally {
2780
+ setBusyId(null);
2781
+ }
2782
+ };
2731
2783
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
2732
2784
  titleError ? /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] text-destructive", children: [
2733
2785
  "Showing names recorded at attach time \u2014 they may be out of date.",
@@ -2742,6 +2794,7 @@ function AttachedItemsBody({
2742
2794
  const missing = !titles.has(link.resourceId);
2743
2795
  const name = resolved ?? link.label ?? (missing ? null : "Untitled");
2744
2796
  const busy = busyId === link.resourceId;
2797
+ const pinned = metadataIsPinned(link.metadata);
2745
2798
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
2746
2799
  "li",
2747
2800
  {
@@ -2761,12 +2814,35 @@ function AttachedItemsBody({
2761
2814
  className: "text-foreground"
2762
2815
  }
2763
2816
  ) }),
2817
+ onSetPinned ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2818
+ "button",
2819
+ {
2820
+ type: "button",
2821
+ disabled: busy,
2822
+ onClick: () => void setPinned(link, !pinned),
2823
+ title: pinned ? "Unpin (stops staying in agent context)" : "Pin \u2014 always in agent context",
2824
+ "aria-label": pinned ? "Unpin" : "Pin",
2825
+ className: (0, import_design_system5.cn)(
2826
+ "flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50",
2827
+ pinned ? "text-primary" : "text-muted-foreground"
2828
+ ),
2829
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2830
+ PinIcon,
2831
+ {
2832
+ className: (0, import_design_system5.cn)(
2833
+ "h-3.5 w-3.5",
2834
+ pinned && "fill-primary/20"
2835
+ )
2836
+ }
2837
+ )
2838
+ }
2839
+ ) : null,
2764
2840
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2765
2841
  "button",
2766
2842
  {
2767
2843
  type: "button",
2768
2844
  disabled: busy,
2769
- onClick: () => void detach(link.resourceId),
2845
+ onClick: () => void detach(link),
2770
2846
  title: "Detach",
2771
2847
  className: (0, import_design_system5.cn)(
2772
2848
  "flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors",
@@ -2862,7 +2938,15 @@ function AssociationCard({
2862
2938
  const role = getContentRoleMeta(info.contentRole);
2863
2939
  const Icon = info.Icon ?? DefaultEntityIcon;
2864
2940
  const [listOpen, setListOpen] = (0, import_react16.useState)(false);
2865
- const { status, countFor, attachedIdsFor, linksFor, attach, detach } = useContainerLinks({
2941
+ const {
2942
+ status,
2943
+ countFor,
2944
+ attachedIdsFor,
2945
+ linksFor,
2946
+ attach,
2947
+ detach,
2948
+ setPinned
2949
+ } = useContainerLinks({
2866
2950
  containerType: container?.type ?? "organization",
2867
2951
  containerId: container?.id ?? null,
2868
2952
  orgId: container?.orgId ?? null
@@ -2959,7 +3043,8 @@ function AssociationCard({
2959
3043
  setOpen(true);
2960
3044
  }
2961
3045
  } : {},
2962
- onDetach: (resourceId) => detach(token, resourceId)
3046
+ onDetach: (resourceId, role2) => detach(token, resourceId, role2 ?? void 0),
3047
+ onSetPinned: (resourceId, pinned, role2) => setPinned(token, resourceId, pinned, role2 ?? void 0)
2963
3048
  }
2964
3049
  ),
2965
3050
  canAttach && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
@@ -3018,6 +3103,9 @@ function AssociationCardGrid({
3018
3103
  var import_react17 = require("react");
3019
3104
  var import_design_system8 = require("@ai-matrx/design-system");
3020
3105
  var import_jsx_runtime12 = require("react/jsx-runtime");
3106
+ function metadataIsPinned2(metadata) {
3107
+ return metadata !== null && typeof metadata === "object" && !Array.isArray(metadata) && Reflect.get(metadata, "pinned") === true;
3108
+ }
3021
3109
  function useContainerLinksAdapter(container, tokens) {
3022
3110
  const store = useAssociationsStore();
3023
3111
  const links = useContainerLinks({
@@ -3032,6 +3120,7 @@ function useContainerLinksAdapter(container, tokens) {
3032
3120
  resourceId: l.resourceId,
3033
3121
  label: l.label,
3034
3122
  role: l.role,
3123
+ pinned: metadataIsPinned2(l.metadata),
3035
3124
  removable: true
3036
3125
  }))
3037
3126
  ) : [];
@@ -3041,11 +3130,13 @@ function useContainerLinksAdapter(container, tokens) {
3041
3130
  reload: links.reload,
3042
3131
  rows,
3043
3132
  attach: async (token, id, title) => links.attach(token, id, title),
3044
- detach: async (token, id, role) => links.detach(token, id, role)
3133
+ detach: async (token, id, role) => links.detach(token, id, role),
3134
+ setPinned: async (token, id, pinned, role) => links.setPinned(token, id, pinned, role)
3045
3135
  };
3046
3136
  }
3047
3137
  function AssociationList(props) {
3048
3138
  const store = useAssociationsStore();
3139
+ const notifier = useNotifier();
3049
3140
  const ctxEntity = usePrimaryEntity();
3050
3141
  const container = props.container ?? ctxEntity ?? null;
3051
3142
  const defaultAdapter = useContainerLinksAdapter(
@@ -3058,6 +3149,7 @@ function AssociationList(props) {
3058
3149
  const [pickerToken, setPickerToken] = (0, import_react17.useState)(null);
3059
3150
  const [showUniversal, setShowUniversal] = (0, import_react17.useState)(false);
3060
3151
  const [removingKeys, setRemovingKeys] = (0, import_react17.useState)(/* @__PURE__ */ new Set());
3152
+ const [pinningKeys, setPinningKeys] = (0, import_react17.useState)(/* @__PURE__ */ new Set());
3061
3153
  const rows = tokenFilter ? adapter.rows.filter((r) => tokenFilter.includes(r.token)) : adapter.rows;
3062
3154
  const visibleRows = rows.filter((r) => !removingKeys.has(r.key));
3063
3155
  const { titleFor } = useEntityTitles(
@@ -3081,6 +3173,31 @@ function AssociationList(props) {
3081
3173
  });
3082
3174
  return res;
3083
3175
  };
3176
+ const handlePin = async (row, pinned) => {
3177
+ if (!adapter.setPinned) return;
3178
+ const info = store.registry.tryGetEntityInfo(row.token);
3179
+ if (!info) return;
3180
+ setPinningKeys((prev) => new Set(prev).add(row.key));
3181
+ try {
3182
+ const res = await adapter.setPinned(
3183
+ info.token,
3184
+ row.resourceId,
3185
+ pinned,
3186
+ row.role ?? void 0
3187
+ );
3188
+ if (!res.ok) {
3189
+ notifier.error(
3190
+ `Couldn't ${pinned ? "pin" : "unpin"} resource${res.error ? `: ${res.error}` : ""}`
3191
+ );
3192
+ }
3193
+ } finally {
3194
+ setPinningKeys((prev) => {
3195
+ const next = new Set(prev);
3196
+ next.delete(row.key);
3197
+ return next;
3198
+ });
3199
+ }
3200
+ };
3084
3201
  const openRow = (row) => {
3085
3202
  if (props.openEntity) {
3086
3203
  props.openEntity(row.token, row.resourceId);
@@ -3271,18 +3388,15 @@ function AssociationList(props) {
3271
3388
  "button",
3272
3389
  {
3273
3390
  type: "button",
3274
- onClick: () => void adapter.setPinned?.(
3275
- info.token,
3276
- row.resourceId,
3277
- !(row.pinned ?? false)
3278
- ),
3391
+ disabled: pinningKeys.has(row.key),
3392
+ onClick: () => void handlePin(row, !(row.pinned ?? false)),
3279
3393
  title: row.pinned ? "Unpin (stops staying in agent context)" : "Pin \u2014 always in agent context",
3280
3394
  "aria-label": row.pinned ? "Unpin" : "Pin",
3281
3395
  className: (0, import_design_system8.cn)(
3282
3396
  "shrink-0 rounded p-0.5 transition-colors hover:!text-foreground",
3283
3397
  row.pinned ? "text-primary" : "text-muted-foreground/0 group-hover:text-muted-foreground"
3284
3398
  ),
3285
- children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
3399
+ children: pinningKeys.has(row.key) ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(SpinnerIcon, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
3286
3400
  PinIcon,
3287
3401
  {
3288
3402
  className: (0, import_design_system8.cn)(