@wtfalch/reporting 0.1.2 → 0.2.1

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,199 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { rowsOf } from '../housekeeping/tasks.js';
3
+ const HOUR = 60 * 60 * 1000;
4
+ const DAY = 24 * HOUR;
5
+ /** Completed days rolled up per run, so a long outage catches up in bounded steps under a renewed lease. */
6
+ const CATCH_UP_DAYS = 14;
7
+ /** The weekly rollup's raw lookback: returning means seen in one of the four weeks before. */
8
+ export const WEEKLY_LOOKBACK_DAYS = 28;
9
+ const iso = (d) => d.toISOString().slice(0, 10);
10
+ const utcDay = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
11
+ const addDays = (d, n) => new Date(d.getTime() + n * DAY);
12
+ /** The Monday of the ISO week holding `d`, at UTC midnight. */
13
+ export const mondayOf = (d) => {
14
+ const day = utcDay(d);
15
+ const dow = (day.getUTCDay() + 6) % 7;
16
+ return addDays(day, -dow);
17
+ };
18
+ async function rollupDay(db, day) {
19
+ const result = await db.execute(sql `select reporting_rollup_day(${day}::date) as n`);
20
+ return Number(rowsOf(result)[0]?.n ?? 0);
21
+ }
22
+ async function rollupWeek(db, monday) {
23
+ const result = await db.execute(sql `select reporting_rollup_week(${monday}::date) as n`);
24
+ return Number(rowsOf(result)[0]?.n ?? 0);
25
+ }
26
+ /** The first day worth rolling up when there is no watermark yet: the oldest raw row's day, or nothing. */
27
+ async function firstRawDay(db) {
28
+ const result = await db.execute(sql `select min((occurred_at at time zone 'UTC')::date)::text as d from reporting_analytics`);
29
+ const d = rowsOf(result)[0]?.d;
30
+ return typeof d === 'string' && d.length === 10 ? d : null;
31
+ }
32
+ /**
33
+ * The daily rollup (addendum A3). Completed days first: from the day after
34
+ * the watermark up to yesterday, each day's aggregate set and the watermark
35
+ * move committed together, fenced by the claim token, in bounded batches
36
+ * with the lease renewed between them. Then the open day (today) is
37
+ * recomputed without moving the watermark, so the dashboard is current and
38
+ * history stays contiguous.
39
+ */
40
+ export const rollupAnalytics = {
41
+ name: 'reporting.rollup_analytics',
42
+ every: HOUR,
43
+ lease: 10 * 60 * 1000,
44
+ retry: 15 * 60 * 1000,
45
+ async run(ctx) {
46
+ const today = utcDay(ctx.now());
47
+ const yesterday = addDays(today, -1);
48
+ let cursor = ctx.watermark ? addDays(new Date(`${ctx.watermark}T00:00:00Z`), 1) : null;
49
+ if (!cursor) {
50
+ const first = await firstRawDay(ctx.db);
51
+ cursor = first ? new Date(`${first}T00:00:00Z`) : null;
52
+ }
53
+ let completed = 0;
54
+ while (cursor && cursor.getTime() <= yesterday.getTime() && completed < CATCH_UP_DAYS) {
55
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 60_000)
56
+ break;
57
+ const day = iso(cursor);
58
+ const moved = await ctx.db.transaction(async (tx) => {
59
+ await rollupDay(tx, day);
60
+ return ctx.commitWatermark(day, tx);
61
+ });
62
+ if (!moved) {
63
+ // The lease went to another process; its run owns the watermark now.
64
+ ctx.reporting.event({
65
+ kind: 'reporting.rollup_yielded',
66
+ level: 'warn',
67
+ message: `rollup of ${day} yielded: the lease was lost before the watermark moved`,
68
+ data: { day },
69
+ });
70
+ return;
71
+ }
72
+ completed += 1;
73
+ cursor = addDays(cursor, 1);
74
+ if (!(await ctx.renew()))
75
+ return;
76
+ }
77
+ // The open day, and yesterday if the catch-up has not reached it yet:
78
+ // recomputed for the dashboard, never vouched for.
79
+ const openFrom = cursor && cursor.getTime() < today.getTime() ? cursor : today;
80
+ for (let d = openFrom; d.getTime() <= today.getTime(); d = addDays(d, 1)) {
81
+ await rollupDay(ctx.db, iso(d));
82
+ }
83
+ if (completed > 0) {
84
+ ctx.reporting.event({
85
+ kind: 'reporting.rolled_up',
86
+ message: `rolled up ${completed} completed day(s), watermark ${iso(addDays(cursor ?? today, -1))}`,
87
+ data: { days: completed, watermark: iso(addDays(cursor ?? today, -1)) },
88
+ });
89
+ }
90
+ },
91
+ };
92
+ /**
93
+ * The weekly rollup: completed ISO weeks (Monday to Sunday, wholly before
94
+ * today) from the week after its own watermark, then the open week
95
+ * recomputed. Its watermark is the Monday of the last completed week.
96
+ */
97
+ export const rollupAnalyticsWeekly = {
98
+ name: 'reporting.rollup_analytics_weekly',
99
+ every: DAY,
100
+ lease: 10 * 60 * 1000,
101
+ retry: HOUR,
102
+ async run(ctx) {
103
+ const today = utcDay(ctx.now());
104
+ const thisMonday = mondayOf(today);
105
+ let cursor = ctx.watermark
106
+ ? addDays(new Date(`${ctx.watermark}T00:00:00Z`), 7)
107
+ : null;
108
+ if (!cursor) {
109
+ const first = await firstRawDay(ctx.db);
110
+ cursor = first ? mondayOf(new Date(`${first}T00:00:00Z`)) : null;
111
+ }
112
+ let completed = 0;
113
+ while (cursor && cursor.getTime() < thisMonday.getTime() && completed < 26) {
114
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 60_000)
115
+ break;
116
+ const monday = iso(cursor);
117
+ const moved = await ctx.db.transaction(async (tx) => {
118
+ await rollupWeek(tx, monday);
119
+ return ctx.commitWatermark(monday, tx);
120
+ });
121
+ if (!moved)
122
+ return;
123
+ completed += 1;
124
+ cursor = addDays(cursor, 7);
125
+ if (!(await ctx.renew()))
126
+ return;
127
+ }
128
+ await rollupWeek(ctx.db, iso(thisMonday));
129
+ },
130
+ };
131
+ /**
132
+ * Raw rows leave by age, and never past what the rollups still need: the
133
+ * daily watermark, and the weekly watermark less its lookback, whichever is
134
+ * earlier. Until both rollups have completed a day there is nothing safe to
135
+ * prune, and the function says so.
136
+ */
137
+ export const pruneAnalytics = {
138
+ name: 'reporting.prune_analytics',
139
+ every: HOUR,
140
+ lease: 10 * 60 * 1000,
141
+ retry: 15 * 60 * 1000,
142
+ async run(ctx) {
143
+ const days = ctx.settings['analytics.retention_days'];
144
+ const safeBefore = await safePruneBoundary(ctx.db);
145
+ if (!safeBefore)
146
+ return;
147
+ // The window's cutoff must fall before what the rollups still need. A
148
+ // window shorter than that (an operator's edit against a stalled
149
+ // rollup, or one shorter than the weekly lookback) defers pruning with
150
+ // a warning rather than failing every hour; the function refuses too.
151
+ const cutoffDay = iso(addDays(utcDay(ctx.now()), -days));
152
+ if (cutoffDay >= safeBefore) {
153
+ ctx.reporting.event({
154
+ kind: 'reporting.prune_deferred',
155
+ level: 'warn',
156
+ message: `analytics pruning deferred: a ${days}-day window would cut at ${cutoffDay}, but the rollups still need raw rows from ${safeBefore}`,
157
+ data: { days, cutoffDay, safeBefore },
158
+ });
159
+ return;
160
+ }
161
+ let total = 0;
162
+ for (let i = 0; i < 20; i += 1) {
163
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 30_000)
164
+ break;
165
+ const n = await pruneAnalyticsBatch(ctx.db, days, 5000, safeBefore);
166
+ total += n;
167
+ if (n < 5000)
168
+ break;
169
+ if (!(await ctx.renew()))
170
+ break;
171
+ }
172
+ if (total > 0) {
173
+ ctx.reporting.event({
174
+ kind: 'reporting.pruned_analytics',
175
+ message: `pruned ${total} analytics row(s) older than ${days} days`,
176
+ data: { rows: total, days, safeBefore },
177
+ });
178
+ }
179
+ },
180
+ };
181
+ /** The earliest day the rollups still need, as an ISO date, or null when a rollup has not completed anything yet. */
182
+ export async function safePruneBoundary(db) {
183
+ const result = await db.execute(sql `select task, watermark from reporting_tasks where task in ('reporting.rollup_analytics', 'reporting.rollup_analytics_weekly')`);
184
+ const marks = new Map(rowsOf(result).map((r) => [String(r.task), r.watermark]));
185
+ const daily = marks.get('reporting.rollup_analytics');
186
+ const weekly = marks.get('reporting.rollup_analytics_weekly');
187
+ if (typeof daily !== 'string' || typeof weekly !== 'string')
188
+ return null;
189
+ const weeklyNeeds = addDays(new Date(`${weekly}T00:00:00Z`), -WEEKLY_LOOKBACK_DAYS);
190
+ const dailyDate = new Date(`${daily}T00:00:00Z`);
191
+ return iso(weeklyNeeds.getTime() < dailyDate.getTime() ? weeklyNeeds : dailyDate);
192
+ }
193
+ export async function pruneAnalyticsBatch(db, days, batch, safeBefore) {
194
+ return db.transaction(async (tx) => {
195
+ await tx.execute(sql `set local statement_timeout = '30s'`);
196
+ const result = await tx.execute(sql `select reporting_prune_analytics(make_interval(days => ${days}::int), ${batch}::int, ${safeBefore}::date) as n`);
197
+ return Number(rowsOf(result)[0]?.n ?? 0);
198
+ });
199
+ }
@@ -0,0 +1,19 @@
1
+ import type { Db } from '../types.js';
2
+ import type { Device } from './schema.js';
3
+ /** One raw row as the collector or `track` writes it; every field already validated and normalised. */
4
+ export interface AnalyticsRow {
5
+ readonly occurredAt: Date;
6
+ readonly receivedAt: Date;
7
+ readonly site: string;
8
+ readonly tenantId: string | null;
9
+ readonly visitorId: string | null;
10
+ readonly sessionId: string | null;
11
+ readonly userId: string | null;
12
+ readonly name: string;
13
+ readonly path: string;
14
+ readonly referrerHost: string | null;
15
+ readonly device: Device;
16
+ readonly country: string | null;
17
+ readonly props: Record<string, string | number | boolean | null>;
18
+ }
19
+ export declare function insertAnalytics(db: Db, rows: readonly AnalyticsRow[]): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { reportingAnalytics } from '../tables.js';
2
+ export async function insertAnalytics(db, rows) {
3
+ if (rows.length === 0)
4
+ return;
5
+ await db.insert(reportingAnalytics).values(rows.map((r) => ({
6
+ occurredAt: r.occurredAt,
7
+ receivedAt: r.receivedAt,
8
+ site: r.site,
9
+ tenantId: r.tenantId,
10
+ visitorId: r.visitorId,
11
+ sessionId: r.sessionId,
12
+ userId: r.userId,
13
+ name: r.name,
14
+ path: r.path,
15
+ referrerHost: r.referrerHost,
16
+ device: r.device,
17
+ country: r.country,
18
+ props: r.props,
19
+ })));
20
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The beacon: `page.view` on load and on history changes, `page.leave` with
3
+ * the seconds on the page, and whatever the host `track`s, batched twenty at
4
+ * a time or every five seconds and posted with `sendBeacon` (falling back to
5
+ * `fetch` with `keepalive`). It sends the pathname, never the query string,
6
+ * and never a user id: the collector knows who is signed in from the session
7
+ * cookie, and a client-supplied id would be a claim (D14).
8
+ *
9
+ * Consent (D15): `none` keeps nothing; `consented` starts as `none` until
10
+ * `accept()`; `always` sets the first-party cookie without asking. The cookie
11
+ * `_rp` holds the visitor id and the answer, one year, SameSite=Lax, Secure;
12
+ * the session id lives in `sessionStorage` under `consented`/`always` and in
13
+ * memory under `none`. Nothing about the browser is stored beyond that.
14
+ *
15
+ * No dependencies, no globals beyond `window`; under 3 KB gzipped.
16
+ */
17
+ export type ConsentMode = 'none' | 'consented' | 'always';
18
+ export type ConsentAnswer = 'accepted' | 'declined' | null;
19
+ export interface BeaconOptions {
20
+ /** The collector URL, same-origin (`/api/reporting/collect`) or the hub's. */
21
+ readonly collector: string;
22
+ /** This app's site id. */
23
+ readonly site: string;
24
+ readonly consent?: ConsentMode;
25
+ /** Batch size and flush interval; the defaults are the collector's caps. */
26
+ readonly batch?: number;
27
+ readonly flushMs?: number;
28
+ /** For tests: the window to observe and the transport to post with. */
29
+ readonly window?: Window;
30
+ readonly send?: (url: string, body: string) => void;
31
+ }
32
+ export interface Beacon {
33
+ track(name: string, props?: Record<string, string | number | boolean | null>): void;
34
+ pageView(): void;
35
+ /** The person's answer under `consented`; sets or clears the cookie and remembers it. */
36
+ accept(): void;
37
+ decline(): void;
38
+ answer(): ConsentAnswer;
39
+ visitorId(): string | null;
40
+ flush(): void;
41
+ destroy(): void;
42
+ }
43
+ export declare function createBeacon(options: BeaconOptions): Beacon;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The beacon: `page.view` on load and on history changes, `page.leave` with
3
+ * the seconds on the page, and whatever the host `track`s, batched twenty at
4
+ * a time or every five seconds and posted with `sendBeacon` (falling back to
5
+ * `fetch` with `keepalive`). It sends the pathname, never the query string,
6
+ * and never a user id: the collector knows who is signed in from the session
7
+ * cookie, and a client-supplied id would be a claim (D14).
8
+ *
9
+ * Consent (D15): `none` keeps nothing; `consented` starts as `none` until
10
+ * `accept()`; `always` sets the first-party cookie without asking. The cookie
11
+ * `_rp` holds the visitor id and the answer, one year, SameSite=Lax, Secure;
12
+ * the session id lives in `sessionStorage` under `consented`/`always` and in
13
+ * memory under `none`. Nothing about the browser is stored beyond that.
14
+ *
15
+ * No dependencies, no globals beyond `window`; under 3 KB gzipped.
16
+ */
17
+ const COOKIE = '_rp';
18
+ const YEAR = 365 * 24 * 60 * 60;
19
+ const NAME = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/;
20
+ function randomId() {
21
+ const bytes = new Uint8Array(16);
22
+ crypto.getRandomValues(bytes);
23
+ let s = '';
24
+ for (const b of bytes)
25
+ s += b.toString(16).padStart(2, '0');
26
+ return s;
27
+ }
28
+ function readCookie(doc) {
29
+ const m = doc.cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]*)`));
30
+ if (!m?.[1])
31
+ return null;
32
+ const [id, answer] = decodeURIComponent(m[1]).split('.');
33
+ if (!id || !/^[a-f0-9]{32}$/.test(id))
34
+ return null;
35
+ return { id, answer: answer === 'a' ? 'accepted' : answer === 'd' ? 'declined' : null };
36
+ }
37
+ function writeCookie(doc, value) {
38
+ doc.cookie =
39
+ value === null
40
+ ? `${COOKIE}=; Max-Age=0; Path=/; SameSite=Lax; Secure`
41
+ : `${COOKIE}=${encodeURIComponent(value)}; Max-Age=${YEAR}; Path=/; SameSite=Lax; Secure`;
42
+ }
43
+ export function createBeacon(options) {
44
+ const win = options.window ?? window;
45
+ const doc = win.document;
46
+ const mode = options.consent ?? 'consented';
47
+ const batchSize = Math.min(20, options.batch ?? 20);
48
+ const flushMs = options.flushMs ?? 5000;
49
+ const send = options.send ??
50
+ ((url, body) => {
51
+ const blob = new Blob([body], { type: 'application/json' });
52
+ if (!win.navigator.sendBeacon?.(url, blob)) {
53
+ void win.fetch(url, { method: 'POST', body, keepalive: true, credentials: 'include' });
54
+ }
55
+ });
56
+ // Identity: a visitor id only with consent; a session id per tab.
57
+ let cookie = readCookie(doc);
58
+ let answer = cookie?.answer ?? null;
59
+ const declined = () => mode === 'none' || (mode === 'consented' && answer !== 'accepted');
60
+ if (mode === 'always' && !cookie) {
61
+ cookie = { id: randomId(), answer: 'accepted' };
62
+ writeCookie(doc, `${cookie.id}.a`);
63
+ }
64
+ if (declined()) {
65
+ if (cookie && answer !== 'declined')
66
+ cookie = null;
67
+ }
68
+ let sessionId = null;
69
+ function inMemorySession() {
70
+ if (!sessionId)
71
+ sessionId = randomId();
72
+ return sessionId;
73
+ }
74
+ function session() {
75
+ if (declined())
76
+ return inMemorySession();
77
+ try {
78
+ const stored = win.sessionStorage.getItem(`${COOKIE}s`);
79
+ if (stored && /^[a-f0-9]{32}$/.test(stored))
80
+ return stored;
81
+ const fresh = randomId();
82
+ win.sessionStorage.setItem(`${COOKIE}s`, fresh);
83
+ return fresh;
84
+ }
85
+ catch {
86
+ return inMemorySession();
87
+ }
88
+ }
89
+ const queue = [];
90
+ let timer = null;
91
+ function flush() {
92
+ if (timer) {
93
+ clearTimeout(timer);
94
+ timer = null;
95
+ }
96
+ if (queue.length === 0)
97
+ return;
98
+ const events = queue.splice(0, batchSize);
99
+ const body = JSON.stringify({
100
+ site: options.site,
101
+ visitorId: declined() ? null : (cookie?.id ?? null),
102
+ sessionId: session(),
103
+ events,
104
+ });
105
+ try {
106
+ send(options.collector, body);
107
+ }
108
+ catch {
109
+ // A beacon that cannot leave is dropped; nothing here may throw into the page.
110
+ }
111
+ if (queue.length > 0)
112
+ flush();
113
+ }
114
+ function push(event) {
115
+ queue.push(event);
116
+ if (queue.length >= batchSize)
117
+ flush();
118
+ else if (!timer)
119
+ timer = setTimeout(flush, flushMs);
120
+ }
121
+ const pathOf = () => win.location.pathname || '/';
122
+ let currentPath = pathOf();
123
+ let enteredAt = Date.now();
124
+ let lastReferrer = doc.referrer || null;
125
+ function pageView() {
126
+ currentPath = pathOf();
127
+ enteredAt = Date.now();
128
+ push({
129
+ name: 'page.view',
130
+ at: Date.now(),
131
+ path: currentPath,
132
+ referrer: lastReferrer,
133
+ props: {},
134
+ });
135
+ lastReferrer = null;
136
+ }
137
+ function pageLeave() {
138
+ const duration = Math.max(0, Math.round((Date.now() - enteredAt) / 1000));
139
+ push({ name: 'page.leave', at: Date.now(), path: currentPath, props: {}, duration });
140
+ flush();
141
+ }
142
+ function track(name, props = {}) {
143
+ if (!NAME.test(name) || name.startsWith('page.'))
144
+ return;
145
+ push({ name, at: Date.now(), path: pathOf(), props });
146
+ }
147
+ // History: a SPA navigation is a page view too.
148
+ const history = win.history;
149
+ const pushState = history.pushState.bind(history);
150
+ const replaceState = history.replaceState.bind(history);
151
+ const onNavigate = () => {
152
+ if (pathOf() === currentPath)
153
+ return;
154
+ pageLeave();
155
+ pageView();
156
+ };
157
+ history.pushState = (...args) => {
158
+ pushState(...args);
159
+ onNavigate();
160
+ };
161
+ history.replaceState = (...args) => {
162
+ replaceState(...args);
163
+ onNavigate();
164
+ };
165
+ const onPop = () => onNavigate();
166
+ const onHide = () => {
167
+ if (doc.visibilityState === 'hidden')
168
+ pageLeave();
169
+ };
170
+ win.addEventListener('popstate', onPop);
171
+ doc.addEventListener('visibilitychange', onHide);
172
+ win.addEventListener('pagehide', pageLeave);
173
+ pageView();
174
+ return {
175
+ track,
176
+ pageView,
177
+ accept() {
178
+ if (mode !== 'consented')
179
+ return;
180
+ answer = 'accepted';
181
+ cookie = { id: cookie?.id ?? randomId(), answer };
182
+ writeCookie(doc, `${cookie.id}.a`);
183
+ },
184
+ decline() {
185
+ if (mode !== 'consented')
186
+ return;
187
+ answer = 'declined';
188
+ cookie = null;
189
+ writeCookie(doc, `${randomId()}.d`);
190
+ },
191
+ answer: () => answer,
192
+ visitorId: () => (declined() ? null : (cookie?.id ?? null)),
193
+ flush,
194
+ destroy() {
195
+ flush();
196
+ history.pushState = pushState;
197
+ history.replaceState = replaceState;
198
+ win.removeEventListener('popstate', onPop);
199
+ doc.removeEventListener('visibilitychange', onHide);
200
+ win.removeEventListener('pagehide', pageLeave);
201
+ },
202
+ };
203
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ReportingEventRow } from '../tables.js';
2
- import type { AlertFinding, Logger, Reporting } from '../types.js';
2
+ import type { AlertFinding, Logger, Reporting, TrackInput } from '../types.js';
3
3
  /**
4
4
  * An in-memory `Reporting` for a host's unit tests: same validation, same
5
5
  * shape, no database. `rows` is the table; `alerts` is what `onAlert` would
@@ -8,6 +8,10 @@ import type { AlertFinding, Logger, Reporting } from '../types.js';
8
8
  export interface FakeReporting extends Reporting {
9
9
  readonly rows: ReportingEventRow[];
10
10
  readonly alerts: AlertFinding[];
11
+ /** What `analytics.track` received. */
12
+ readonly tracked: (TrackInput & {
13
+ site: string;
14
+ })[];
11
15
  reset(): void;
12
16
  }
13
17
  export declare function createFakeReporting(opts?: {
@@ -8,6 +8,7 @@ export function createFakeReporting(opts = {}) {
8
8
  const log = opts.log ?? silent;
9
9
  const rows = [];
10
10
  const alerts = [];
11
+ const tracked = [];
11
12
  let settings = { ...DEFAULT_SETTINGS };
12
13
  let id = 0;
13
14
  const fake = {
@@ -16,6 +17,7 @@ export function createFakeReporting(opts = {}) {
16
17
  tables,
17
18
  rows,
18
19
  alerts,
20
+ tracked,
19
21
  event(input) {
20
22
  const v = eventInputSchema.parse(input);
21
23
  id += 1;
@@ -88,6 +90,20 @@ export function createFakeReporting(opts = {}) {
88
90
  return { before, after: settings, by, changed };
89
91
  },
90
92
  },
93
+ analytics: {
94
+ async track(input) {
95
+ tracked.push({ ...input, site });
96
+ },
97
+ async series() {
98
+ return [];
99
+ },
100
+ async weekly() {
101
+ return [];
102
+ },
103
+ async recent() {
104
+ return [];
105
+ },
106
+ },
91
107
  stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length }),
92
108
  reset() {
93
109
  rows.length = 0;
@@ -40,6 +40,10 @@ export interface TaskContext {
40
40
  renew(): Promise<boolean>;
41
41
  /** The run must finish, or stop scheduling work, by this instant. */
42
42
  readonly deadline: Date;
43
+ /** The task's completed-history watermark as claimed, or null. */
44
+ readonly watermark: string | null;
45
+ /** Moves the watermark, fenced by the claim token; false when the lease was lost. Pass a transaction to commit it with the work it vouches for. */
46
+ commitWatermark(value: string, tx?: Db): Promise<boolean>;
43
47
  }
44
48
  export interface HousekeepingOptions {
45
49
  reporting: Reporting;
@@ -59,3 +63,4 @@ export interface Housekeeping {
59
63
  }
60
64
  export declare function createHousekeeping(options: HousekeepingOptions): Housekeeping;
61
65
  export { pruneEvents, retentionLag } from './tasks.js';
66
+ export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
@@ -51,10 +51,19 @@ export function createHousekeeping(options) {
51
51
  .where(sql `${reportingTasks.task} = ${task.name}
52
52
  and ${reportingTasks.nextDueAt} <= now()
53
53
  and (${reportingTasks.leaseExpiresAt} is null or ${reportingTasks.leaseExpiresAt} < now())`)
54
- .returning({ token: reportingTasks.claimToken });
55
- return claimed[0]?.token ?? null;
54
+ .returning({ token: reportingTasks.claimToken, watermark: reportingTasks.watermark });
55
+ const row = claimed[0];
56
+ return row?.token ? { token: row.token, watermark: row.watermark ?? null } : null;
56
57
  });
57
58
  }
59
+ async function commitWatermark(task, token, value, handle) {
60
+ const rows = await handle
61
+ .update(reportingTasks)
62
+ .set({ watermark: value })
63
+ .where(sql `${reportingTasks.task} = ${task.name} and ${reportingTasks.claimToken} = ${token}`)
64
+ .returning({ task: reportingTasks.task });
65
+ return rows.length === 1;
66
+ }
58
67
  async function renew(task, token) {
59
68
  const rows = await db
60
69
  .update(reportingTasks)
@@ -78,7 +87,7 @@ export function createHousekeeping(options) {
78
87
  })
79
88
  .where(sql `${reportingTasks.task} = ${task.name} and ${reportingTasks.claimToken} = ${token}`);
80
89
  }
81
- async function runClaimed(task, token, settings) {
90
+ async function runClaimed(task, token, watermark, settings) {
82
91
  const started = now();
83
92
  const deadline = new Date(started.getTime() + task.lease);
84
93
  let timer;
@@ -95,6 +104,8 @@ export function createHousekeeping(options) {
95
104
  now,
96
105
  renew: () => renew(task, token),
97
106
  deadline,
107
+ watermark,
108
+ commitWatermark: (value, tx) => commitWatermark(task, token, value, tx ?? db),
98
109
  }),
99
110
  timeout,
100
111
  ]);
@@ -123,10 +134,10 @@ export function createHousekeeping(options) {
123
134
  }
124
135
  }
125
136
  async function runOne(task, settings) {
126
- const token = await claim(task);
127
- if (!token)
137
+ const claimed = await claim(task);
138
+ if (!claimed)
128
139
  return 'skipped';
129
- return runClaimed(task, token, settings);
140
+ return runClaimed(task, claimed.token, claimed.watermark, settings);
130
141
  }
131
142
  async function tickNow() {
132
143
  await ensureRows();
@@ -197,3 +208,4 @@ export function createHousekeeping(options) {
197
208
  };
198
209
  }
199
210
  export { pruneEvents, retentionLag } from './tasks.js';
211
+ export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
package/dist/index.d.ts CHANGED
@@ -3,7 +3,11 @@ export * from './schema.js';
3
3
  export * from './tables.js';
4
4
  export * from './types.js';
5
5
  export { alertKind, projectDetail } from './alerts.js';
6
- export { DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
6
+ export { CONSENT_MODES, DEFAULT_SETTINGS, RETENTION_BOUNDS } from './settings.js';
7
+ export * from './analytics/schema.js';
8
+ export { createCollector, type CollectorOptions, type Sites } from './analytics/collector.js';
9
+ export { analyticsRecent, analyticsSeries, analyticsWeekly, hideSmallGroups, type Grain, type RecentEvent, type RecentOptions, type SeriesOptions, type SeriesPoint, type WeeklyPoint, } from './analytics/reader.js';
10
+ export type { AnalyticsRow } from './analytics/write.js';
7
11
  /**
8
12
  * The host's one instance, built once over its drizzle handle and its
9
13
  * logger. Framework-neutral: `defer` is how the host says "after this unit of