@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/README.md +149 -0
- package/dist/alerts.d.ts +17 -0
- package/dist/alerts.js +53 -0
- package/dist/bin/copy.d.ts +25 -0
- package/dist/bin/copy.js +57 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +22 -0
- package/dist/fake/index.d.ts +16 -0
- package/dist/fake/index.js +105 -0
- package/dist/housekeeping/index.d.ts +63 -0
- package/dist/housekeeping/index.js +198 -0
- package/dist/housekeeping/tasks.d.ts +20 -0
- package/dist/housekeeping/tasks.js +82 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +82 -0
- package/dist/migrations/0001_reporting.sql +154 -0
- package/dist/next/index.d.ts +40 -0
- package/dist/next/index.js +67 -0
- package/dist/reader.d.ts +8 -0
- package/dist/reader.js +40 -0
- package/dist/schema.d.ts +78 -0
- package/dist/schema.js +80 -0
- package/dist/settings.d.ts +16 -0
- package/dist/settings.js +71 -0
- package/dist/tables.d.ts +1014 -0
- package/dist/tables.js +55 -0
- package/dist/types.d.ts +106 -0
- package/dist/types.js +1 -0
- package/dist/writer.d.ts +39 -0
- package/dist/writer.js +166 -0
- package/package.json +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @wtfalch/reporting
|
|
2
|
+
|
|
3
|
+
The estate's operational event log and the housekeeping tick behind it: a
|
|
4
|
+
writer that never fails a request, a claim protocol that needs no scheduler,
|
|
5
|
+
and the readers an operator opens. Per-app Postgres, a host-supplied logger,
|
|
6
|
+
a framework-neutral core and a Next entry. The design and its decisions are
|
|
7
|
+
`docs/plans/reporting.md` and `docs/plans/reporting/core.md` in
|
|
8
|
+
`wtfalch/app-template`.
|
|
9
|
+
|
|
10
|
+
Three records make up reporting on the estate. This package holds the
|
|
11
|
+
**events** (what the system did: jobs, drains, alerts, an integration saying
|
|
12
|
+
no) and, from 0.2, **analytics** (how people use the product). The **audit**
|
|
13
|
+
trail (who did what to what) is `@wtfalch/audit` and is not here: an audit
|
|
14
|
+
row is transactional and its actor is unforgeable; an event row is best
|
|
15
|
+
effort. The two share column names so a reader can walk between them, and
|
|
16
|
+
nothing else.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add @wtfalch/reporting
|
|
22
|
+
pnpm exec reporting-migrations # copies migrations/*.sql into drizzle/ as the next numbers
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The copied migration creates `reporting_events`, `reporting_tasks` and
|
|
26
|
+
`reporting_settings`, a `SECURITY DEFINER` prune function, and, when a role
|
|
27
|
+
named `<database>_rt` exists, revokes UPDATE, DELETE and TRUNCATE on events
|
|
28
|
+
from it. That is the estate's runtime-role convention; a host without the
|
|
29
|
+
role gets the tables and the function and no revokes take effect. Your
|
|
30
|
+
migrate script applies the file; this package never connects to apply
|
|
31
|
+
anything.
|
|
32
|
+
|
|
33
|
+
## Bind it once
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// src/lib/reporting/core.ts, the one file that imports your raw handle
|
|
37
|
+
import { createReporting } from '@wtfalch/reporting';
|
|
38
|
+
import { createHousekeeping, pruneEvents, retentionLag } from '@wtfalch/reporting/housekeeping';
|
|
39
|
+
import { deferWithAfter } from '@wtfalch/reporting/next';
|
|
40
|
+
|
|
41
|
+
export const reporting = createReporting({
|
|
42
|
+
db, // your drizzle handle
|
|
43
|
+
log, // pino-shaped: info / warn / error
|
|
44
|
+
site: 'my-app', // stamped on every row
|
|
45
|
+
defer: deferWithAfter, // after() under Next; a timer elsewhere
|
|
46
|
+
onAlert: (f) => Sentry.captureMessage(f.message, { tags: { alert: f.check } }), // optional
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const housekeeping = createHousekeeping({ reporting });
|
|
50
|
+
housekeeping.register(pruneEvents);
|
|
51
|
+
housekeeping.register(retentionLag);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Re-export the tables from your schema module so your drizzle instance knows
|
|
55
|
+
them:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
export { reportingEvents, reportingTasks, reportingSettings } from '@wtfalch/reporting';
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Write
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
reporting.event({
|
|
65
|
+
kind: 'mail.write_refused', // namespace.name
|
|
66
|
+
level: 'warn', // info | warn | error | alert
|
|
67
|
+
message: 'the instance said no', // ≤ 512
|
|
68
|
+
tenantId, requestId, // optional
|
|
69
|
+
actor: { class: 'human', id }, // optional; never a bare id
|
|
70
|
+
target: { type: 'mailbox', id }, // optional
|
|
71
|
+
data: { type: 'forbidden', attempt: 2 }, // flat scalars only, ≤ 16 KB, no banned keys
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
reporting.alert({ check: 'orphan_memberships', message: '2 rows', detail: rows });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`event()` writes a logger line first, queues the row, and flushes after the
|
|
78
|
+
current unit of work and on a timer. It never throws in production: a row it
|
|
79
|
+
refuses (a nested `data`, a key named `email`, `password`, `token`, ...)
|
|
80
|
+
becomes one `reporting.invalid` row and a logger error. In development and
|
|
81
|
+
test it throws, so the mistake is found. `alert()` projects a finding's
|
|
82
|
+
`detail` into flat fields (an array becomes its count) and then calls your
|
|
83
|
+
`onAlert`.
|
|
84
|
+
|
|
85
|
+
## Read
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const page = await reporting.events.page({ level: 'error', kindNs: 'mail', limit: 50 });
|
|
89
|
+
// { items, next } newest first; pass `after: page.next` for the next page
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
No permission is applied here. Gate in your page.
|
|
93
|
+
|
|
94
|
+
## Housekeeping
|
|
95
|
+
|
|
96
|
+
Hang the tick off something that already happens often. Under Next, the
|
|
97
|
+
health route:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// src/app/api/health/route.ts
|
|
101
|
+
import { healthHandler } from '@wtfalch/reporting/next';
|
|
102
|
+
export const GET = healthHandler(housekeeping);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each registered task has a cadence, a lease and a retry. The tick claims a
|
|
106
|
+
due task with `SELECT ... FOR UPDATE SKIP LOCKED` and a token in one short
|
|
107
|
+
transaction, runs it outside, and completes it fenced on the token, so any
|
|
108
|
+
number of containers share the work without an advisory lock. Execution is
|
|
109
|
+
at least once; write idempotent tasks. Register your own:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
housekeeping.register({
|
|
113
|
+
name: 'authz.alerts', every: 24 * 3600_000, lease: 60_000, retry: 3600_000,
|
|
114
|
+
run: async (ctx) => { for (const f of await runAlerts(ctx.db)) ctx.reporting.alert(f); },
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Settings
|
|
119
|
+
|
|
120
|
+
`reporting.settings.get()` / `set(patch, by)`. `events.retention_days`
|
|
121
|
+
(default 30, bounds 7 to 400) is what `pruneEvents` reads. The prune
|
|
122
|
+
function clamps whatever it is handed to the same bounds, so an operator's
|
|
123
|
+
edit can shorten history to a week and no further. Pass `audit` to
|
|
124
|
+
`createReporting` to have every change handed to your audit ledger after it
|
|
125
|
+
commits.
|
|
126
|
+
|
|
127
|
+
## Shutdown
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { registerShutdownFlush } from '@wtfalch/reporting/next';
|
|
131
|
+
registerShutdownFlush(reporting); // one bounded flush on SIGTERM
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Best effort. The queue is per process and bounded at 1,000; the logger has
|
|
135
|
+
every row regardless.
|
|
136
|
+
|
|
137
|
+
## Tests
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { createFakeReporting } from '@wtfalch/reporting/fake';
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Same validation, no database; `fake.rows` is the table.
|
|
144
|
+
|
|
145
|
+
## What this package never does
|
|
146
|
+
|
|
147
|
+
Read a request or a session, decide a permission, connect to a database of
|
|
148
|
+
its own, import `next` from the core, or store an ip, a user agent, an
|
|
149
|
+
email address or a display name. See `PRIVACY.md`.
|
package/dist/alerts.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AlertFinding, FlatData } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A finding becomes one `alert.<check>` row. Its `detail` is arbitrary and
|
|
4
|
+
* often nested (a list of orphan rows, an object of counts), and the table
|
|
5
|
+
* refuses nesting, so it is projected: an array becomes its count, an
|
|
6
|
+
* object keeps its top-level scalars with banned keys dropped and nested
|
|
7
|
+
* values named as such, anything else is described. Never the detail
|
|
8
|
+
* wholesale: that is how an address ends up in an operational row.
|
|
9
|
+
*/
|
|
10
|
+
export declare function alertKind(check: string): string;
|
|
11
|
+
export declare function projectDetail(detail: unknown): FlatData;
|
|
12
|
+
export declare function alertRow(finding: AlertFinding): {
|
|
13
|
+
kind: string;
|
|
14
|
+
level: 'alert';
|
|
15
|
+
message: string;
|
|
16
|
+
data: FlatData;
|
|
17
|
+
};
|
package/dist/alerts.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { BANNED_KEYS, KIND_PATTERN } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* A finding becomes one `alert.<check>` row. Its `detail` is arbitrary and
|
|
4
|
+
* often nested (a list of orphan rows, an object of counts), and the table
|
|
5
|
+
* refuses nesting, so it is projected: an array becomes its count, an
|
|
6
|
+
* object keeps its top-level scalars with banned keys dropped and nested
|
|
7
|
+
* values named as such, anything else is described. Never the detail
|
|
8
|
+
* wholesale: that is how an address ends up in an operational row.
|
|
9
|
+
*/
|
|
10
|
+
export function alertKind(check) {
|
|
11
|
+
const name = check
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
14
|
+
.replace(/^_+|_+$/g, '')
|
|
15
|
+
.replace(/^[^a-z]+/, '');
|
|
16
|
+
const kind = `alert.${name || 'unnamed'}`;
|
|
17
|
+
return KIND_PATTERN.test(kind) ? kind : 'alert.unnamed';
|
|
18
|
+
}
|
|
19
|
+
export function projectDetail(detail) {
|
|
20
|
+
if (detail === undefined || detail === null)
|
|
21
|
+
return {};
|
|
22
|
+
if (Array.isArray(detail))
|
|
23
|
+
return { count: detail.length };
|
|
24
|
+
if (typeof detail === 'object') {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
27
|
+
if (BANNED_KEYS.includes(key))
|
|
28
|
+
continue;
|
|
29
|
+
if (value === null || typeof value === 'boolean')
|
|
30
|
+
out[key] = value;
|
|
31
|
+
else if (typeof value === 'number')
|
|
32
|
+
out[key] = Number.isFinite(value) ? value : String(value);
|
|
33
|
+
else if (typeof value === 'string')
|
|
34
|
+
out[key] = value.slice(0, 500);
|
|
35
|
+
else if (Array.isArray(value))
|
|
36
|
+
out[key] = `[${value.length} items]`;
|
|
37
|
+
else
|
|
38
|
+
out[key] = '[nested]';
|
|
39
|
+
if (Object.keys(out).length >= 40)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
return { detail: String(detail).slice(0, 500) };
|
|
45
|
+
}
|
|
46
|
+
export function alertRow(finding) {
|
|
47
|
+
return {
|
|
48
|
+
kind: alertKind(finding.check),
|
|
49
|
+
level: 'alert',
|
|
50
|
+
message: (finding.message || finding.check).slice(0, 512),
|
|
51
|
+
data: { check: finding.check.slice(0, 200), ...projectDetail(finding.detail) },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations in this estate are hand-written SQL, numbered per app, applied
|
|
3
|
+
* by psql on boot, additive. A package cannot own a number in an app's
|
|
4
|
+
* sequence, so this copies any migration the app has not yet copied into the
|
|
5
|
+
* app's `drizzle/` as the next numbers and records which in a manifest. The
|
|
6
|
+
* same mechanism `@wtfalch/threads` uses; the manifest name differs.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MANIFEST = ".reporting-migrations.json";
|
|
9
|
+
export interface Manifest {
|
|
10
|
+
/** package file name -> the name it was copied to in the app */
|
|
11
|
+
copied: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export interface CopyResult {
|
|
14
|
+
copied: Array<{
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
}>;
|
|
18
|
+
manifest: Manifest;
|
|
19
|
+
}
|
|
20
|
+
export declare function copyMigrations(opts: {
|
|
21
|
+
from: string;
|
|
22
|
+
to: string;
|
|
23
|
+
version?: string;
|
|
24
|
+
}): CopyResult;
|
|
25
|
+
export declare function describeCopy(result: CopyResult): string;
|
package/dist/bin/copy.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Migrations in this estate are hand-written SQL, numbered per app, applied
|
|
5
|
+
* by psql on boot, additive. A package cannot own a number in an app's
|
|
6
|
+
* sequence, so this copies any migration the app has not yet copied into the
|
|
7
|
+
* app's `drizzle/` as the next numbers and records which in a manifest. The
|
|
8
|
+
* same mechanism `@wtfalch/threads` uses; the manifest name differs.
|
|
9
|
+
*/
|
|
10
|
+
export const MANIFEST = '.reporting-migrations.json';
|
|
11
|
+
const NUMBERED = /^(\d{4})_(.+\.sql)$/;
|
|
12
|
+
function readManifest(dir) {
|
|
13
|
+
const file = join(dir, MANIFEST);
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
return { copied: {} };
|
|
16
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
17
|
+
return { copied: parsed.copied ?? {} };
|
|
18
|
+
}
|
|
19
|
+
function nextNumber(dir) {
|
|
20
|
+
let max = -1;
|
|
21
|
+
for (const name of readdirSync(dir)) {
|
|
22
|
+
const m = NUMBERED.exec(name);
|
|
23
|
+
if (m?.[1])
|
|
24
|
+
max = Math.max(max, Number(m[1]));
|
|
25
|
+
}
|
|
26
|
+
return max + 1;
|
|
27
|
+
}
|
|
28
|
+
export function copyMigrations(opts) {
|
|
29
|
+
mkdirSync(opts.to, { recursive: true });
|
|
30
|
+
const manifest = readManifest(opts.to);
|
|
31
|
+
const copied = [];
|
|
32
|
+
const sources = readdirSync(opts.from)
|
|
33
|
+
.filter((n) => NUMBERED.test(n))
|
|
34
|
+
.sort();
|
|
35
|
+
let next = nextNumber(opts.to);
|
|
36
|
+
for (const name of sources) {
|
|
37
|
+
if (manifest.copied[name])
|
|
38
|
+
continue;
|
|
39
|
+
const rest = NUMBERED.exec(name)?.[2] ?? name;
|
|
40
|
+
const target = `${String(next).padStart(4, '0')}_${rest}`;
|
|
41
|
+
const body = readFileSync(join(opts.from, name), 'utf8');
|
|
42
|
+
const header = `-- Copied from @wtfalch/reporting${opts.version ? ` ${opts.version}` : ''} (migrations/${name}) by reporting-migrations.\n-- Do not edit here; the next package version ships the next file.\n\n`;
|
|
43
|
+
writeFileSync(join(opts.to, target), header + body);
|
|
44
|
+
manifest.copied[name] = target;
|
|
45
|
+
copied.push({ from: name, to: target });
|
|
46
|
+
next += 1;
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(join(opts.to, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
49
|
+
return { copied, manifest };
|
|
50
|
+
}
|
|
51
|
+
export function describeCopy(result) {
|
|
52
|
+
if (result.copied.length === 0)
|
|
53
|
+
return 'reporting-migrations: nothing to copy';
|
|
54
|
+
return result.copied
|
|
55
|
+
.map((c) => `reporting-migrations: ${c.from} -> ${basename(c.to)}`)
|
|
56
|
+
.join('\n');
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { copyMigrations, describeCopy } from './copy.js';
|
|
6
|
+
/**
|
|
7
|
+
* `reporting-migrations [dir]`: copy this package's migrations the app has
|
|
8
|
+
* not yet copied into `dir` (default `drizzle`) as the next numbers.
|
|
9
|
+
* Idempotent; run it after every upgrade of @wtfalch/reporting, then commit
|
|
10
|
+
* what it wrote.
|
|
11
|
+
*/
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const from = join(here, '..', 'migrations');
|
|
14
|
+
const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
|
|
15
|
+
let version;
|
|
16
|
+
try {
|
|
17
|
+
version = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8')).version;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
version = undefined;
|
|
21
|
+
}
|
|
22
|
+
console.log(describeCopy(copyMigrations({ from, to, version })));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReportingEventRow } from '../tables.js';
|
|
2
|
+
import type { AlertFinding, Logger, Reporting } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* An in-memory `Reporting` for a host's unit tests: same validation, same
|
|
5
|
+
* shape, no database. `rows` is the table; `alerts` is what `onAlert` would
|
|
6
|
+
* have received. Validation failures throw, as in development.
|
|
7
|
+
*/
|
|
8
|
+
export interface FakeReporting extends Reporting {
|
|
9
|
+
readonly rows: ReportingEventRow[];
|
|
10
|
+
readonly alerts: AlertFinding[];
|
|
11
|
+
reset(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createFakeReporting(opts?: {
|
|
14
|
+
site?: string;
|
|
15
|
+
log?: Logger;
|
|
16
|
+
}): FakeReporting;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { alertRow } from '../alerts.js';
|
|
2
|
+
import { eventInputSchema } from '../schema.js';
|
|
3
|
+
import { DEFAULT_SETTINGS, RETENTION_BOUNDS } from '../settings.js';
|
|
4
|
+
import { tables } from '../tables.js';
|
|
5
|
+
const silent = { info() { }, warn() { }, error() { } };
|
|
6
|
+
export function createFakeReporting(opts = {}) {
|
|
7
|
+
const site = opts.site ?? 'test';
|
|
8
|
+
const log = opts.log ?? silent;
|
|
9
|
+
const rows = [];
|
|
10
|
+
const alerts = [];
|
|
11
|
+
let settings = { ...DEFAULT_SETTINGS };
|
|
12
|
+
let id = 0;
|
|
13
|
+
const fake = {
|
|
14
|
+
site,
|
|
15
|
+
log,
|
|
16
|
+
tables,
|
|
17
|
+
rows,
|
|
18
|
+
alerts,
|
|
19
|
+
// Not a database; a host's test that reaches `db` through the fake is
|
|
20
|
+
// testing the wrong thing, and this makes that loud.
|
|
21
|
+
get db() {
|
|
22
|
+
throw new Error('createFakeReporting has no database');
|
|
23
|
+
},
|
|
24
|
+
event(input) {
|
|
25
|
+
const v = eventInputSchema.parse(input);
|
|
26
|
+
id += 1;
|
|
27
|
+
rows.push({
|
|
28
|
+
id,
|
|
29
|
+
occurredAt: v.occurredAt ?? new Date(),
|
|
30
|
+
level: v.level,
|
|
31
|
+
kind: v.kind,
|
|
32
|
+
kindNs: v.kind.split('.')[0] ?? '',
|
|
33
|
+
site,
|
|
34
|
+
tenantId: v.tenantId ?? null,
|
|
35
|
+
actorClass: v.actor?.class ?? null,
|
|
36
|
+
actorId: v.actor?.id ?? null,
|
|
37
|
+
requestId: v.requestId ?? null,
|
|
38
|
+
targetType: v.target?.type ?? null,
|
|
39
|
+
targetId: v.target?.id ?? null,
|
|
40
|
+
message: v.message,
|
|
41
|
+
data: v.data,
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
alert(finding) {
|
|
46
|
+
fake.event(alertRow(finding));
|
|
47
|
+
alerts.push(finding);
|
|
48
|
+
},
|
|
49
|
+
async flush() { },
|
|
50
|
+
events: {
|
|
51
|
+
async page(o = {}) {
|
|
52
|
+
const limit = Math.max(1, Math.min(o.limit ?? 50, 200));
|
|
53
|
+
let list = [...rows].sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime() || b.id - a.id);
|
|
54
|
+
if (o.level)
|
|
55
|
+
list = list.filter((r) => r.level === o.level);
|
|
56
|
+
if (o.kindNs)
|
|
57
|
+
list = list.filter((r) => r.kindNs === o.kindNs);
|
|
58
|
+
if (o.tenantId)
|
|
59
|
+
list = list.filter((r) => r.tenantId === o.tenantId);
|
|
60
|
+
if (o.site)
|
|
61
|
+
list = list.filter((r) => r.site === o.site);
|
|
62
|
+
if (o.requestId)
|
|
63
|
+
list = list.filter((r) => r.requestId === o.requestId);
|
|
64
|
+
if (o.after) {
|
|
65
|
+
const { occurredAt, id: afterId } = o.after;
|
|
66
|
+
list = list.filter((r) => r.occurredAt.getTime() < occurredAt.getTime() ||
|
|
67
|
+
(r.occurredAt.getTime() === occurredAt.getTime() && r.id < afterId));
|
|
68
|
+
}
|
|
69
|
+
const items = list.slice(0, limit);
|
|
70
|
+
const last = items.at(-1);
|
|
71
|
+
return {
|
|
72
|
+
items,
|
|
73
|
+
next: list.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
settings: {
|
|
78
|
+
async get() {
|
|
79
|
+
return settings;
|
|
80
|
+
},
|
|
81
|
+
async set(patch, by) {
|
|
82
|
+
const before = settings;
|
|
83
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
84
|
+
if (typeof value !== 'number' ||
|
|
85
|
+
!Number.isInteger(value) ||
|
|
86
|
+
value < RETENTION_BOUNDS.min ||
|
|
87
|
+
value > RETENTION_BOUNDS.max) {
|
|
88
|
+
throw new Error(`reporting.settings: ${key} out of bounds`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
settings = { ...settings, ...patch };
|
|
92
|
+
const changed = Object.keys(patch).filter((k) => before[k] !== settings[k]);
|
|
93
|
+
return { before, after: settings, by, changed };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length, failedFlushes: 0 }),
|
|
97
|
+
reset() {
|
|
98
|
+
rows.length = 0;
|
|
99
|
+
alerts.length = 0;
|
|
100
|
+
settings = { ...DEFAULT_SETTINGS };
|
|
101
|
+
id = 0;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
return fake;
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Db, Reporting, Settings } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The tick, and the claim protocol that lets any number of containers share
|
|
4
|
+
* it without a scheduler.
|
|
5
|
+
*
|
|
6
|
+
* A host calls `tick()` from something that already happens often (the
|
|
7
|
+
* `./next` entry hangs it off the health check the platform sends every ten
|
|
8
|
+
* seconds). The tick debounces itself per process, then for every registered
|
|
9
|
+
* task opens one short transaction: SELECT the task's row FOR UPDATE SKIP
|
|
10
|
+
* LOCKED (no row: another container is deciding about it right now), then
|
|
11
|
+
* one conditional UPDATE that claims it only if it is due and no live lease
|
|
12
|
+
* holds it, stamping a fresh token and a lease. The task runs outside that
|
|
13
|
+
* transaction; renewing the lease and recording completion are UPDATEs fenced
|
|
14
|
+
* on the token, so a superseded worker touches zero rows.
|
|
15
|
+
*
|
|
16
|
+
* All time comparisons use the database's `now()`: two containers do not
|
|
17
|
+
* share a clock. Execution is at least once: a lease that expires under a
|
|
18
|
+
* paused worker lets its replacement start, so every task is idempotent.
|
|
19
|
+
*
|
|
20
|
+
* Not an advisory lock, which is session state on whichever pooled
|
|
21
|
+
* connection served the statement, and would have been released on another.
|
|
22
|
+
*/
|
|
23
|
+
export interface Task {
|
|
24
|
+
/** `namespace.name`, the same shape as an event kind. */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** Cadence, ms. */
|
|
27
|
+
readonly every: number;
|
|
28
|
+
/** How long a claim is honoured before another container may take over, ms. Renew inside a long task. */
|
|
29
|
+
readonly lease: number;
|
|
30
|
+
/** Delay before the next attempt after a failure, ms. */
|
|
31
|
+
readonly retry: number;
|
|
32
|
+
run(ctx: TaskContext): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface TaskContext {
|
|
35
|
+
readonly reporting: Reporting;
|
|
36
|
+
readonly db: Db;
|
|
37
|
+
readonly settings: Settings;
|
|
38
|
+
now(): Date;
|
|
39
|
+
/** Extends the lease if this claim still holds. `false` means superseded: stop scheduling work. */
|
|
40
|
+
renew(): Promise<boolean>;
|
|
41
|
+
/** The run must finish, or stop scheduling work, by this instant. */
|
|
42
|
+
readonly deadline: Date;
|
|
43
|
+
}
|
|
44
|
+
export interface HousekeepingOptions {
|
|
45
|
+
reporting: Reporting;
|
|
46
|
+
db?: Db;
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
/** Per process. Default 60,000 ms. */
|
|
49
|
+
debounceMs?: number;
|
|
50
|
+
/** Tasks run at once. Default 2. */
|
|
51
|
+
concurrency?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface Housekeeping {
|
|
54
|
+
/** Idempotent by name. The row is created on the next tick. */
|
|
55
|
+
register(task: Task): void;
|
|
56
|
+
/** Never throws. */
|
|
57
|
+
tick(): Promise<void>;
|
|
58
|
+
/** For tests and scripts: run the protocol for one task now, ignoring the debounce. */
|
|
59
|
+
runNow(name: string): Promise<'ran' | 'skipped' | 'failed'>;
|
|
60
|
+
tasks(): readonly Task[];
|
|
61
|
+
}
|
|
62
|
+
export declare function createHousekeeping(options: HousekeepingOptions): Housekeeping;
|
|
63
|
+
export { pruneEvents, retentionLag } from './tasks.js';
|