@ai-matrx/associations 0.4.0 → 0.5.1

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,74 @@
1
1
  # Changelog — @ai-matrx/associations
2
2
 
3
+ ## 0.5.1 — 2026-08-30 (vocabulary patch)
4
+
5
+ - Entity-token vocabulary regenerated from the live registry: **655 tokens**
6
+ (+`workflow_comparison`). The C18 patch-release lane — matrx-frontend's
7
+ `check:entity-types` parity gate demanded it during the W6 host adoption.
8
+ No code changes.
9
+
10
+ ## 0.5.0 — 2026-08-29 (W6 — the comments canonical UI)
11
+
12
+ W6 per rulings C4/C18: the comments canonical UI is built IN this package —
13
+ a GREENFIELD build (matrx-frontend had only a 165-line service, zero
14
+ components), designed against the platform comments satellite
15
+ (`platform.comments` behind the four `cmt_*` SECURITY-DEFINER RPCs, all
16
+ four verified live: SECURITY DEFINER + authenticated EXECUTE on the
17
+ platform DB).
18
+
19
+ - **Demanded schema grows to 27 functions**: the `cmt_list` / `cmt_add` /
20
+ `cmt_edit` / `cmt_delete` family added to `DEMANDED_RPC_FAMILIES`,
21
+ regenerated from the live-generated DB contract; `assertDemandedSchema`
22
+ probes all four with the unparseable-uuid sentinel (every cmt_* fn takes
23
+ a uuid arg, so 22P02 fires before any body runs — zero side effects).
24
+ - **`/core` — `createCommentsService`
25
+ (`src/core/commentsService.ts`)** — the `cmt_*` chokepoint, behavior
26
+ ported from matrx-frontend `features/comments/service/commentsService.ts`
27
+ with the package's guard wall added in front (entity token writes-strict
28
+ per C18 + legacy-alias normalization, uuid wall on entityId/parentId/
29
+ commentId, non-empty body). Access decisions stay in the RPCs (edit is
30
+ author-only, delete author-or-org-member, list org-filtered) — link ≠
31
+ grant, zero access decisions here.
32
+ - **`/core` — the comment-thread cache slice on the store**
33
+ (`getComments` / `loadComments` / `subscribeComments` / `addComment` /
34
+ `editComment` / `deleteComment` / `invalidateComments`, +
35
+ `services.comments`; `invalidateAll` clears comment threads too): same
36
+ lifecycle contract as the association cache — per-key in-flight dedup,
37
+ ready short-circuit unless forced, pending retains previous comments AND
38
+ the visible error, stable frozen idle default, writes force-reload the
39
+ thread on success only.
40
+ - **Root types (`src/comments.ts`)** — `PlatformComment` / `CommentAuthor`
41
+ / `CommentsEntry`, ported from `features/comments/types.ts` and renamed
42
+ to the package vocabulary.
43
+ - **NEW `authorDisplay` port (`src/ports.ts` + provider)** — the package
44
+ cannot resolve user profiles itself; `cmt_list` denormalizes
45
+ email/displayName/avatarUrl and a host with a richer profile store
46
+ overrides them through this optional function port. DEGRADATION: absent →
47
+ the denormalized fields render as-is; a row with neither displayName nor
48
+ email shows "Unknown user" + the initial avatar. Never blank, never a
49
+ crash.
50
+ - **`/react` — `useComments`** — the thread of comments on any entity
51
+ (token + id) via `useSyncExternalStore`; `currentUserId` is the LENIENT
52
+ identity read gating the own-comment affordances (writes stay loud
53
+ through the services).
54
+ - **`/react` — `CommentThread` (+ `CommentComposer`, `buildCommentTree`,
55
+ `formatRelativeTime`)** — the canonical face: threaded list
56
+ (oldest→newest, replies nested by `parentId`, orphaned replies promoted
57
+ to roots — never dropped), composer (Cmd/Ctrl+Enter submits; a failed
58
+ post keeps the text beside the inline error — input is never lost),
59
+ per-item reply, edit-own inline, delete-own as a TWO-STEP that names the
60
+ consequence ("Deletes this comment for everyone.") before the
61
+ destructive click exists, relative timestamps via
62
+ `Intl.RelativeTimeFormat` (+ absolute on hover, "(edited)" marker),
63
+ author names through the `entityDoors.EntityRef` door when bound (token
64
+ `user`), avatars/names through `authorDisplay`. Chrome from
65
+ design-system (`Button`/`Skeleton`/`cn`, peer >=0.3.0 unchanged); glyphs
66
+ inlined (C19 — `RefreshIcon`/`SpinnerIcon`/`CornerDownRightIcon`
67
+ reused, zero new icon deps).
68
+ - 30 new behavioral tests (11 /core service+slice, 12 jsdom face tests +
69
+ tree/relative-time units) → **177 total**; full gate green (`typecheck`,
70
+ `test`, `check:package` incl. the packed-tarball canary).
71
+
3
72
  ## 0.4.0 — 2026-08-29 (unreleased; W4 completion)
4
73
 
5
74
  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,
@@ -694,6 +695,7 @@ var ENTITY_TYPE_METADATA = {
694
695
  "workflow": { token: "workflow", schema: "workflow", table: "definition", label: "Workflow", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: true, titleColumn: "name", contentRole: "utility", referenceCategory: null },
695
696
  "workflow_card": { token: "workflow_card", schema: "workflow", table: "card", label: "Workflow Card", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
696
697
  "workflow_checkpoint": { token: "workflow_checkpoint", schema: "workflow", table: "checkpoint", label: "Workflow Checkpoint", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
698
+ "workflow_comparison": { token: "workflow_comparison", schema: "workflow", table: "comparison", label: "Workflow Comparison", baseTier: 1, isComponent: false, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
697
699
  "workflow_definition_version": { token: "workflow_definition_version", schema: "workflow", table: "definition_version", label: "Workflow Definition Version", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: "name", contentRole: null, referenceCategory: null },
698
700
  "workflow_idempotency": { token: "workflow_idempotency", schema: "workflow", table: "idempotency", label: "Workflow Idempotency", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
699
701
  "workflow_job": { token: "workflow_job", schema: "workflow", table: "job", label: "Workflow Job", baseTier: 1, isComponent: true, isModule: false, isListed: false, scopeable: true, category: null, referencePickable: false, titleColumn: null, contentRole: null, referenceCategory: null },
@@ -1350,6 +1352,7 @@ var ENTITY_TYPE_TOKENS = [
1350
1352
  "workflow",
1351
1353
  "workflow_card",
1352
1354
  "workflow_checkpoint",
1355
+ "workflow_comparison",
1353
1356
  "workflow_definition_version",
1354
1357
  "workflow_idempotency",
1355
1358
  "workflow_job",
@@ -2714,6 +2717,122 @@ function createAssociationHelpers(service) {
2714
2717
  };
2715
2718
  }
2716
2719
 
2720
+ // src/core/commentsService.ts
2721
+ function toComment(row) {
2722
+ return {
2723
+ id: row.id,
2724
+ orgId: row.organization_id ?? null,
2725
+ entityType: row.entity_type,
2726
+ entityId: row.entity_id,
2727
+ parentId: row.parent_id ?? null,
2728
+ body: row.body,
2729
+ createdAt: row.created_at,
2730
+ updatedAt: row.updated_at,
2731
+ createdBy: row.created_by ?? null,
2732
+ author: {
2733
+ email: row.author_email ?? null,
2734
+ displayName: row.author_display_name ?? null,
2735
+ avatarUrl: row.author_avatar_url ?? null
2736
+ }
2737
+ };
2738
+ }
2739
+ function createCommentsService(deps) {
2740
+ const { dataSource, identity, guards } = deps;
2741
+ const { ok: ok2, err: err2, mapPgError, mapPgErrorPair } = deps.rpc;
2742
+ return {
2743
+ /**
2744
+ * All comments on `${entityType}:${entityId}`, org-filtered by RLS
2745
+ * inside the RPC, ordered oldest→newest. Build the thread from each
2746
+ * row's `parentId` (top-level comments have `parentId === null`).
2747
+ */
2748
+ async listForEntity(entityType, entityId) {
2749
+ try {
2750
+ identity.requireUserId();
2751
+ const token = guards.normalizeEntityToken(entityType);
2752
+ const invalid = firstError(
2753
+ guards.checkToken("entityType", token),
2754
+ guards.checkUuid("entityId", entityId)
2755
+ );
2756
+ if (invalid) return { ok: false, error: invalid };
2757
+ const { data, error } = await dataSource.rpc("cmt_list", {
2758
+ p_entity_type: token,
2759
+ p_entity_id: entityId
2760
+ });
2761
+ if (error) return err2(...mapPgErrorPair(error));
2762
+ const rows = Array.isArray(data) ? data : [];
2763
+ return ok2({ comments: rows.map(toComment) });
2764
+ } catch (e) {
2765
+ return { ok: false, error: mapPgError(e) };
2766
+ }
2767
+ },
2768
+ /**
2769
+ * Post `body` on `${entityType}:${entityId}`; pass `parentId` to reply.
2770
+ * Returns the new comment id.
2771
+ */
2772
+ async add(args) {
2773
+ try {
2774
+ identity.requireUserId();
2775
+ const token = guards.normalizeEntityToken(args.entityType);
2776
+ const invalid = firstError(
2777
+ guards.checkToken("entityType", token),
2778
+ guards.checkUuid("entityId", args.entityId),
2779
+ args.parentId != null ? guards.checkUuid("parentId", args.parentId) : null
2780
+ );
2781
+ if (invalid) return { ok: false, error: invalid };
2782
+ if (!args.body || args.body.trim().length === 0) {
2783
+ return err2("invalid_argument", "comment body must be non-empty");
2784
+ }
2785
+ const { data, error } = await dataSource.rpc("cmt_add", {
2786
+ p_entity_type: token,
2787
+ p_entity_id: args.entityId,
2788
+ p_body: args.body,
2789
+ p_parent_id: args.parentId ?? void 0,
2790
+ p_org_id: args.orgId ?? void 0
2791
+ });
2792
+ if (error) return err2(...mapPgErrorPair(error));
2793
+ if (!data || typeof data !== "string") {
2794
+ return err2("internal", "cmt_add returned no comment id");
2795
+ }
2796
+ return ok2({ id: data });
2797
+ } catch (e) {
2798
+ return { ok: false, error: mapPgError(e) };
2799
+ }
2800
+ },
2801
+ /** Replace a comment's body. Author-only; the RPC rejects everyone else. */
2802
+ async edit(id, body) {
2803
+ try {
2804
+ identity.requireUserId();
2805
+ const invalid = guards.checkUuid("commentId", id);
2806
+ if (invalid) return { ok: false, error: invalid };
2807
+ if (!body || body.trim().length === 0) {
2808
+ return err2("invalid_argument", "comment body must be non-empty");
2809
+ }
2810
+ const { error } = await dataSource.rpc("cmt_edit", {
2811
+ p_id: id,
2812
+ p_body: body
2813
+ });
2814
+ if (error) return err2(...mapPgErrorPair(error));
2815
+ return ok2(null);
2816
+ } catch (e) {
2817
+ return { ok: false, error: mapPgError(e) };
2818
+ }
2819
+ },
2820
+ /** Soft-delete a comment. Author or org member; enforced server-side. */
2821
+ async remove(id) {
2822
+ try {
2823
+ identity.requireUserId();
2824
+ const invalid = guards.checkUuid("commentId", id);
2825
+ if (invalid) return { ok: false, error: invalid };
2826
+ const { error } = await dataSource.rpc("cmt_delete", { p_id: id });
2827
+ if (error) return err2(...mapPgErrorPair(error));
2828
+ return ok2(null);
2829
+ } catch (e) {
2830
+ return { ok: false, error: mapPgError(e) };
2831
+ }
2832
+ }
2833
+ };
2834
+ }
2835
+
2717
2836
  // src/core/store.ts
2718
2837
  function associationsKey(type, id) {
2719
2838
  return `${type}:${id}`;
@@ -2730,6 +2849,12 @@ var IDLE_CATEGORIES = Object.freeze({
2730
2849
  fetchedAt: null,
2731
2850
  error: null
2732
2851
  });
2852
+ var IDLE_COMMENTS = Object.freeze({
2853
+ status: "idle",
2854
+ comments: Object.freeze([]),
2855
+ fetchedAt: null,
2856
+ error: null
2857
+ });
2733
2858
  function createAssociationsStore(config) {
2734
2859
  if (!config || typeof config.dataSource?.rpc !== "function") {
2735
2860
  throw new Error(
@@ -2763,12 +2888,16 @@ function createAssociationsStore(config) {
2763
2888
  const titles = createTitlesService(deps, candidates);
2764
2889
  const entityRows = createEntityRowsService(deps, titles);
2765
2890
  const helpers = createAssociationHelpers(associations);
2891
+ const comments = createCommentsService(deps);
2766
2892
  const assocByKey = /* @__PURE__ */ new Map();
2767
2893
  const assocSubs = /* @__PURE__ */ new Map();
2768
2894
  const assocInFlight = /* @__PURE__ */ new Map();
2769
2895
  const catByDim = /* @__PURE__ */ new Map();
2770
2896
  const catSubs = /* @__PURE__ */ new Map();
2771
2897
  const catInFlight = /* @__PURE__ */ new Map();
2898
+ const cmtByKey = /* @__PURE__ */ new Map();
2899
+ const cmtSubs = /* @__PURE__ */ new Map();
2900
+ const cmtInFlight = /* @__PURE__ */ new Map();
2772
2901
  function notifyAssoc(key) {
2773
2902
  const subs = assocSubs.get(key);
2774
2903
  if (subs) for (const cb of [...subs]) cb();
@@ -2785,6 +2914,14 @@ function createAssociationsStore(config) {
2785
2914
  catByDim.set(dimension, entry);
2786
2915
  notifyCat(dimension);
2787
2916
  }
2917
+ function notifyCmt(key) {
2918
+ const subs = cmtSubs.get(key);
2919
+ if (subs) for (const cb of [...subs]) cb();
2920
+ }
2921
+ function setCmt(key, entry) {
2922
+ cmtByKey.set(key, entry);
2923
+ notifyCmt(key);
2924
+ }
2788
2925
  async function load(type, id, opts = {}) {
2789
2926
  const force = opts.force ?? false;
2790
2927
  if (!type || !id) return;
@@ -2874,6 +3011,49 @@ function createAssociationsStore(config) {
2874
3011
  catInFlight.set(dimension, promise);
2875
3012
  return promise;
2876
3013
  }
3014
+ async function loadComments(type, id, opts = {}) {
3015
+ const force = opts.force ?? false;
3016
+ if (!type || !id) return;
3017
+ const key = associationsKey(type, id);
3018
+ const entry = cmtByKey.get(key);
3019
+ if (!force && entry?.status === "ready") return;
3020
+ const pending = cmtInFlight.get(key);
3021
+ if (!force && entry?.status === "loading" && pending) return pending;
3022
+ const prev = cmtByKey.get(key);
3023
+ setCmt(key, {
3024
+ status: "loading",
3025
+ comments: prev?.comments ?? [],
3026
+ fetchedAt: prev?.fetchedAt ?? null,
3027
+ // Retain the visible failure while a retry is pending (same contract
3028
+ // as the association cache).
3029
+ error: prev?.error ?? null
3030
+ });
3031
+ const promise = (async () => {
3032
+ try {
3033
+ const res = await comments.listForEntity(type, id);
3034
+ if (isAssociationsRpcErr(res)) {
3035
+ const before = cmtByKey.get(key);
3036
+ setCmt(key, {
3037
+ status: "error",
3038
+ comments: before?.comments ?? [],
3039
+ fetchedAt: before?.fetchedAt ?? null,
3040
+ error: res.error.message
3041
+ });
3042
+ } else {
3043
+ setCmt(key, {
3044
+ status: "ready",
3045
+ comments: res.data.comments,
3046
+ fetchedAt: Date.now(),
3047
+ error: null
3048
+ });
3049
+ }
3050
+ } finally {
3051
+ cmtInFlight.delete(key);
3052
+ }
3053
+ })();
3054
+ cmtInFlight.set(key, promise);
3055
+ return promise;
3056
+ }
2877
3057
  return {
2878
3058
  // ── associations ─────────────────────────────────────────────────────
2879
3059
  getEdges(type, id) {
@@ -2944,6 +3124,9 @@ function createAssociationsStore(config) {
2944
3124
  const dims = [...catByDim.keys()];
2945
3125
  catByDim.clear();
2946
3126
  for (const dim of dims) notifyCat(dim);
3127
+ const cmtKeys = [...cmtByKey.keys()];
3128
+ cmtByKey.clear();
3129
+ for (const key of cmtKeys) notifyCmt(key);
2947
3130
  },
2948
3131
  // ── categories ───────────────────────────────────────────────────────
2949
3132
  getCategories(dimension) {
@@ -3018,6 +3201,52 @@ function createAssociationsStore(config) {
3018
3201
  await loadCategories(args.dimension, { force: true });
3019
3202
  return { ok: true, id: res.data.id };
3020
3203
  },
3204
+ // ── comments (W6) ────────────────────────────────────────────────────
3205
+ getComments(type, id) {
3206
+ if (!type || !id) return IDLE_COMMENTS;
3207
+ return cmtByKey.get(associationsKey(type, id)) ?? IDLE_COMMENTS;
3208
+ },
3209
+ loadComments,
3210
+ subscribeComments(key, cb) {
3211
+ let subs = cmtSubs.get(key);
3212
+ if (!subs) {
3213
+ subs = /* @__PURE__ */ new Set();
3214
+ cmtSubs.set(key, subs);
3215
+ }
3216
+ subs.add(cb);
3217
+ return () => {
3218
+ subs.delete(cb);
3219
+ if (subs.size === 0) cmtSubs.delete(key);
3220
+ };
3221
+ },
3222
+ async addComment(args) {
3223
+ const res = await comments.add(args);
3224
+ if (isAssociationsRpcErr(res)) {
3225
+ return { ok: false, error: res.error.message };
3226
+ }
3227
+ await loadComments(args.entityType, args.entityId, { force: true });
3228
+ return { ok: true, id: res.data.id };
3229
+ },
3230
+ async editComment(args) {
3231
+ const res = await comments.edit(args.id, args.body);
3232
+ if (isAssociationsRpcErr(res)) {
3233
+ return { ok: false, error: res.error.message };
3234
+ }
3235
+ await loadComments(args.entityType, args.entityId, { force: true });
3236
+ return { ok: true, id: args.id };
3237
+ },
3238
+ async deleteComment(args) {
3239
+ const res = await comments.remove(args.id);
3240
+ if (isAssociationsRpcErr(res)) {
3241
+ return { ok: false, error: res.error.message };
3242
+ }
3243
+ await loadComments(args.entityType, args.entityId, { force: true });
3244
+ return { ok: true, id: args.id };
3245
+ },
3246
+ invalidateComments(type, id) {
3247
+ const key = associationsKey(type, id);
3248
+ if (cmtByKey.delete(key)) notifyCmt(key);
3249
+ },
3021
3250
  // ── seams ────────────────────────────────────────────────────────────
3022
3251
  titles,
3023
3252
  favorites,
@@ -3025,7 +3254,7 @@ function createAssociationsStore(config) {
3025
3254
  entityRows,
3026
3255
  helpers,
3027
3256
  registry,
3028
- services: { associations, categories },
3257
+ services: { associations, categories, comments },
3029
3258
  registerEntityOverlay: registry.registerEntityOverlay,
3030
3259
  errorSink,
3031
3260
  identity: config.identity
@@ -3056,7 +3285,11 @@ var DEMANDED_RPC_NAMES = [
3056
3285
  "ues_list",
3057
3286
  "ues_get_bulk",
3058
3287
  "ues_touch",
3059
- "reference_search_candidates"
3288
+ "reference_search_candidates",
3289
+ "cmt_list",
3290
+ "cmt_add",
3291
+ "cmt_edit",
3292
+ "cmt_delete"
3060
3293
  ];
3061
3294
 
3062
3295
  // src/core/assertDemandedSchema.ts
@@ -3121,7 +3354,17 @@ var PROBE_ARGS = {
3121
3354
  ues_get_bulk: { p_entity_type: "__probe__", p_entity_ids: [BAD_UUID] },
3122
3355
  ues_touch: { p_entity_type: "__probe__", p_entity_id: BAD_UUID },
3123
3356
  // candidates
3124
- reference_search_candidates: { p_token: "__probe__", p_limit: 1 }
3357
+ reference_search_candidates: { p_token: "__probe__", p_limit: 1 },
3358
+ // comments (W6) — every fn takes a uuid arg, so the unparseable sentinel
3359
+ // guarantees 22P02 before any SECURITY DEFINER body runs (no write occurs).
3360
+ cmt_list: { p_entity_type: "__probe__", p_entity_id: BAD_UUID },
3361
+ cmt_add: {
3362
+ p_entity_type: "__probe__",
3363
+ p_entity_id: BAD_UUID,
3364
+ p_body: "__probe__"
3365
+ },
3366
+ cmt_edit: { p_id: BAD_UUID, p_body: "__probe__" },
3367
+ cmt_delete: { p_id: BAD_UUID }
3125
3368
  };
3126
3369
  function isMissingFunctionError(error) {
3127
3370
  if (!error || typeof error !== "object") return false;