@better-apps/comments 0.0.0-stage → 0.0.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,136 @@
1
+ import { CommentsError } from "./errors.js";
2
+ /** Size and count limits. Queries that pass lists of ids stay under D1's 100 bound parameters. */
3
+ export const limits = {
4
+ bodyBytes: 16 * 1024,
5
+ anchorBytes: 4 * 1024,
6
+ metadataBytes: 4 * 1024,
7
+ mentions: 20,
8
+ threadsPerPage: 20,
9
+ maxThreadsPerPage: 50,
10
+ commentsPerPage: 50,
11
+ maxCommentsPerPage: 100,
12
+ subjectsPerCount: 50,
13
+ suggestions: 10
14
+ };
15
+ const encoder = new TextEncoder();
16
+ /** The size of a value as JSON, in UTF-8 bytes. */
17
+ export function jsonBytes(value) {
18
+ return encoder.encode(JSON.stringify(value) ?? "").length;
19
+ }
20
+ /** JSON with object keys sorted, so equal values compare equal however they were built. */
21
+ export function stableJson(value) {
22
+ return JSON.stringify(value, (_, inner) => inner && typeof inner === "object" && !Array.isArray(inner)
23
+ ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : 1)))
24
+ : inner);
25
+ }
26
+ async function check(schema, value, what) {
27
+ const result = await schema["~standard"].validate(value);
28
+ if (result.issues) {
29
+ throw new CommentsError("INVALID_INPUT", `The ${what} isn't valid.`, { issues: result.issues });
30
+ }
31
+ return result.value;
32
+ }
33
+ function capped(value, bytes, what) {
34
+ if (jsonBytes(value) > bytes) {
35
+ throw new CommentsError("LIMIT_EXCEEDED", `The ${what} is over ${bytes / 1024} KB.`);
36
+ }
37
+ }
38
+ /** Checks, sanitizes and measures a body in a registered format. */
39
+ export async function checkContent(formats, formatName, body) {
40
+ const name = formatName ?? "text";
41
+ const format = formats.get(name);
42
+ if (!format) {
43
+ throw new CommentsError("INVALID_INPUT", `Comments aren't set up for the "${name}" body format.`);
44
+ }
45
+ const checked = await check(format.schema, body, "comment body");
46
+ const sanitized = format.sanitize ? format.sanitize(checked) : checked;
47
+ capped(sanitized, limits.bodyBytes, "comment");
48
+ const mentions = format.mentions(sanitized);
49
+ if (mentions.length > limits.mentions) {
50
+ throw new CommentsError("LIMIT_EXCEEDED", `A comment can mention at most ${limits.mentions} people.`);
51
+ }
52
+ return { format: name, body: sanitized, bodyText: format.toText(sanitized), mentions };
53
+ }
54
+ /** Checks an anchor against its registered kind. `null` puts the thread on the whole subject. */
55
+ export async function checkAnchor(anchors, anchor) {
56
+ if (!anchor) {
57
+ return { anchorKind: null, anchor: null, anchorVersion: null };
58
+ }
59
+ const kind = anchors.get(anchor.kind);
60
+ if (!kind) {
61
+ throw new CommentsError("INVALID_INPUT", `Comments aren't set up for the "${anchor.kind}" anchor kind.`);
62
+ }
63
+ const value = await check(kind.schema, anchor.value, "anchor");
64
+ capped(value, limits.anchorBytes, "anchor");
65
+ return { anchorKind: kind.kind, anchor: value, anchorVersion: anchor.version ?? null };
66
+ }
67
+ /** Checks metadata against a subject type's schema, when it has one, and its size. */
68
+ export async function checkMetadata(schema, metadata) {
69
+ if (metadata === null) {
70
+ return null;
71
+ }
72
+ const checked = schema ? await check(schema, metadata, "metadata") : metadata;
73
+ capped(checked, limits.metadataBytes, "metadata");
74
+ return checked;
75
+ }
76
+ function fieldValue(name, field, value) {
77
+ const invalid = () => new CommentsError("INVALID_INPUT", `The "${name}" field isn't valid.`);
78
+ if (value === null) {
79
+ if (field.required === false) {
80
+ return null;
81
+ }
82
+ throw invalid();
83
+ }
84
+ const { type } = field;
85
+ if (Array.isArray(type)) {
86
+ if (typeof value === "string" && type.includes(value)) {
87
+ return value;
88
+ }
89
+ throw invalid();
90
+ }
91
+ switch (type) {
92
+ case "string":
93
+ case "number":
94
+ case "boolean":
95
+ if (typeof value === type && (type !== "number" || Number.isFinite(value))) {
96
+ return value;
97
+ }
98
+ throw invalid();
99
+ case "date": {
100
+ // JSON carries dates as strings.
101
+ if (value instanceof Date || typeof value === "string") {
102
+ const date = new Date(value);
103
+ if (!Number.isNaN(date.getTime())) {
104
+ return date;
105
+ }
106
+ }
107
+ throw invalid();
108
+ }
109
+ case "string[]":
110
+ case "number[]": {
111
+ const item = type === "string[]" ? "string" : "number";
112
+ if (Array.isArray(value) && value.every((entry) => typeof entry === item)) {
113
+ return value;
114
+ }
115
+ throw invalid();
116
+ }
117
+ default:
118
+ capped(value, limits.metadataBytes, `"${name}" field`);
119
+ return value;
120
+ }
121
+ }
122
+ /** Checks values for an app's additional fields. Fields it didn't declare are refused. */
123
+ export function checkFields(declared, values) {
124
+ const checked = {};
125
+ for (const [name, value] of Object.entries(values ?? {})) {
126
+ if (value === undefined) {
127
+ continue;
128
+ }
129
+ const field = Object.hasOwn(declared, name) ? declared[name] : undefined;
130
+ if (!field) {
131
+ throw new CommentsError("INVALID_INPUT", `There's no "${name}" field.`);
132
+ }
133
+ checked[name] = fieldValue(name, field, value);
134
+ }
135
+ return checked;
136
+ }
@@ -0,0 +1,7 @@
1
+ /** Encodes the position after a row. */
2
+ export declare function encodeCursor(time: Date, id: string): string;
3
+ /** Decodes a cursor from `encodeCursor`, or refuses it as `INVALID_INPUT`. */
4
+ export declare function decodeCursor(cursor: string): {
5
+ time: Date;
6
+ id: string;
7
+ };
package/dist/cursor.js ADDED
@@ -0,0 +1,23 @@
1
+ import { CommentsError } from "./errors.js";
2
+ // A page cursor holds the last row's sort time and id, so rows that share a time are never skipped or repeated.
3
+ /** Encodes the position after a row. */
4
+ export function encodeCursor(time, id) {
5
+ return btoa(JSON.stringify([time.toISOString(), id]))
6
+ .replace(/\+/g, "-")
7
+ .replace(/\//g, "_")
8
+ .replace(/=+$/, "");
9
+ }
10
+ /** Decodes a cursor from `encodeCursor`, or refuses it as `INVALID_INPUT`. */
11
+ export function decodeCursor(cursor) {
12
+ try {
13
+ const [time, id] = JSON.parse(atob(cursor.replace(/-/g, "+").replace(/_/g, "/")));
14
+ const date = new Date(String(time));
15
+ if (typeof time === "string" && typeof id === "string" && !Number.isNaN(date.getTime())) {
16
+ return { time: date, id };
17
+ }
18
+ }
19
+ catch {
20
+ // Reported below.
21
+ }
22
+ throw new CommentsError("INVALID_INPUT", "That page cursor isn't valid.");
23
+ }
@@ -0,0 +1,14 @@
1
+ import { BetterAppsError, type StandardSchemaIssue } from "better-apps";
2
+ /** The codes comments answer with. The client uses them to roll back optimistic changes. */
3
+ export type CommentsErrorCode = "UNAUTHENTICATED" | "NOT_FOUND" | "FORBIDDEN" | "INVALID_INPUT" | "LIMIT_EXCEEDED" | "RATE_LIMITED" | "CONFLICT" | "REJECTED_BY_HOOK";
4
+ /**
5
+ * An error comments return on purpose. A before hook throws one to refuse a write with its code, such as `FORBIDDEN` or
6
+ * `RATE_LIMITED`; any other error from a hook becomes `REJECTED_BY_HOOK`.
7
+ */
8
+ export declare class CommentsError extends BetterAppsError {
9
+ readonly code: CommentsErrorCode;
10
+ constructor(code: CommentsErrorCode, message?: string, options?: {
11
+ issues?: readonly StandardSchemaIssue[] | undefined;
12
+ cause?: unknown;
13
+ });
14
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ import { BetterAppsError } from "better-apps";
2
+ /**
3
+ * An error comments return on purpose. A before hook throws one to refuse a write with its code, such as `FORBIDDEN` or
4
+ * `RATE_LIMITED`; any other error from a hook becomes `REJECTED_BY_HOOK`.
5
+ */
6
+ export class CommentsError extends BetterAppsError {
7
+ constructor(code, message = code, options = {}) {
8
+ super(code, message, options);
9
+ this.name = "CommentsError";
10
+ }
11
+ }
@@ -0,0 +1,74 @@
1
+ import type { Comment, CommentThread } from "./schema.js";
2
+ /** What every thread event carries, so a subscriber never needs to query back. */
3
+ export interface ThreadEventData {
4
+ subjectType: string;
5
+ subjectId: string;
6
+ orgId: string;
7
+ /** The thread after the change, or as it was before it was deleted. */
8
+ thread: CommentThread;
9
+ }
10
+ /** A thread event about one comment. */
11
+ export interface CommentEventData extends ThreadEventData {
12
+ comment: Comment;
13
+ }
14
+ /** A deleted thread, with the comments that went with it, so features attached to them can clean up. */
15
+ export interface ThreadDeletedData extends ThreadEventData {
16
+ commentIds: string[];
17
+ }
18
+ /** A thread that moved to another organization with its record, and its comments that moved with it. */
19
+ export interface ThreadMovedData extends ThreadDeletedData {
20
+ fromOrgId: string;
21
+ }
22
+ /** A thread's anchor, as `thread.anchorUpdated` reports the previous one. */
23
+ export interface StoredAnchor {
24
+ kind: string;
25
+ value: unknown;
26
+ version: string | null;
27
+ }
28
+ /**
29
+ * The events comments emit. They stay on the server, because they can hold previous bodies and threads in restricted
30
+ * visibilities; browsers get them only through `toClientEvent`.
31
+ */
32
+ export declare const commentEvents: {
33
+ "thread.created": import("better-apps").EventDefinition<CommentEventData>;
34
+ "thread.updated": import("better-apps").EventDefinition<ThreadEventData & {
35
+ previous: Partial<CommentThread>;
36
+ }>;
37
+ "comment.created": import("better-apps").EventDefinition<CommentEventData>;
38
+ "comment.edited": import("better-apps").EventDefinition<CommentEventData & {
39
+ previousFormat: string;
40
+ previousBody: unknown;
41
+ }>;
42
+ "comment.deleted": import("better-apps").EventDefinition<CommentEventData & {
43
+ previousBody: unknown;
44
+ previousText: string;
45
+ }>;
46
+ "comment.mentioned": import("better-apps").EventDefinition<CommentEventData & {
47
+ userId: string;
48
+ }>;
49
+ "thread.resolved": import("better-apps").EventDefinition<ThreadEventData>;
50
+ "thread.reopened": import("better-apps").EventDefinition<ThreadEventData>;
51
+ "thread.anchorUpdated": import("better-apps").EventDefinition<ThreadEventData & {
52
+ previousAnchor: StoredAnchor | null;
53
+ }>;
54
+ "thread.deleted": import("better-apps").EventDefinition<ThreadDeletedData>;
55
+ "thread.moved": import("better-apps").EventDefinition<ThreadMovedData>;
56
+ "subject.moved": import("better-apps").EventDefinition<{
57
+ subjectType: string;
58
+ subjectId: string;
59
+ toOrgId: string;
60
+ }>;
61
+ "subject.removed": import("better-apps").EventDefinition<{
62
+ subjectType: string;
63
+ subjectId: string;
64
+ }>;
65
+ "user.removed": import("better-apps").EventDefinition<{
66
+ userId: string;
67
+ }>;
68
+ };
69
+ /** The data of each comments event, by type. */
70
+ export type CommentsEventData = {
71
+ [Type in keyof typeof commentEvents]: (typeof commentEvents)[Type] extends {
72
+ "~data"?: infer Data;
73
+ } ? NonNullable<Data> : never;
74
+ };
package/dist/events.js ADDED
@@ -0,0 +1,21 @@
1
+ import { defineEvent } from "better-apps";
2
+ /**
3
+ * The events comments emit. They stay on the server, because they can hold previous bodies and threads in restricted
4
+ * visibilities; browsers get them only through `toClientEvent`.
5
+ */
6
+ export const commentEvents = {
7
+ "thread.created": defineEvent(),
8
+ "thread.updated": defineEvent(),
9
+ "comment.created": defineEvent(),
10
+ "comment.edited": defineEvent(),
11
+ "comment.deleted": defineEvent(),
12
+ "comment.mentioned": defineEvent(),
13
+ "thread.resolved": defineEvent(),
14
+ "thread.reopened": defineEvent(),
15
+ "thread.anchorUpdated": defineEvent(),
16
+ "thread.deleted": defineEvent(),
17
+ "thread.moved": defineEvent(),
18
+ "subject.moved": defineEvent(),
19
+ "subject.removed": defineEvent(),
20
+ "user.removed": defineEvent()
21
+ };
@@ -0,0 +1,270 @@
1
+ import type { AnchorKind } from "@better-apps/anchors";
2
+ import { type Actor, type BetterAppsEvent, type IdentityUser, type StandardSchemaV1 } from "better-apps";
3
+ import type { CommentTarget, CommentsOptions, CreateThreadInput, EditCommentInput, ReplyInput, ThreadTarget, TypesOf, UpdateAnchorInput, UpdateThreadInput } from "./options.js";
4
+ import { type ClientEvent, type LiveViewer, type ThreadPage, type ThreadWithComments } from "./reads.js";
5
+ import { type Comment, type CommentThread } from "./schema.js";
6
+ import { type UpkeepStep } from "./upkeep.js";
7
+ /** The input of `listThreads`. */
8
+ export interface ListThreadsInput<SubjectType extends string = string, AnchorKindName extends string = string> {
9
+ subjectType: SubjectType;
10
+ subjectId: string;
11
+ status?: "open" | "resolved" | undefined;
12
+ /** `null` lists threads on the whole subject. */
13
+ anchorKind?: AnchorKindName | null | undefined;
14
+ /** Equality filters on additional fields declared with `index: true`. */
15
+ where?: Record<string, string | number | boolean | null> | undefined;
16
+ /** `activity`, the default, puts the most recently active first; `created` the newest. */
17
+ order?: "activity" | "created" | undefined;
18
+ cursor?: string | undefined;
19
+ /** 20 by default, at most 50. */
20
+ limit?: number | undefined;
21
+ }
22
+ /** The input of `getThread`. */
23
+ export interface GetThreadInput {
24
+ threadId: string;
25
+ cursor?: string | undefined;
26
+ /** 50 by default, at most 100. */
27
+ limit?: number | undefined;
28
+ }
29
+ /** The input of `countThreads`. */
30
+ export interface CountThreadsInput<SubjectType extends string = string> {
31
+ subjectType: SubjectType;
32
+ /** Up to 50. */
33
+ subjectIds: readonly string[];
34
+ status?: "open" | "resolved" | undefined;
35
+ }
36
+ /** Open and total threads on a subject, counting only visibilities the caller can see. */
37
+ export interface ThreadCounts {
38
+ open: number;
39
+ total: number;
40
+ }
41
+ /** The input of `suggestMentions`: a thread, or a subject for a thread not created yet. */
42
+ export type SuggestMentionsInput<SubjectType extends string = string> = {
43
+ threadId: string;
44
+ query: string;
45
+ } | {
46
+ subjectType: SubjectType;
47
+ subjectId: string;
48
+ query: string;
49
+ };
50
+ /** What `createThread` and `reply` return. */
51
+ export interface WriteResult<Thread, Message> {
52
+ thread: Thread;
53
+ comment: Message;
54
+ /** Mentions that won't notify anyone because those users can't view the thread. Only moderators get the list. */
55
+ droppedMentions: string[];
56
+ }
57
+ /**
58
+ * Comment threads on records the app owns, optionally pinned to a spot inside them. Set up at least one of the app's
59
+ * subject types; anchor kinds, body formats, before hooks and additional fields are optional. Threads and comments are
60
+ * subject types too, `comments.thread` and `comments.comment`, so other features can attach to them.
61
+ */
62
+ export declare function comments<const Options extends CommentsOptions>(options: Options): {
63
+ readonly id: "comments";
64
+ readonly schema: import("better-apps").FeatureSchema;
65
+ readonly events: {
66
+ "thread.created": import("better-apps").EventDefinition<import("./events.js").CommentEventData>;
67
+ "thread.updated": import("better-apps").EventDefinition<import("./events.js").ThreadEventData & {
68
+ previous: Partial<CommentThread>;
69
+ }>;
70
+ "comment.created": import("better-apps").EventDefinition<import("./events.js").CommentEventData>;
71
+ "comment.edited": import("better-apps").EventDefinition<import("./events.js").CommentEventData & {
72
+ previousFormat: string;
73
+ previousBody: unknown;
74
+ }>;
75
+ "comment.deleted": import("better-apps").EventDefinition<import("./events.js").CommentEventData & {
76
+ previousBody: unknown;
77
+ previousText: string;
78
+ }>;
79
+ "comment.mentioned": import("better-apps").EventDefinition<import("./events.js").CommentEventData & {
80
+ userId: string;
81
+ }>;
82
+ "thread.resolved": import("better-apps").EventDefinition<import("./events.js").ThreadEventData>;
83
+ "thread.reopened": import("better-apps").EventDefinition<import("./events.js").ThreadEventData>;
84
+ "thread.anchorUpdated": import("better-apps").EventDefinition<import("./events.js").ThreadEventData & {
85
+ previousAnchor: import("./events.js").StoredAnchor | null;
86
+ }>;
87
+ "thread.deleted": import("better-apps").EventDefinition<import("./events.js").ThreadDeletedData>;
88
+ "thread.moved": import("better-apps").EventDefinition<import("./events.js").ThreadMovedData>;
89
+ "subject.moved": import("better-apps").EventDefinition<{
90
+ subjectType: string;
91
+ subjectId: string;
92
+ toOrgId: string;
93
+ }>;
94
+ "subject.removed": import("better-apps").EventDefinition<{
95
+ subjectType: string;
96
+ subjectId: string;
97
+ }>;
98
+ "user.removed": import("better-apps").EventDefinition<{
99
+ userId: string;
100
+ }>;
101
+ };
102
+ readonly subjects: Record<string, import("better-apps").SubjectDefinition>;
103
+ readonly grants: import("better-apps").SubjectGrants;
104
+ readonly usesSubjects: string[];
105
+ readonly options: Options;
106
+ readonly actions: {
107
+ listThreads: import("better-apps").ActionDefinition<StandardSchemaV1<ListThreadsInput<keyof Options["subjects"] & string, (Options["anchors"] extends infer T ? T extends Options["anchors"] ? T extends readonly (infer Anchor)[] ? Anchor extends AnchorKind<infer Kind extends string, infer Value> ? {
108
+ kind: Kind;
109
+ value: Value;
110
+ version?: string | null | undefined;
111
+ } : never : never : never : never)["kind"]>, ListThreadsInput<keyof Options["subjects"] & string, (Options["anchors"] extends infer T ? T extends Options["anchors"] ? T extends readonly (infer Anchor)[] ? Anchor extends AnchorKind<infer Kind extends string, infer Value> ? {
112
+ kind: Kind;
113
+ value: Value;
114
+ version?: string | null | undefined;
115
+ } : never : never : never : never)["kind"]>>, ThreadPage<CommentThread & (Options["additionalFields"] extends {
116
+ thread: infer Fields;
117
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>)>> & {
118
+ serverOnly?: false | undefined;
119
+ };
120
+ getThread: import("better-apps").ActionDefinition<StandardSchemaV1<GetThreadInput, GetThreadInput>, ThreadWithComments<CommentThread & (Options["additionalFields"] extends {
121
+ thread: infer Fields;
122
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>), Comment & (Options["additionalFields"] extends {
123
+ comment: infer Fields_1;
124
+ } ? import("./schema.js").InferFields<Fields_1> : Record<never, never>)>> & {
125
+ serverOnly?: false | undefined;
126
+ };
127
+ countThreads: import("better-apps").ActionDefinition<StandardSchemaV1<CountThreadsInput<keyof Options["subjects"] & string>, CountThreadsInput<keyof Options["subjects"] & string>>, Record<string, ThreadCounts>> & {
128
+ serverOnly?: false | undefined;
129
+ };
130
+ createThread: import("better-apps").ActionDefinition<StandardSchemaV1<CreateThreadInput<TypesOf<Options>>, CreateThreadInput<TypesOf<Options>>>, WriteResult<CommentThread & (Options["additionalFields"] extends {
131
+ thread: infer Fields;
132
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>), Comment & (Options["additionalFields"] extends {
133
+ comment: infer Fields_1;
134
+ } ? import("./schema.js").InferFields<Fields_1> : Record<never, never>)>> & {
135
+ serverOnly?: false | undefined;
136
+ };
137
+ reply: import("better-apps").ActionDefinition<StandardSchemaV1<ReplyInput<TypesOf<Options>>, ReplyInput<TypesOf<Options>>>, WriteResult<CommentThread & (Options["additionalFields"] extends {
138
+ thread: infer Fields;
139
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>), Comment & (Options["additionalFields"] extends {
140
+ comment: infer Fields_1;
141
+ } ? import("./schema.js").InferFields<Fields_1> : Record<never, never>)>> & {
142
+ serverOnly?: false | undefined;
143
+ };
144
+ editComment: import("better-apps").ActionDefinition<StandardSchemaV1<EditCommentInput<TypesOf<Options>>, EditCommentInput<TypesOf<Options>>>, {
145
+ comment: Comment & (Options["additionalFields"] extends {
146
+ comment: infer Fields_1;
147
+ } ? import("./schema.js").InferFields<Fields_1> : Record<never, never>);
148
+ droppedMentions: string[];
149
+ }> & {
150
+ serverOnly?: false | undefined;
151
+ };
152
+ deleteComment: import("better-apps").ActionDefinition<StandardSchemaV1<CommentTarget, CommentTarget>, {
153
+ comment: Comment & (Options["additionalFields"] extends {
154
+ comment: infer Fields_1;
155
+ } ? import("./schema.js").InferFields<Fields_1> : Record<never, never>);
156
+ threadDeleted: boolean;
157
+ }> & {
158
+ serverOnly?: false | undefined;
159
+ };
160
+ resolveThread: import("better-apps").ActionDefinition<StandardSchemaV1<ThreadTarget, ThreadTarget>, CommentThread & (Options["additionalFields"] extends {
161
+ thread: infer Fields;
162
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>)> & {
163
+ serverOnly?: false | undefined;
164
+ };
165
+ reopenThread: import("better-apps").ActionDefinition<StandardSchemaV1<ThreadTarget, ThreadTarget>, CommentThread & (Options["additionalFields"] extends {
166
+ thread: infer Fields;
167
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>)> & {
168
+ serverOnly?: false | undefined;
169
+ };
170
+ updateThread: import("better-apps").ActionDefinition<StandardSchemaV1<UpdateThreadInput<TypesOf<Options>>, UpdateThreadInput<TypesOf<Options>>>, CommentThread & (Options["additionalFields"] extends {
171
+ thread: infer Fields;
172
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>)> & {
173
+ serverOnly?: false | undefined;
174
+ };
175
+ updateAnchor: import("better-apps").ActionDefinition<StandardSchemaV1<UpdateAnchorInput<TypesOf<Options>>, UpdateAnchorInput<TypesOf<Options>>>, CommentThread & (Options["additionalFields"] extends {
176
+ thread: infer Fields;
177
+ } ? import("./schema.js").InferFields<Fields> : Record<never, never>)> & {
178
+ serverOnly?: false | undefined;
179
+ };
180
+ deleteThread: import("better-apps").ActionDefinition<StandardSchemaV1<ThreadTarget, ThreadTarget>, {
181
+ threadId: string;
182
+ }> & {
183
+ serverOnly?: false | undefined;
184
+ };
185
+ suggestMentions: import("better-apps").ActionDefinition<StandardSchemaV1<SuggestMentionsInput<keyof Options["subjects"] & string>, SuggestMentionsInput<keyof Options["subjects"] & string>>, IdentityUser[]> & {
186
+ serverOnly?: false | undefined;
187
+ };
188
+ removeSubject: import("better-apps").ActionDefinition<StandardSchemaV1<{
189
+ subjectType: keyof Options["subjects"] & string;
190
+ subjectId: string;
191
+ }, {
192
+ subjectType: keyof Options["subjects"] & string;
193
+ subjectId: string;
194
+ }>, UpkeepStep> & {
195
+ serverOnly?: true | undefined;
196
+ };
197
+ moveSubject: import("better-apps").ActionDefinition<StandardSchemaV1<{
198
+ subjectType: keyof Options["subjects"] & string;
199
+ subjectId: string;
200
+ toOrgId: string | null;
201
+ }, {
202
+ subjectType: keyof Options["subjects"] & string;
203
+ subjectId: string;
204
+ toOrgId: string | null;
205
+ }>, UpkeepStep> & {
206
+ serverOnly?: true | undefined;
207
+ };
208
+ removeUser: import("better-apps").ActionDefinition<StandardSchemaV1<{
209
+ userId: string;
210
+ }, {
211
+ userId: string;
212
+ }>, UpkeepStep> & {
213
+ serverOnly?: true | undefined;
214
+ };
215
+ recountThreads: import("better-apps").ActionDefinition<StandardSchemaV1<{
216
+ threadIds: readonly string[];
217
+ } | {
218
+ subjectType: keyof Options["subjects"] & string;
219
+ subjectId: string;
220
+ cursor?: string;
221
+ }, {
222
+ threadIds: readonly string[];
223
+ } | {
224
+ subjectType: keyof Options["subjects"] & string;
225
+ subjectId: string;
226
+ cursor?: string;
227
+ }>, UpkeepStep & {
228
+ cursor: string | null;
229
+ }> & {
230
+ serverOnly?: true | undefined;
231
+ };
232
+ canViewThread: import("better-apps").ActionDefinition<StandardSchemaV1<{
233
+ actor: Actor | null;
234
+ threadId: string;
235
+ } | {
236
+ actor: Actor | null;
237
+ thread: CommentThread;
238
+ }, {
239
+ actor: Actor | null;
240
+ threadId: string;
241
+ } | {
242
+ actor: Actor | null;
243
+ thread: CommentThread;
244
+ }>, boolean> & {
245
+ serverOnly?: true | undefined;
246
+ };
247
+ toClientEvent: import("better-apps").ActionDefinition<StandardSchemaV1<{
248
+ event: BetterAppsEvent;
249
+ viewer: Actor | null;
250
+ request?: Request | null | undefined;
251
+ }, {
252
+ event: BetterAppsEvent;
253
+ viewer: Actor | null;
254
+ request?: Request | null | undefined;
255
+ }>, ClientEvent | null> & {
256
+ serverOnly?: true | undefined;
257
+ };
258
+ toClientEvents: import("better-apps").ActionDefinition<StandardSchemaV1<{
259
+ event: BetterAppsEvent;
260
+ viewers: readonly LiveViewer[];
261
+ }, {
262
+ event: BetterAppsEvent;
263
+ viewers: readonly LiveViewer[];
264
+ }>, (ClientEvent | null)[]> & {
265
+ serverOnly?: true | undefined;
266
+ };
267
+ };
268
+ };
269
+ /** The comments feature as `comments()` returns it. */
270
+ export type CommentsFeature<Options extends CommentsOptions = CommentsOptions> = ReturnType<typeof comments<Options>>;
@@ -0,0 +1,97 @@
1
+ import { defineAction, defineFeature } from "better-apps";
2
+ import { commentEvents } from "./events.js";
3
+ import { textFormat } from "./formats.js";
4
+ import { inputs } from "./inputs.js";
5
+ import { canViewThread, countThreads, getThread, listThreads, suggestMentions, toClientEvent, toClientEvents } from "./reads.js";
6
+ import { commentModel, commentsSchema, threadModel } from "./schema.js";
7
+ import { commentGrants, commentSubjects } from "./subjects.js";
8
+ import { moveSubject, recountThreads, removeSubject, removeUser } from "./upkeep.js";
9
+ import { createThread, deleteComment, deleteThread, editComment, reopenThread, reply, resolveThread, updateAnchor, updateThread } from "./writes.js";
10
+ const reserved = new Set([
11
+ ...Object.keys(commentsSchema()[threadModel]?.fields ?? {}),
12
+ ...Object.keys(commentsSchema()[commentModel]?.fields ?? {}),
13
+ "id"
14
+ ]);
15
+ function resolve(options) {
16
+ if (Object.keys(options.subjects).length === 0) {
17
+ throw new Error("Comments need at least one subject type.");
18
+ }
19
+ const formats = new Map([[textFormat.name, textFormat]]);
20
+ for (const format of options.formats ?? []) {
21
+ if (formats.has(format.name)) {
22
+ throw new Error(`The "${format.name}" body format is registered twice.`);
23
+ }
24
+ formats.set(format.name, format);
25
+ }
26
+ const anchors = new Map();
27
+ for (const anchor of options.anchors ?? []) {
28
+ if (anchors.has(anchor.kind)) {
29
+ throw new Error(`The "${anchor.kind}" anchor kind is registered twice.`);
30
+ }
31
+ anchors.set(anchor.kind, anchor);
32
+ }
33
+ const threadFields = options.additionalFields?.thread ?? {};
34
+ const commentFields = options.additionalFields?.comment ?? {};
35
+ for (const name of [...Object.keys(threadFields), ...Object.keys(commentFields)]) {
36
+ if (reserved.has(name)) {
37
+ throw new Error(`"${name}" is a comments field, so it can't be an additional field.`);
38
+ }
39
+ }
40
+ return {
41
+ subjects: options.subjects,
42
+ anchors,
43
+ formats,
44
+ hooks: options.hooks?.before ?? {},
45
+ threadFields,
46
+ commentFields
47
+ };
48
+ }
49
+ const unchecked = () => ({
50
+ "~standard": { version: 1, vendor: "better-apps", validate: (value) => ({ value: value }) }
51
+ });
52
+ /**
53
+ * Comment threads on records the app owns, optionally pinned to a spot inside them. Set up at least one of the app's
54
+ * subject types; anchor kinds, body formats, before hooks and additional fields are optional. Threads and comments are
55
+ * subject types too, `comments.thread` and `comments.comment`, so other features can attach to them.
56
+ */
57
+ export function comments(options) {
58
+ const config = resolve(options);
59
+ // `serverOnly` keeps its literal type, so the browser client leaves server-only actions out.
60
+ const action = (schema, run, serverOnly) => defineAction({
61
+ input: (schema ?? unchecked()),
62
+ serverOnly,
63
+ handler: async (context) => (await run(config, context))
64
+ });
65
+ const serverAction = (schema, run) => action(schema, run, true);
66
+ return defineFeature({
67
+ id: "comments",
68
+ schema: commentsSchema(options.additionalFields),
69
+ events: commentEvents,
70
+ subjects: commentSubjects(config),
71
+ grants: commentGrants(config),
72
+ usesSubjects: Object.keys(options.subjects),
73
+ options,
74
+ actions: {
75
+ listThreads: action(inputs.listThreads, listThreads),
76
+ getThread: action(inputs.getThread, getThread),
77
+ countThreads: action(inputs.countThreads, countThreads),
78
+ createThread: action(inputs.createThread, createThread),
79
+ reply: action(inputs.reply, reply),
80
+ editComment: action(inputs.editComment, editComment),
81
+ deleteComment: action(inputs.deleteComment, deleteComment),
82
+ resolveThread: action(inputs.thread, resolveThread),
83
+ reopenThread: action(inputs.thread, reopenThread),
84
+ updateThread: action(inputs.updateThread, updateThread),
85
+ updateAnchor: action(inputs.updateAnchor, updateAnchor),
86
+ deleteThread: action(inputs.thread, deleteThread),
87
+ suggestMentions: action(inputs.suggestMentions, suggestMentions),
88
+ removeSubject: serverAction(inputs.removeSubject, removeSubject),
89
+ moveSubject: serverAction(inputs.moveSubject, moveSubject),
90
+ removeUser: serverAction(inputs.removeUser, removeUser),
91
+ recountThreads: serverAction(inputs.recountThreads, recountThreads),
92
+ canViewThread: serverAction(null, canViewThread),
93
+ toClientEvent: serverAction(null, toClientEvent),
94
+ toClientEvents: serverAction(null, toClientEvents)
95
+ }
96
+ });
97
+ }