@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,110 @@
1
+ import { BetterAppsError } from "better-apps";
2
+ import { accessTo, mentionable, nothing, orgOf, subjectOf } from "./access.js";
3
+ import { CommentsError } from "./errors.js";
4
+ import { threadModel } from "./schema.js";
5
+ /**
6
+ * Who a call acts as. Server code that passes neither an actor nor a request is trusted, so its reads and writes skip
7
+ * access checks; server code acting for a request or an actor is checked as that user.
8
+ */
9
+ export function callerOf(context) {
10
+ return {
11
+ actor: context.actor,
12
+ trusted: context.source === "api" && context.actor === null && context.request === null
13
+ };
14
+ }
15
+ /** The author of a write, or `UNAUTHENTICATED` without one. */
16
+ export function requireAuthor(context) {
17
+ if (!context.author) {
18
+ throw new CommentsError("UNAUTHENTICATED", "Sign in to comment.");
19
+ }
20
+ return context.author;
21
+ }
22
+ /** The id stored for an author: a user id, or the id server code gave an external or system author. */
23
+ export function authorIdOf(author) {
24
+ return author.type === "user" ? author.userId : (author.id ?? "");
25
+ }
26
+ /** Whether an author wrote a comment. */
27
+ export function wrote(author, comment) {
28
+ return comment.authorType === author.type && comment.authorId === authorIdOf(author);
29
+ }
30
+ /** A thread by id, or `NOT_FOUND`. */
31
+ export async function loadThread(context, threadId) {
32
+ const thread = await context.db.findOne({
33
+ model: threadModel,
34
+ where: [{ field: "id", value: threadId }]
35
+ });
36
+ if (!thread) {
37
+ throw new CommentsError("NOT_FOUND", "That thread doesn't exist, or you can't see it.");
38
+ }
39
+ return thread;
40
+ }
41
+ /**
42
+ * What the caller may do with a thread. A thread outside its subject's current organization can't be seen. Reads pass
43
+ * the request, so a grant it carries counts; writes leave it out.
44
+ */
45
+ export async function threadAccess(config, context, thread, request) {
46
+ const rules = subjectOf(config.subjects, thread.subjectType);
47
+ const caller = callerOf(context);
48
+ const ref = { subjectType: thread.subjectType, subjectId: thread.subjectId };
49
+ let access = nothing;
50
+ if (caller.trusted || (await orgOf(context.subjects, caller, ref)) === thread.orgId) {
51
+ access = await accessTo(rules, context.subjects, caller, ref, thread.visibility, request);
52
+ }
53
+ return { rules, caller, access };
54
+ }
55
+ /** Refuses an action the caller can view but can't take. */
56
+ export function forbid(allowed, message) {
57
+ if (!allowed) {
58
+ throw new CommentsError("FORBIDDEN", message);
59
+ }
60
+ }
61
+ /**
62
+ * Runs a before hook and checks what it returns with the action's schema. A `BetterAppsError`, such as a
63
+ * `CommentsError`, keeps its code; any other error becomes `REJECTED_BY_HOOK`.
64
+ */
65
+ export async function runHook(hook, args, schema) {
66
+ if (!hook) {
67
+ return args.input;
68
+ }
69
+ let changed;
70
+ try {
71
+ changed = await hook(args);
72
+ }
73
+ catch (error) {
74
+ if (error instanceof BetterAppsError) {
75
+ throw error;
76
+ }
77
+ throw new CommentsError("REJECTED_BY_HOOK", "The change was refused.", { cause: error });
78
+ }
79
+ const result = await schema["~standard"].validate(changed);
80
+ if (result.issues) {
81
+ throw new CommentsError("INVALID_INPUT", "The input a hook returned isn't valid.", { issues: result.issues });
82
+ }
83
+ return result.value;
84
+ }
85
+ /** Splits ids into lists of at most 90, under D1's limit of 100 bound parameters. */
86
+ export function chunks(items, size = 90) {
87
+ const lists = [];
88
+ for (let start = 0; start < items.length; start += size) {
89
+ lists.push(items.slice(start, start + size));
90
+ }
91
+ return lists;
92
+ }
93
+ /** Looks up users' names and avatars, keyed by id. Users the identity adapter can't find are left out. */
94
+ export async function lookupUsers(context, ids) {
95
+ const unique = [...new Set(ids)].filter((id) => id !== "");
96
+ const { getUsers } = context.identity;
97
+ if (!getUsers || unique.length === 0) {
98
+ return {};
99
+ }
100
+ const found = await Promise.all(chunks(unique).map(async (list) => getUsers.call(context.identity, list)));
101
+ return Object.fromEntries(found.flat().map((user) => [user.id, user]));
102
+ }
103
+ /** Where a thread's mentions are checked: its organization, and who may be mentioned there. */
104
+ export function mentionScope(config, context, thread) {
105
+ return {
106
+ identity: context.identity,
107
+ orgId: thread.orgId === "" ? null : thread.orgId,
108
+ allowed: async (actors) => mentionable(config.subjects, context.subjects, actors, thread)
109
+ };
110
+ }
@@ -0,0 +1,62 @@
1
+ import type { FeatureSchema, FieldAttribute } from "better-apps";
2
+ /** Typed columns an app adds to threads or comments. The CLI generates them with the rest of the schema. */
3
+ export interface AdditionalFields {
4
+ thread?: Readonly<Record<string, FieldAttribute>> | undefined;
5
+ comment?: Readonly<Record<string, FieldAttribute>> | undefined;
6
+ }
7
+ type FieldValueOf<Field extends FieldAttribute> = Field["type"] extends "string" ? string : Field["type"] extends "number" ? number : Field["type"] extends "boolean" ? boolean : Field["type"] extends "date" ? Date : Field["type"] extends "string[]" ? string[] : Field["type"] extends "number[]" ? number[] : Field["type"] extends readonly (infer Value extends string)[] ? Value : unknown;
8
+ /** The values of additional fields as rows hold them. Optional fields may be `null`. */
9
+ export type InferFields<Fields> = {
10
+ -readonly [Name in keyof Fields]: Fields[Name] extends FieldAttribute ? Fields[Name]["required"] extends false ? FieldValueOf<Fields[Name]> | null : FieldValueOf<Fields[Name]> : never;
11
+ };
12
+ /** One conversation at one place in a subject. */
13
+ export type CommentThread = {
14
+ id: string;
15
+ /** The organization that owns the subject, or `""` when the app has none. */
16
+ orgId: string;
17
+ subjectType: string;
18
+ subjectId: string;
19
+ /** `default`, or a visibility the subject type declares, such as `staff`. */
20
+ visibility: string;
21
+ /** `null` means the whole subject. */
22
+ anchorKind: string | null;
23
+ anchor: unknown;
24
+ /** The content version the anchor was made against, such as a deploy, commit or document revision. */
25
+ anchorVersion: string | null;
26
+ status: "open" | "resolved";
27
+ resolvedAt: Date | null;
28
+ resolvedBy: string | null;
29
+ createdBy: string;
30
+ createdAt: Date;
31
+ /** When the newest comment was written, deleted or not. */
32
+ lastActivityAt: Date;
33
+ /** Comments that aren't deleted. */
34
+ commentCount: number;
35
+ metadata: Record<string, unknown> | null;
36
+ };
37
+ /** One message in a thread. */
38
+ export type Comment = {
39
+ id: string;
40
+ threadId: string;
41
+ orgId: string;
42
+ /** `user`, `external` for people without an account, or `system` for bots. */
43
+ authorType: "user" | "external" | "system";
44
+ /** A user id, or the id server code gave an external or system author (`""` without one). */
45
+ authorId: string;
46
+ /** Stored only for external and system authors, who the identity adapter can't look up. */
47
+ authorName: string | null;
48
+ format: string;
49
+ /** `null` once the comment is deleted, or its author removed. */
50
+ body: unknown;
51
+ /** Plain text from the format, with mentions as tokens. Empty once deleted. */
52
+ bodyText: string;
53
+ createdAt: Date;
54
+ editedAt: Date | null;
55
+ deletedAt: Date | null;
56
+ metadata: Record<string, unknown> | null;
57
+ };
58
+ export declare const threadModel = "commentThread";
59
+ export declare const commentModel = "comment";
60
+ /** The comments tables, with the app's additional fields. */
61
+ export declare function commentsSchema(additional?: AdditionalFields): FeatureSchema;
62
+ export {};
package/dist/schema.js ADDED
@@ -0,0 +1,53 @@
1
+ export const threadModel = "commentThread";
2
+ export const commentModel = "comment";
3
+ /** The comments tables, with the app's additional fields. */
4
+ export function commentsSchema(additional = {}) {
5
+ return {
6
+ [threadModel]: {
7
+ fields: {
8
+ orgId: { type: "string", defaultValue: "" },
9
+ subjectType: { type: "string" },
10
+ subjectId: { type: "string" },
11
+ visibility: { type: "string", defaultValue: "default" },
12
+ anchorKind: { type: "string", required: false },
13
+ anchor: { type: "json", required: false },
14
+ anchorVersion: { type: "string", required: false },
15
+ status: { type: ["open", "resolved"], defaultValue: "open" },
16
+ resolvedAt: { type: "date", required: false },
17
+ resolvedBy: { type: "string", required: false },
18
+ createdBy: { type: "string" },
19
+ createdAt: { type: "date" },
20
+ lastActivityAt: { type: "date" },
21
+ commentCount: { type: "number", defaultValue: 0 },
22
+ metadata: { type: "json", required: false },
23
+ ...additional.thread
24
+ },
25
+ // Better Auth's schema format indexes declared fields only, so ties on time are broken by the id outside the index.
26
+ indexes: [
27
+ { fields: ["orgId", "subjectType", "subjectId", "lastActivityAt"], name: "comment_thread_by_activity" },
28
+ { fields: ["orgId", "subjectType", "subjectId", "createdAt"], name: "comment_thread_by_creation" }
29
+ ]
30
+ },
31
+ [commentModel]: {
32
+ fields: {
33
+ threadId: { type: "string", references: { model: threadModel, field: "id", onDelete: "cascade" } },
34
+ orgId: { type: "string", defaultValue: "" },
35
+ authorType: { type: ["user", "external", "system"] },
36
+ authorId: { type: "string" },
37
+ authorName: { type: "string", required: false },
38
+ format: { type: "string", defaultValue: "text" },
39
+ body: { type: "json", required: false },
40
+ bodyText: { type: "string", defaultValue: "" },
41
+ createdAt: { type: "date" },
42
+ editedAt: { type: "date", required: false },
43
+ deletedAt: { type: "date", required: false },
44
+ metadata: { type: "json", required: false },
45
+ ...additional.comment
46
+ },
47
+ indexes: [
48
+ { fields: ["threadId", "createdAt"], name: "comment_by_thread" },
49
+ { fields: ["authorType", "authorId"], name: "comment_by_author" }
50
+ ]
51
+ }
52
+ };
53
+ }
@@ -0,0 +1,17 @@
1
+ import type { SubjectDefinition, SubjectGrants } from "better-apps";
2
+ import { type Config } from "./runtime.js";
3
+ /**
4
+ * The subject types comments declare for their own records, so other features, such as reactions, can attach to a
5
+ * thread or a comment. Whoever can see a thread can see it and its comments.
6
+ */
7
+ export declare const commentSubjectTypes: {
8
+ readonly thread: "comments.thread";
9
+ readonly comment: "comments.comment";
10
+ };
11
+ /** The subject types for comments' own records, with access following each thread's. */
12
+ export declare function commentSubjects(config: Config): Record<string, SubjectDefinition>;
13
+ /**
14
+ * Lets a request see a thread and its comments, such as their reactions, when a grant it carries, such as a share link,
15
+ * lets it see the thread's subject. Like reads, it reaches only default threads in their subject's current organization.
16
+ */
17
+ export declare function commentGrants(config: Config): SubjectGrants;
@@ -0,0 +1,93 @@
1
+ import { canViewThreads, viewThreads } from "./access.js";
2
+ import { chunks } from "./runtime.js";
3
+ import { commentModel, threadModel } from "./schema.js";
4
+ /**
5
+ * The subject types comments declare for their own records, so other features, such as reactions, can attach to a
6
+ * thread or a comment. Whoever can see a thread can see it and its comments.
7
+ */
8
+ export const commentSubjectTypes = { thread: "comments.thread", comment: "comments.comment" };
9
+ async function findRows(db, model, ids) {
10
+ const lists = chunks([...new Set(ids)]);
11
+ const rows = await Promise.all(lists.map(async (list) => db.findMany({ model, where: [{ field: "id", operator: "in", value: list }] })));
12
+ return new Map(rows.flat().map((row) => [row.id, row]));
13
+ }
14
+ const threadsById = async (db, ids) => findRows(db, threadModel, ids);
15
+ // A comment's thread, keyed by the comment's id. A deleted comment has none, so nothing attaches to it any more.
16
+ const threadsOfComments = async (db, ids) => {
17
+ const comments = await findRows(db, commentModel, ids);
18
+ const threads = await threadsById(db, [...comments.values()].map((comment) => comment.threadId));
19
+ const found = new Map();
20
+ for (const [id, comment] of comments) {
21
+ const thread = threads.get(comment.threadId);
22
+ if (thread && !comment.deletedAt) {
23
+ found.set(id, thread);
24
+ }
25
+ }
26
+ return found;
27
+ };
28
+ function subjectOfThreads(config, find) {
29
+ const viewable = async (checks, context) => {
30
+ const threads = await find(context.db, checks.map((check) => check.subjectId));
31
+ const pairs = checks.flatMap(({ actor, subjectId }, index) => {
32
+ const thread = threads.get(subjectId);
33
+ return thread ? [{ index, actor, thread }] : [];
34
+ });
35
+ const allowed = await canViewThreads(config.subjects, context.subjects, pairs);
36
+ const result = checks.map(() => false);
37
+ for (const [position, { index }] of pairs.entries()) {
38
+ result[index] = allowed[position] === true;
39
+ }
40
+ return result;
41
+ };
42
+ const orgIds = async (refs, context) => {
43
+ const threads = await find(context.db, refs.map((ref) => ref.subjectId));
44
+ return refs.map((ref) => threads.get(ref.subjectId)?.orgId || null);
45
+ };
46
+ return {
47
+ canView: async (args, context) => (await viewable([args], context))[0] === true,
48
+ canViewMany: viewable,
49
+ getOrgId: async (ref, context) => (await orgIds([ref], context))[0] ?? null,
50
+ getOrgIds: orgIds,
51
+ publicRead: async ({ subjectId }, context) => (await viewable([{ actor: null, subjectId }], context))[0] === true,
52
+ publicReadMany: async (refs, context) => viewable(refs.map(({ subjectId }) => ({ actor: null, subjectId })), context)
53
+ };
54
+ }
55
+ /** The subject types for comments' own records, with access following each thread's. */
56
+ export function commentSubjects(config) {
57
+ return {
58
+ [commentSubjectTypes.thread]: subjectOfThreads(config, threadsById),
59
+ [commentSubjectTypes.comment]: subjectOfThreads(config, threadsOfComments)
60
+ };
61
+ }
62
+ /**
63
+ * Lets a request see a thread and its comments, such as their reactions, when a grant it carries, such as a share link,
64
+ * lets it see the thread's subject. Like reads, it reaches only default threads in their subject's current organization.
65
+ */
66
+ export function commentGrants(config) {
67
+ const finders = {
68
+ [commentSubjectTypes.thread]: threadsById,
69
+ [commentSubjectTypes.comment]: threadsOfComments
70
+ };
71
+ return {
72
+ async canViewMany(checks, _args, context) {
73
+ const answers = checks.map(() => false);
74
+ await Promise.all(Object.entries(finders).map(async ([subjectType, find]) => {
75
+ const indexes = checks.flatMap((check, index) => (check.subjectType === subjectType ? [index] : []));
76
+ if (indexes.length === 0) {
77
+ return;
78
+ }
79
+ const threads = await find(context.db, indexes.map((index) => checks[index]?.subjectId ?? ""));
80
+ const pairs = indexes.flatMap((index) => {
81
+ const check = checks[index];
82
+ const thread = threads.get(check?.subjectId ?? "");
83
+ return check && thread ? [{ index, actor: null, thread, request: check.request }] : [];
84
+ });
85
+ const views = await viewThreads(config.subjects, context.subjects, pairs);
86
+ for (const [position, { index }] of pairs.entries()) {
87
+ answers[index] = views[position] !== null;
88
+ }
89
+ }));
90
+ return answers;
91
+ }
92
+ };
93
+ }
package/dist/ulid.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Makes a ULID, such as the id of a comment a client is about to write. */
2
+ export declare function ulid(time?: number): string;
3
+ /** Whether a value is a ULID in canonical upper case. */
4
+ export declare function isUlid(value: unknown): value is string;
package/dist/ulid.js ADDED
@@ -0,0 +1,24 @@
1
+ // ULIDs: 48 bits of time and 80 random bits in Crockford's base 32, so ids sort roughly by creation and a client can
2
+ // make one before the server sees the write.
3
+ const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4
+ const pattern = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
5
+ /** Makes a ULID, such as the id of a comment a client is about to write. */
6
+ export function ulid(time = Date.now()) {
7
+ let timePart = "";
8
+ let rest = Math.floor(time);
9
+ for (let index = 0; index < 10; index += 1) {
10
+ timePart = alphabet[rest % 32] + timePart;
11
+ rest = Math.floor(rest / 32);
12
+ }
13
+ // 256 is a multiple of 32, so each byte maps to a character without bias.
14
+ const random = crypto.getRandomValues(new Uint8Array(16));
15
+ let randomPart = "";
16
+ for (const byte of random) {
17
+ randomPart += alphabet[byte % 32];
18
+ }
19
+ return timePart + randomPart;
20
+ }
21
+ /** Whether a value is a ULID in canonical upper case. */
22
+ export function isUlid(value) {
23
+ return typeof value === "string" && pattern.test(value);
24
+ }
@@ -0,0 +1,27 @@
1
+ import type { Config, Context } from "./runtime.js";
2
+ /** The result of one step of upkeep. */
3
+ export interface UpkeepStep {
4
+ done: boolean;
5
+ }
6
+ /**
7
+ * Deletes up to 50 of a removed record's threads, emitting `thread.deleted` for each. The last step emits
8
+ * `subject.removed`.
9
+ */
10
+ export declare function removeSubject(config: Config, context: Context): Promise<UpkeepStep>;
11
+ /**
12
+ * Moves up to 50 of a record's threads, and their comments, to another organization, emitting `thread.moved` for each.
13
+ * The last step emits `subject.moved`.
14
+ */
15
+ export declare function moveSubject(config: Config, context: Context): Promise<UpkeepStep>;
16
+ /**
17
+ * Clears the bodies of up to 90 of a removed user's comments. The comments stay, so threads keep their shape, and show
18
+ * as removed text from a former user. The last step emits `user.removed`.
19
+ */
20
+ export declare function removeUser(_config: Config, context: Context): Promise<UpkeepStep>;
21
+ /**
22
+ * Recomputes `commentCount` and `lastActivityAt` for up to 50 threads, to repair data changed outside comments, such
23
+ * as by a hand-written migration. Pass thread ids, or a subject and the cursor from the previous step.
24
+ */
25
+ export declare function recountThreads(config: Config, context: Context): Promise<UpkeepStep & {
26
+ cursor: string | null;
27
+ }>;
package/dist/upkeep.js ADDED
@@ -0,0 +1,189 @@
1
+ import { subjectOf } from "./access.js";
2
+ import { limits } from "./content.js";
3
+ import { commentModel, threadModel } from "./schema.js";
4
+ const step = limits.maxThreadsPerPage;
5
+ const usersStep = 90;
6
+ async function subjectThreads(context, subjectType, subjectId, orgIdNot) {
7
+ const threads = await context.db.findMany({
8
+ model: threadModel,
9
+ where: [
10
+ { field: "subjectType", value: subjectType },
11
+ { field: "subjectId", value: subjectId },
12
+ ...(orgIdNot === undefined ? [] : [{ field: "orgId", operator: "ne", value: orgIdNot }])
13
+ ],
14
+ sortBy: { field: "id", direction: "asc" },
15
+ limit: step
16
+ });
17
+ const comments = threads.length === 0
18
+ ? []
19
+ : await context.db.findMany({
20
+ model: commentModel,
21
+ where: [{ field: "threadId", operator: "in", value: threads.map((thread) => thread.id) }],
22
+ select: ["id", "threadId"]
23
+ });
24
+ const commentIds = (thread) => comments.filter((comment) => comment.threadId === thread.id).map((comment) => comment.id);
25
+ return { threads, ids: threads.map((thread) => thread.id), commentIds };
26
+ }
27
+ const eventData = (thread) => ({
28
+ subjectType: thread.subjectType,
29
+ subjectId: thread.subjectId,
30
+ orgId: thread.orgId,
31
+ thread
32
+ });
33
+ /**
34
+ * Deletes up to 50 of a removed record's threads, emitting `thread.deleted` for each. The last step emits
35
+ * `subject.removed`.
36
+ */
37
+ export async function removeSubject(config, context) {
38
+ const { subjectType, subjectId } = context.input;
39
+ subjectOf(config.subjects, subjectType);
40
+ const { threads, ids, commentIds } = await subjectThreads(context, subjectType, subjectId);
41
+ const done = ids.length < step;
42
+ for (const thread of threads) {
43
+ context.emit("thread.deleted", { ...eventData(thread), commentIds: commentIds(thread) });
44
+ }
45
+ if (done) {
46
+ context.emit("subject.removed", { subjectType, subjectId });
47
+ }
48
+ if (ids.length > 0) {
49
+ await context.db.batch([
50
+ { type: "deleteMany", model: commentModel, where: [{ field: "threadId", operator: "in", value: ids }] },
51
+ { type: "deleteMany", model: threadModel, where: [{ field: "id", operator: "in", value: ids }] }
52
+ ]);
53
+ }
54
+ return { done };
55
+ }
56
+ /**
57
+ * Moves up to 50 of a record's threads, and their comments, to another organization, emitting `thread.moved` for each.
58
+ * The last step emits `subject.moved`.
59
+ */
60
+ export async function moveSubject(config, context) {
61
+ const input = context.input;
62
+ const { subjectType, subjectId } = input;
63
+ subjectOf(config.subjects, subjectType);
64
+ const toOrgId = input.toOrgId ?? "";
65
+ const { threads, ids, commentIds } = await subjectThreads(context, subjectType, subjectId, toOrgId);
66
+ const done = ids.length < step;
67
+ for (const thread of threads) {
68
+ context.emit("thread.moved", {
69
+ ...eventData({ ...thread, orgId: toOrgId }),
70
+ fromOrgId: thread.orgId,
71
+ commentIds: commentIds(thread)
72
+ });
73
+ }
74
+ if (done) {
75
+ context.emit("subject.moved", { subjectType, subjectId, toOrgId });
76
+ }
77
+ if (ids.length > 0) {
78
+ await context.db.batch([
79
+ {
80
+ type: "updateMany",
81
+ model: commentModel,
82
+ where: [{ field: "threadId", operator: "in", value: ids }],
83
+ update: { orgId: toOrgId }
84
+ },
85
+ {
86
+ type: "updateMany",
87
+ model: threadModel,
88
+ where: [{ field: "id", operator: "in", value: ids }],
89
+ update: { orgId: toOrgId }
90
+ }
91
+ ]);
92
+ }
93
+ return { done };
94
+ }
95
+ /**
96
+ * Clears the bodies of up to 90 of a removed user's comments. The comments stay, so threads keep their shape, and show
97
+ * as removed text from a former user. The last step emits `user.removed`.
98
+ */
99
+ export async function removeUser(_config, context) {
100
+ const { userId } = context.input;
101
+ const comments = await context.db.findMany({
102
+ model: commentModel,
103
+ where: [
104
+ { field: "authorType", value: "user" },
105
+ { field: "authorId", value: userId },
106
+ { field: "body", operator: "ne", value: null }
107
+ ],
108
+ sortBy: { field: "id", direction: "asc" },
109
+ limit: usersStep,
110
+ select: ["id"]
111
+ });
112
+ const ids = comments.map((comment) => comment.id);
113
+ const done = ids.length < usersStep;
114
+ if (done) {
115
+ context.emit("user.removed", { userId });
116
+ }
117
+ if (ids.length > 0) {
118
+ await context.db.batch([
119
+ {
120
+ type: "updateMany",
121
+ model: commentModel,
122
+ where: [{ field: "id", operator: "in", value: ids }],
123
+ update: { body: null, bodyText: "" }
124
+ }
125
+ ]);
126
+ }
127
+ return { done };
128
+ }
129
+ /**
130
+ * Recomputes `commentCount` and `lastActivityAt` for up to 50 threads, to repair data changed outside comments, such
131
+ * as by a hand-written migration. Pass thread ids, or a subject and the cursor from the previous step.
132
+ */
133
+ export async function recountThreads(config, context) {
134
+ const input = context.input;
135
+ let ids;
136
+ let cursor = null;
137
+ if (input.threadIds) {
138
+ ids = [...new Set(input.threadIds)];
139
+ }
140
+ else {
141
+ const subjectType = input.subjectType;
142
+ subjectOf(config.subjects, subjectType);
143
+ const threads = await context.db.findMany({
144
+ model: threadModel,
145
+ where: [
146
+ { field: "subjectType", value: subjectType },
147
+ { field: "subjectId", value: input.subjectId },
148
+ ...(input.cursor === undefined ? [] : [{ field: "id", operator: "gt", value: input.cursor }])
149
+ ],
150
+ sortBy: { field: "id", direction: "asc" },
151
+ limit: step,
152
+ select: ["id"]
153
+ });
154
+ ids = threads.map((thread) => thread.id);
155
+ cursor = ids.length === step ? (ids.at(-1) ?? null) : null;
156
+ }
157
+ if (ids.length === 0) {
158
+ return { done: true, cursor: null };
159
+ }
160
+ const [counts, latest] = await Promise.all([
161
+ context.db.countGroups({
162
+ model: commentModel,
163
+ where: [
164
+ { field: "threadId", operator: "in", value: ids },
165
+ { field: "deletedAt", value: null }
166
+ ],
167
+ groupBy: ["threadId"]
168
+ }),
169
+ Promise.all(ids.map(async (threadId) => {
170
+ const [newest] = await context.db.findMany({
171
+ model: commentModel,
172
+ where: [{ field: "threadId", value: threadId }],
173
+ sortBy: { field: "createdAt", direction: "desc" },
174
+ limit: 1,
175
+ select: ["createdAt"]
176
+ });
177
+ return [threadId, newest?.createdAt];
178
+ }))
179
+ ]);
180
+ const countOf = new Map(counts.map(({ group, count }) => [String(group.threadId), count]));
181
+ const operations = latest.map(([threadId, newest]) => ({
182
+ type: "update",
183
+ model: threadModel,
184
+ where: [{ field: "id", value: threadId }],
185
+ update: { commentCount: countOf.get(threadId) ?? 0, ...(newest ? { lastActivityAt: newest } : {}) }
186
+ }));
187
+ await context.db.batch(operations);
188
+ return { done: cursor === null, cursor };
189
+ }
@@ -0,0 +1,27 @@
1
+ import { type Config, type Context } from "./runtime.js";
2
+ import { type Comment, type CommentThread } from "./schema.js";
3
+ export declare function createThread(config: Config, context: Context): Promise<{
4
+ thread: CommentThread;
5
+ comment: Comment;
6
+ droppedMentions: string[];
7
+ }>;
8
+ export declare function reply(config: Config, context: Context): Promise<{
9
+ thread: CommentThread;
10
+ comment: Comment;
11
+ droppedMentions: string[];
12
+ }>;
13
+ export declare function editComment(config: Config, context: Context): Promise<{
14
+ comment: Comment;
15
+ droppedMentions: string[];
16
+ }>;
17
+ export declare function deleteComment(config: Config, context: Context): Promise<{
18
+ comment: Comment;
19
+ threadDeleted: boolean;
20
+ }>;
21
+ export declare const resolveThread: (config: Config, context: Context) => Promise<CommentThread>;
22
+ export declare const reopenThread: (config: Config, context: Context) => Promise<CommentThread>;
23
+ export declare function updateThread(config: Config, context: Context): Promise<CommentThread>;
24
+ export declare function updateAnchor(config: Config, context: Context): Promise<CommentThread>;
25
+ export declare function deleteThread(config: Config, context: Context): Promise<{
26
+ threadId: string;
27
+ }>;