@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 +69 -0
- package/README.md +3 -3
- package/dist/core/index.cjs +246 -3
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +67 -3
- package/dist/core/index.d.ts +67 -3
- package/dist/core/index.js +246 -3
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +7 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -3
- package/dist/index.d.ts +124 -3
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +538 -53
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +253 -5
- package/dist/react/index.d.ts +253 -5
- package/dist/react/index.js +490 -5
- package/dist/react/index.js.map +1 -1
- package/package.json +3 -3
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
|
|
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
|
|
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
|
package/dist/core/index.cjs
CHANGED
|
@@ -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;
|