@wtfalch/reporting 0.3.1 → 0.4.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.
@@ -0,0 +1,142 @@
1
+ -- @wtfalch/reporting 0.4.0: per-tenant retention overrides. Mirrors src/tables.ts.
2
+ --
3
+ -- Retention is one setting per deployed app today (settings.ts:
4
+ -- events.retention_days, analytics.retention_days), all-or-nothing. A
5
+ -- multi-tenant company app the factory stamps may need a longer window for
6
+ -- one customer's contract (gap issue #11) without moving the default for
7
+ -- everyone else.
8
+ --
9
+ -- A separate table, not a row in reporting_settings: that table's key is
10
+ -- its whole primary key, with no tenant dimension, and giving it one would
11
+ -- change every existing key's shape. `reporting_tenant_settings` is the
12
+ -- same idea -- one JSONB value per key, an operator's edit, a plain read on
13
+ -- every housekeeping run -- keyed by (tenant_id, key) instead. Clearing an
14
+ -- override never deletes the row (same "nothing that deletes" convention
15
+ -- as reporting_settings): it sets the value to JSON null, and the prune
16
+ -- functions below treat that exactly like no row at all. Every statement is
17
+ -- idempotent.
18
+
19
+ create table if not exists reporting_tenant_settings (
20
+ tenant_id uuid not null,
21
+ key text not null,
22
+ value jsonb not null,
23
+ updated_at timestamptz not null default now(),
24
+ updated_by text,
25
+ constraint reporting_tenant_settings_pkey primary key (tenant_id, key),
26
+ -- Only the two retention keys this release supports; unlike
27
+ -- reporting_settings_key_check this is not a namespace pattern; there is
28
+ -- exactly one thing this table does.
29
+ constraint reporting_tenant_settings_key_check
30
+ check (key in ('events.retention_days', 'analytics.retention_days')),
31
+ -- JSON null (an explicit "no override") or a number in the same bounds
32
+ -- settings.ts's RETENTION_BOUNDS enforces in TypeScript.
33
+ constraint reporting_tenant_settings_value_check check (
34
+ jsonb_typeof(value) = 'null'
35
+ or (jsonb_typeof(value) = 'number' and (value #>> '{}')::numeric between 7 and 400)
36
+ )
37
+ );
38
+
39
+ -- ---------------------------------------------------- reporting_prune_events
40
+ -- Same clamping and batching as before; the per-row window now checks this
41
+ -- tenant's override first, falling back to the site-wide one. A row with no
42
+ -- tenant_id (most of them) never joins, so it always uses the default --
43
+ -- unaffected by this migration until an operator sets an override.
44
+ create or replace function reporting_prune_events(retention interval, batch integer)
45
+ returns integer
46
+ language plpgsql
47
+ security definer
48
+ set search_path = pg_catalog, public
49
+ as $$
50
+ declare
51
+ w interval := greatest(interval '7 days', least(coalesce(retention, interval '30 days'), interval '400 days'));
52
+ b integer := greatest(1, least(coalesce(batch, 5000), 5000));
53
+ n integer;
54
+ begin
55
+ with victims as (
56
+ select e.id
57
+ from reporting_events e
58
+ left join reporting_tenant_settings ts
59
+ on ts.tenant_id = e.tenant_id and ts.key = 'events.retention_days'
60
+ where e.occurred_at < now() - (
61
+ case when jsonb_typeof(ts.value) = 'number'
62
+ then greatest(interval '7 days', least((ts.value #>> '{}')::numeric * interval '1 day', interval '400 days'))
63
+ else w
64
+ end
65
+ )
66
+ order by e.occurred_at, e.id
67
+ limit b
68
+ )
69
+ delete from reporting_events ev using victims v where ev.id = v.id;
70
+ get diagnostics n = row_count;
71
+ return n;
72
+ end
73
+ $$;
74
+ revoke all on function reporting_prune_events(interval, integer) from public;
75
+
76
+ -- -------------------------------------------------- reporting_prune_analytics
77
+ -- Same shape: a tenant's own retention_days overrides the site-wide window,
78
+ -- still bounded by safe_before so pruning never outruns the rollups
79
+ -- (addendum A3) -- the per-tenant window is clamped the same 7-400 days
80
+ -- before comparison, so an override cannot defeat that guarantee either.
81
+ create or replace function reporting_prune_analytics(retention interval, batch integer, safe_before date)
82
+ returns integer
83
+ language plpgsql
84
+ security definer
85
+ set search_path = pg_catalog, public
86
+ as $$
87
+ declare
88
+ w interval := greatest(interval '7 days', least(coalesce(retention, interval '90 days'), interval '400 days'));
89
+ b integer := greatest(1, least(coalesce(batch, 5000), 5000));
90
+ cutoff timestamptz := now() - w;
91
+ n integer;
92
+ begin
93
+ if safe_before is null then
94
+ raise exception 'reporting_prune_analytics: the rollups have not completed a day yet; nothing is safe to prune';
95
+ end if;
96
+ if cutoff >= (safe_before::timestamp at time zone 'UTC') then
97
+ raise exception 'reporting_prune_analytics: cutoff % is not before the rollups'' watermark %', cutoff, safe_before;
98
+ end if;
99
+ with victims as (
100
+ select a.id
101
+ from reporting_analytics a
102
+ left join reporting_tenant_settings ts
103
+ on ts.tenant_id = a.tenant_id and ts.key = 'analytics.retention_days'
104
+ where a.occurred_at < least(
105
+ case when jsonb_typeof(ts.value) = 'number'
106
+ then now() - greatest(interval '7 days', least((ts.value #>> '{}')::numeric * interval '1 day', interval '400 days'))
107
+ else cutoff
108
+ end,
109
+ (safe_before::timestamp at time zone 'UTC')
110
+ )
111
+ order by a.occurred_at, a.id
112
+ limit b
113
+ )
114
+ delete from reporting_analytics an using victims v where an.id = v.id;
115
+ get diagnostics n = row_count;
116
+ return n;
117
+ end
118
+ $$;
119
+ revoke all on function reporting_prune_analytics(interval, integer, date) from public;
120
+
121
+ -- The runtime role reads and edits overrides (an operator's settings page),
122
+ -- same shape as reporting_settings: select, insert, update, nothing that
123
+ -- deletes -- clearing an override is a value update, not a row removal.
124
+ --
125
+ -- CREATE OR REPLACE FUNCTION keeps the two prune functions' existing grants
126
+ -- (their signatures are unchanged from 0001/0002); the two re-grants below
127
+ -- are a no-op on a host that already has them and a safety net on one
128
+ -- whose grants somehow drifted, cheap either way.
129
+ do $$
130
+ declare
131
+ rt text := current_database() || '_rt';
132
+ begin
133
+ if exists (select 1 from pg_roles where rolname = rt) then
134
+ execute format('revoke delete, truncate on reporting_tenant_settings from %I', rt);
135
+ execute format('grant execute on function reporting_prune_events(interval, integer) to %I', rt);
136
+ execute format(
137
+ 'grant execute on function reporting_prune_analytics(interval, integer, date) to %I',
138
+ rt
139
+ );
140
+ end if;
141
+ end
142
+ $$;
@@ -0,0 +1,50 @@
1
+ import type { Reporting } from '../types.js';
2
+ import type { NextOnRequestError } from './index.js';
3
+ export interface EdgeErrorReport {
4
+ readonly kind: string;
5
+ readonly message: string;
6
+ readonly stack: string | null;
7
+ readonly release: string | null;
8
+ readonly requestId: string;
9
+ }
10
+ export interface EdgeCaptureOptions {
11
+ /** Where `edgeErrorIngestHandler` (./index.js) is mounted; same-origin path or an absolute URL. */
12
+ readonly endpoint: string;
13
+ /** Must match `EdgeErrorIngestOptions.secret` on the receiving end. */
14
+ readonly secret: string;
15
+ readonly release?: string | null;
16
+ /** Bounds how long a slow or hung node route can hold this up. Default 2000ms. */
17
+ readonly timeoutMs?: number;
18
+ /** For tests: replaces the global `fetch`. */
19
+ readonly fetch?: typeof fetch;
20
+ }
21
+ /**
22
+ * Binds to Next's `onRequestError` for the edge runtime. Builds the report
23
+ * from the error alone -- no `db`, no `Reporting` instance needed -- and
24
+ * POSTs it to the node route.
25
+ *
26
+ * Awaited, not fire-and-forget: `NextOnRequestError` may return a promise
27
+ * exactly so a hook like this can be waited on, and an edge isolate is free
28
+ * to be torn down the instant this function returns -- a POST left running
29
+ * behind an unawaited call is a coin flip on whether it ever leaves the
30
+ * process. `timeoutMs` bounds the wait so a hung node route cannot hold
31
+ * Next's own error handling open indefinitely. Never throws back into Next,
32
+ * the same rule `errorHandler` (./index.js) already keeps.
33
+ */
34
+ export declare function edgeErrorHandler(options: EdgeCaptureOptions): NextOnRequestError;
35
+ export interface EdgeErrorIngestOptions {
36
+ readonly reporting: Reporting;
37
+ /** Must match `EdgeCaptureOptions.secret` on the sending end. */
38
+ readonly secret: string;
39
+ }
40
+ /**
41
+ * The node half of the pair: not a public ingest like `clientErrorHandler`'s
42
+ * (no origin allowance, no rate limiter, no batching) because it is not
43
+ * meant to be reachable except by this app's own edge runtime holding the
44
+ * shared secret. A request missing or failing that check is dropped the
45
+ * same silent way every ingest route in this package answers a bad one --
46
+ * always 204, so a prober learns nothing.
47
+ */
48
+ export declare function createEdgeErrorIngestCollector(options: EdgeErrorIngestOptions): {
49
+ handle(request: Request): Promise<Response>;
50
+ };
@@ -0,0 +1,131 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ /**
4
+ * The edge runtime has no database connection (docs/plans/errors.md never
5
+ * covered it; `errorHandler`/`captureError` assume one). `errorHandler`
6
+ * (./index.js) still gets called there -- `NEXT_RUNTIME` says `edge` -- but
7
+ * a `Reporting` built over a driver that needs a TCP socket cannot even be
8
+ * constructed in that runtime, so the capture it tries to defer never runs.
9
+ *
10
+ * The fix is not to teach the edge runtime to write Postgres. It is to keep
11
+ * capture edge-safe: serialise the error and hand it to a node route
12
+ * handler over `fetch`, the one thing both runtimes share, and let that
13
+ * route -- which does have a `db` -- call `captureError` for real.
14
+ * `edgeErrorHandler` is the edge half; `createEdgeErrorIngestCollector`
15
+ * (wrapped as `edgeErrorIngestHandler` in ./index.js) is the node half.
16
+ *
17
+ * The shared `secret` is what stops a stranger from writing rows into
18
+ * `reporting_errors` through this route: unlike `clientErrorHandler`, whose
19
+ * public ingest is meant for any browser to call, this one exists only for
20
+ * this app's own edge runtime to call, and has no session or origin to
21
+ * check instead.
22
+ */
23
+ const RAY_PATTERN = /^[0-9a-f]{16}-[A-Z0-9]{3,6}$/;
24
+ function headerValue(dict, name) {
25
+ const lower = name.toLowerCase();
26
+ for (const key of Object.keys(dict)) {
27
+ if (key.toLowerCase() !== lower)
28
+ continue;
29
+ const value = dict[key];
30
+ return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Binds to Next's `onRequestError` for the edge runtime. Builds the report
36
+ * from the error alone -- no `db`, no `Reporting` instance needed -- and
37
+ * POSTs it to the node route.
38
+ *
39
+ * Awaited, not fire-and-forget: `NextOnRequestError` may return a promise
40
+ * exactly so a hook like this can be waited on, and an edge isolate is free
41
+ * to be torn down the instant this function returns -- a POST left running
42
+ * behind an unawaited call is a coin flip on whether it ever leaves the
43
+ * process. `timeoutMs` bounds the wait so a hung node route cannot hold
44
+ * Next's own error handling open indefinitely. Never throws back into Next,
45
+ * the same rule `errorHandler` (./index.js) already keeps.
46
+ */
47
+ export function edgeErrorHandler(options) {
48
+ const doFetch = options.fetch ?? fetch;
49
+ return async (error, request) => {
50
+ try {
51
+ const ray = headerValue(request.headers, 'cf-ray');
52
+ const requestId = ray && RAY_PATTERN.test(ray) ? ray : randomUUID();
53
+ const body = {
54
+ kind: error instanceof Error ? error.name || 'Error' : 'Error',
55
+ message: error instanceof Error
56
+ ? error.message || error.name || 'Error'
57
+ : typeof error === 'string'
58
+ ? error
59
+ : 'unknown edge error',
60
+ stack: error instanceof Error && typeof error.stack === 'string' ? error.stack : null,
61
+ release: options.release ?? null,
62
+ requestId,
63
+ };
64
+ await doFetch(options.endpoint, {
65
+ method: 'POST',
66
+ headers: {
67
+ 'content-type': 'application/json',
68
+ 'x-reporting-edge-secret': options.secret,
69
+ },
70
+ body: JSON.stringify(body),
71
+ signal: AbortSignal.timeout(options.timeoutMs ?? 2000),
72
+ });
73
+ }
74
+ catch {
75
+ // Never throw back into Next: see errorHandler's own doc comment. A
76
+ // failed or timed-out forward just means this occurrence is lost.
77
+ }
78
+ };
79
+ }
80
+ const reportSchema = z.object({
81
+ kind: z.string().min(1),
82
+ message: z.string().min(1),
83
+ stack: z.string().nullish(),
84
+ release: z.string().nullish(),
85
+ requestId: z.string().min(1),
86
+ });
87
+ const NO_CONTENT = () => new Response(null, { status: 204 });
88
+ /** Constant-time so a network trace of response latency cannot narrow the secret. */
89
+ function secretMatches(provided, expected) {
90
+ const a = Buffer.from(provided);
91
+ const b = Buffer.from(expected);
92
+ return a.length === b.length && timingSafeEqual(a, b);
93
+ }
94
+ /**
95
+ * The node half of the pair: not a public ingest like `clientErrorHandler`'s
96
+ * (no origin allowance, no rate limiter, no batching) because it is not
97
+ * meant to be reachable except by this app's own edge runtime holding the
98
+ * shared secret. A request missing or failing that check is dropped the
99
+ * same silent way every ingest route in this package answers a bad one --
100
+ * always 204, so a prober learns nothing.
101
+ */
102
+ export function createEdgeErrorIngestCollector(options) {
103
+ async function handle(request) {
104
+ if (request.method !== 'POST')
105
+ return NO_CONTENT();
106
+ const provided = request.headers.get('x-reporting-edge-secret');
107
+ if (!provided || !secretMatches(provided, options.secret))
108
+ return NO_CONTENT();
109
+ let json;
110
+ try {
111
+ json = await request.json();
112
+ }
113
+ catch {
114
+ return NO_CONTENT();
115
+ }
116
+ const parsed = reportSchema.safeParse(json);
117
+ if (!parsed.success)
118
+ return NO_CONTENT();
119
+ const { kind, message, stack, release, requestId } = parsed.data;
120
+ const err = new Error(message);
121
+ err.stack = stack ?? undefined;
122
+ options.reporting.captureError(err, {
123
+ kind,
124
+ runtime: 'edge',
125
+ release: release ?? null,
126
+ requestId,
127
+ });
128
+ return NO_CONTENT();
129
+ }
130
+ return { handle };
131
+ }
@@ -2,6 +2,8 @@ import { type CollectorOptions } from '../analytics/collector.js';
2
2
  import { deviceOf } from '../analytics/schema.js';
3
3
  import type { Reporting } from '../types.js';
4
4
  import { type ClientErrorCollectorOptions } from './client-errors.js';
5
+ import { type EdgeErrorIngestOptions } from './edge-errors.js';
6
+ export { edgeErrorHandler, type EdgeCaptureOptions, type EdgeErrorReport } from './edge-errors.js';
5
7
  /**
6
8
  * The Next bindings: everything in the package that knows what a request is.
7
9
  * Peer on `next`; a host without Next never imports this entry.
@@ -88,6 +90,14 @@ export declare function errorHandler(reporting: Reporting): NextOnRequestError;
88
90
  * origin allowance and why identity is never the body's.
89
91
  */
90
92
  export declare function clientErrorHandler(options: ClientErrorCollectorOptions): (request: Request) => Promise<Response>;
93
+ /**
94
+ * The node half of `edgeErrorHandler` (./edge-errors.js): mount as `POST` of
95
+ * an internal route (docs/plans/errors.md has no "Surface" entry for this,
96
+ * because it is not a public one -- `// authz: public` would be wrong here;
97
+ * the shared `secret` is the gate). This is where `reporting`'s real `db`
98
+ * lives, so this is where the edge-forwarded report finally becomes a row.
99
+ */
100
+ export declare function edgeErrorIngestHandler(options: EdgeErrorIngestOptions): (request: Request) => Promise<Response>;
91
101
  /** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
92
102
  export declare function trackContext(): Promise<{
93
103
  device: ReturnType<typeof deviceOf>;
@@ -5,6 +5,8 @@ import { after, connection } from 'next/server';
5
5
  import { createCollector } from '../analytics/collector.js';
6
6
  import { countryOf, deviceOf } from '../analytics/schema.js';
7
7
  import { createClientErrorCollector } from './client-errors.js';
8
+ import { createEdgeErrorIngestCollector } from './edge-errors.js';
9
+ export { edgeErrorHandler } from './edge-errors.js';
8
10
  /**
9
11
  * The Next bindings: everything in the package that knows what a request is.
10
12
  * Peer on `next`; a host without Next never imports this entry.
@@ -132,6 +134,17 @@ export function clientErrorHandler(options) {
132
134
  const collector = createClientErrorCollector(options);
133
135
  return (request) => collector.handle(request);
134
136
  }
137
+ /**
138
+ * The node half of `edgeErrorHandler` (./edge-errors.js): mount as `POST` of
139
+ * an internal route (docs/plans/errors.md has no "Surface" entry for this,
140
+ * because it is not a public one -- `// authz: public` would be wrong here;
141
+ * the shared `secret` is the gate). This is where `reporting`'s real `db`
142
+ * lives, so this is where the edge-forwarded report finally becomes a row.
143
+ */
144
+ export function edgeErrorIngestHandler(options) {
145
+ const collector = createEdgeErrorIngestCollector(options);
146
+ return (request) => collector.handle(request);
147
+ }
135
148
  /** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
136
149
  export async function trackContext() {
137
150
  try {
package/dist/reader.js CHANGED
@@ -19,6 +19,8 @@ export async function eventsPage(db, opts = {}) {
19
19
  conditions.push(eq(reportingEvents.site, opts.site));
20
20
  if (opts.requestId)
21
21
  conditions.push(eq(reportingEvents.requestId, opts.requestId));
22
+ if (opts.environment)
23
+ conditions.push(eq(reportingEvents.environment, opts.environment));
22
24
  if (opts.after) {
23
25
  const { occurredAt, id } = opts.after;
24
26
  const keyset = or(lt(reportingEvents.occurredAt, occurredAt), and(eq(reportingEvents.occurredAt, occurredAt), lt(reportingEvents.id, id)));
package/dist/schema.d.ts CHANGED
@@ -90,4 +90,14 @@ export declare const eventInputSchema: z.ZodObject<{
90
90
  export type EventInput = z.input<typeof eventInputSchema>;
91
91
  export type ValidEvent = z.output<typeof eventInputSchema>;
92
92
  export declare const siteSchema: z.ZodString;
93
+ /**
94
+ * Deploy target, not per-event: stamped once on `ReportingOptions.environment`,
95
+ * the same as `site`, because a preview or stage deploy is a whole process
96
+ * running with that value, not a claim an individual event makes (gap issue
97
+ * #6). Unlike `runtime` or `level`, the set of names is not fixed -- a host
98
+ * may run `production`/`stage`/`preview`, or its own scheme -- so this only
99
+ * keeps it slug-shaped.
100
+ */
101
+ export declare const ENVIRONMENT_PATTERN: RegExp;
102
+ export declare const environmentSchema: z.ZodString;
93
103
  export declare const fingerprintSchema: z.ZodString;
package/dist/schema.js CHANGED
@@ -90,6 +90,18 @@ export const eventInputSchema = z.strictObject({
90
90
  export const siteSchema = z
91
91
  .string()
92
92
  .regex(SITE_PATTERN, 'a site id is lower case, digits and dashes');
93
+ /**
94
+ * Deploy target, not per-event: stamped once on `ReportingOptions.environment`,
95
+ * the same as `site`, because a preview or stage deploy is a whole process
96
+ * running with that value, not a claim an individual event makes (gap issue
97
+ * #6). Unlike `runtime` or `level`, the set of names is not fixed -- a host
98
+ * may run `production`/`stage`/`preview`, or its own scheme -- so this only
99
+ * keeps it slug-shaped.
100
+ */
101
+ export const ENVIRONMENT_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
102
+ export const environmentSchema = z
103
+ .string()
104
+ .regex(ENVIRONMENT_PATTERN, 'an environment is lower case, digits, dashes and underscores');
93
105
  export const fingerprintSchema = z
94
106
  .string()
95
107
  .regex(FINGERPRINT_PATTERN, 'a fingerprint is 32 lower-case hex characters');
@@ -14,3 +14,24 @@ export declare const DEFAULT_SETTINGS: Settings;
14
14
  export declare function getSettings(db: Db): Promise<Settings>;
15
15
  /** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
16
16
  export declare function setSettings(db: Db, patch: Partial<Settings>, by: Actor): Promise<SettingsChange>;
17
+ /**
18
+ * A multi-tenant company app may need a longer or shorter retention window
19
+ * for one customer's contract without moving the site-wide default (gap
20
+ * issue #11): `reporting_tenant_settings`, keyed by (tenant_id, key), one
21
+ * JSONB value the same shape `getSettings`/`setSettings` already parse.
22
+ * `reporting_prune_events`/`reporting_prune_analytics` (0006_tenant_settings.sql)
23
+ * check this table for the row they are about to prune before falling back
24
+ * to the site-wide setting.
25
+ */
26
+ export declare const TENANT_RETENTION_KEYS: readonly ["events.retention_days", "analytics.retention_days"];
27
+ export type TenantRetentionKey = (typeof TENANT_RETENTION_KEYS)[number];
28
+ /** Only the keys this tenant has overridden; a key absent here means "use the site-wide default". */
29
+ export declare function getTenantRetention(db: Db, tenantId: string): Promise<Partial<Record<TenantRetentionKey, number>>>;
30
+ /**
31
+ * Sets a tenant's override, or clears it back to the site-wide default when
32
+ * `days` is `null` -- clearing never deletes the row, same "nothing that
33
+ * deletes" convention as `reportingSettings`: it stores the JSON `null`
34
+ * `reporting_prune_events`/`reporting_prune_analytics` already treat as "no
35
+ * override". Throws on an out-of-bounds value: the caller is a form.
36
+ */
37
+ export declare function setTenantRetention(db: Db, tenantId: string, key: TenantRetentionKey, days: number | null, by: Actor): Promise<void>;
package/dist/settings.js CHANGED
@@ -1,6 +1,6 @@
1
- import { sql } from 'drizzle-orm';
1
+ import { eq, sql } from 'drizzle-orm';
2
2
  import { z } from 'zod';
3
- import { reportingSettings } from './tables.js';
3
+ import { reportingSettings, reportingTenantSettings } from './tables.js';
4
4
  /**
5
5
  * Operator-edited settings, one row per key, read by housekeeping on every
6
6
  * run. Bounds are enforced here and again inside `reporting_prune_events`,
@@ -75,3 +75,54 @@ export async function setSettings(db, patch, by) {
75
75
  return { before, after, by, changed };
76
76
  });
77
77
  }
78
+ /**
79
+ * A multi-tenant company app may need a longer or shorter retention window
80
+ * for one customer's contract without moving the site-wide default (gap
81
+ * issue #11): `reporting_tenant_settings`, keyed by (tenant_id, key), one
82
+ * JSONB value the same shape `getSettings`/`setSettings` already parse.
83
+ * `reporting_prune_events`/`reporting_prune_analytics` (0006_tenant_settings.sql)
84
+ * check this table for the row they are about to prune before falling back
85
+ * to the site-wide setting.
86
+ */
87
+ export const TENANT_RETENTION_KEYS = ['events.retention_days', 'analytics.retention_days'];
88
+ /** Only the keys this tenant has overridden; a key absent here means "use the site-wide default". */
89
+ export async function getTenantRetention(db, tenantId) {
90
+ const rows = await db
91
+ .select({ key: reportingTenantSettings.key, value: reportingTenantSettings.value })
92
+ .from(reportingTenantSettings)
93
+ .where(eq(reportingTenantSettings.tenantId, tenantId));
94
+ const out = {};
95
+ for (const row of rows) {
96
+ if (typeof row.value === 'number' && Number.isInteger(row.value)) {
97
+ out[row.key] = row.value;
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ /**
103
+ * Sets a tenant's override, or clears it back to the site-wide default when
104
+ * `days` is `null` -- clearing never deletes the row, same "nothing that
105
+ * deletes" convention as `reportingSettings`: it stores the JSON `null`
106
+ * `reporting_prune_events`/`reporting_prune_analytics` already treat as "no
107
+ * override". Throws on an out-of-bounds value: the caller is a form.
108
+ */
109
+ export async function setTenantRetention(db, tenantId, key, days, by) {
110
+ if (days !== null) {
111
+ const parsed = retention.safeParse(days);
112
+ if (!parsed.success) {
113
+ throw new Error(`reporting.tenantSettings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`);
114
+ }
115
+ }
116
+ // The column is `jsonb not null`, and a JS `null` value maps to a SQL
117
+ // NULL parameter, not the JSON literal `null` -- an explicit cast is the
118
+ // only way to store "no override" as the value the CHECK and the prune
119
+ // functions both expect, rather than violate the NOT NULL constraint.
120
+ const value = days === null ? sql `'null'::jsonb` : sql `to_jsonb(${days}::int)`;
121
+ await db
122
+ .insert(reportingTenantSettings)
123
+ .values({ tenantId, key, value, updatedBy: `${by.class}:${by.id}` })
124
+ .onConflictDoUpdate({
125
+ target: [reportingTenantSettings.tenantId, reportingTenantSettings.key],
126
+ set: { value, updatedAt: sql `now()`, updatedBy: `${by.class}:${by.id}` },
127
+ });
128
+ }