@@ -11,6 +11,13 @@ read, tested and reasoned about. This applies to db columns, API payloads, store
1111config keys alike; only real migrations under ` backend/db/migrations/ ` are exempt, because Drizzle
1212needs the history to get a live dev database to the current schema.
1313
14+ The one sanctioned exception is a value an existing installation ** cannot** have and no static
15+ default can stand in for — a secret generated per installation, say. That goes in a ** startup check**
16+ (` core/startupChecks.ts ` ), which every boot runs once to fill in what is missing; it is the only place
17+ such a value is made, and nothing that reads it falls back on its own. A new setting with a fixed
18+ default needs no check: put the default in ` base.yml ` , which is merged under the stored settings on
19+ every boot.
20+
1421Three independently-installed workspaces (each has its own ` package.json ` / ` node_modules ` , there is
1522no root package or monorepo tooling):
1623
@@ -74,7 +81,10 @@ path in silence.
7481 and nothing else.
7582- ` core/ ` — long-lived singletons: ` config.ts ` (yml + db-backed settings), ` db.ts ` (pg pool, Drizzle
7683 instance, migrations, LISTEN/NOTIFY pubsub), ` logger.ts ` , ` scheduler.ts ` (poolifier thread pool +
77- postgres-backed job queue).
84+ postgres-backed job queue), and ` startupChecks.ts ` — what ` preBoot() ` makes sure of right after the
85+ settings are loaded, on a fresh install and an upgraded one alike: idempotent, add-only, run in one
86+ transaction under an advisory lock so that the instances of an HA set booting together cannot each
87+ generate a different value.
7888- ` db/ ` — ` schema.ts ` (all Drizzle table definitions), ` relations.ts ` , ` migrations/ ` (generated).
7989- ` models/ ` — data-access classes over Drizzle, aggregated by ` models/index.ts ` and exposed as
8090 ` WIKI.models.* ` . Business logic belongs here, not in route handlers. ` types.ts ` holds the shared
@@ -84,6 +94,9 @@ path in silence.
8494 ` modules/authentication/local/ ` . ` modules/storage/* ` ships ` db ` and ` disk ` — see
8595 [ Storage targets] ( #storage-targets ) . ` modules/analytics/* ` is the odd one out: a pair of YAML files
8696 and no implementation at all — see [ Analytics] ( #analytics ) .
97+ - ` notifications/ ` — the notification categories (` categories/*.ts ` , registered in ` index.ts ` ) and
98+ the worker-side halves of delivery: the fan-out and the mail drain. Not under ` modules/ ` , which
99+ expects a ` definition.yml ` per directory. See [ Notifications] ( #notifications ) .
87100- ` tasks/simple/ ` — jobs run in-process by the scheduler; each exports ` task(payload, { signal }) ` .
88101 File name is kebab-case, the task key is its camelCase form. ` scheduler.taskTimeout ` applies here
89102 as it does to workers, but a promise cannot be killed: at the timeout ` signal ` is aborted, and a
@@ -92,7 +105,11 @@ path in silence.
92105 is abandoned — failed, left running, and no second copy of that task starts on the instance until
93106 it ends (` executeInProcess ` in ` core/scheduler.ts ` ).
94107- ` tasks/workers/ ` — CPU-bound jobs run in a worker thread via ` worker.ts ` , which boots a minimal
95- ` WIKI ` global (config + logger + lazy ` ensureDb() ` ) and dynamically imports the task.
108+ ` WIKI ` global (config + logger + lazy ` ensureDb() ` ) and dynamically imports the task. ** A worker's
109+ database pool has ONE connection** (` core/db.ts ` ), so a transaction held open in a worker while
110+ anything else queries deadlocks the task against itself until the pool aborts it. And a worker reads
111+ the settings once, when its thread first opens the database, and never hears ` reloadConfig ` — a task
112+ that depends on settings an admin can change re-reads them per run (` refreshWorkerConfig ` ).
96113- ` base.yml ` — system defaults for every config key. Do not edit as a user-facing config; it defines
97114 the shape merged with ` config.yml ` and the db ` settings ` table.
98115- ` helpers/ ` — small pure utilities (` common.ts ` , ` config.ts ` ), plus two that are not: ` storageFiles.ts ` ,
@@ -1269,8 +1286,11 @@ produce and has to describe.
12691286
12701287### Emails
12711288
1272- The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test
1273- button — and ` models/mail.ts ` is the only place nodemailer is used. ` MailTemplateData ` is the
1289+ The wiki sends a registration confirmation, a forgotten password, the admin area's test, and
1290+ notifications (one at a time or as a digest — see [ Notifications] ( #notifications ) ), and
1291+ ` models/mail.ts ` is the only place nodemailer is used. ` send() ` takes a ` MailSite ` from its caller
1292+ (` mail.siteFor(siteId) ` ) rather than a site id, because the notification mails are sent from a worker
1293+ thread, which has no ` WIKI.sites ` . ` MailTemplateData ` is the
12741294closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did
12751295not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain
12761296code there anyway. A wiki with no SMTP settings is the normal case, which is why ` isConfigured ` is
@@ -1333,6 +1353,43 @@ has not been built; if it is, the shape to keep is sparse overrides on top of th
13331353rather than a replacement for them, so that a wiki that rewords one sentence keeps getting
13341354translations and improvements for everything else.
13351355
1356+ ### Notifications
1357+
1358+ Telling a person about something that happened: a change to a page they watch, a comment or a reply,
1359+ a mention, a suggestion to review, and — opt-in — every page created or deleted. In-app (the inbox
1360+ and the header badge) and by email, chosen per person and per category under ** Profile →
1361+ Notifications** . ** ` dev/specs/notifications.md ` is the design** , and says why each part is the shape
1362+ it is; what follows is what is easy to get wrong.
1363+
1364+ - ** ` notifications.emit() ` is one INSERT and never throws** , called from the models (so imports and
1365+ git pulls emit too) after the action succeeded, like ` hooks.emit ` . Nothing is resolved on the request
1366+ path. Events go into an outbox (` notificationEvents ` ) that a worker fans out in batches — not one
1367+ scheduler job per event, nor per recipient.
1368+ - ** A category is a file** under ` notifications/categories/ ` plus a key in ` NOTIFICATION_CATEGORIES `
1369+ and its strings (` notifications.categories.<key>.* ` , ` notifications.messages.<key>.<variant> ` ,
1370+ ` mail.notification.actions.<key> ` ). Leaving the actor out, the access check, preferences,
1371+ deduplication and coalescing are the fan-out's, done once for every category.
1372+ - ** Access is checked per group set** , against the rows the worker reads — ` rulesAllow ` on the pooled
1373+ rules of a set of groups, memoized — which is what makes an audience of every account affordable.
1374+ - ** Origins.** ` notifications.withOrigin('bulk' | 'import', work) ` marks the events emitted inside it,
1375+ and ` storage.importingFrom ` counts as ` import ` on its own. A category's ` origins ` decides whether it
1376+ fires: ` watchedPage ` fires for all of them, ` pageCreated ` / ` pageDeleted ` only for ` user ` .
1377+ - ** A deletion captures its watchers in the event** , before the page row goes and the watch rows with
1378+ it (` watchersOf ` on ` emit ` ). Any new path that deletes page rows has to emit the same way first.
1379+ - ** One unread entry per person per ` groupKey ` ** (a partial unique index), so forty saves are one entry
1380+ counting to forty, and a replayed event changes nothing (` lastEventId ` ).
1381+ - ** Email is a window, then quiet until read.** ` emailAfter ` is the only thing the drain looks at, and
1382+ ` emailAfterFor ` in the fan-out the only place it is set — the seam a digest schedule would use.
1383+ Opening a page or its Talk tab marks its entries read (` viewer.unreadNotifications ` , ` markSeen ` ),
1384+ which is what lets the next change email again.
1385+ - ** The drain claims rows with a lease** (` emailState = 'sending' ` , ` emailAfter ` as the expiry) rather
1386+ than a transaction held across the send — see the note on worker connections under ` tasks/workers/ ` .
1387+ - ** Every mail carries RFC 8058 one-click unsubscribe.** The token is an HMAC with
1388+ ` notifications.unsubscribeSecret ` , generated by a startup check wherever it is missing, and
1389+ deliberately not ` auth.secret ` (which rotating the sessions replaces). A GET of the link only redirects to ` /_unsubscribe ` , which asks.
1390+ - ** The site switch is ` features.notifications ` ** ; the instance settings (retention, email delay, mail
1391+ batch size) are ** Admin → Notifications** , ` GET ` /` PUT /system/notifications ` , behind ` manage:system ` .
1392+
13361393### Audit log
13371394
13381395Every action a ** person** takes is one row in ` auditLog ` — ` userId ` , ` clientIP ` , ` ts ` , ` kind `
0 commit comments