@wtfalch/threads 0.1.1 → 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
@@ -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');
@@ -430,21 +437,40 @@ export function createThreads(opts) {
430
437
  return { t, who: await mayModerate(subjectOf(t), who) };
431
438
  }
432
439
  async function setFlag(who, threadId, flag, value) {
433
- await moderated(who, threadId);
434
- await db
435
- .update(threads)
436
- .set({ [flag]: value, updatedAt: sql `now()` })
437
- .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
+ });
438
455
  }
439
456
  async function hideComment(who, commentId, hidden = true) {
440
457
  const [c] = await db.select().from(threadComments).where(eq(threadComments.id, commentId));
441
458
  if (!c)
442
459
  throw new ThreadsError('not_found', 'no such comment');
443
- await moderated(who, c.threadId);
444
- await db
445
- .update(threadComments)
446
- .set({ hidden, updatedAt: sql `now()` })
447
- .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
+ });
448
474
  }
449
475
  async function move(who, threadId, categoryId) {
450
476
  const { t } = await moderated(who, threadId);
@@ -453,18 +479,26 @@ export function createThreads(opts) {
453
479
  const cat = await categoryById(categoryId);
454
480
  if (!cat)
455
481
  throw new ThreadsError('not_found', 'no such category');
456
- await mayModerate(categorySubject(categoryId), who);
457
- await db
458
- .update(threads)
459
- .set({
460
- subjectId: String(categoryId),
461
- status: cat.kind === 'feedback' ? (t.status ?? 'open') : null,
462
- updatedAt: sql `now()`,
463
- })
464
- .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
+ });
465
499
  }
466
500
  async function setStatus(who, threadId, status) {
467
- const { t } = await moderated(who, threadId);
501
+ const { t, who: mod } = await moderated(who, threadId);
468
502
  if (t.status === null)
469
503
  throw new ThreadsError('invalid', 'only feedback has a status');
470
504
  if (t.status === status)
@@ -474,6 +508,12 @@ export function createThreads(opts) {
474
508
  .update(threads)
475
509
  .set({ status, updatedAt: sql `now()` })
476
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
+ });
477
517
  const listeners = await tx
478
518
  .select({ personId: threadSubscriptions.personId })
479
519
  .from(threadSubscriptions)
@@ -490,11 +530,16 @@ export function createThreads(opts) {
490
530
  }
491
531
  /** The duplicate points at the original and its votes move there; a page redirects. */
492
532
  async function markDuplicate(who, threadId, ofId) {
493
- const { t } = await moderated(who, threadId);
533
+ const { t, who: mod } = await moderated(who, threadId);
494
534
  const [original] = await db.select().from(threads).where(eq(threads.id, ofId));
495
535
  if (!original || original.id === t.id)
496
536
  throw new ThreadsError('not_found', 'no such original');
497
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
+ });
498
543
  const votes = await tx.select().from(threadVotes).where(eq(threadVotes.threadId, threadId));
499
544
  if (votes.length > 0) {
500
545
  await tx
@@ -516,8 +561,21 @@ export function createThreads(opts) {
516
561
  }
517
562
  /** Standing is per person, not per subject; the gate asked is `category:*`, the owner's. */
518
563
  async function setStanding(who, personId, standing) {
519
- await mayModerate(categorySubject('*'), who);
520
- 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
+ });
521
579
  }
522
580
  // ---- outbox ------------------------------------------------------------
523
581
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/threads",
3
- "version": "0.1.1",
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",
@@ -64,10 +68,5 @@
64
68
  "react-dom": "^19",
65
69
  "typescript": "^5.9.0",
66
70
  "vitest": "^4.1.6"
67
- },
68
- "scripts": {
69
- "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",
70
- "typecheck": "tsc --noEmit",
71
- "test": "vitest run"
72
71
  }
73
- }
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.