@wtfalch/threads 0.1.0 → 0.2.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,74 @@
1
+ import type { Db } from './threads.js';
2
+ /**
3
+ * The moderation events this package can write, when the host hands
4
+ * `createThreads` a writer. Declared here so the host can merge them into
5
+ * its ledger's vocabulary before it binds the writer; the names sit in the
6
+ * `thread` namespace and nowhere else, which is what lets a host bind the
7
+ * writer to that namespace alone. `tenantVisible` is false throughout: a
8
+ * forum is one per app, not one per organisation.
9
+ */
10
+ export declare const THREADS_AUDIT_EVENTS: {
11
+ readonly 'thread.pinned': {
12
+ readonly tenantVisible: false;
13
+ };
14
+ readonly 'thread.unpinned': {
15
+ readonly tenantVisible: false;
16
+ };
17
+ readonly 'thread.locked': {
18
+ readonly tenantVisible: false;
19
+ };
20
+ readonly 'thread.unlocked': {
21
+ readonly tenantVisible: false;
22
+ };
23
+ readonly 'thread.hidden': {
24
+ readonly tenantVisible: false;
25
+ };
26
+ readonly 'thread.unhidden': {
27
+ readonly tenantVisible: false;
28
+ };
29
+ readonly 'thread.comment_hidden': {
30
+ readonly tenantVisible: false;
31
+ };
32
+ readonly 'thread.comment_unhidden': {
33
+ readonly tenantVisible: false;
34
+ };
35
+ readonly 'thread.moved': {
36
+ readonly tenantVisible: false;
37
+ };
38
+ readonly 'thread.status_changed': {
39
+ readonly tenantVisible: false;
40
+ };
41
+ readonly 'thread.marked_duplicate': {
42
+ readonly tenantVisible: false;
43
+ };
44
+ readonly 'thread.standing_changed': {
45
+ readonly tenantVisible: false;
46
+ };
47
+ };
48
+ export type ThreadsAuditAction = keyof typeof THREADS_AUDIT_EVENTS;
49
+ /** One moderation act, as the host's writer receives it. The actor is the `who` the host's own gate admitted. */
50
+ export interface ThreadsAuditEvent {
51
+ readonly action: ThreadsAuditAction;
52
+ readonly actor: {
53
+ readonly id: string;
54
+ readonly display: string;
55
+ };
56
+ readonly target: {
57
+ readonly type: 'thread' | 'thread_comment' | 'person';
58
+ readonly id: string;
59
+ };
60
+ readonly before?: unknown;
61
+ readonly after?: unknown;
62
+ /** The person the act was about, when that is not the actor: a standing change. */
63
+ readonly subject?: {
64
+ readonly class: 'human';
65
+ readonly id: string;
66
+ };
67
+ }
68
+ /**
69
+ * What a host passes as `createThreads({ audit })`: a writer already bound
70
+ * to its ledger and to the `thread` namespace (`@wtfalch/audit`'s
71
+ * `ledger.writer`, structurally). The package calls it inside the
72
+ * transaction that makes the change, so a refused row rolls the change back.
73
+ */
74
+ export type ThreadsAudit = (event: ThreadsAuditEvent, tx: Db) => Promise<void>;
package/dist/audit.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The moderation events this package can write, when the host hands
3
+ * `createThreads` a writer. Declared here so the host can merge them into
4
+ * its ledger's vocabulary before it binds the writer; the names sit in the
5
+ * `thread` namespace and nowhere else, which is what lets a host bind the
6
+ * writer to that namespace alone. `tenantVisible` is false throughout: a
7
+ * forum is one per app, not one per organisation.
8
+ */
9
+ export const THREADS_AUDIT_EVENTS = {
10
+ 'thread.pinned': { tenantVisible: false },
11
+ 'thread.unpinned': { tenantVisible: false },
12
+ 'thread.locked': { tenantVisible: false },
13
+ 'thread.unlocked': { tenantVisible: false },
14
+ 'thread.hidden': { tenantVisible: false },
15
+ 'thread.unhidden': { tenantVisible: false },
16
+ 'thread.comment_hidden': { tenantVisible: false },
17
+ 'thread.comment_unhidden': { tenantVisible: false },
18
+ 'thread.moved': { tenantVisible: false },
19
+ 'thread.status_changed': { tenantVisible: false },
20
+ 'thread.marked_duplicate': { tenantVisible: false },
21
+ 'thread.standing_changed': { tenantVisible: false },
22
+ };
File without changes
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export * from './schema.js';
2
2
  export * from './gates.js';
3
3
  export * from './tree.js';
4
4
  export * from './threads.js';
5
+ export * from './audit.js';
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export * from './schema.js';
2
2
  export * from './gates.js';
3
3
  export * from './tree.js';
4
4
  export * from './threads.js';
5
+ export * from './audit.js';
package/dist/threads.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { ThreadsAudit } from './audit.js';
2
3
  import { type Gates, type Subject, type Who } from './gates.js';
3
4
  import { type Category, type Comment, type Person, type Thread, type ThreadStatus, schema } from './schema.js';
4
5
  /**
@@ -22,6 +23,8 @@ export interface ThreadsOptions {
22
23
  gates: Gates;
23
24
  limits?: Partial<Limits>;
24
25
  now?: () => Date;
26
+ /** The host's audit writer for moderation, bound to the `thread` namespace. Optional: without it, moderation leaves no trail here. */
27
+ audit?: ThreadsAudit;
25
28
  }
26
29
  export type Sort = 'activity' | 'newest' | 'votes';
27
30
  export interface ListOptions {
package/dist/threads.js CHANGED
@@ -1,4 +1,4 @@
1
- import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
1
+ import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { CATEGORY, ThreadsError, categorySubject, } from './gates.js';
3
3
  import { schema, threadCategories, threadComments, threadOutbox, threadPeople, threadSubscriptions, threadVotes, threads, } from './schema.js';
4
4
  export const DEFAULT_LIMITS = { threadsPerHour: 5, commentsPerTenMinutes: 30 };
@@ -18,6 +18,13 @@ export function createThreads(opts) {
18
18
  const { db, gates } = opts;
19
19
  const limits = { ...DEFAULT_LIMITS, ...opts.limits };
20
20
  const now = opts.now ?? (() => new Date());
21
+ const display = (who) => who.name ?? who.id;
22
+ /** Records a moderation act on the caller's transaction, when the host wants a trail. */
23
+ async function audited(tx, who, event) {
24
+ if (!opts.audit)
25
+ return;
26
+ await opts.audit({ ...event, actor: { id: who.id, display: display(who) } }, tx);
27
+ }
21
28
  async function mayRead(subject, who) {
22
29
  if (!(await gates.read(subject, who))) {
23
30
  throw new ThreadsError('forbidden', 'not allowed to read here');
@@ -70,10 +77,13 @@ export function createThreads(opts) {
70
77
  }
71
78
  async function underLimit(table, authorId, windowMs, max, what) {
72
79
  const since = new Date(now().getTime() - windowMs);
80
+ // `gt`, not a raw `sql` fragment: the operator maps the Date through the
81
+ // column's type, which every driver accepts; a bare Date parameter is
82
+ // refused by postgres-js and only happened to work on PGlite.
73
83
  const [row] = await db
74
84
  .select({ n: sql `count(*)::int` })
75
85
  .from(table)
76
- .where(and(eq(table.authorId, authorId), sql `${table.createdAt} > ${since}`));
86
+ .where(and(eq(table.authorId, authorId), gt(table.createdAt, since)));
77
87
  if ((row?.n ?? 0) >= max)
78
88
  throw new ThreadsError('rate_limited', `too many ${what}, try later`);
79
89
  }
@@ -427,21 +437,40 @@ export function createThreads(opts) {
427
437
  return { t, who: await mayModerate(subjectOf(t), who) };
428
438
  }
429
439
  async function setFlag(who, threadId, flag, value) {
430
- await moderated(who, threadId);
431
- await db
432
- .update(threads)
433
- .set({ [flag]: value, updatedAt: sql `now()` })
434
- .where(eq(threads.id, threadId));
440
+ const { t, who: mod } = await moderated(who, threadId);
441
+ if (t[flag] === value)
442
+ return;
443
+ const action = {
444
+ pinned: value ? 'thread.pinned' : 'thread.unpinned',
445
+ locked: value ? 'thread.locked' : 'thread.unlocked',
446
+ hidden: value ? 'thread.hidden' : 'thread.unhidden',
447
+ }[flag];
448
+ await db.transaction(async (tx) => {
449
+ await tx
450
+ .update(threads)
451
+ .set({ [flag]: value, updatedAt: sql `now()` })
452
+ .where(eq(threads.id, threadId));
453
+ await audited(tx, mod, { action, target: { type: 'thread', id: String(threadId) } });
454
+ });
435
455
  }
436
456
  async function hideComment(who, commentId, hidden = true) {
437
457
  const [c] = await db.select().from(threadComments).where(eq(threadComments.id, commentId));
438
458
  if (!c)
439
459
  throw new ThreadsError('not_found', 'no such comment');
440
- await moderated(who, c.threadId);
441
- await db
442
- .update(threadComments)
443
- .set({ hidden, updatedAt: sql `now()` })
444
- .where(eq(threadComments.id, commentId));
460
+ const { who: mod } = await moderated(who, c.threadId);
461
+ if (c.hidden === hidden)
462
+ return;
463
+ await db.transaction(async (tx) => {
464
+ await tx
465
+ .update(threadComments)
466
+ .set({ hidden, updatedAt: sql `now()` })
467
+ .where(eq(threadComments.id, commentId));
468
+ await audited(tx, mod, {
469
+ action: hidden ? 'thread.comment_hidden' : 'thread.comment_unhidden',
470
+ target: { type: 'thread_comment', id: String(commentId) },
471
+ after: { threadId: c.threadId },
472
+ });
473
+ });
445
474
  }
446
475
  async function move(who, threadId, categoryId) {
447
476
  const { t } = await moderated(who, threadId);
@@ -450,18 +479,26 @@ export function createThreads(opts) {
450
479
  const cat = await categoryById(categoryId);
451
480
  if (!cat)
452
481
  throw new ThreadsError('not_found', 'no such category');
453
- await mayModerate(categorySubject(categoryId), who);
454
- await db
455
- .update(threads)
456
- .set({
457
- subjectId: String(categoryId),
458
- status: cat.kind === 'feedback' ? (t.status ?? 'open') : null,
459
- updatedAt: sql `now()`,
460
- })
461
- .where(eq(threads.id, threadId));
482
+ const mod = await mayModerate(categorySubject(categoryId), who);
483
+ await db.transaction(async (tx) => {
484
+ await tx
485
+ .update(threads)
486
+ .set({
487
+ subjectId: String(categoryId),
488
+ status: cat.kind === 'feedback' ? (t.status ?? 'open') : null,
489
+ updatedAt: sql `now()`,
490
+ })
491
+ .where(eq(threads.id, threadId));
492
+ await audited(tx, mod, {
493
+ action: 'thread.moved',
494
+ target: { type: 'thread', id: String(threadId) },
495
+ before: { categoryId: t.subjectId },
496
+ after: { categoryId: String(categoryId) },
497
+ });
498
+ });
462
499
  }
463
500
  async function setStatus(who, threadId, status) {
464
- const { t } = await moderated(who, threadId);
501
+ const { t, who: mod } = await moderated(who, threadId);
465
502
  if (t.status === null)
466
503
  throw new ThreadsError('invalid', 'only feedback has a status');
467
504
  if (t.status === status)
@@ -471,6 +508,12 @@ export function createThreads(opts) {
471
508
  .update(threads)
472
509
  .set({ status, updatedAt: sql `now()` })
473
510
  .where(eq(threads.id, threadId));
511
+ await audited(tx, mod, {
512
+ action: 'thread.status_changed',
513
+ target: { type: 'thread', id: String(threadId) },
514
+ before: { status: t.status },
515
+ after: { status },
516
+ });
474
517
  const listeners = await tx
475
518
  .select({ personId: threadSubscriptions.personId })
476
519
  .from(threadSubscriptions)
@@ -487,11 +530,16 @@ export function createThreads(opts) {
487
530
  }
488
531
  /** The duplicate points at the original and its votes move there; a page redirects. */
489
532
  async function markDuplicate(who, threadId, ofId) {
490
- const { t } = await moderated(who, threadId);
533
+ const { t, who: mod } = await moderated(who, threadId);
491
534
  const [original] = await db.select().from(threads).where(eq(threads.id, ofId));
492
535
  if (!original || original.id === t.id)
493
536
  throw new ThreadsError('not_found', 'no such original');
494
537
  await db.transaction(async (tx) => {
538
+ await audited(tx, mod, {
539
+ action: 'thread.marked_duplicate',
540
+ target: { type: 'thread', id: String(threadId) },
541
+ after: { duplicateOf: ofId },
542
+ });
495
543
  const votes = await tx.select().from(threadVotes).where(eq(threadVotes.threadId, threadId));
496
544
  if (votes.length > 0) {
497
545
  await tx
@@ -513,8 +561,21 @@ export function createThreads(opts) {
513
561
  }
514
562
  /** Standing is per person, not per subject; the gate asked is `category:*`, the owner's. */
515
563
  async function setStanding(who, personId, standing) {
516
- await mayModerate(categorySubject('*'), who);
517
- await db.update(threadPeople).set({ standing }).where(eq(threadPeople.id, personId));
564
+ const mod = await mayModerate(categorySubject('*'), who);
565
+ const [before] = await db
566
+ .select({ standing: threadPeople.standing })
567
+ .from(threadPeople)
568
+ .where(eq(threadPeople.id, personId));
569
+ await db.transaction(async (tx) => {
570
+ await tx.update(threadPeople).set({ standing }).where(eq(threadPeople.id, personId));
571
+ await audited(tx, mod, {
572
+ action: 'thread.standing_changed',
573
+ target: { type: 'person', id: personId },
574
+ subject: { class: 'human', id: personId },
575
+ before: { standing: before?.standing ?? null },
576
+ after: { standing },
577
+ });
578
+ });
518
579
  }
519
580
  // ---- outbox ------------------------------------------------------------
520
581
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/threads",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Threads, comments, votes and subscriptions attached to a subject the host names: a forum, a feedback board, a comment section. Per-app Postgres, host-supplied gates, React on @wtfalch/design.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,9 +9,7 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "type": "module",
12
- "files": [
13
- "dist"
14
- ],
12
+ "files": ["dist"],
15
13
  "bin": {
16
14
  "threads-migrations": "./dist/bin/migrations.js"
17
15
  },
@@ -34,6 +32,12 @@
34
32
  "engines": {
35
33
  "node": ">=22.0.0"
36
34
  },
35
+ "scripts": {
36
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/ && cp src/react/threads.css dist/threads.css",
37
+ "prepack": "pnpm build",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run"
40
+ },
37
41
  "peerDependencies": {
38
42
  "@wtfalch/design": ">=0.3.0",
39
43
  "drizzle-orm": ">=0.39.0",
@@ -59,14 +63,10 @@
59
63
  "@wtfalch/design": "^0.3.0",
60
64
  "drizzle-orm": "^0.39.3",
61
65
  "jsdom": "^30.0.1",
66
+ "postgres": "^3.4.5",
62
67
  "react": "^19",
63
68
  "react-dom": "^19",
64
69
  "typescript": "^5.9.0",
65
70
  "vitest": "^4.1.6"
66
- },
67
- "scripts": {
68
- "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/ && cp src/react/threads.css dist/threads.css",
69
- "typecheck": "tsc --noEmit",
70
- "test": "vitest run"
71
71
  }
72
- }
72
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 William Tallis Falch
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.