@ai-matrx/associations 0.4.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,67 @@
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
+
3
65
  ## 0.4.0 — 2026-08-29 (unreleased; W4 completion)
4
66
 
5
67
  The three faces STOPPED at the W3+W4 build for missing design-system
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
@@ -33,6 +33,7 @@ __export(core_exports, {
33
33
  createAssociationsStore: () => createAssociationsStore,
34
34
  createCandidatesService: () => createCandidatesService,
35
35
  createCategoriesService: () => createCategoriesService,
36
+ createCommentsService: () => createCommentsService,
36
37
  createEntityRegistry: () => createEntityRegistry,
37
38
  createEntityRowsService: () => createEntityRowsService,
38
39
  createFavoritesService: () => createFavoritesService,
@@ -2714,6 +2715,122 @@ function createAssociationHelpers(service) {
2714
2715
  };
2715
2716
  }
2716
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
+
2717
2834
  // src/core/store.ts
2718
2835
  function associationsKey(type, id) {
2719
2836
  return `${type}:${id}`;
@@ -2730,6 +2847,12 @@ var IDLE_CATEGORIES = Object.freeze({
2730
2847
  fetchedAt: null,
2731
2848
  error: null
2732
2849
  });
2850
+ var IDLE_COMMENTS = Object.freeze({
2851
+ status: "idle",
2852
+ comments: Object.freeze([]),
2853
+ fetchedAt: null,
2854
+ error: null
2855
+ });
2733
2856
  function createAssociationsStore(config) {
2734
2857
  if (!config || typeof config.dataSource?.rpc !== "function") {
2735
2858
  throw new Error(
@@ -2763,12 +2886,16 @@ function createAssociationsStore(config) {
2763
2886
  const titles = createTitlesService(deps, candidates);
2764
2887
  const entityRows = createEntityRowsService(deps, titles);
2765
2888
  const helpers = createAssociationHelpers(associations);
2889
+ const comments = createCommentsService(deps);
2766
2890
  const assocByKey = /* @__PURE__ */ new Map();
2767
2891
  const assocSubs = /* @__PURE__ */ new Map();
2768
2892
  const assocInFlight = /* @__PURE__ */ new Map();
2769
2893
  const catByDim = /* @__PURE__ */ new Map();
2770
2894
  const catSubs = /* @__PURE__ */ new Map();
2771
2895
  const catInFlight = /* @__PURE__ */ new Map();
2896
+ const cmtByKey = /* @__PURE__ */ new Map();
2897
+ const cmtSubs = /* @__PURE__ */ new Map();
2898
+ const cmtInFlight = /* @__PURE__ */ new Map();
2772
2899
  function notifyAssoc(key) {
2773
2900
  const subs = assocSubs.get(key);
2774
2901
  if (subs) for (const cb of [...subs]) cb();
@@ -2785,6 +2912,14 @@ function createAssociationsStore(config) {
2785
2912
  catByDim.set(dimension, entry);
2786
2913
  notifyCat(dimension);
2787
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
+ }
2788
2923
  async function load(type, id, opts = {}) {
2789
2924
  const force = opts.force ?? false;
2790
2925
  if (!type || !id) return;
@@ -2874,6 +3009,49 @@ function createAssociationsStore(config) {
2874
3009
  catInFlight.set(dimension, promise);
2875
3010
  return promise;
2876
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
+ }
2877
3055
  return {
2878
3056
  // ── associations ─────────────────────────────────────────────────────
2879
3057
  getEdges(type, id) {
@@ -2944,6 +3122,9 @@ function createAssociationsStore(config) {
2944
3122
  const dims = [...catByDim.keys()];
2945
3123
  catByDim.clear();
2946
3124
  for (const dim of dims) notifyCat(dim);
3125
+ const cmtKeys = [...cmtByKey.keys()];
3126
+ cmtByKey.clear();
3127
+ for (const key of cmtKeys) notifyCmt(key);
2947
3128
  },
2948
3129
  // ── categories ───────────────────────────────────────────────────────
2949
3130
  getCategories(dimension) {
@@ -3018,6 +3199,52 @@ function createAssociationsStore(config) {
3018
3199
  await loadCategories(args.dimension, { force: true });
3019
3200
  return { ok: true, id: res.data.id };
3020
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
+ },
3021
3248
  // ── seams ────────────────────────────────────────────────────────────
3022
3249
  titles,
3023
3250
  favorites,
@@ -3025,7 +3252,7 @@ function createAssociationsStore(config) {
3025
3252
  entityRows,
3026
3253
  helpers,
3027
3254
  registry,
3028
- services: { associations, categories },
3255
+ services: { associations, categories, comments },
3029
3256
  registerEntityOverlay: registry.registerEntityOverlay,
3030
3257
  errorSink,
3031
3258
  identity: config.identity
@@ -3056,7 +3283,11 @@ var DEMANDED_RPC_NAMES = [
3056
3283
  "ues_list",
3057
3284
  "ues_get_bulk",
3058
3285
  "ues_touch",
3059
- "reference_search_candidates"
3286
+ "reference_search_candidates",
3287
+ "cmt_list",
3288
+ "cmt_add",
3289
+ "cmt_edit",
3290
+ "cmt_delete"
3060
3291
  ];
3061
3292
 
3062
3293
  // src/core/assertDemandedSchema.ts
@@ -3121,7 +3352,17 @@ var PROBE_ARGS = {
3121
3352
  ues_get_bulk: { p_entity_type: "__probe__", p_entity_ids: [BAD_UUID] },
3122
3353
  ues_touch: { p_entity_type: "__probe__", p_entity_id: BAD_UUID },
3123
3354
  // candidates
3124
- 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 }
3125
3366
  };
3126
3367
  function isMissingFunctionError(error) {
3127
3368
  if (!error || typeof error !== "object") return false;