All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m26s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m49s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m27s
CI / Import/export fidelity gate (push) Successful in 46s
New notifications table (payload denormalized for join-free rendering; mailed_at already prepares the #95 digests). Generation fans page events out to page and pond watchers, excluding the actors, and re-checks page read permission per watcher at delivery time — a revoked watcher gets nothing. Sources: named version snapshots (api), new comments (api), and the collab server's automatic session-close snapshots — announced over a new pg NOTIFY channel (the reverse of the established api→collab bus) consumed by a dedicated LISTEN client in the api, since the collab server has no permission resolution of its own. API: paginated list (unread first via nulls-first ordering), mark read, mark all read. UI: bell with unread badge in the top bar (30 s polling, no push in v1) and a dropdown whose entries navigate and mark themselves read; comment notifications deep-link with ?comments=1, which now opens the comments panel on load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
168 lines
6.7 KiB
TypeScript
168 lines
6.7 KiB
TypeScript
import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared';
|
|
import type { Pool } from 'pg';
|
|
import type { Logger } from 'pino';
|
|
import * as Y from 'yjs';
|
|
|
|
/**
|
|
* Automatic version snapshots created by the collab server (issue #41, ADR
|
|
* 0013). Contributors are tracked per open document: {@link recordContributor}
|
|
* collects the users who edit, {@link onStore} flushes them to the shared
|
|
* `page_pending_contributors` accumulator (so the api's named-version endpoint
|
|
* sees them too) and creates the periodic interval snapshot, and
|
|
* {@link onSessionEnd} creates the end-of-session snapshot.
|
|
*
|
|
* A snapshot is only written when there were edits since the previous version
|
|
* (the accumulator is non-empty), which both implements "skip no-op periods"
|
|
* and prevents duplicate versions on a quick reconnect with no changes.
|
|
*/
|
|
export interface VersionStore {
|
|
/** Attribute a change to a user (called from `onChange`). */
|
|
recordContributor(pageId: string, userId: string): void;
|
|
/** Initialise interval bookkeeping when a document opens. */
|
|
noteOpened(pageId: string): void;
|
|
/** Flush contributors and create an interval snapshot if one is due. */
|
|
onStore(pageId: string, doc: Y.Doc): Promise<void>;
|
|
/** Create the end-of-session snapshot when the last participant leaves. */
|
|
onSessionEnd(pageId: string, doc: Y.Doc): Promise<void>;
|
|
/** Drop per-document bookkeeping when the document unloads. */
|
|
forget(pageId: string): void;
|
|
}
|
|
|
|
export interface VersionStoreDeps {
|
|
pool: Pool;
|
|
logger: Logger;
|
|
/** Active-editing interval between automatic snapshots (default 30 min). */
|
|
intervalMs?: number;
|
|
}
|
|
|
|
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
|
|
|
export class PostgresVersionStore implements VersionStore {
|
|
private readonly intervalMs: number;
|
|
/** Users who have edited since the last flush, per open document. */
|
|
private readonly dirty = new Map<string, Set<string>>();
|
|
/** Timestamp (ms) of the last automatic snapshot, per open document. */
|
|
private readonly lastVersionAt = new Map<string, number>();
|
|
|
|
constructor(private readonly deps: VersionStoreDeps) {
|
|
this.intervalMs = deps.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
}
|
|
|
|
recordContributor(pageId: string, userId: string): void {
|
|
let set = this.dirty.get(pageId);
|
|
if (!set) {
|
|
set = new Set();
|
|
this.dirty.set(pageId, set);
|
|
}
|
|
set.add(userId);
|
|
}
|
|
|
|
noteOpened(pageId: string): void {
|
|
this.lastVersionAt.set(pageId, Date.now());
|
|
}
|
|
|
|
async onStore(pageId: string, doc: Y.Doc): Promise<void> {
|
|
await this.flushContributors(pageId);
|
|
const last = this.lastVersionAt.get(pageId) ?? Date.now();
|
|
if (Date.now() - last >= this.intervalMs) {
|
|
const created = await this.createVersion(pageId, doc);
|
|
// Reset the interval clock only when a snapshot was actually written, so
|
|
// an idle-but-open page doesn't churn empty checks into versions.
|
|
if (created) this.lastVersionAt.set(pageId, Date.now());
|
|
}
|
|
}
|
|
|
|
async onSessionEnd(pageId: string, doc: Y.Doc): Promise<void> {
|
|
await this.flushContributors(pageId);
|
|
await this.createVersion(pageId, doc);
|
|
}
|
|
|
|
forget(pageId: string): void {
|
|
this.dirty.delete(pageId);
|
|
this.lastVersionAt.delete(pageId);
|
|
}
|
|
|
|
/** Move the in-memory contributor set into the shared DB accumulator. */
|
|
private async flushContributors(pageId: string): Promise<void> {
|
|
const set = this.dirty.get(pageId);
|
|
if (!set || set.size === 0) return;
|
|
// Swap in a fresh set first so contributors arriving during the await are
|
|
// not lost with the ones being flushed.
|
|
this.dirty.set(pageId, new Set());
|
|
const users = [...set];
|
|
try {
|
|
await this.deps.pool.query(
|
|
`INSERT INTO page_pending_contributors (page_id, user_id)
|
|
SELECT $1, u FROM unnest($2::text[]) AS u
|
|
ON CONFLICT (page_id, user_id) DO NOTHING`,
|
|
[pageId, users],
|
|
);
|
|
} catch (error) {
|
|
// Put the users back so the next flush retries them; never throw out of a
|
|
// hook (a page purged mid-session is the expected benign failure here).
|
|
const current = this.dirty.get(pageId) ?? new Set<string>();
|
|
for (const u of users) current.add(u);
|
|
this.dirty.set(pageId, current);
|
|
this.deps.logger.warn(
|
|
{ event: 'version.contributors.flush_failed', pageId, err: (error as Error).message },
|
|
'could not flush contributors',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write an automatic snapshot if there are pending contributors, consuming
|
|
* them atomically. Returns whether a version row was created.
|
|
*/
|
|
private async createVersion(pageId: string, doc: Y.Doc): Promise<boolean> {
|
|
const snapshot = Buffer.from(Y.encodeStateAsUpdate(doc));
|
|
const client = await this.deps.pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const pending = await client.query<{ user_id: string }>(
|
|
'SELECT user_id FROM page_pending_contributors WHERE page_id = $1 FOR UPDATE',
|
|
[pageId],
|
|
);
|
|
if (pending.rows.length === 0) {
|
|
await client.query('ROLLBACK');
|
|
return false; // nothing changed since the last version
|
|
}
|
|
const contributors = pending.rows.map((row) => row.user_id);
|
|
// Guard the FK: skip (but still clear the accumulator) if the page was
|
|
// trashed/purged mid-session.
|
|
const inserted = await client.query(
|
|
`INSERT INTO page_versions (id, page_id, ydoc_snapshot, trigger, label, contributor_ids, created_at)
|
|
SELECT gen_random_uuid(), $1, $2, 'AUTO', NULL, $3::text[], now()
|
|
WHERE EXISTS (SELECT 1 FROM pages WHERE id = $1 AND deleted_at IS NULL)`,
|
|
[pageId, snapshot, contributors],
|
|
);
|
|
await client.query('DELETE FROM page_pending_contributors WHERE page_id = $1', [pageId]);
|
|
await client.query('COMMIT');
|
|
const created = (inserted.rowCount ?? 0) > 0;
|
|
if (created) {
|
|
this.deps.logger.debug(
|
|
{ event: 'version.auto.created', pageId, contributors: contributors.length },
|
|
'automatic version snapshot created',
|
|
);
|
|
// Tell the api so it can notify watchers (issue #94) — it owns the
|
|
// permission resolution. Fire-and-forget: a lost event costs a
|
|
// notification, never the snapshot.
|
|
const event: PageVersionCreatedEvent = { pageId, contributorIds: contributors };
|
|
await client
|
|
.query('SELECT pg_notify($1, $2)', [PAGE_VERSION_CREATED_CHANNEL, JSON.stringify(event)])
|
|
.catch(() => undefined);
|
|
}
|
|
return created;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK').catch(() => undefined);
|
|
this.deps.logger.warn(
|
|
{ event: 'version.auto.failed', pageId, err: (error as Error).message },
|
|
'could not create automatic version snapshot',
|
|
);
|
|
return false;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
}
|