@ai-matrx/associations
THE one way to relate two entities on the AI Matrx platform: association edges, categories, favorites/recents, and the generated entity-type vocabulary — shipped as one package a Matrx client or enterprise app installs.
The package ships the types root, the headless /core, the React binding
/react, and the package-owned lazy React entry /react/lazy: the service
chokepoint, guards, never-throw result funnel, registry merge engine,
subscribable cache store, hooks, and the association faces (cards, list,
pickers, attached-items sheet, capture toolbar). Everything below about the
schema is binding — it is the package's product.
import {
type EntityTypeToken, // generated from platform.entity_types
ENTITY_TYPE_METADATA, // per-token schema/table/label/titleColumn/contentRole
isEntityTypeToken, // runtime guard, narrows to the union
type AssociationEdge, // one assoc_for_entity row (both directions)
type AssociationsRpcResult, // the never-throw result envelope
type DemandedRpcName, // the 27-function demanded RPC surface
type AssociationsConfig, // the seven ports a host binds
} from "@ai-matrx/associations";
import {
createAssociationsStore, // the ONE runtime object a host constructs
} from "@ai-matrx/associations/core";
const store = createAssociationsStore({
dataSource: supabase, // structural subset of SupabaseClient
identity: { requireUserId }, // throws when unauthenticated
errorSink: (e) => report(e), // OPTIONAL — default screams to console
entityOverlay: { file: { hrefFor: (id) => `/files/f/${id}` } }, // optional
});
await store.load("task", taskId); // lazy, deduped, subscribable
store.getEdges("task", taskId); // sync, stable idle default
await store.add({ sourceType: "note", sourceId, targetType: "task", targetId });
store.titles.prime("task", taskId, "Renamed");
await store.favorites.setFavorite("note", noteId, true);
// There is NO boot check to call (0.9.0). A database that cannot answer a
// demanded function says so at the first real call: PostgREST returns
// PGRST202 and the package screams `demanded_schema_violation` with a remedy.
useContainerLinks({ containerType, containerId, orgId }) is the canonical
container-side React API. Its attach, detach, and setPinned operations all
target the exact role-qualified edge; setPinned preserves the edge's other
metadata fields. AssociationCardGrid and the default AssociationList
adapter expose those same operations without host wiring.
// /react — the banner'd React binding (peer: react >=18;
// @ai-matrx/design-system is a real dependency — the faces' chrome
// installs automatically). Bind ONCE:
import {
AssociationsProvider,
useAssociations, // the seven hooks keep the app originals' exact signatures
AssociationCardGrid, // + AssociationCard / AssociationList / pickers / capture toolbar
PrimaryEntityProvider,
} from "@ai-matrx/associations/react";
<AssociationsProvider
store={store} // the /core store from createAssociationsStore
notifier={toastAdapter} // optional — absent warns once, failures still hit errorSink
windowShell={windowAdapter} // optional — absent uses the package's OWN draggable window
capture={{ uploadFile }} // optional — bind ONE primitive, get the whole upload flow
pickerOverrides={{ file: FilePickerWindowAdapter }} // optional — the in-package picker is the default
entityDoors={{ EntityRef, DoorControls, UnresolvedRef }} // optional — hrefFor anchors by default
>
<PrimaryEntityProvider value={{ type: "organization", id, orgId, label }}>
<AssociationCardGrid />
</PrimaryEntityProvider>
</AssociationsProvider>;
The faces style with Tailwind semantic tokens (bg-card,
text-muted-foreground, …): register this package as a Tailwind v4 source in
the consuming app (@source "../node_modules/@ai-matrx/associations/dist")
the same way @ai-matrx/design-system documents.
The demanded schema — read this first
This package is opinionated like the Python matrx-* packages: it does not
adapt to your database — it DEMANDS ours. A consumer who won't run our schema is
not this package's consumer. It ships no DDL (the platform DB owns that; managed
consumers run on our infrastructure); it ships the demand three ways —
documented (here), typed (DemandedRpcSignatures, generated from the live DB),
and screamed at the first real call (PGRST202 → demanded_schema_violation,
with a remedy). Every violation
screams, never degrades.
- The edge.
platform.associations(source_type, source_id, target_type, target_id, organization_id, label, metadata, role, position, created_by, created_at, deleted_at, …)— one polymorphic table, unique on the 5-tuple(source_type, source_id, target_type, target_id, role)NULLS NOT DISTINCT. Type columns are FK-validated againstplatform.entity_types.token. - RPC-only. The client holds no grant on the table. Every operation goes
through the 27 SECURITY DEFINER functions in
DEMANDED_RPC_NAMES(assoc_*×8,conversation_file*×3,agent_resource_*×2,cat_*×5,ues_*×4,reference_search_candidates, and the W6 comments familycmt_*×4). Those readers consume the live-onlyassociations_liveview, so tombstone semantics (a trashed entity soft-removes its edges; restore revives them) hold by construction — this package never writes a.is("deleted_at", null)filter. The DB's legacyassoc_link/assoc_list/assoc_unlinktrio is deliberately NOT demanded. - Direction is registry-owned: little→big. The resource is the SOURCE, the
container the TARGET (
task → organization,file → scope). The DB triggertrg_associations_auto_orientrejects reversed writes — this package surfaces that error verbatim and never flips an edge for you. - A link is never a grant. Access lives in
iam.permissions/iam.memberships. The ONLY sanctioned association→access bridge is DB-side conveyance (platform.association_types.conveys_max→ reachability). This package makes zero access decisions. - A missing function screams. A PostgREST PGRST202 on any demanded RPC maps
to the
demanded_schema_violationresult code and always reaches your errorSink: a database without our schema produces one unambiguous scream, not a mystery toast.
The token vocabulary — writes strict, reads tolerant
EntityTypeToken mirrors platform.entity_types 1:1 (651 active tokens),
regenerated by pnpm gen:entity-types inside this package. Skew policy:
- Writes are STRICT against the installed vocabulary: a token this version
doesn't know fails at the guard, in code, before Postgres — exactly like a
missing DB type. The fix is
pnpm up @ai-matrx/associations(registry changes ship as patch releases), never a raw-string widening. - Reads are TOLERANT: an edge arriving with a token this version doesn't know renders through the fallback (default icon, raw label, no door) instead of crashing.
The ports — two required, the rest DEFAULTED
One AssociationsConfig, bound once per host. Only dataSource (a
structural subset of SupabaseClient answering the demanded RPCs) and
identity are required — construction throws without them, because they
ARE your app's identity and no package can invent them.
Everything else ships a working default (C23 — you override ports, you never have to fill them):
| Port | Default when unbound |
|---|---|
errorSink | createDefaultErrorSink() — tagged console.error per event; announces ONCE that no sink is bound and names the remedy. |
windowShell | DefaultWindowShell — a real draggable / resizable / maximizable / viewport-clamped / portalled / non-modal window; full-width bottom card under 640px. Bind this port only for a real window MANAGER (tray docking, workspace persistence). |
capture | Bind uploadFile(file, { folderPath, visibility }) alone and the package ships the file input, drag-and-drop, batching, the stall watchdog and every loud outcome. Bind requestUpload instead when you own a richer pipeline. Chips with no path behind them are never rendered. |
pickerOverrides | The in-package AssociationPicker serves every token (candidate list, search, attach/detach, create-then-associate footer). |
entityDoors | DoorControlsSlot renders a real labeled target="_blank" anchor from the overlay's hrefFor; a token with no route renders nothing rather than a door to nowhere. |
entityOverlay | Convention columns, default icon, ${label}s, no door. |
notifier | Warn-once through the errorSink, and every user-facing failure still reaches the errorSink. (No toast stack ships: a toast surface needs a host-mounted root, so a "default" would be the silent no-op this package exists to prevent.) |
authorDisplay | cmt_list's denormalized author fields, then "Unknown user" — never blank. |
Code splitting — the package owns it
Import the heavy faces from @ai-matrx/associations/react/lazy and each one
arrives in its own chunk on first render. No next/dynamic, no ssr: false,
no host glue:
import { AssociationCardGrid } from "@ai-matrx/associations/react/lazy";
For a HOST component you bind to a port, use the same seam:
windowShell: {
Window: lazyWindowShell(() => import("./hostWindow").then((m) => m.HostWindow)),
},
pickerOverrides: {
file: lazyPickerOverride(() => import("./hostPicker").then((m) => m.HostPicker)),
},
A per-token picker override should bind useAssociationPickerBridge(props) for
its attach/detach writes — it carries the loud partial-outcome, refusal and
created-but-unattached reporting so an override never re-implements them.
Versioning
Consumed by Matrx repos as latest (workspace:* inside the aidream monorepo)
per THE LATEST LAW — never pinned. Registry token changes → regenerate → patch.
Contract changes → minor/major per semver.
License
MIT