@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 +116 -0
- package/README.md +3 -3
- package/dist/core/index.cjs +315 -3
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +85 -3
- package/dist/core/index.d.ts +85 -3
- package/dist/core/index.js +315 -3
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +11 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -3
- package/dist/index.d.ts +156 -3
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +1270 -39
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +336 -5
- package/dist/react/index.d.ts +336 -5
- package/dist/react/index.js +1273 -5
- package/dist/react/index.js.map +1 -1
- package/package.json +3 -3
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
|
|
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
|
@@ -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
|