@ai-matrx/associations 0.3.0 → 0.5.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,121 @@
1
1
  # Changelog — @ai-matrx/associations
2
2
 
3
+ ## 0.5.0 — 2026-08-29 (unreleased; W6 — the comments canonical UI)
4
+
5
+ W6 per rulings C4/C18: the comments canonical UI is built IN this package —
6
+ a GREENFIELD build (matrx-frontend had only a 165-line service, zero
7
+ components), designed against the platform comments satellite
8
+ (`platform.comments` behind the four `cmt_*` SECURITY-DEFINER RPCs, all
9
+ four verified live: SECURITY DEFINER + authenticated EXECUTE on the
10
+ platform DB).
11
+
12
+ - **Demanded schema grows to 27 functions**: the `cmt_list` / `cmt_add` /
13
+ `cmt_edit` / `cmt_delete` family added to `DEMANDED_RPC_FAMILIES`,
14
+ regenerated from the live-generated DB contract; `assertDemandedSchema`
15
+ probes all four with the unparseable-uuid sentinel (every cmt_* fn takes
16
+ a uuid arg, so 22P02 fires before any body runs — zero side effects).
17
+ - **`/core` — `createCommentsService`
18
+ (`src/core/commentsService.ts`)** — the `cmt_*` chokepoint, behavior
19
+ ported from matrx-frontend `features/comments/service/commentsService.ts`
20
+ with the package's guard wall added in front (entity token writes-strict
21
+ per C18 + legacy-alias normalization, uuid wall on entityId/parentId/
22
+ commentId, non-empty body). Access decisions stay in the RPCs (edit is
23
+ author-only, delete author-or-org-member, list org-filtered) — link ≠
24
+ grant, zero access decisions here.
25
+ - **`/core` — the comment-thread cache slice on the store**
26
+ (`getComments` / `loadComments` / `subscribeComments` / `addComment` /
27
+ `editComment` / `deleteComment` / `invalidateComments`, +
28
+ `services.comments`; `invalidateAll` clears comment threads too): same
29
+ lifecycle contract as the association cache — per-key in-flight dedup,
30
+ ready short-circuit unless forced, pending retains previous comments AND
31
+ the visible error, stable frozen idle default, writes force-reload the
32
+ thread on success only.
33
+ - **Root types (`src/comments.ts`)** — `PlatformComment` / `CommentAuthor`
34
+ / `CommentsEntry`, ported from `features/comments/types.ts` and renamed
35
+ to the package vocabulary.
36
+ - **NEW `authorDisplay` port (`src/ports.ts` + provider)** — the package
37
+ cannot resolve user profiles itself; `cmt_list` denormalizes
38
+ email/displayName/avatarUrl and a host with a richer profile store
39
+ overrides them through this optional function port. DEGRADATION: absent →
40
+ the denormalized fields render as-is; a row with neither displayName nor
41
+ email shows "Unknown user" + the initial avatar. Never blank, never a
42
+ crash.
43
+ - **`/react` — `useComments`** — the thread of comments on any entity
44
+ (token + id) via `useSyncExternalStore`; `currentUserId` is the LENIENT
45
+ identity read gating the own-comment affordances (writes stay loud
46
+ through the services).
47
+ - **`/react` — `CommentThread` (+ `CommentComposer`, `buildCommentTree`,
48
+ `formatRelativeTime`)** — the canonical face: threaded list
49
+ (oldest→newest, replies nested by `parentId`, orphaned replies promoted
50
+ to roots — never dropped), composer (Cmd/Ctrl+Enter submits; a failed
51
+ post keeps the text beside the inline error — input is never lost),
52
+ per-item reply, edit-own inline, delete-own as a TWO-STEP that names the
53
+ consequence ("Deletes this comment for everyone.") before the
54
+ destructive click exists, relative timestamps via
55
+ `Intl.RelativeTimeFormat` (+ absolute on hover, "(edited)" marker),
56
+ author names through the `entityDoors.EntityRef` door when bound (token
57
+ `user`), avatars/names through `authorDisplay`. Chrome from
58
+ design-system (`Button`/`Skeleton`/`cn`, peer >=0.3.0 unchanged); glyphs
59
+ inlined (C19 — `RefreshIcon`/`SpinnerIcon`/`CornerDownRightIcon`
60
+ reused, zero new icon deps).
61
+ - 30 new behavioral tests (11 /core service+slice, 12 jsdom face tests +
62
+ tree/relative-time units) → **177 total**; full gate green (`typecheck`,
63
+ `test`, `check:package` incl. the packed-tarball canary).
64
+
65
+ ## 0.4.0 — 2026-08-29 (unreleased; W4 completion)
66
+
67
+ The three faces STOPPED at the W3+W4 build for missing design-system
68
+ primitives, now built against `@ai-matrx/design-system` 0.3.0 (Command
69
+ family incl. CommandDialog, the Select family, the CreatablePicker
70
+ composite) — the optional peer floor moves to **`>=0.3.0`**. Behavior ported
71
+ VERBATIM from the matrx-frontend originals (each re-read in full
72
+ 2026-08-29); only the measured seams inverted, chrome never vendored.
73
+
74
+ - **`AssociationEntitySelect` (`src/react/components/AssociationEntitySelect.tsx`)**
75
+ — the canonical "name dropdown" (display / inline rename / switch / add new
76
+ / unlink), adapter-driven via the already-shipping
77
+ `AssociationEntitySelectAdapter` contract + default adapter hook. Seams:
78
+ `getEntityInfo` → the store's registry merge engine (`Icon: null` →
79
+ `DefaultEntityIcon`); `@/lib/toast` → the notifier port;
80
+ `EntityDoorControls` → the entityDoors port's `DoorControls` (absent → the
81
+ name and rows render without doors, matching the picker precedent); lucide
82
+ → inlined glyphs (C19).
83
+ - **`CategorySelect` (`src/react/components/CategorySelect.tsx`)** — THE
84
+ single-value picker over one `platform.categories` dimension, the
85
+ CreatablePicker composite with the hierarchy parent-select in
86
+ `createExtra`. Seams: the Redux active-organization read
87
+ (`selectOrganizationId`) → the identity port's optional `ensureOrgId()`
88
+ (`orgId` prop wins; with neither, create refuses LOUDLY — notifier + no
89
+ RPC); toasts → notifier. NOTE: the origin's two-part success toast
90
+ (message + description) is joined into the notifier's single success
91
+ message (the port's `success(msg)` carries no description); content
92
+ preserved.
93
+ - **`CategoryTagPicker` (`src/react/components/CategoryTagPicker.tsx`)** —
94
+ THE multi-select tagger over the canonical `entity → category` edge
95
+ (full-role `setTargets` semantics verbatim, one write at a time). Seams:
96
+ the host-shaped `ensureOrgId` (`@/lib/organizations/personalOrg`) →
97
+ `identity.ensureOrgId` per the design's seam table, with the loud
98
+ `ensure_org_missing` refusal when neither the `orgId` prop nor the port
99
+ can answer; `console.error` → errorSink
100
+ (`category_tag_set_targets_failed` / `category_tag_create_failed`);
101
+ toasts → notifier; the `icon` prop's `LucideIcon` type → the structural
102
+ `TagPickerIcon`.
103
+ - **`buildCategoryHierarchy` → `/core`
104
+ (`src/core/categoryHierarchy.ts`)** — the two-level display-order util
105
+ both category faces consume, ported verbatim from
106
+ `features/scopes/utils/categoryHierarchy.ts` (pure, React-free), exported
107
+ from `/core`.
108
+ - **Four new inlined glyphs (C19)**: `ChevronDownIcon`,
109
+ `ChevronsUpDownIcon`, `CornerDownRightIcon`, `TagIcon`.
110
+ - **Token vocabulary regenerated from the live registry: 651 → 654 tokens**
111
+ (`platform_continued_access`, `route_manifest_entry`, `short_link` landed
112
+ after the 0.3.0 generation; caught by the W5 swap agent's token-parity
113
+ gate). `check:entity-types` green against live.
114
+ - 16 new jsdom behavioral tests (render + interaction + degradations against
115
+ fake ports) → 147 total; full gate green (`typecheck`, `test`,
116
+ `check:package` incl. the packed-tarball canary).
117
+
118
+
3
119
  ## 0.3.0 — 2026-08-29 (unreleased; W3 + W4 of the extraction)
4
120
 
5
121
  Waves 3 and 4: the `/react` subpath — the seven hooks riding the /core store
package/README.md CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  isEntityTypeToken, // runtime guard, narrows to the union
19
19
  type AssociationEdge, // one assoc_for_entity row (both directions)
20
20
  type AssociationsRpcResult, // the never-throw result envelope
21
- type DemandedRpcName, // the 23-function demanded RPC surface
21
+ type DemandedRpcName, // the 27-function demanded RPC surface
22
22
  type AssociationsConfig, // the seven ports a host binds
23
23
  } from "@ai-matrx/associations";
24
24
  ```
@@ -92,9 +92,9 @@ and probed (`assertDemandedSchema` in `/core`). Every violation
92
92
  `(source_type, source_id, target_type, target_id, role)` `NULLS NOT DISTINCT`.
93
93
  Type columns are FK-validated against `platform.entity_types.token`.
94
94
  2. **RPC-only.** The client holds **no grant** on the table. Every operation goes
95
- through the 23 SECURITY DEFINER functions in `DEMANDED_RPC_NAMES` (`assoc_*`
95
+ through the 27 SECURITY DEFINER functions in `DEMANDED_RPC_NAMES` (`assoc_*`
96
96
  ×8, `conversation_file*` ×3, `agent_resource_*` ×2, `cat_*` ×5, `ues_*` ×4,
97
- `reference_search_candidates`). Those readers consume the live-only
97
+ `reference_search_candidates`, and the W6 comments family `cmt_*` ×4). Those readers consume the live-only
98
98
  `associations_live` view, so tombstone semantics (a trashed entity
99
99
  soft-removes its edges; restore revives them) hold by construction — this
100
100
  package never writes a `.is("deleted_at", null)` filter. The DB's legacy
@@ -26,12 +26,14 @@ __export(core_exports, {
26
26
  SELF_TEST_MISSING_RPC: () => SELF_TEST_MISSING_RPC,
27
27
  assertDemandedSchema: () => assertDemandedSchema,
28
28
  associationsKey: () => associationsKey,
29
+ buildCategoryHierarchy: () => buildCategoryHierarchy,
29
30
  createAssociationGuards: () => createAssociationGuards,
30
31
  createAssociationHelpers: () => createAssociationHelpers,
31
32
  createAssociationsService: () => createAssociationsService,
32
33
  createAssociationsStore: () => createAssociationsStore,
33
34
  createCandidatesService: () => createCandidatesService,
34
35
  createCategoriesService: () => createCategoriesService,
36
+ createCommentsService: () => createCommentsService,
35
37
  createEntityRegistry: () => createEntityRegistry,
36
38
  createEntityRowsService: () => createEntityRowsService,
37
39
  createFavoritesService: () => createFavoritesService,
@@ -475,6 +477,7 @@ var ENTITY_TYPE_METADATA = {
475
477
  "platform_actor_session": { token: "platform_actor_session", schema: "platform", table: "actor_session", label: "Actor session", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
476
478
  "platform_actor_token": { token: "platform_actor_token", schema: "platform", table: "actor_token", label: "Actor token", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
477
479
  "platform_actor_token_event": { token: "platform_actor_token_event", schema: "platform", table: "actor_token_event", label: "Actor token event", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
480
+ "platform_continued_access": { token: "platform_continued_access", schema: "platform", table: "continued_access", label: "Continued Access", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
478
481
  "platform_outcome_event": { token: "platform_outcome_event", schema: "platform", table: "outcome_event", label: "Outcome Event", baseTier: 1, isComponent: false, isModule: false, isListed: true, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
479
482
  "platform_outsider_consumer": { token: "platform_outsider_consumer", schema: "platform", table: "outsider_consumer", label: "Outsider consumer", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
480
483
  "platform_saved_view": { token: "platform_saved_view", schema: "platform", table: "saved_view", label: "Saved view", baseTier: 1, isComponent: false, isModule: false, isListed: true, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
@@ -503,6 +506,7 @@ var ENTITY_TYPE_METADATA = {
503
506
  "research_tag": { token: "research_tag", schema: "research", table: "rs_tag", label: "Research Tag", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: "name", contentRole: null, referenceCategory: null },
504
507
  "research_template": { token: "research_template", schema: "research", table: "rs_template", label: "Research Template", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: true, titleColumn: "name", contentRole: null, referenceCategory: null },
505
508
  "research_topic": { token: "research_topic", schema: "research", table: "rs_topic", label: "Research Topic", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: true, titleColumn: "name", contentRole: null, referenceCategory: null },
509
+ "route_manifest_entry": { token: "route_manifest_entry", schema: "platform", table: "route_manifest", label: "Route Manifest Entry", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
506
510
  "rulebook": { token: "rulebook", schema: "platform", table: "rulebook", label: "Rulebook", baseTier: 1, isComponent: false, isModule: false, isListed: true, scopeable: true, category: "Knowledge", referencePickable: true, titleColumn: "name", contentRole: "source", referenceCategory: null },
507
511
  "sandbox_instance": { token: "sandbox_instance", schema: "public", table: "sandbox_instances", label: "Sandbox Instance", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
508
512
  "sch_agent_task": { token: "sch_agent_task", schema: "scheduler", table: "sch_agent_task", label: "Agent Task Config", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
@@ -581,6 +585,7 @@ var ENTITY_TYPE_METADATA = {
581
585
  "seo_topic": { token: "seo_topic", schema: "seo", table: "topic", label: "SEO Topic", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: "seo", referencePickable: true, titleColumn: "name", contentRole: null, referenceCategory: null },
582
586
  "seo_web_analytics_daily": { token: "seo_web_analytics_daily", schema: "seo", table: "web_analytics_daily", label: "Web Analytics Daily", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: "marketing", referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
583
587
  "shared_canvas_item": { token: "shared_canvas_item", schema: "canvas", table: "shared_canvas_items", label: "Shared Canvas Item", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: true, titleColumn: "title", contentRole: null, referenceCategory: null },
588
+ "short_link": { token: "short_link", schema: "platform", table: "short_links", label: "Short Link", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
584
589
  "skill": { token: "skill", schema: "skill", table: "definition", label: "Skill", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: "Skills", referencePickable: true, titleColumn: "label", contentRole: "utility", referenceCategory: null },
585
590
  "skill_render_definition": { token: "skill_render_definition", schema: "skill", table: "render_definition", label: "Skill Render Definition", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: "Skills", referencePickable: true, titleColumn: "label", contentRole: null, referenceCategory: null },
586
591
  "sms_consent": { token: "sms_consent", schema: "communication", table: "sms_consent", label: "SMS Consent", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
@@ -1128,6 +1133,7 @@ var ENTITY_TYPE_TOKENS = [
1128
1133
  "platform_actor_session",
1129
1134
  "platform_actor_token",
1130
1135
  "platform_actor_token_event",
1136
+ "platform_continued_access",
1131
1137
  "platform_outcome_event",
1132
1138
  "platform_outsider_consumer",
1133
1139
  "platform_saved_view",
@@ -1156,6 +1162,7 @@ var ENTITY_TYPE_TOKENS = [
1156
1162
  "research_tag",
1157
1163
  "research_template",
1158
1164
  "research_topic",
1165
+ "route_manifest_entry",
1159
1166
  "rulebook",
1160
1167
  "sandbox_instance",
1161
1168
  "sch_agent_task",
@@ -1234,6 +1241,7 @@ var ENTITY_TYPE_TOKENS = [
1234
1241
  "seo_topic",
1235
1242
  "seo_web_analytics_daily",
1236
1243
  "shared_canvas_item",
1244
+ "short_link",
1237
1245
  "skill",
1238
1246
  "skill_render_definition",
1239
1247
  "sms_consent",
@@ -2707,6 +2715,122 @@ function createAssociationHelpers(service) {
2707
2715
  };
2708
2716
  }
2709
2717
 
2718
+ // src/core/commentsService.ts
2719
+ function toComment(row) {
2720
+ return {
2721
+ id: row.id,
2722
+ orgId: row.organization_id ?? null,
2723
+ entityType: row.entity_type,
2724
+ entityId: row.entity_id,
2725
+ parentId: row.parent_id ?? null,
2726
+ body: row.body,
2727
+ createdAt: row.created_at,
2728
+ updatedAt: row.updated_at,
2729
+ createdBy: row.created_by ?? null,
2730
+ author: {
2731
+ email: row.author_email ?? null,
2732
+ displayName: row.author_display_name ?? null,
2733
+ avatarUrl: row.author_avatar_url ?? null
2734
+ }
2735
+ };
2736
+ }
2737
+ function createCommentsService(deps) {
2738
+ const { dataSource, identity, guards } = deps;
2739
+ const { ok: ok2, err: err2, mapPgError, mapPgErrorPair } = deps.rpc;
2740
+ return {
2741
+ /**
2742
+ * All comments on `${entityType}:${entityId}`, org-filtered by RLS
2743
+ * inside the RPC, ordered oldest→newest. Build the thread from each
2744
+ * row's `parentId` (top-level comments have `parentId === null`).
2745
+ */
2746
+ async listForEntity(entityType, entityId) {
2747
+ try {
2748
+ identity.requireUserId();
2749
+ const token = guards.normalizeEntityToken(entityType);
2750
+ const invalid = firstError(
2751
+ guards.checkToken("entityType", token),
2752
+ guards.checkUuid("entityId", entityId)
2753
+ );
2754
+ if (invalid) return { ok: false, error: invalid };
2755
+ const { data, error } = await dataSource.rpc("cmt_list", {
2756
+ p_entity_type: token,
2757
+ p_entity_id: entityId
2758
+ });
2759
+ if (error) return err2(...mapPgErrorPair(error));
2760
+ const rows = Array.isArray(data) ? data : [];
2761
+ return ok2({ comments: rows.map(toComment) });
2762
+ } catch (e) {
2763
+ return { ok: false, error: mapPgError(e) };
2764
+ }
2765
+ },
2766
+ /**
2767
+ * Post `body` on `${entityType}:${entityId}`; pass `parentId` to reply.
2768
+ * Returns the new comment id.
2769
+ */
2770
+ async add(args) {
2771
+ try {
2772
+ identity.requireUserId();
2773
+ const token = guards.normalizeEntityToken(args.entityType);
2774
+ const invalid = firstError(
2775
+ guards.checkToken("entityType", token),
2776
+ guards.checkUuid("entityId", args.entityId),
2777
+ args.parentId != null ? guards.checkUuid("parentId", args.parentId) : null
2778
+ );
2779
+ if (invalid) return { ok: false, error: invalid };
2780
+ if (!args.body || args.body.trim().length === 0) {
2781
+ return err2("invalid_argument", "comment body must be non-empty");
2782
+ }
2783
+ const { data, error } = await dataSource.rpc("cmt_add", {
2784
+ p_entity_type: token,
2785
+ p_entity_id: args.entityId,
2786
+ p_body: args.body,
2787
+ p_parent_id: args.parentId ?? void 0,
2788
+ p_org_id: args.orgId ?? void 0
2789
+ });
2790
+ if (error) return err2(...mapPgErrorPair(error));
2791
+ if (!data || typeof data !== "string") {
2792
+ return err2("internal", "cmt_add returned no comment id");
2793
+ }
2794
+ return ok2({ id: data });
2795
+ } catch (e) {
2796
+ return { ok: false, error: mapPgError(e) };
2797
+ }
2798
+ },
2799
+ /** Replace a comment's body. Author-only; the RPC rejects everyone else. */
2800
+ async edit(id, body) {
2801
+ try {
2802
+ identity.requireUserId();
2803
+ const invalid = guards.checkUuid("commentId", id);
2804
+ if (invalid) return { ok: false, error: invalid };
2805
+ if (!body || body.trim().length === 0) {
2806
+ return err2("invalid_argument", "comment body must be non-empty");
2807
+ }
2808
+ const { error } = await dataSource.rpc("cmt_edit", {
2809
+ p_id: id,
2810
+ p_body: body
2811
+ });
2812
+ if (error) return err2(...mapPgErrorPair(error));
2813
+ return ok2(null);
2814
+ } catch (e) {
2815
+ return { ok: false, error: mapPgError(e) };
2816
+ }
2817
+ },
2818
+ /** Soft-delete a comment. Author or org member; enforced server-side. */
2819
+ async remove(id) {
2820
+ try {
2821
+ identity.requireUserId();
2822
+ const invalid = guards.checkUuid("commentId", id);
2823
+ if (invalid) return { ok: false, error: invalid };
2824
+ const { error } = await dataSource.rpc("cmt_delete", { p_id: id });
2825
+ if (error) return err2(...mapPgErrorPair(error));
2826
+ return ok2(null);
2827
+ } catch (e) {
2828
+ return { ok: false, error: mapPgError(e) };
2829
+ }
2830
+ }
2831
+ };
2832
+ }
2833
+
2710
2834
  // src/core/store.ts
2711
2835
  function associationsKey(type, id) {
2712
2836
  return `${type}:${id}`;
@@ -2723,6 +2847,12 @@ var IDLE_CATEGORIES = Object.freeze({
2723
2847
  fetchedAt: null,
2724
2848
  error: null
2725
2849
  });
2850
+ var IDLE_COMMENTS = Object.freeze({
2851
+ status: "idle",
2852
+ comments: Object.freeze([]),
2853
+ fetchedAt: null,
2854
+ error: null
2855
+ });
2726
2856
  function createAssociationsStore(config) {
2727
2857
  if (!config || typeof config.dataSource?.rpc !== "function") {
2728
2858
  throw new Error(
@@ -2756,12 +2886,16 @@ function createAssociationsStore(config) {
2756
2886
  const titles = createTitlesService(deps, candidates);
2757
2887
  const entityRows = createEntityRowsService(deps, titles);
2758
2888
  const helpers = createAssociationHelpers(associations);
2889
+ const comments = createCommentsService(deps);
2759
2890
  const assocByKey = /* @__PURE__ */ new Map();
2760
2891
  const assocSubs = /* @__PURE__ */ new Map();
2761
2892
  const assocInFlight = /* @__PURE__ */ new Map();
2762
2893
  const catByDim = /* @__PURE__ */ new Map();
2763
2894
  const catSubs = /* @__PURE__ */ new Map();
2764
2895
  const catInFlight = /* @__PURE__ */ new Map();
2896
+ const cmtByKey = /* @__PURE__ */ new Map();
2897
+ const cmtSubs = /* @__PURE__ */ new Map();
2898
+ const cmtInFlight = /* @__PURE__ */ new Map();
2765
2899
  function notifyAssoc(key) {
2766
2900
  const subs = assocSubs.get(key);
2767
2901
  if (subs) for (const cb of [...subs]) cb();
@@ -2778,6 +2912,14 @@ function createAssociationsStore(config) {
2778
2912
  catByDim.set(dimension, entry);
2779
2913
  notifyCat(dimension);
2780
2914
  }
2915
+ function notifyCmt(key) {
2916
+ const subs = cmtSubs.get(key);
2917
+ if (subs) for (const cb of [...subs]) cb();
2918
+ }
2919
+ function setCmt(key, entry) {
2920
+ cmtByKey.set(key, entry);
2921
+ notifyCmt(key);
2922
+ }
2781
2923
  async function load(type, id, opts = {}) {
2782
2924
  const force = opts.force ?? false;
2783
2925
  if (!type || !id) return;
@@ -2867,6 +3009,49 @@ function createAssociationsStore(config) {
2867
3009
  catInFlight.set(dimension, promise);
2868
3010
  return promise;
2869
3011
  }
3012
+ async function loadComments(type, id, opts = {}) {
3013
+ const force = opts.force ?? false;
3014
+ if (!type || !id) return;
3015
+ const key = associationsKey(type, id);
3016
+ const entry = cmtByKey.get(key);
3017
+ if (!force && entry?.status === "ready") return;
3018
+ const pending = cmtInFlight.get(key);
3019
+ if (!force && entry?.status === "loading" && pending) return pending;
3020
+ const prev = cmtByKey.get(key);
3021
+ setCmt(key, {
3022
+ status: "loading",
3023
+ comments: prev?.comments ?? [],
3024
+ fetchedAt: prev?.fetchedAt ?? null,
3025
+ // Retain the visible failure while a retry is pending (same contract
3026
+ // as the association cache).
3027
+ error: prev?.error ?? null
3028
+ });
3029
+ const promise = (async () => {
3030
+ try {
3031
+ const res = await comments.listForEntity(type, id);
3032
+ if (isAssociationsRpcErr(res)) {
3033
+ const before = cmtByKey.get(key);
3034
+ setCmt(key, {
3035
+ status: "error",
3036
+ comments: before?.comments ?? [],
3037
+ fetchedAt: before?.fetchedAt ?? null,
3038
+ error: res.error.message
3039
+ });
3040
+ } else {
3041
+ setCmt(key, {
3042
+ status: "ready",
3043
+ comments: res.data.comments,
3044
+ fetchedAt: Date.now(),
3045
+ error: null
3046
+ });
3047
+ }
3048
+ } finally {
3049
+ cmtInFlight.delete(key);
3050
+ }
3051
+ })();
3052
+ cmtInFlight.set(key, promise);
3053
+ return promise;
3054
+ }
2870
3055
  return {
2871
3056
  // ── associations ─────────────────────────────────────────────────────
2872
3057
  getEdges(type, id) {
@@ -2937,6 +3122,9 @@ function createAssociationsStore(config) {
2937
3122
  const dims = [...catByDim.keys()];
2938
3123
  catByDim.clear();
2939
3124
  for (const dim of dims) notifyCat(dim);
3125
+ const cmtKeys = [...cmtByKey.keys()];
3126
+ cmtByKey.clear();
3127
+ for (const key of cmtKeys) notifyCmt(key);
2940
3128
  },
2941
3129
  // ── categories ───────────────────────────────────────────────────────
2942
3130
  getCategories(dimension) {
@@ -3011,6 +3199,52 @@ function createAssociationsStore(config) {
3011
3199
  await loadCategories(args.dimension, { force: true });
3012
3200
  return { ok: true, id: res.data.id };
3013
3201
  },
3202
+ // ── comments (W6) ────────────────────────────────────────────────────
3203
+ getComments(type, id) {
3204
+ if (!type || !id) return IDLE_COMMENTS;
3205
+ return cmtByKey.get(associationsKey(type, id)) ?? IDLE_COMMENTS;
3206
+ },
3207
+ loadComments,
3208
+ subscribeComments(key, cb) {
3209
+ let subs = cmtSubs.get(key);
3210
+ if (!subs) {
3211
+ subs = /* @__PURE__ */ new Set();
3212
+ cmtSubs.set(key, subs);
3213
+ }
3214
+ subs.add(cb);
3215
+ return () => {
3216
+ subs.delete(cb);
3217
+ if (subs.size === 0) cmtSubs.delete(key);
3218
+ };
3219
+ },
3220
+ async addComment(args) {
3221
+ const res = await comments.add(args);
3222
+ if (isAssociationsRpcErr(res)) {
3223
+ return { ok: false, error: res.error.message };
3224
+ }
3225
+ await loadComments(args.entityType, args.entityId, { force: true });
3226
+ return { ok: true, id: res.data.id };
3227
+ },
3228
+ async editComment(args) {
3229
+ const res = await comments.edit(args.id, args.body);
3230
+ if (isAssociationsRpcErr(res)) {
3231
+ return { ok: false, error: res.error.message };
3232
+ }
3233
+ await loadComments(args.entityType, args.entityId, { force: true });
3234
+ return { ok: true, id: args.id };
3235
+ },
3236
+ async deleteComment(args) {
3237
+ const res = await comments.remove(args.id);
3238
+ if (isAssociationsRpcErr(res)) {
3239
+ return { ok: false, error: res.error.message };
3240
+ }
3241
+ await loadComments(args.entityType, args.entityId, { force: true });
3242
+ return { ok: true, id: args.id };
3243
+ },
3244
+ invalidateComments(type, id) {
3245
+ const key = associationsKey(type, id);
3246
+ if (cmtByKey.delete(key)) notifyCmt(key);
3247
+ },
3014
3248
  // ── seams ────────────────────────────────────────────────────────────
3015
3249
  titles,
3016
3250
  favorites,
@@ -3018,7 +3252,7 @@ function createAssociationsStore(config) {
3018
3252
  entityRows,
3019
3253
  helpers,
3020
3254
  registry,
3021
- services: { associations, categories },
3255
+ services: { associations, categories, comments },
3022
3256
  registerEntityOverlay: registry.registerEntityOverlay,
3023
3257
  errorSink,
3024
3258
  identity: config.identity
@@ -3049,7 +3283,11 @@ var DEMANDED_RPC_NAMES = [
3049
3283
  "ues_list",
3050
3284
  "ues_get_bulk",
3051
3285
  "ues_touch",
3052
- "reference_search_candidates"
3286
+ "reference_search_candidates",
3287
+ "cmt_list",
3288
+ "cmt_add",
3289
+ "cmt_edit",
3290
+ "cmt_delete"
3053
3291
  ];
3054
3292
 
3055
3293
  // src/core/assertDemandedSchema.ts
@@ -3114,7 +3352,17 @@ var PROBE_ARGS = {
3114
3352
  ues_get_bulk: { p_entity_type: "__probe__", p_entity_ids: [BAD_UUID] },
3115
3353
  ues_touch: { p_entity_type: "__probe__", p_entity_id: BAD_UUID },
3116
3354
  // candidates
3117
- reference_search_candidates: { p_token: "__probe__", p_limit: 1 }
3355
+ reference_search_candidates: { p_token: "__probe__", p_limit: 1 },
3356
+ // comments (W6) — every fn takes a uuid arg, so the unparseable sentinel
3357
+ // guarantees 22P02 before any SECURITY DEFINER body runs (no write occurs).
3358
+ cmt_list: { p_entity_type: "__probe__", p_entity_id: BAD_UUID },
3359
+ cmt_add: {
3360
+ p_entity_type: "__probe__",
3361
+ p_entity_id: BAD_UUID,
3362
+ p_body: "__probe__"
3363
+ },
3364
+ cmt_edit: { p_id: BAD_UUID, p_body: "__probe__" },
3365
+ cmt_delete: { p_id: BAD_UUID }
3118
3366
  };
3119
3367
  function isMissingFunctionError(error) {
3120
3368
  if (!error || typeof error !== "object") return false;
@@ -3190,4 +3438,68 @@ function isContentSourceEdge(sourceType, metadata) {
3190
3438
  if (isMembershipMetadata(metadata)) return false;
3191
3439
  return true;
3192
3440
  }
3441
+
3442
+ // src/core/categoryHierarchy.ts
3443
+ function buildCategoryHierarchy(categories) {
3444
+ const byId = new Map(categories.map((category) => [category.id, category]));
3445
+ const hasHierarchy = categories.some(
3446
+ (category) => category.parentId !== null && byId.has(category.parentId)
3447
+ );
3448
+ if (!hasHierarchy) {
3449
+ return {
3450
+ items: categories.map((category) => ({
3451
+ category,
3452
+ depth: 0,
3453
+ parent: null,
3454
+ displayName: category.name
3455
+ })),
3456
+ roots: categories.filter((category) => category.parentId === null),
3457
+ hasHierarchy: false
3458
+ };
3459
+ }
3460
+ const roots = categories.filter(
3461
+ (category) => category.parentId === null || !byId.has(category.parentId)
3462
+ );
3463
+ const childrenByParent = /* @__PURE__ */ new Map();
3464
+ for (const category of categories) {
3465
+ if (!category.parentId || !byId.has(category.parentId)) continue;
3466
+ const children = childrenByParent.get(category.parentId) ?? [];
3467
+ children.push(category);
3468
+ childrenByParent.set(category.parentId, children);
3469
+ }
3470
+ const items = [];
3471
+ const seen = /* @__PURE__ */ new Set();
3472
+ for (const root of roots) {
3473
+ items.push({
3474
+ category: root,
3475
+ depth: 0,
3476
+ parent: null,
3477
+ displayName: root.name
3478
+ });
3479
+ seen.add(root.id);
3480
+ for (const child of childrenByParent.get(root.id) ?? []) {
3481
+ items.push({
3482
+ category: child,
3483
+ depth: 1,
3484
+ parent: root,
3485
+ displayName: `${root.name} / ${child.name}`
3486
+ });
3487
+ seen.add(child.id);
3488
+ }
3489
+ }
3490
+ for (const category of categories) {
3491
+ if (seen.has(category.id)) continue;
3492
+ items.push({
3493
+ category,
3494
+ depth: 0,
3495
+ parent: null,
3496
+ displayName: category.name
3497
+ });
3498
+ }
3499
+ return {
3500
+ items,
3501
+ roots: categories.filter((category) => category.parentId === null),
3502
+ hasHierarchy: true
3503
+ };
3504
+ }
3193
3505
  //# sourceMappingURL=index.cjs.map