Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.
**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
- a thread of notes, where internal notes are visible only to those deciding
- an assignee (moderators assign cases to moderators)
- a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
- Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
- Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
- Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
- From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
- Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
- Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.
**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
- a moderator edited or locked your product, or undid a change (with the reason)
- your product was removed or restored
- your case was decided
- someone replied on your case
- a catalog decided your membership
**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.
**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.
**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)
**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
- who sees and decides each kind of case, and that the reporter stays private
- rate limits and duplicate reports
- the thread and internal notes, and assigning
- what deciding each kind of case does
- appeals by the person affected only, decided by another moderator
- restoring a removed product
- the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
- The catalog's team could see who reported.
- A missing Resolve/Decline choice was treated as Decline; now it's refused.
- The Unassign button sent the typed name instead of nothing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.
**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
- a thread of notes, where internal notes are visible only to those deciding
- an assignee (moderators assign cases to moderators)
- a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
- Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
- Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
- Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
- From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
- Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
- Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.
**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
- a moderator edited or locked your product, or undid a change (with the reason)
- your product was removed or restored
- your case was decided
- someone replied on your case
- a catalog decided your membership
**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.
**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.
**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)
**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
- who sees and decides each kind of case, and that the reporter stays private
- rate limits and duplicate reports
- the thread and internal notes, and assigning
- what deciding each kind of case does
- appeals by the person affected only, decided by another moderator
- restoring a removed product
- the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
- The catalog's team could see who reported.
- A missing Resolve/Decline choice was treated as Decline; now it's refused.
- The Unassign button sent the typed name instead of nothing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: dhkatz <[email protected]>
401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host,
|
|
// suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do.
|
|
import { createHash } from "node:crypto";
|
|
import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db";
|
|
import { ProductError } from "./products";
|
|
|
|
const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } =
|
|
schema;
|
|
|
|
export type Catalog = typeof catalog.$inferSelect;
|
|
export type MemberStatus = "requested" | "member" | "banned";
|
|
|
|
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
|
|
// Invite codes: 10 letters and digits without look-alikes.
|
|
const inviteCode = () => {
|
|
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join("");
|
|
};
|
|
|
|
const plain = (s: unknown, max: number) => {
|
|
const v = typeof s === "string" ? s.trim() : "";
|
|
// eslint-disable-next-line no-control-regex
|
|
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
|
|
return v;
|
|
};
|
|
|
|
/** A catalog's address, checked and without a trailing slash (https, or localhost for development). */
|
|
export function catalogURL(input: string): string {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(input);
|
|
} catch {
|
|
throw new ProductError(400, "invalid", "That address isn't a URL.");
|
|
}
|
|
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
|
|
throw new ProductError(400, "invalid", "Catalogs need an https address.");
|
|
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
}
|
|
|
|
export type CatalogSettings = {
|
|
name?: string;
|
|
url?: string;
|
|
contact?: string;
|
|
visibility?: "public" | "private";
|
|
membership?: "open" | "approval" | "invite";
|
|
maxRating?: "general" | "adult";
|
|
uploads?: "anyone" | "members" | "team";
|
|
mirrors?: "auto" | "approve" | "off";
|
|
};
|
|
|
|
export class Catalogs {
|
|
constructor(
|
|
private readonly db: Database,
|
|
/** Whether a catalog answers at an address (its /health); checked when its address changes. */
|
|
private readonly healthy: (url: string) => Promise<boolean> = defaultHealthy,
|
|
) {}
|
|
|
|
async get(id: string): Promise<Catalog> {
|
|
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id));
|
|
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
|
|
return c;
|
|
}
|
|
|
|
/** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */
|
|
async update(id: string, input: CatalogSettings) {
|
|
const c = await this.get(id);
|
|
const set: Partial<Catalog> = {};
|
|
if (input.name !== undefined) {
|
|
const name = plain(input.name, 64);
|
|
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
|
|
set.name = name;
|
|
}
|
|
if (input.contact !== undefined) {
|
|
const contact = plain(input.contact, 200);
|
|
if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed.");
|
|
set.contact = contact;
|
|
}
|
|
if (input.url !== undefined) {
|
|
const url = catalogURL(input.url);
|
|
if (url !== c.url) {
|
|
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url));
|
|
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
|
|
if (!(await this.healthy(url)))
|
|
throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?");
|
|
set.url = url;
|
|
}
|
|
}
|
|
for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const)
|
|
if (input[k] !== undefined) (set as Record<string, unknown>)[k] = input[k];
|
|
const changed: Record<string, { from: unknown; to: unknown }> = {};
|
|
for (const [k, v] of Object.entries(set)) {
|
|
const before = (c as Record<string, unknown>)[k];
|
|
if (before !== v) changed[k] = { from: before, to: v };
|
|
}
|
|
if (Object.keys(changed).length === 0) return { catalog: c, changed };
|
|
const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning();
|
|
return { catalog: updated!, changed };
|
|
}
|
|
|
|
/** A new key for the catalog; the old one stops working at once. */
|
|
async rotateKey(id: string): Promise<string> {
|
|
await this.get(id);
|
|
const key = randomKey();
|
|
await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id));
|
|
return key;
|
|
}
|
|
|
|
/** Suspends or reinstates an approved catalog; tells it through its changes feed. */
|
|
async setSuspended(id: string, suspended: boolean, reason: string) {
|
|
const c = await this.get(id);
|
|
if (suspended && c.status !== "approved")
|
|
throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended.");
|
|
if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended.");
|
|
const status = suspended ? "suspended" : "approved";
|
|
const [updated] = await this.db
|
|
.update(catalog)
|
|
.set({ status, statusReason: suspended ? reason : "" })
|
|
.where(eq(catalog.id, id))
|
|
.returning();
|
|
await this.change(id, { type: "status", status });
|
|
return updated!;
|
|
}
|
|
|
|
/** Deletes a catalog (after a moderator accepted its deletion request): it stays on record as deleted, nothing
|
|
* is available from it, and it learns so from its changes feed. */
|
|
async markDeleted(id: string, reason: string) {
|
|
await this.get(id);
|
|
const [updated] = await this.db
|
|
.update(catalog)
|
|
.set({ status: "deleted", statusReason: reason })
|
|
.where(eq(catalog.id, id))
|
|
.returning();
|
|
await this.change(id, { type: "status", status: "deleted" });
|
|
return updated!;
|
|
}
|
|
|
|
// ---- teams ----
|
|
|
|
async addRole(catalogId: string, personId: string, role: string, by: string) {
|
|
const c = await this.get(catalogId);
|
|
if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner.");
|
|
await this.db
|
|
.insert(roleAssignment)
|
|
.values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by })
|
|
.onConflictDoNothing();
|
|
}
|
|
|
|
async removeRole(catalogId: string, personId: string, role: string) {
|
|
const gone = await this.db
|
|
.delete(roleAssignment)
|
|
.where(
|
|
and(
|
|
eq(roleAssignment.userId, personId),
|
|
eq(roleAssignment.role, role),
|
|
eq(roleAssignment.scopeType, "catalog"),
|
|
eq(roleAssignment.scopeId, catalogId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here.");
|
|
}
|
|
|
|
// ---- members ----
|
|
|
|
async membership(catalogId: string, personId: string): Promise<MemberStatus | undefined> {
|
|
const [m] = await this.db
|
|
.select({ status: catalogMember.status })
|
|
.from(catalogMember)
|
|
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)));
|
|
return m?.status as MemberStatus | undefined;
|
|
}
|
|
|
|
/**
|
|
* A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which
|
|
* also lets them into approval catalogs at once). Returns their status.
|
|
*/
|
|
async join(catalogId: string, personId: string, invite?: string): Promise<MemberStatus> {
|
|
const c = await this.get(catalogId);
|
|
if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
|
|
const now = await this.membership(catalogId, personId);
|
|
if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it.");
|
|
if (now === "member") return "member";
|
|
let status: MemberStatus;
|
|
let usedInvite: string | null = null;
|
|
if (invite) {
|
|
const updated = await this.db
|
|
.update(catalogInvite)
|
|
.set({ uses: sql`${catalogInvite.uses} + 1` })
|
|
.where(
|
|
and(
|
|
eq(catalogInvite.code, invite),
|
|
eq(catalogInvite.catalogId, catalogId),
|
|
isNull(catalogInvite.revokedAt),
|
|
or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())),
|
|
or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`),
|
|
),
|
|
)
|
|
.returning();
|
|
if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked.");
|
|
status = "member";
|
|
usedInvite = invite;
|
|
} else if (c.membership === "open") status = "member";
|
|
else if (c.membership === "approval") status = "requested";
|
|
else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only.");
|
|
await this.db
|
|
.insert(catalogMember)
|
|
.values({ catalogId, userId: personId, status, invite: usedInvite })
|
|
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } });
|
|
return status;
|
|
}
|
|
|
|
/** A person leaves (or withdraws their request). A ban stays. */
|
|
async leave(catalogId: string, personId: string) {
|
|
await this.db
|
|
.delete(catalogMember)
|
|
.where(
|
|
and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`),
|
|
);
|
|
}
|
|
|
|
async members(catalogId: string, status?: MemberStatus) {
|
|
const conds = [eq(catalogMember.catalogId, catalogId)];
|
|
if (status) conds.push(eq(catalogMember.status, status));
|
|
const rows = await this.db
|
|
.select({
|
|
personId: catalogMember.userId,
|
|
username: user.username,
|
|
name: user.displayUsername,
|
|
status: catalogMember.status,
|
|
invite: catalogMember.invite,
|
|
since: catalogMember.updatedAt,
|
|
})
|
|
.from(catalogMember)
|
|
.innerJoin(user, eq(user.id, catalogMember.userId))
|
|
.where(and(...conds))
|
|
.orderBy(desc(catalogMember.updatedAt))
|
|
.limit(500);
|
|
return rows.map((r) => ({
|
|
personId: r.personId,
|
|
username: r.name ?? r.username ?? "",
|
|
status: r.status as MemberStatus,
|
|
invited: r.invite !== null,
|
|
since: r.since.toISOString(),
|
|
}));
|
|
}
|
|
|
|
async memberCounts(catalogId: string) {
|
|
const rows = await this.db
|
|
.select({ status: catalogMember.status, n: count() })
|
|
.from(catalogMember)
|
|
.where(eq(catalogMember.catalogId, catalogId))
|
|
.groupBy(catalogMember.status);
|
|
const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0;
|
|
return { members: get("member"), requests: get("requested"), banned: get("banned") };
|
|
}
|
|
|
|
/** The team sets a person's membership: let them in, or ban them. */
|
|
async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) {
|
|
await this.get(catalogId);
|
|
await this.db
|
|
.insert(catalogMember)
|
|
.values({ catalogId, userId: personId, status, decidedBy: by })
|
|
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } });
|
|
}
|
|
|
|
async removeMember(catalogId: string, personId: string) {
|
|
const gone = await this.db
|
|
.delete(catalogMember)
|
|
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)))
|
|
.returning();
|
|
if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be.");
|
|
}
|
|
|
|
// ---- invites ----
|
|
|
|
async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) {
|
|
await this.get(catalogId);
|
|
const [inv] = await this.db
|
|
.insert(catalogInvite)
|
|
.values({
|
|
code: inviteCode(),
|
|
catalogId,
|
|
createdBy: by,
|
|
maxUses: opts.maxUses ?? null,
|
|
expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null,
|
|
})
|
|
.returning();
|
|
return inv!;
|
|
}
|
|
|
|
async invites(catalogId: string) {
|
|
return this.db
|
|
.select()
|
|
.from(catalogInvite)
|
|
.where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt)))
|
|
.orderBy(desc(catalogInvite.createdAt));
|
|
}
|
|
|
|
async revokeInvite(catalogId: string, code: string) {
|
|
const r = await this.db
|
|
.update(catalogInvite)
|
|
.set({ revokedAt: new Date() })
|
|
.where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt)))
|
|
.returning();
|
|
if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation.");
|
|
}
|
|
|
|
// ---- what it hosts ----
|
|
|
|
/** The products a catalog hosts, newest first, with the revisions it hosts of each. */
|
|
async hosted(catalogId: string) {
|
|
const rows = await this.db
|
|
.select({
|
|
id: product.id,
|
|
name: product.name,
|
|
rating: product.rating,
|
|
creator: user.displayUsername,
|
|
creatorUsername: user.username,
|
|
removed: product.removedAt,
|
|
deleted: product.deletedAt,
|
|
revisions: sql<number[]>`array_agg(${hosting.number} order by ${hosting.number})`,
|
|
since: sql<string>`min(${hosting.createdAt})`,
|
|
})
|
|
.from(hosting)
|
|
.innerJoin(product, eq(product.id, hosting.productId))
|
|
.innerJoin(user, eq(user.id, product.creatorId))
|
|
.where(eq(hosting.catalogId, catalogId))
|
|
.groupBy(product.id, user.id)
|
|
.orderBy(desc(sql`min(${hosting.createdAt})`))
|
|
.limit(500);
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
rating: r.rating,
|
|
creator: r.creator ?? r.creatorUsername ?? "",
|
|
removed: r.removed !== null,
|
|
deleted: r.deleted !== null,
|
|
revisions: r.revisions,
|
|
since: new Date(r.since).toISOString(),
|
|
}));
|
|
}
|
|
|
|
async hostedCounts(catalogId: string) {
|
|
const [r] = await this.db
|
|
.select({
|
|
products: sql<number>`count(distinct ${hosting.productId})::int`,
|
|
revisions: sql<number>`count(*)::int`,
|
|
bytes: sql<number>`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`,
|
|
})
|
|
.from(hosting)
|
|
.innerJoin(
|
|
productRevision,
|
|
and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)),
|
|
)
|
|
.where(eq(hosting.catalogId, catalogId));
|
|
return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) };
|
|
}
|
|
|
|
/** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */
|
|
async delist(catalogId: string, productId: string) {
|
|
const gone = await this.db
|
|
.delete(hosting)
|
|
.where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId)))
|
|
.returning();
|
|
if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product.");
|
|
const revs = await this.db
|
|
.select({ number: productRevision.number, files: productRevision.files })
|
|
.from(productRevision)
|
|
.where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number))));
|
|
for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files });
|
|
return gone.map((g) => g.number);
|
|
}
|
|
|
|
// ---- the changes feed ----
|
|
|
|
async change(catalogId: string, data: Record<string, unknown>) {
|
|
await this.db.insert(catalogChange).values({ catalogId, data });
|
|
}
|
|
|
|
/** Changes after a cursor, oldest first (at most 500 at a time). */
|
|
async changes(catalogId: string, after: number) {
|
|
const rows = await this.db
|
|
.select()
|
|
.from(catalogChange)
|
|
.where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after)))
|
|
.orderBy(asc(catalogChange.id))
|
|
.limit(500);
|
|
return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after };
|
|
}
|
|
}
|
|
|
|
async function defaultHealthy(url: string): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|