@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.
- package/CHANGELOG.md +9987 -0
- package/LICENSE +61 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +627 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +364 -0
- package/dist/rpc.d.ts +129 -0
- package/dist/rpc.js +161 -0
- package/dist/web.d.ts +60 -0
- package/dist/web.js +35 -0
- package/package.json +62 -0
package/dist/index.d.ts
ADDED
|
@@ -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 { }
|