@wtfalch/reporting 0.1.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.
package/dist/tables.js ADDED
@@ -0,0 +1,55 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { bigint, index, jsonb, pgTable, smallint, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
3
+ /**
4
+ * The three tables, mirrored from `migrations/0001_reporting.sql`, which is
5
+ * the source of truth: the SQL carries the CHECKs, the partial indexes, the
6
+ * generated column and the prune function that drizzle-kit does not
7
+ * generate. A host re-exports these from its own schema module so its
8
+ * drizzle instance and its types know them.
9
+ */
10
+ export const reportingEvents = pgTable('reporting_events', {
11
+ id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
12
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
13
+ level: text('level').notNull(),
14
+ kind: text('kind').notNull(),
15
+ kindNs: text('kind_ns').generatedAlwaysAs(sql `split_part(kind, '.', 1)`),
16
+ site: text('site').notNull(),
17
+ tenantId: uuid('tenant_id'),
18
+ actorClass: text('actor_class'),
19
+ actorId: text('actor_id'),
20
+ requestId: text('request_id'),
21
+ targetType: text('target_type'),
22
+ targetId: text('target_id'),
23
+ message: text('message').notNull(),
24
+ data: jsonb('data').$type().notNull(),
25
+ schemaVersion: smallint('schema_version').notNull().default(1),
26
+ }, (t) => [
27
+ index('reporting_events_time_idx').on(t.occurredAt.desc(), t.id.desc()),
28
+ // The partial predicates live in the SQL; these declare the columns.
29
+ index('reporting_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
30
+ index('reporting_events_ns_time_idx').on(t.kindNs, t.occurredAt.desc(), t.id.desc()),
31
+ index('reporting_events_problem_time_idx').on(t.occurredAt.desc(), t.id.desc()),
32
+ index('reporting_events_request_idx').on(t.requestId, t.occurredAt.desc()),
33
+ ]);
34
+ export const reportingTasks = pgTable('reporting_tasks', {
35
+ task: text('task').primaryKey(),
36
+ nextDueAt: timestamp('next_due_at', { withTimezone: true }).notNull().default(sql `now()`),
37
+ lastStartedAt: timestamp('last_started_at', { withTimezone: true }),
38
+ lastFinishedAt: timestamp('last_finished_at', { withTimezone: true }),
39
+ leaseExpiresAt: timestamp('lease_expires_at', { withTimezone: true }),
40
+ claimToken: uuid('claim_token'),
41
+ lastOutcome: text('last_outcome'),
42
+ lastError: text('last_error'),
43
+ watermark: text('watermark'),
44
+ });
45
+ export const reportingSettings = pgTable('reporting_settings', {
46
+ key: text('key').primaryKey(),
47
+ value: jsonb('value').notNull(),
48
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
49
+ updatedBy: text('updated_by'),
50
+ });
51
+ export const tables = {
52
+ events: reportingEvents,
53
+ tasks: reportingTasks,
54
+ settings: reportingSettings,
55
+ };
@@ -0,0 +1,106 @@
1
+ import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { Actor, EventInput, FlatData, Level } from './schema.js';
3
+ import type { ReportingEventRow, tables } from './tables.js';
4
+ /**
5
+ * The host's drizzle handle, whatever driver it runs on. postgres-js in the
6
+ * apps, PGlite in this package's own tests; the queries here use nothing
7
+ * driver-specific.
8
+ */
9
+ export type Db = PgDatabase<PgQueryResultHKT, any, any>;
10
+ /** pino-shaped, so a host passes its logger and this package depends on none. */
11
+ export interface Logger {
12
+ info(obj: object, msg?: string): void;
13
+ warn(obj: object, msg?: string): void;
14
+ error(obj: object, msg?: string): void;
15
+ }
16
+ export type Mode = 'production' | 'development' | 'test';
17
+ /** What `alert()` takes: the shape the template's `alerts.ts` already produces. */
18
+ export interface AlertFinding {
19
+ readonly check: string;
20
+ readonly message: string;
21
+ readonly detail?: unknown;
22
+ }
23
+ /** A settings change handed to the host's audit writer, when it bound one. */
24
+ export interface SettingsChange {
25
+ readonly before: Settings;
26
+ readonly after: Settings;
27
+ readonly by: Actor;
28
+ readonly changed: readonly string[];
29
+ }
30
+ export interface Settings {
31
+ readonly 'events.retention_days': number;
32
+ }
33
+ export interface ReportingOptions {
34
+ db: Db;
35
+ log: Logger;
36
+ /** This host's id, stamped on every row: `^[a-z][a-z0-9-]{0,63}$`. */
37
+ site: string;
38
+ /**
39
+ * Runs a flush after the current unit of work. The `./next` entry passes
40
+ * `after()`; the default is `setTimeout(0)`, which a script keeps until it
41
+ * exits. Errors thrown by `fn` are the flush's own and never reach here.
42
+ */
43
+ defer?: (fn: () => Promise<void>) => void;
44
+ now?: () => Date;
45
+ /** Defaults from NODE_ENV: anything but `development` or `test` is production. */
46
+ mode?: Mode;
47
+ /** The host's second sink (Sentry, say). Called after the row is queued; a throw is swallowed. */
48
+ onAlert?: (finding: AlertFinding) => void;
49
+ /** Bound by the host to its audit ledger; called after a settings change commits. */
50
+ audit?: (change: SettingsChange) => Promise<void>;
51
+ /** Queue bound; past it rows go to the logger only. Default 1,000. */
52
+ queueLimit?: number;
53
+ /** Rows per insert. Default 500. */
54
+ batchSize?: number;
55
+ /** Timer flush interval, ms. Default 1,000. */
56
+ flushEveryMs?: number;
57
+ }
58
+ export interface EventsPageOptions {
59
+ readonly limit?: number;
60
+ readonly after?: {
61
+ readonly occurredAt: Date;
62
+ readonly id: number;
63
+ };
64
+ readonly level?: Level;
65
+ readonly kindNs?: string;
66
+ readonly tenantId?: string;
67
+ readonly site?: string;
68
+ readonly requestId?: string;
69
+ }
70
+ export interface EventsPage {
71
+ readonly items: readonly ReportingEventRow[];
72
+ readonly next: {
73
+ readonly occurredAt: Date;
74
+ readonly id: number;
75
+ } | null;
76
+ }
77
+ export interface Stats {
78
+ readonly queued: number;
79
+ readonly dropped: number;
80
+ readonly invalid: number;
81
+ readonly flushed: number;
82
+ readonly failedFlushes: number;
83
+ }
84
+ export interface Reporting {
85
+ /** Never throws in production; see writer.ts. */
86
+ event(input: EventInput): void;
87
+ /** An `alert.<check>` row at level `alert`, then the host's `onAlert`. */
88
+ alert(finding: AlertFinding): void;
89
+ /** Drain the queue now. Bounded by `deadlineMs` (default 5,000). Never throws. */
90
+ flush(opts?: {
91
+ deadlineMs?: number;
92
+ }): Promise<void>;
93
+ readonly events: {
94
+ page(opts?: EventsPageOptions): Promise<EventsPage>;
95
+ };
96
+ readonly settings: {
97
+ get(): Promise<Settings>;
98
+ set(patch: Partial<Settings>, by: Actor): Promise<SettingsChange>;
99
+ };
100
+ readonly tables: typeof tables;
101
+ readonly site: string;
102
+ readonly db: Db;
103
+ readonly log: Logger;
104
+ stats(): Stats;
105
+ }
106
+ export type { Actor, EventInput, FlatData, Level };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { Db, EventInput, Logger, Mode, Stats } from './types.js';
2
+ export interface WriterOptions {
3
+ db: Db;
4
+ log: Logger;
5
+ site: string;
6
+ mode: Mode;
7
+ now: () => Date;
8
+ defer: (fn: () => Promise<void>) => void;
9
+ queueLimit: number;
10
+ batchSize: number;
11
+ flushEveryMs: number;
12
+ }
13
+ export declare class Writer {
14
+ private readonly o;
15
+ private readonly queue;
16
+ private inFlight;
17
+ private timer;
18
+ private deferred;
19
+ private dropped;
20
+ private droppedSinceReport;
21
+ private invalid;
22
+ private flushed;
23
+ private failedFlushes;
24
+ constructor(o: WriterOptions);
25
+ event(input: EventInput): void;
26
+ private toRow;
27
+ private enqueue;
28
+ /** Once per idle period through `defer`, and always on the timer. */
29
+ private schedule;
30
+ /** Drain until empty or the deadline. One flush in flight at a time; a second call waits on the first. */
31
+ flush(opts?: {
32
+ deadlineMs?: number;
33
+ }): Promise<void>;
34
+ private lastFailed;
35
+ private flushOnce;
36
+ stats(): Stats;
37
+ }
38
+ /** A string for the logger, never the error object: an error's `cause` can carry a request body. */
39
+ export declare function describe(error: unknown): string;
package/dist/writer.js ADDED
@@ -0,0 +1,166 @@
1
+ import { eventInputSchema } from './schema.js';
2
+ import { reportingEvents } from './tables.js';
3
+ export class Writer {
4
+ o;
5
+ queue = [];
6
+ inFlight = null;
7
+ timer = null;
8
+ deferred = false;
9
+ dropped = 0;
10
+ droppedSinceReport = 0;
11
+ invalid = 0;
12
+ flushed = 0;
13
+ failedFlushes = 0;
14
+ constructor(o) {
15
+ this.o = o;
16
+ }
17
+ event(input) {
18
+ const parsed = eventInputSchema.safeParse(input);
19
+ if (!parsed.success) {
20
+ this.invalid += 1;
21
+ const issue = parsed.error.issues[0];
22
+ const where = issue ? `${issue.path.join('.') || '(root)'}: ${issue.message}` : 'invalid';
23
+ if (this.o.mode !== 'production') {
24
+ throw new Error(`reporting.event refused: ${where}`);
25
+ }
26
+ // The offending payload is exactly what must not be written; the
27
+ // kind, if it is even a string, and the failing path are enough.
28
+ const kind = typeof input?.kind === 'string' ? input.kind.slice(0, 128) : '(not a string)';
29
+ this.o.log.error({ kind, issue: where }, 'reporting: event refused');
30
+ this.enqueue(this.toRow({
31
+ kind: 'reporting.invalid',
32
+ level: 'error',
33
+ message: `event refused: ${where}`.slice(0, 512),
34
+ data: { kind },
35
+ tenantId: null,
36
+ actor: null,
37
+ requestId: null,
38
+ target: null,
39
+ }));
40
+ return;
41
+ }
42
+ const row = this.toRow(parsed.data);
43
+ this.o.log[parsed.data.level === 'alert' ? 'warn' : parsed.data.level]({
44
+ site: row.site,
45
+ kind: row.kind,
46
+ tenantId: row.tenantId,
47
+ requestId: row.requestId,
48
+ data: row.data,
49
+ }, row.message);
50
+ this.enqueue(row);
51
+ }
52
+ toRow(v) {
53
+ return {
54
+ occurredAt: v.occurredAt ?? this.o.now(),
55
+ level: v.level,
56
+ kind: v.kind,
57
+ site: this.o.site,
58
+ tenantId: v.tenantId ?? null,
59
+ actorClass: v.actor?.class ?? null,
60
+ actorId: v.actor?.id ?? null,
61
+ requestId: v.requestId ?? null,
62
+ targetType: v.target?.type ?? null,
63
+ targetId: v.target?.id ?? null,
64
+ message: v.message,
65
+ data: v.data,
66
+ };
67
+ }
68
+ enqueue(row) {
69
+ if (this.queue.length >= this.o.queueLimit) {
70
+ this.dropped += 1;
71
+ this.droppedSinceReport += 1;
72
+ return;
73
+ }
74
+ this.queue.push(row);
75
+ this.schedule();
76
+ }
77
+ /** Once per idle period through `defer`, and always on the timer. */
78
+ schedule() {
79
+ if (!this.deferred) {
80
+ this.deferred = true;
81
+ try {
82
+ this.o.defer(() => this.flush({ deadlineMs: 5000 }));
83
+ }
84
+ catch (error) {
85
+ // A host's defer that throws (called outside a request scope, say)
86
+ // must not take the event with it; the timer still runs.
87
+ this.o.log.warn({ err: describe(error) }, 'reporting: defer threw; relying on the timer');
88
+ }
89
+ }
90
+ if (!this.timer) {
91
+ this.timer = setTimeout(() => {
92
+ this.timer = null;
93
+ void this.flush({ deadlineMs: 5000 });
94
+ }, this.o.flushEveryMs);
95
+ this.timer.unref?.();
96
+ }
97
+ }
98
+ /** Drain until empty or the deadline. One flush in flight at a time; a second call waits on the first. */
99
+ async flush(opts = {}) {
100
+ const deadline = Date.now() + (opts.deadlineMs ?? 5000);
101
+ if (this.inFlight)
102
+ await this.inFlight;
103
+ while (this.queue.length > 0 && Date.now() < deadline) {
104
+ this.inFlight = this.flushOnce();
105
+ try {
106
+ await this.inFlight;
107
+ }
108
+ finally {
109
+ this.inFlight = null;
110
+ }
111
+ if (this.failedFlushes > 0 && this.queue.length > 0 && this.lastFailed)
112
+ break;
113
+ }
114
+ this.deferred = false;
115
+ }
116
+ lastFailed = false;
117
+ async flushOnce() {
118
+ const batch = this.queue.splice(0, this.o.batchSize);
119
+ if (this.droppedSinceReport > 0) {
120
+ const count = this.droppedSinceReport;
121
+ this.droppedSinceReport = 0;
122
+ batch.push(this.toRow({
123
+ kind: 'reporting.dropped',
124
+ level: 'warn',
125
+ message: `${count} event(s) went to the logger only: the queue was full`,
126
+ data: { count },
127
+ tenantId: null,
128
+ actor: null,
129
+ requestId: null,
130
+ target: null,
131
+ }));
132
+ }
133
+ try {
134
+ await this.o.db.insert(reportingEvents).values(batch);
135
+ this.flushed += batch.length;
136
+ this.lastFailed = false;
137
+ }
138
+ catch (error) {
139
+ this.failedFlushes += 1;
140
+ this.lastFailed = true;
141
+ // Back at the front, so order survives; the bound still applies, and
142
+ // whatever does not fit is counted as dropped rather than kept for ever.
143
+ const room = Math.max(0, this.o.queueLimit - this.queue.length);
144
+ const kept = batch.slice(0, room);
145
+ this.dropped += batch.length - kept.length;
146
+ this.droppedSinceReport += batch.length - kept.length;
147
+ this.queue.unshift(...kept);
148
+ this.o.log.error({ err: describe(error), rows: batch.length, queued: this.queue.length }, 'reporting: flush failed; rows stay queued');
149
+ }
150
+ }
151
+ stats() {
152
+ return {
153
+ queued: this.queue.length,
154
+ dropped: this.dropped,
155
+ invalid: this.invalid,
156
+ flushed: this.flushed,
157
+ failedFlushes: this.failedFlushes,
158
+ };
159
+ }
160
+ }
161
+ /** A string for the logger, never the error object: an error's `cause` can carry a request body. */
162
+ export function describe(error) {
163
+ if (error instanceof Error)
164
+ return `${error.name}: ${error.message}`.slice(0, 500);
165
+ return String(error).slice(0, 500);
166
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@wtfalch/reporting",
3
+ "version": "0.1.0",
4
+ "description": "The estate's operational event log and housekeeping tick: a writer that never fails a request, a claim protocol that needs no scheduler, and the readers an operator opens. Per-app Postgres, host-supplied logger, framework-neutral core with a Next entry.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/wtfalch/reporting",
8
+ "directory": "packages/reporting"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "files": ["dist"],
13
+ "bin": {
14
+ "reporting-migrations": "./dist/bin/migrations.js"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./housekeeping": {
22
+ "types": "./dist/housekeeping/index.d.ts",
23
+ "default": "./dist/housekeeping/index.js"
24
+ },
25
+ "./next": {
26
+ "types": "./dist/next/index.d.ts",
27
+ "default": "./dist/next/index.js"
28
+ },
29
+ "./fake": {
30
+ "types": "./dist/fake/index.d.ts",
31
+ "default": "./dist/fake/index.js"
32
+ },
33
+ "./migrations/*.sql": "./dist/migrations/*.sql",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "sideEffects": false,
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "engines": {
41
+ "node": ">=22.0.0"
42
+ },
43
+ "scripts": {
44
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/",
45
+ "prepack": "pnpm build",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ },
49
+ "peerDependencies": {
50
+ "drizzle-orm": ">=0.39.0",
51
+ "next": ">=16.0.0",
52
+ "zod": "^4.0.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "next": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@electric-sql/pglite": "^0.5.8",
61
+ "@types/node": "^22",
62
+ "drizzle-orm": "^0.39.3",
63
+ "next": "^16.2.4",
64
+ "postgres": "^3.4.5",
65
+ "react": "^19",
66
+ "react-dom": "^19",
67
+ "typescript": "^5.9.0",
68
+ "vitest": "^4.1.6",
69
+ "zod": "^4.5.4"
70
+ }
71
+ }