@voltro/plugin-comments 0.53.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.
@@ -0,0 +1,161 @@
1
+ import { ColumnBuilder } from '@voltro/database';
2
+ import { DataStore } from '@voltro/database';
3
+ import { FieldDefinitions } from '@voltro/database';
4
+ import { ReactivityChannel } from '@voltro/protocol';
5
+ import { Schema } from 'effect';
6
+ import { Table } from '@voltro/database';
7
+ import { VoltroPlugin } from '@voltro/protocol';
8
+
9
+ export declare interface CommentAccessInput {
10
+ /** The thread's anchor, verbatim as the app wrote it. */
11
+ readonly anchor: string;
12
+ readonly subject: {
13
+ readonly id: string | null;
14
+ readonly tenantId: string | null;
15
+ readonly scopes: ReadonlyArray<string>;
16
+ };
17
+ /** The app's data store — how the rule reads the anchored entity's own row
18
+ * (existence, soft-delete flag, ownership). `undefined` only while the
19
+ * store is still binding at boot; answer false then (fail-closed). */
20
+ readonly store: DataStore | undefined;
21
+ }
22
+
23
+ /** The anchor's access check refused the caller — or no access rule is
24
+ * declared at all (fail-closed: undeclared access is a refusal, not a pass).
25
+ * Also the answer for a thread whose anchor row no longer resolves (a
26
+ * soft-deleted entity): already-delivered mention notifications keep
27
+ * pointing here, and this is what they find — "no longer available", not a
28
+ * leak and not a crash. */
29
+ export declare class CommentAccessRefused extends CommentAccessRefused_base {
30
+ }
31
+
32
+ declare const CommentAccessRefused_base: Schema.TaggedErrorClass<CommentAccessRefused, "CommentAccessRefused", {
33
+ readonly _tag: Schema.tag<"CommentAccessRefused">;
34
+ } & {
35
+ anchor: typeof Schema.String;
36
+ reason: typeof Schema.String;
37
+ }>;
38
+
39
+ /** The thread / comment id resolves to nothing the caller may see. */
40
+ export declare class CommentNotFound extends CommentNotFound_base {
41
+ }
42
+
43
+ declare const CommentNotFound_base: Schema.TaggedErrorClass<CommentNotFound, "CommentNotFound", {
44
+ readonly _tag: Schema.tag<"CommentNotFound">;
45
+ } & {
46
+ id: typeof Schema.String;
47
+ }>;
48
+
49
+ /** Only the author edits or deletes their own comment (a `comments:moderate`
50
+ * scope overrides for delete). */
51
+ export declare class CommentNotYours extends CommentNotYours_base {
52
+ }
53
+
54
+ declare const CommentNotYours_base: Schema.TaggedErrorClass<CommentNotYours, "CommentNotYours", {
55
+ readonly _tag: Schema.tag<"CommentNotYours">;
56
+ } & {
57
+ id: typeof Schema.String;
58
+ }>;
59
+
60
+ export declare const commentReactionsTable: Table<"_voltro_comment_reactions", FieldDefinitions<{
61
+ readonly id: ColumnBuilder<string, "id", boolean>;
62
+ readonly commentId: ColumnBuilder<string, "text", boolean>;
63
+ readonly subjectId: ColumnBuilder<string, "text", boolean>;
64
+ readonly emoji: ColumnBuilder<string, "text", boolean>;
65
+ }>, true, "byReactionComment">;
66
+
67
+ export declare const commentReadsTable: Table<"_voltro_comment_reads", FieldDefinitions<{
68
+ readonly id: ColumnBuilder<string, "id", boolean>;
69
+ readonly threadId: ColumnBuilder<string, "text", boolean>;
70
+ readonly subjectId: ColumnBuilder<string, "text", boolean>;
71
+ readonly lastReadAt: ColumnBuilder<Date, "timestamp", boolean>;
72
+ }>, true, never>;
73
+
74
+ /** One push channel for the whole surface: every comment write publishes, and
75
+ * `comments.list` re-runs for subscribers. Table-granular by design for now —
76
+ * the read-set work (plan 01 P3) is the named narrowing. */
77
+ export declare const commentsFeed: ReactivityChannel;
78
+
79
+ export declare const commentsPlugin: (options?: CommentsPluginOptions) => VoltroPlugin;
80
+
81
+ declare interface CommentsPluginOptions {
82
+ /**
83
+ * WHO may read and write a given anchor's threads. Exactly one of:
84
+ *
85
+ * - `viaEntity` — guard delegation: the app resolves the anchor to its own
86
+ * entity and answers with its own rules (the same guard its queries use).
87
+ * A row the guard cannot read — including a soft-deleted one — is a
88
+ * refusal, which is precisely the fail-closed semantic wanted there.
89
+ * - `scope` — one scope every comment reader/writer must hold, for apps
90
+ * whose comments are team-internal rather than entity-scoped.
91
+ *
92
+ * UNDECLARED IS REFUSED. A comments surface with no access rule serves
93
+ * nobody rather than everybody.
94
+ */
95
+ readonly access?: {
96
+ readonly viaEntity?: (input: CommentAccessInput) => Promise<boolean>;
97
+ readonly scope?: string;
98
+ };
99
+ /**
100
+ * Resolve an @-mention autocomplete query to subjects. The default returns
101
+ * NOTHING (there is no framework-wide user directory to leak); an app wires
102
+ * its own directory here. Whatever it returns is additionally filtered to
103
+ * the caller's tenant unless `crossTenant: true` is set — the seam is
104
+ * tenant-safe by construction, not by every implementor remembering.
105
+ */
106
+ readonly resolveMentions?: (input: MentionSearchInput) => Promise<ReadonlyArray<{
107
+ readonly subjectId: string;
108
+ readonly label: string;
109
+ readonly tenantId?: string | null;
110
+ }>>;
111
+ /** Allow mention resolution across tenants (single-tenant apps, admin
112
+ * surfaces). Default false. */
113
+ readonly crossTenant?: boolean;
114
+ /** Notification category for mention sends. Default `mention`. */
115
+ readonly mentionCategory?: string;
116
+ /** Namespace for rpc tags + inspect endpoints. Default `comments`. */
117
+ readonly alias?: string;
118
+ /** Second-installation discriminator. */
119
+ readonly name?: string;
120
+ }
121
+ export { CommentsPluginOptions as CommentsOptions }
122
+ export { CommentsPluginOptions }
123
+
124
+ export declare const commentsTable: Table<"_voltro_comments", FieldDefinitions<{
125
+ readonly id: ColumnBuilder<string, "id", boolean>;
126
+ readonly threadId: ColumnBuilder<string, "text", boolean>;
127
+ readonly body: ColumnBuilder<string, "text", boolean>;
128
+ readonly authorSubjectId: ColumnBuilder<string, "text", boolean>;
129
+ readonly mentions: ColumnBuilder<readonly string[], "json", boolean>;
130
+ readonly editedAt: ColumnBuilder<Date | null, "timestamp", boolean>;
131
+ readonly createdAt: ColumnBuilder<Date, "timestamp", true>;
132
+ readonly tenantId: ColumnBuilder<string | null, "text", boolean>;
133
+ }>, true, "byCommentThread">;
134
+
135
+ export declare const commentThreadsTable: Table<"_voltro_comment_threads", FieldDefinitions<{
136
+ readonly id: ColumnBuilder<string, "id", boolean>;
137
+ /** Free string key (`order:123`, `doc:abc#section-2`) — the app decides the
138
+ * vocabulary; the access rule receives it verbatim. */
139
+ readonly anchor: ColumnBuilder<string, "text", boolean>;
140
+ readonly status: ColumnBuilder<string, "text", true>;
141
+ readonly createdBy: ColumnBuilder<string, "text", boolean>;
142
+ readonly createdAt: ColumnBuilder<Date, "timestamp", true>;
143
+ readonly tenantId: ColumnBuilder<string | null, "text", boolean>;
144
+ }>, true, "byThreadAnchor">;
145
+
146
+ export declare interface MentionSearchInput {
147
+ readonly query: string;
148
+ /** The CALLING subject — REQUIRED in the seam signature so no implementation
149
+ * can forget it: the default filters to its tenantId, and without that the
150
+ * autocomplete leaks existence + names across the tenant boundary. */
151
+ readonly subject: {
152
+ readonly id: string | null;
153
+ readonly tenantId: string | null;
154
+ };
155
+ }
156
+
157
+ /** Placeholder export kept for the scaffold's smoke import — the plugin above
158
+ * is the API. */
159
+ export declare const pluginComments: () => string;
160
+
161
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,364 @@
1
+ import { CommentAccessRefused as e, CommentNotFound as t, CommentNotYours as n } from "./errors.js";
2
+ import { commentsFeed as r, commentsRpcClientImports as i, createDescriptor as a, deleteDescriptor as o, editDescriptor as s, listDescriptor as c, markReadDescriptor as l, mentionSearchDescriptor as u, reactDescriptor as d, resolveDescriptor as f } from "./rpc.js";
3
+ import { Effect as p, Option as m } from "effect";
4
+ import { and as h, eq as g, generateId as _, id as v, json as y, table as b, text as x, timestamp as S } from "@voltro/database";
5
+ import { definePlugin as C, pluginInstanceName as w, publishReactivity as T } from "@voltro/protocol";
6
+ import { NotificationService as E } from "@voltro/plugin-notifications";
7
+ //#region src/index.ts
8
+ var D = b("_voltro_comment_threads", {
9
+ id: v({ prefix: "cmtthr" }),
10
+ anchor: x(),
11
+ status: x().default("open"),
12
+ createdBy: x(),
13
+ createdAt: S().default("now"),
14
+ tenantId: x().nullable()
15
+ }).index("byThreadAnchor", ["anchor", "tenantId"]), O = b("_voltro_comments", {
16
+ id: v({ prefix: "cmt" }),
17
+ threadId: x(),
18
+ body: x(),
19
+ authorSubjectId: x(),
20
+ mentions: y(),
21
+ editedAt: S().nullable(),
22
+ createdAt: S().default("now"),
23
+ tenantId: x().nullable()
24
+ }).index("byCommentThread", ["threadId"]), k = b("_voltro_comment_reactions", {
25
+ id: v({ prefix: "cmtrx" }),
26
+ commentId: x(),
27
+ subjectId: x(),
28
+ emoji: x()
29
+ }).unique("byReactionIdentity", [
30
+ "commentId",
31
+ "subjectId",
32
+ "emoji"
33
+ ]).index("byReactionComment", ["commentId"]), A = b("_voltro_comment_reads", {
34
+ id: v({ prefix: "cmtread" }),
35
+ threadId: x(),
36
+ subjectId: x(),
37
+ lastReadAt: S()
38
+ }).unique("byReadThreadSubject", ["threadId", "subjectId"]), j = "@voltro/plugin-comments", M = (e) => {
39
+ let t = e.request.subject ?? {};
40
+ return {
41
+ id: t.id ?? null,
42
+ tenantId: t.tenantId ?? null,
43
+ scopes: t.scopes ?? []
44
+ };
45
+ }, N = (v = {}) => {
46
+ let y = w({
47
+ base: j,
48
+ alias: v.alias,
49
+ instance: v.name
50
+ }), b, x, S = async (e, t, n) => b === void 0 ? [] : b.query({
51
+ table: e,
52
+ predicate: t,
53
+ order: [],
54
+ take: n,
55
+ skip: void 0,
56
+ projection: void 0
57
+ }), N = () => {
58
+ T(x, r);
59
+ }, P = (t, n) => p.gen(function* () {
60
+ let r = M(n);
61
+ return v.access?.viaEntity === void 0 ? v.access?.scope === void 0 ? yield* p.fail(new e({
62
+ anchor: t,
63
+ reason: "no access rule declared — set commentsPlugin({ access: { viaEntity } }) or { access: { scope } }"
64
+ })) : r.scopes.includes(v.access.scope) ? void 0 : yield* p.fail(new e({
65
+ anchor: t,
66
+ reason: `missing scope '${v.access.scope}'`
67
+ })) : (yield* p.promise(async () => {
68
+ try {
69
+ return await v.access.viaEntity({
70
+ anchor: t,
71
+ subject: r,
72
+ store: b
73
+ });
74
+ } catch {
75
+ return !1;
76
+ }
77
+ })) ? void 0 : yield* p.fail(new e({
78
+ anchor: t,
79
+ reason: "the anchored entity is not readable by this caller (or no longer available)"
80
+ }));
81
+ }), F = async (e) => (await S("_voltro_comment_threads", g("id", e), 1))[0], I = (e) => e instanceof Date ? e.toISOString() : typeof e == "string" ? e : "", L = (e) => p.gen(function* () {
82
+ if (e.mentions.length === 0) return;
83
+ let t = yield* p.serviceOption(E);
84
+ if (m.isNone(t)) {
85
+ yield* p.logInfo("comments: mention recorded without notification — @voltro/plugin-notifications is not configured");
86
+ return;
87
+ }
88
+ for (let n of e.mentions) n !== e.author && (yield* p.promise(() => t.value.send({
89
+ to: n,
90
+ category: v.mentionCategory ?? "mention",
91
+ title: "You were mentioned",
92
+ body: e.body.length > 200 ? `${e.body.slice(0, 200)}…` : e.body,
93
+ data: {
94
+ anchor: e.anchor,
95
+ threadId: e.threadId
96
+ },
97
+ tenantId: e.tenantId
98
+ }).then(() => void 0).catch(() => void 0)));
99
+ }), R = async (e, t) => {
100
+ if (e.length === 0 || v.resolveMentions === void 0) return [];
101
+ let n = [];
102
+ for (let r of [...new Set(e)].slice(0, 20)) {
103
+ let e = (await v.resolveMentions({
104
+ query: r,
105
+ subject: t
106
+ }).catch(() => [])).find((e) => e.subjectId === r);
107
+ e !== void 0 && (v.crossTenant === !0 || e.tenantId === void 0 || e.tenantId === null || e.tenantId === t.tenantId) && n.push(r);
108
+ }
109
+ return n;
110
+ }, z = async (e, t, n) => {
111
+ let r = await S("_voltro_comment_threads", h(g("anchor", e), g("tenantId", t))), i = [];
112
+ for (let e of r) {
113
+ let t = [...await S("_voltro_comments", g("threadId", String(e.id)))];
114
+ t.sort((e, t) => I(e.createdAt) < I(t.createdAt) ? -1 : 1);
115
+ let r = n === null ? [] : await S("_voltro_comment_reads", h(g("threadId", String(e.id)), g("subjectId", n)), 1), a = r[0] ? I(r[0].lastReadAt) : "", o = [];
116
+ for (let e of t) {
117
+ let t = await S("_voltro_comment_reactions", g("commentId", String(e.id))), r = /* @__PURE__ */ new Map();
118
+ for (let e of t) {
119
+ let t = r.get(String(e.emoji)) ?? {
120
+ count: 0,
121
+ mine: !1
122
+ };
123
+ t.count += 1, String(e.subjectId) === n && (t.mine = !0), r.set(String(e.emoji), t);
124
+ }
125
+ o.push({
126
+ id: String(e.id),
127
+ threadId: String(e.threadId),
128
+ body: String(e.body),
129
+ authorSubjectId: String(e.authorSubjectId),
130
+ mentions: e.mentions ?? [],
131
+ editedAt: e.editedAt == null ? null : I(e.editedAt),
132
+ createdAt: I(e.createdAt),
133
+ reactions: [...r.entries()].map(([e, t]) => ({
134
+ emoji: e,
135
+ count: t.count,
136
+ mine: t.mine
137
+ }))
138
+ });
139
+ }
140
+ i.push({
141
+ id: String(e.id),
142
+ anchor: String(e.anchor),
143
+ status: e.status === "resolved" ? "resolved" : "open",
144
+ createdBy: String(e.createdBy),
145
+ createdAt: I(e.createdAt),
146
+ comments: o,
147
+ unreadCount: o.filter((e) => String(e.authorSubjectId) !== n && String(e.createdAt) > a).length
148
+ });
149
+ }
150
+ return i.sort((e, t) => String(e.createdAt) < String(t.createdAt) ? -1 : 1), i;
151
+ }, B = "comments:moderate", V = [
152
+ {
153
+ ...c,
154
+ description: "Threads + comments + reactions + unread for one anchor, live.",
155
+ execute: (e, t) => p.gen(function* () {
156
+ let { anchor: n } = e;
157
+ yield* P(n, t);
158
+ let r = M(t);
159
+ return yield* p.promise(() => z(n, r.tenantId, r.id));
160
+ })
161
+ },
162
+ {
163
+ ...a,
164
+ description: "Comment on an anchor — a reply into a thread, or a new thread.",
165
+ execute: (e, n) => p.gen(function* () {
166
+ let r = e;
167
+ yield* P(r.anchor, n);
168
+ let i = M(n), a = i.id ?? "anonymous", o = b;
169
+ if (o === void 0) return yield* p.fail(new t({ id: r.anchor }));
170
+ let s = r.threadId;
171
+ if (s !== void 0) {
172
+ let e = yield* p.promise(() => F(s));
173
+ if (e === void 0 || String(e.anchor) !== r.anchor) return yield* p.fail(new t({ id: s }));
174
+ } else s = _({
175
+ kind: "typeid",
176
+ prefix: "cmtthr"
177
+ }, "_voltro_comment_threads"), yield* p.promise(() => o.insert("_voltro_comment_threads", {
178
+ id: s,
179
+ anchor: r.anchor,
180
+ status: "open",
181
+ createdBy: a,
182
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
183
+ tenantId: i.tenantId
184
+ }));
185
+ let c = yield* p.promise(() => R(r.mentions ?? [], i)), l = _({
186
+ kind: "typeid",
187
+ prefix: "cmt"
188
+ }, "_voltro_comments");
189
+ return yield* p.promise(() => o.insert("_voltro_comments", {
190
+ id: l,
191
+ threadId: s,
192
+ body: r.body,
193
+ authorSubjectId: a,
194
+ mentions: c,
195
+ editedAt: null,
196
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
197
+ tenantId: i.tenantId
198
+ })), N(), yield* L({
199
+ mentions: c,
200
+ author: a,
201
+ anchor: r.anchor,
202
+ threadId: s,
203
+ body: r.body,
204
+ tenantId: i.tenantId
205
+ }), {
206
+ threadId: s,
207
+ commentId: l
208
+ };
209
+ })
210
+ },
211
+ {
212
+ ...s,
213
+ description: "Edit your own comment.",
214
+ execute: (e, r) => p.gen(function* () {
215
+ let i = e, a = (yield* p.promise(() => S("_voltro_comments", g("id", i.commentId), 1)))[0];
216
+ if (a === void 0) return yield* p.fail(new t({ id: i.commentId }));
217
+ let o = yield* p.promise(() => F(String(a.threadId)));
218
+ return o === void 0 ? yield* p.fail(new t({ id: i.commentId })) : (yield* P(String(o.anchor), r), String(a.authorSubjectId) === (M(r).id ?? "anonymous") ? (yield* p.promise(() => b.update("_voltro_comments", i.commentId, {
219
+ body: i.body,
220
+ editedAt: (/* @__PURE__ */ new Date()).toISOString()
221
+ })), N(), { ok: !0 }) : yield* p.fail(new n({ id: i.commentId })));
222
+ })
223
+ },
224
+ {
225
+ ...f,
226
+ description: "Resolve or reopen a thread.",
227
+ execute: (e, n) => p.gen(function* () {
228
+ let r = e, i = yield* p.promise(() => F(r.threadId));
229
+ return i === void 0 ? yield* p.fail(new t({ id: r.threadId })) : (yield* P(String(i.anchor), n), yield* p.promise(() => b.update("_voltro_comment_threads", r.threadId, { status: r.resolved ? "resolved" : "open" })), N(), { ok: !0 });
230
+ })
231
+ },
232
+ {
233
+ ...o,
234
+ description: "Delete your own comment (comments:moderate deletes any).",
235
+ execute: (e, r) => p.gen(function* () {
236
+ let i = e, a = (yield* p.promise(() => S("_voltro_comments", g("id", i.commentId), 1)))[0];
237
+ if (a === void 0) return yield* p.fail(new t({ id: i.commentId }));
238
+ let o = yield* p.promise(() => F(String(a.threadId)));
239
+ if (o === void 0) return yield* p.fail(new t({ id: i.commentId }));
240
+ yield* P(String(o.anchor), r);
241
+ let s = M(r);
242
+ return String(a.authorSubjectId) !== (s.id ?? "anonymous") && !s.scopes.includes(B) ? yield* p.fail(new n({ id: i.commentId })) : (yield* p.promise(async () => {
243
+ for (let e of await S("_voltro_comment_reactions", g("commentId", i.commentId))) await b.delete("_voltro_comment_reactions", String(e.id));
244
+ await b.delete("_voltro_comments", i.commentId);
245
+ }), N(), { ok: !0 });
246
+ })
247
+ },
248
+ {
249
+ ...d,
250
+ description: "Toggle an emoji reaction on a comment.",
251
+ execute: (e, n) => p.gen(function* () {
252
+ let r = e, i = (yield* p.promise(() => S("_voltro_comments", g("id", r.commentId), 1)))[0];
253
+ if (i === void 0) return yield* p.fail(new t({ id: r.commentId }));
254
+ let a = yield* p.promise(() => F(String(i.threadId)));
255
+ if (a === void 0) return yield* p.fail(new t({ id: r.commentId }));
256
+ yield* P(String(a.anchor), n);
257
+ let o = M(n).id ?? "anonymous", s = (yield* p.promise(() => S("_voltro_comment_reactions", h(g("commentId", r.commentId), h(g("subjectId", o), g("emoji", r.emoji))), 1)))[0];
258
+ if (s !== void 0) return yield* p.promise(() => b.delete("_voltro_comment_reactions", String(s.id))), N(), { reacted: !1 };
259
+ let c = _({
260
+ kind: "typeid",
261
+ prefix: "cmtrx"
262
+ }, "_voltro_comment_reactions");
263
+ return yield* p.promise(() => b.insert("_voltro_comment_reactions", {
264
+ id: c,
265
+ commentId: r.commentId,
266
+ subjectId: o,
267
+ emoji: r.emoji
268
+ })), N(), { reacted: !0 };
269
+ })
270
+ },
271
+ {
272
+ ...l,
273
+ description: "Stamp the caller's read marker for a thread.",
274
+ execute: (e, n) => p.gen(function* () {
275
+ let r = e;
276
+ if ((yield* p.promise(() => F(r.threadId))) === void 0) return yield* p.fail(new t({ id: r.threadId }));
277
+ let i = M(n).id ?? "anonymous", a = _({
278
+ kind: "typeid",
279
+ prefix: "cmtread"
280
+ }, "_voltro_comment_reads");
281
+ return yield* p.promise(() => b.upsert("_voltro_comment_reads", {
282
+ id: a,
283
+ threadId: r.threadId,
284
+ subjectId: i,
285
+ lastReadAt: (/* @__PURE__ */ new Date()).toISOString()
286
+ }, {
287
+ conflictColumns: ["threadId", "subjectId"],
288
+ update: ["lastReadAt"]
289
+ })), { ok: !0 };
290
+ })
291
+ },
292
+ {
293
+ ...u,
294
+ description: "@-mention autocomplete over the app-declared directory, tenant-filtered.",
295
+ execute: (e, t) => p.promise(async () => {
296
+ let { query: n } = e;
297
+ if (v.resolveMentions === void 0) return [];
298
+ let r = M(t);
299
+ return (await v.resolveMentions({
300
+ query: n,
301
+ subject: {
302
+ id: r.id,
303
+ tenantId: r.tenantId
304
+ }
305
+ }).catch(() => [])).filter((e) => v.crossTenant === !0 || e.tenantId === void 0 || e.tenantId === null || e.tenantId === r.tenantId).map((e) => ({
306
+ subjectId: e.subjectId,
307
+ label: e.label
308
+ }));
309
+ })
310
+ }
311
+ ], H = (e) => ({
312
+ kind: "json",
313
+ data: e
314
+ });
315
+ return C({
316
+ name: y,
317
+ baseName: j,
318
+ description: "Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread — live over the reactive engine.",
319
+ permissions: ["inspect:read", "store:write"],
320
+ declaredScopes: [B],
321
+ extendSchema: { tables: [
322
+ D,
323
+ O,
324
+ k,
325
+ A
326
+ ] },
327
+ routes: V,
328
+ rpcClientDescriptors: i,
329
+ inspectEndpoints: [{
330
+ method: "GET",
331
+ path: "/threads",
332
+ description: "Thread volume + the most recent threads (operator surface).",
333
+ handler: () => p.promise(async () => {
334
+ let e = await S("_voltro_comment_threads", void 0), t = await S("_voltro_comments", void 0), n = [...e].sort((e, t) => I(e.createdAt) > I(t.createdAt) ? -1 : 1).slice(0, 50).map((e) => ({
335
+ id: String(e.id),
336
+ anchor: String(e.anchor),
337
+ status: String(e.status),
338
+ createdBy: String(e.createdBy),
339
+ createdAt: I(e.createdAt),
340
+ comments: t.filter((t) => String(t.threadId) === String(e.id)).length
341
+ }));
342
+ return H({
343
+ stats: {
344
+ threads: e.length,
345
+ open: e.filter((e) => e.status !== "resolved").length,
346
+ comments: t.length
347
+ },
348
+ recent: n
349
+ });
350
+ })
351
+ }],
352
+ bindDataStore: (e) => {
353
+ b = e, x = e;
354
+ },
355
+ onActivate: (e) => p.sync(() => {
356
+ v.access?.viaEntity === void 0 && v.access?.scope === void 0 && e.logger.warn("comments: no access rule declared — EVERY read and write will be refused (fail-closed). Set commentsPlugin({ access: { viaEntity } }) or { access: { scope } }."), e.logger.info("comments active", { mentions: v.resolveMentions !== void 0 });
357
+ }),
358
+ onDeactivate: () => p.sync(() => {
359
+ b = void 0, x = void 0;
360
+ })
361
+ });
362
+ }, P = () => "plugin-comments";
363
+ //#endregion
364
+ export { e as CommentAccessRefused, t as CommentNotFound, n as CommentNotYours, k as commentReactionsTable, A as commentReadsTable, D as commentThreadsTable, r as commentsFeed, N as commentsPlugin, O as commentsTable, P as pluginComments };
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { MutationProcedureDescriptor } from '@voltro/protocol';
2
+ import { PluginRpcClientDescriptor } from '@voltro/protocol';
3
+ import { QueryProcedureDescriptor } from '@voltro/protocol';
4
+ import { ReactivityChannel } from '@voltro/protocol';
5
+ import { Schema } from 'effect';
6
+
7
+ /** The anchor's access check refused the caller — or no access rule is
8
+ * declared at all (fail-closed: undeclared access is a refusal, not a pass).
9
+ * Also the answer for a thread whose anchor row no longer resolves (a
10
+ * soft-deleted entity): already-delivered mention notifications keep
11
+ * pointing here, and this is what they find — "no longer available", not a
12
+ * leak and not a crash. */
13
+ declare class CommentAccessRefused extends CommentAccessRefused_base {
14
+ }
15
+
16
+ declare const CommentAccessRefused_base: Schema.TaggedErrorClass<CommentAccessRefused, "CommentAccessRefused", {
17
+ readonly _tag: Schema.tag<"CommentAccessRefused">;
18
+ } & {
19
+ anchor: typeof Schema.String;
20
+ reason: typeof Schema.String;
21
+ }>;
22
+
23
+ /** The thread / comment id resolves to nothing the caller may see. */
24
+ declare class CommentNotFound extends CommentNotFound_base {
25
+ }
26
+
27
+ declare const CommentNotFound_base: Schema.TaggedErrorClass<CommentNotFound, "CommentNotFound", {
28
+ readonly _tag: Schema.tag<"CommentNotFound">;
29
+ } & {
30
+ id: typeof Schema.String;
31
+ }>;
32
+
33
+ /** Only the author edits or deletes their own comment (a `comments:moderate`
34
+ * scope overrides for delete). */
35
+ declare class CommentNotYours extends CommentNotYours_base {
36
+ }
37
+
38
+ declare const CommentNotYours_base: Schema.TaggedErrorClass<CommentNotYours, "CommentNotYours", {
39
+ readonly _tag: Schema.tag<"CommentNotYours">;
40
+ } & {
41
+ id: typeof Schema.String;
42
+ }>;
43
+
44
+ /** One push channel for the whole surface: every comment write publishes, and
45
+ * `comments.list` re-runs for subscribers. Table-granular by design for now —
46
+ * the read-set work (plan 01 P3) is the named narrowing. */
47
+ export declare const commentsFeed: ReactivityChannel;
48
+
49
+ export declare const commentsRpcClientImports: ReadonlyArray<PluginRpcClientDescriptor>;
50
+
51
+ export declare const createDescriptor: MutationProcedureDescriptor<"comments.create", Schema.Struct<{
52
+ anchor: typeof Schema.String;
53
+ body: typeof Schema.String;
54
+ /** Reply into an existing thread; absent opens a new thread. */
55
+ threadId: Schema.optional<typeof Schema.String>;
56
+ mentions: Schema.optional<Schema.Array$<typeof Schema.String>>;
57
+ }>, Schema.Struct<{
58
+ threadId: typeof Schema.String;
59
+ commentId: typeof Schema.String;
60
+ }>, Schema.Union<[typeof CommentAccessRefused, typeof CommentNotFound]>>;
61
+
62
+ export declare const deleteDescriptor: MutationProcedureDescriptor<"comments.delete", Schema.Struct<{
63
+ commentId: typeof Schema.String;
64
+ }>, Schema.Struct<{
65
+ ok: typeof Schema.Boolean;
66
+ }>, Schema.Union<[typeof CommentAccessRefused, typeof CommentNotFound, typeof CommentNotYours]>>;
67
+
68
+ export declare const editDescriptor: MutationProcedureDescriptor<"comments.edit", Schema.Struct<{
69
+ commentId: typeof Schema.String;
70
+ body: typeof Schema.String;
71
+ }>, Schema.Struct<{
72
+ ok: typeof Schema.Boolean;
73
+ }>, Schema.Union<[typeof CommentAccessRefused, typeof CommentNotFound, typeof CommentNotYours]>>;
74
+
75
+ export declare const listDescriptor: QueryProcedureDescriptor<"comments.list", Schema.Struct<{
76
+ anchor: typeof Schema.String;
77
+ }>, Schema.Array$<Schema.Struct<{
78
+ id: typeof Schema.String;
79
+ anchor: typeof Schema.String;
80
+ status: Schema.Literal<["open", "resolved"]>;
81
+ createdBy: typeof Schema.String;
82
+ createdAt: typeof Schema.String;
83
+ comments: Schema.Array$<Schema.Struct<{
84
+ id: typeof Schema.String;
85
+ threadId: typeof Schema.String;
86
+ body: typeof Schema.String;
87
+ authorSubjectId: typeof Schema.String;
88
+ mentions: Schema.Array$<typeof Schema.String>;
89
+ editedAt: Schema.NullOr<typeof Schema.String>;
90
+ createdAt: typeof Schema.String;
91
+ reactions: Schema.Array$<Schema.Struct<{
92
+ emoji: typeof Schema.String;
93
+ count: typeof Schema.Number;
94
+ /** Whether the CALLER reacted with this emoji (drives the toggle UI). */
95
+ mine: typeof Schema.Boolean;
96
+ }>>;
97
+ }>>;
98
+ /** Comments newer than the caller's `lastReadAt` for this thread. */
99
+ unreadCount: typeof Schema.Number;
100
+ }>>, typeof CommentAccessRefused>;
101
+
102
+ export declare const markReadDescriptor: MutationProcedureDescriptor<"comments.markRead", Schema.Struct<{
103
+ threadId: typeof Schema.String;
104
+ }>, Schema.Struct<{
105
+ ok: typeof Schema.Boolean;
106
+ }>, typeof CommentNotFound>;
107
+
108
+ export declare const mentionSearchDescriptor: QueryProcedureDescriptor<"comments.mentionSearch", Schema.Struct<{
109
+ query: typeof Schema.String;
110
+ }>, Schema.Array$<Schema.Struct<{
111
+ subjectId: typeof Schema.String;
112
+ label: typeof Schema.String;
113
+ }>>, typeof Schema.Never>;
114
+
115
+ export declare const reactDescriptor: MutationProcedureDescriptor<"comments.react", Schema.Struct<{
116
+ commentId: typeof Schema.String;
117
+ emoji: typeof Schema.String;
118
+ }>, Schema.Struct<{
119
+ reacted: typeof Schema.Boolean;
120
+ }>, Schema.Union<[typeof CommentAccessRefused, typeof CommentNotFound]>>;
121
+
122
+ export declare const resolveDescriptor: MutationProcedureDescriptor<"comments.resolve", Schema.Struct<{
123
+ threadId: typeof Schema.String;
124
+ resolved: typeof Schema.Boolean;
125
+ }>, Schema.Struct<{
126
+ ok: typeof Schema.Boolean;
127
+ }>, Schema.Union<[typeof CommentAccessRefused, typeof CommentNotFound]>>;
128
+
129
+ export { }