@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.
package/dist/tables.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { sql } from 'drizzle-orm';
2
- import { bigint, index, jsonb, pgTable, smallint, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
2
+ import { bigint, date, index, integer, jsonb, pgTable, primaryKey, smallint, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
3
3
  /**
4
4
  * The three tables, mirrored from `migrations/0001_reporting.sql`, which is
5
5
  * the source of truth: the SQL carries the CHECKs, the partial indexes, the
@@ -48,6 +48,8 @@ export const reportingTasks = pgTable('reporting_tasks', {
48
48
  claimToken: uuid('claim_token'),
49
49
  lastOutcome: text('last_outcome'),
50
50
  lastError: text('last_error'),
51
+ /** The rollups' completed-history watermark (0002), one value per task. */
52
+ watermark: text('watermark'),
51
53
  });
52
54
  export const reportingSettings = pgTable('reporting_settings', {
53
55
  key: text('key').primaryKey(),
@@ -55,8 +57,92 @@ export const reportingSettings = pgTable('reporting_settings', {
55
57
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
56
58
  updatedBy: text('updated_by'),
57
59
  });
60
+ /** Raw analytics rows, a window's worth; mirrored from migrations/0002_analytics.sql. */
61
+ export const reportingAnalytics = pgTable('reporting_analytics', {
62
+ id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
63
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
64
+ receivedAt: timestamp('received_at', { withTimezone: true }).notNull().default(sql `now()`),
65
+ site: text('site').notNull(),
66
+ tenantId: uuid('tenant_id'),
67
+ visitorId: text('visitor_id'),
68
+ sessionId: text('session_id'),
69
+ userId: text('user_id'),
70
+ name: text('name').notNull(),
71
+ path: text('path').notNull(),
72
+ referrerHost: text('referrer_host'),
73
+ device: text('device').notNull(),
74
+ country: text('country'),
75
+ props: jsonb('props')
76
+ .$type()
77
+ .notNull()
78
+ .default(sql `'{}'::jsonb`),
79
+ schemaVersion: smallint('schema_version').notNull().default(1),
80
+ }, (t) => [
81
+ index('reporting_analytics_site_time_idx').on(t.site, t.occurredAt.desc(), t.id.desc()),
82
+ index('reporting_analytics_tenant_time_idx')
83
+ .on(t.tenantId, t.occurredAt.desc(), t.id.desc())
84
+ .where(sql `${t.tenantId} is not null`),
85
+ index('reporting_analytics_user_idx').on(t.userId).where(sql `${t.userId} is not null`),
86
+ index('reporting_analytics_site_name_time_idx').on(t.site, t.name, t.occurredAt.desc()),
87
+ ]);
88
+ /** One row per day, site, grain and dimension value; written only by reporting_rollup_day. */
89
+ export const reportingAnalyticsDaily = pgTable('reporting_analytics_daily', {
90
+ day: date('day').notNull(),
91
+ site: text('site').notNull(),
92
+ grain: text('grain').notNull(),
93
+ tenantId: uuid('tenant_id'),
94
+ name: text('name'),
95
+ path: text('path'),
96
+ device: text('device'),
97
+ country: text('country'),
98
+ referrerHost: text('referrer_host'),
99
+ views: integer('views').notNull(),
100
+ visitors: integer('visitors').notNull(),
101
+ people: integer('people').notNull(),
102
+ tenantKey: text('tenant_key').generatedAlwaysAs(sql `coalesce(tenant_id::text, '')`),
103
+ nameKey: text('name_key').generatedAlwaysAs(sql `coalesce(name, '')`),
104
+ pathKey: text('path_key').generatedAlwaysAs(sql `coalesce(path, '')`),
105
+ deviceKey: text('device_key').generatedAlwaysAs(sql `coalesce(device, '')`),
106
+ countryKey: text('country_key').generatedAlwaysAs(sql `coalesce(country, '')`),
107
+ referrerKey: text('referrer_key').generatedAlwaysAs(sql `coalesce(referrer_host, '')`),
108
+ }, (t) => [
109
+ primaryKey({
110
+ name: 'reporting_analytics_daily_pkey',
111
+ columns: [
112
+ t.day,
113
+ t.site,
114
+ t.grain,
115
+ t.tenantKey,
116
+ t.nameKey,
117
+ t.pathKey,
118
+ t.deviceKey,
119
+ t.countryKey,
120
+ t.referrerKey,
121
+ ],
122
+ }),
123
+ index('reporting_analytics_daily_site_grain_idx').on(t.site, t.grain, t.day.desc()),
124
+ ]);
125
+ /** Visitors and people per ISO week and how many returned; written only by reporting_rollup_week. */
126
+ export const reportingAnalyticsWeekly = pgTable('reporting_analytics_weekly', {
127
+ week: date('week').notNull(),
128
+ site: text('site').notNull(),
129
+ tenantId: uuid('tenant_id'),
130
+ visitors: integer('visitors').notNull(),
131
+ returningVisitors: integer('returning_visitors').notNull(),
132
+ people: integer('people').notNull(),
133
+ returningPeople: integer('returning_people').notNull(),
134
+ tenantKey: text('tenant_key').generatedAlwaysAs(sql `coalesce(tenant_id::text, '')`),
135
+ }, (t) => [
136
+ primaryKey({
137
+ name: 'reporting_analytics_weekly_pkey',
138
+ columns: [t.week, t.site, t.tenantKey],
139
+ }),
140
+ ]);
58
141
  export const tables = {
59
142
  events: reportingEvents,
60
143
  tasks: reportingTasks,
61
144
  settings: reportingSettings,
145
+ analytics: reportingAnalytics,
146
+ analyticsDaily: reportingAnalyticsDaily,
147
+ analyticsWeekly: reportingAnalyticsWeekly,
62
148
  };
package/dist/types.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { RecentEvent, RecentOptions, SeriesOptions, SeriesPoint, WeeklyPoint } from './analytics/reader.js';
3
+ import type { Device } from './analytics/schema.js';
2
4
  import type { Actor, EventInput, FlatData, Level } from './schema.js';
3
5
  import type { ReportingEventRow, tables } from './tables.js';
4
6
  /**
@@ -27,8 +29,12 @@ export interface SettingsChange {
27
29
  readonly by: Actor;
28
30
  readonly changed: readonly string[];
29
31
  }
32
+ export type ConsentMode = 'none' | 'consented' | 'always';
30
33
  export interface Settings {
31
34
  readonly 'events.retention_days': number;
35
+ readonly 'analytics.retention_days': number;
36
+ readonly 'analytics.consent': ConsentMode;
37
+ readonly 'analytics.identify_signed_in': boolean;
32
38
  }
33
39
  export interface ReportingOptions {
34
40
  db: Db;
@@ -48,6 +54,8 @@ export interface ReportingOptions {
48
54
  onAlert?: (finding: AlertFinding) => void;
49
55
  /** Bound by the host to its audit ledger; called after a settings change commits. */
50
56
  audit?: (change: SettingsChange) => Promise<void>;
57
+ /** The host's route normalisation for analytics paths (addendum A7); the package's default otherwise. */
58
+ normalisePath?: (pathname: string) => string;
51
59
  /** Queue bound; past it rows go to the logger only. Default 1,000. */
52
60
  queueLimit?: number;
53
61
  /** Rows per insert. Default 500. */
@@ -80,6 +88,34 @@ export interface Stats {
80
88
  readonly invalid: number;
81
89
  readonly flushed: number;
82
90
  }
91
+ /** A server-side analytics event: the server's truth about something a person did, with identity from the `Access`, never a claim. */
92
+ export interface TrackInput {
93
+ readonly name: string;
94
+ readonly props?: Record<string, string | number | boolean | null>;
95
+ readonly tenantId?: string | null;
96
+ /** The signed-in person's id, from the host's resolved principal. */
97
+ readonly userId?: string | null;
98
+ /** The pathname the event belongs to; normalised before storage. Default `/`. */
99
+ readonly path?: string;
100
+ readonly device?: Device;
101
+ readonly country?: string | null;
102
+ /** The consent cookie's value, when the host passes it through. */
103
+ readonly visitorId?: string | null;
104
+ readonly sessionId?: string | null;
105
+ readonly occurredAt?: Date;
106
+ }
107
+ export interface Analytics {
108
+ /** Never throws in production: a failed insert is a log line. */
109
+ track(input: TrackInput): Promise<void>;
110
+ series(opts: SeriesOptions): Promise<readonly SeriesPoint[]>;
111
+ weekly(opts: {
112
+ site: string;
113
+ tenantId?: string;
114
+ from: string;
115
+ to: string;
116
+ }): Promise<readonly WeeklyPoint[]>;
117
+ recent(opts: RecentOptions): Promise<readonly RecentEvent[]>;
118
+ }
83
119
  export interface Reporting {
84
120
  /** Never throws in production; see writer.ts. */
85
121
  event(input: EventInput): void;
@@ -99,6 +135,7 @@ export interface Reporting {
99
135
  readonly tables: typeof tables;
100
136
  readonly site: string;
101
137
  readonly log: Logger;
138
+ readonly analytics: Analytics;
102
139
  stats(): Stats;
103
140
  }
104
141
  export type { Actor, EventInput, FlatData, Level };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/reporting",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
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
5
  "repository": {
6
6
  "type": "git",
@@ -30,6 +30,18 @@
30
30
  "types": "./dist/fake/index.d.ts",
31
31
  "default": "./dist/fake/index.js"
32
32
  },
33
+ "./browser": {
34
+ "types": "./dist/browser/index.d.ts",
35
+ "default": "./dist/browser/index.js"
36
+ },
37
+ "./react": {
38
+ "types": "./dist/react/index.d.ts",
39
+ "default": "./dist/react/index.js"
40
+ },
41
+ "./react/charts": {
42
+ "types": "./dist/react/charts.d.ts",
43
+ "default": "./dist/react/charts.js"
44
+ },
33
45
  "./migrations/*.sql": "./dist/migrations/*.sql",
34
46
  "./package.json": "./package.json"
35
47
  },
@@ -49,6 +61,7 @@
49
61
  "peerDependencies": {
50
62
  "drizzle-orm": ">=0.39.0",
51
63
  "next": ">=16.0.0",
64
+ "react": ">=19.0.0",
52
65
  "zod": "^4.0.0"
53
66
  },
54
67
  "peerDependenciesMeta": {
@@ -59,6 +72,7 @@
59
72
  "devDependencies": {
60
73
  "@electric-sql/pglite": "^0.5.8",
61
74
  "@types/node": "^22",
75
+ "@types/react": "^19",
62
76
  "drizzle-orm": "^0.39.3",
63
77
  "next": "^16.2.4",
64
78
  "postgres": "^3.4.5",