wiki-formant 0.27.1 → 0.29.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 CHANGED
@@ -148,7 +148,7 @@ handler: async (args, ctx) => {
148
148
  }
149
149
  ```
150
150
 
151
- `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope.
151
+ `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
152
152
 
153
153
  ### Tool arguments and the corpus tool
154
154
 
@@ -213,6 +213,13 @@ export async function GET(request: Request) {
213
213
  }
214
214
  ```
215
215
 
216
+ `corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
217
+
218
+ ```ts
219
+ const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true } });
220
+ corpusValidatorsFrom([depth, agg._count], [agg._max.updatedAt]);
221
+ ```
222
+
216
223
  ## Pagination and versioning
217
224
 
218
225
  `parsePagination` clamps `page ≥ 1` and `pageSize` to 1–100; `paginatedResponse` always carries `totalPages`. Reshaping that response is a breaking change to every client that pages, which is why it lives here rather than being re-typed per repo.
@@ -311,14 +318,6 @@ import Link from 'next/link';
311
318
 
312
319
  There is no `next` peer dependency here and there is not going to be one, but a rail that falls back to a bare `<a>` turns every press into a full page load — which is the whole reason a wiki has a persistent rail. So the router arrives through the door.
313
320
 
314
- ## Payment gating
315
-
316
- `wiki-formant/x402` gates an MCP envelope on payment, for a wiki that charges for a bulk export over HTTP and would otherwise hand the same bytes over free through the equivalent MCP tool. Payment travels in-band — `params._meta["x402/payment"]` in, `result._meta["x402/payment-response"]` out — because one HTTP 402 cannot answer a batch of twenty in which one call is priced and nineteen are not.
317
-
318
- `gatePaidCalls` returns what `handleMcp` should dispatch and a `finish` that puts the answer back together. The ordering is the part worth owning once: verify before dispatch, settle only after a non-error answer, splice the challenges back into the caller's original order. A tool that raises cancels rather than settles — the caller pays for an answer, not an attempt.
319
-
320
- It imports nothing. `@x402/core`, `@x402/evm`, `@x402/extensions`, `@x402/next` and `viem` are optional peers, and the resource server arrives as a structural port instead of an import, so a wiki that never imports this subpath never resolves any of them. Which surfaces cost what, the prices and the terms text stay with the caller: those are policy, and this is the envelope surgery.
321
-
322
321
  ## AI crawlers
323
322
 
324
323
  Every wiki kept this roster twice — once in the proxy, keyed by user-agent, to
@@ -490,6 +489,27 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
490
489
 
491
490
  The dispatch over a repo's own block union stays in that repo; the bodies come from here. `wiki-formant/text` has a prose body for every core leaf — `statsToText`, `linkGridToText` and `pageListToText` joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
492
491
 
492
+ The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
493
+
494
+ ```ts
495
+ import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
496
+ import { coreAtomicValidator } from 'wiki-formant/validation';
497
+
498
+ switch (block.type) {
499
+ case 'content': case 'codeTabs': case 'banner': case 'references':
500
+ case 'stats': case 'linkGrid': case 'recentPages': case 'pageList':
501
+ return coreAtomicToMarkdown(block, { siteUrl: SITE_URL, referencesTitle: 'Sources' });
502
+ case 'corpusStats':
503
+ return block.resolvedHtml ? htmlToMarkdown(block.resolvedHtml) : '';
504
+ }
505
+
506
+ const BLOCK_DEFAULTS = { ...coreBlockDefaults({ stats }), daoTimeline: () => ({ type: 'daoTimeline', limit: 25 }) };
507
+ const core = coreAtomicValidator({ pageId: id => /^\d{1,9}$/.test(id) });
508
+ // validateAtomic: b => { switch (b.type) { case 'daoTimeline': …; default: return core(b); } }
509
+ ```
510
+
511
+ `coreAtomicToMarkdown` joins a resolved page's site-relative `href` to `siteUrl`, because a twin is read off-site; a wiki whose lists hold its own page rows keeps those two cases. `coreAtomicValidator` caps `recentPages.limit` and `pageIds` at `MAX_BLOCK_ROWS`, a whole number of rows, because both end in a database `take:`; `blockLimit` is the same bound on the read side.
512
+
493
513
  `createBlockValidator` returns `blockIssues` beside the boolean validators: the same walk, reporting where each failure is.
494
514
 
495
515
  ```ts
@@ -504,7 +524,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
504
524
 
505
525
  ## Dates
506
526
 
507
- `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. Both are in `wiki-formant/freshness`, with `isoDate`.
527
+ `relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. It reads `en-US` unless given a `locale`: `formatDay(day, { locale: 'en-GB', month: 'long' })` is `19 September 2026`. Both are in `wiki-formant/freshness`, with `isoDate`.
508
528
 
509
529
  ## Sanitising stored HTML
510
530
 
@@ -551,27 +571,66 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
551
571
 
552
572
  ## Analytics
553
573
 
554
- Two lanes, one events helper. `mcpCallProps` reads a tool name out of a JSON-RPC
555
- envelope that is untrusted and may be a batch; `searchQueryProps` normalises a
556
- search box's free text so the same question aggregates as one row. Sending stays
557
- with the caller — `plausibleEvent` builds the request, and the framework's own
558
- deferral (`after()` in a route or server action, `event.waitUntil` in a proxy)
559
- decides when it goes, so this package keeps its zero-dependency guarantee.
574
+ Page views and events, counted in the site's own Postgres instead of a hosted
575
+ service. No cookie is set and no address is kept: a visitor is a hash of the
576
+ address and browser under a salt that is replaced every UTC day, and the old
577
+ salt is deleted, so a visitor is told apart within a day and never followed
578
+ into the next. Rows are kept 400 days. The package owns the SQL and the app
579
+ owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
580
+ placeholders, and the three tables it needs are stated as Prisma models at the
581
+ top of `src/analytics.ts`.
582
+
583
+ ```ts
584
+ // src/lib/track.ts
585
+ const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
586
+ export const { trackEvent, trackMcpCall, trackSearch, viewRoute } =
587
+ createTracker({ sql, defer: after, siteUrl: SITE_URL });
588
+
589
+ // src/app/api/view/route.ts – the whole beacon route
590
+ export const POST = viewRoute();
591
+
592
+ // the root layout, once
593
+ <Beacon />
594
+ ```
595
+
596
+ `createTracker` records only on a Vercel production deployment unless told
597
+ otherwise (`enabled`), because dev and previews write to production's
598
+ database. `trackMcpCall(request, body, server?)` fits `McpServerConfig.onCall`;
599
+ `trackSearch(headers, query, results, surface?)` takes the headers or Next's
600
+ `headers` itself, which it reads inside the deferred write. `viewRoute({ site })`
601
+ passes a site resolver to `collect`. The module that builds the tracker
602
+ imports the app's database client, so a proxy reaches it through a dynamic
603
+ `import()` inside `event.waitUntil`, never a static one.
604
+
605
+ `<Beacon>` (`wiki-formant/react`) sends a view each time the path changes,
606
+ client-side moves included, and a click on a link to another host as an
607
+ "Outbound Link: Click" event; `track(name, props)` (`wiki-formant/dom`) sends
608
+ any other browser event. Underneath the tracker, server code calls
609
+ `recordEvent` and leaves the deferral to the framework, so this package keeps
610
+ its zero-dependency guarantee:
560
611
 
561
612
  ```ts
562
613
  const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
563
614
  // null for an empty field, so a blank search cannot fire an event
564
- if (props) after(() => plausibleEvent({ domain }, 'Search Query', url, props, headers, {
565
- // a person triggered this, so it joins their session rather than landing
566
- // as the fixed bot-tracker pseudo-visitor
567
- userAgent: headers.get('user-agent') ?? undefined,
568
- }));
615
+ if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
569
616
  ```
570
617
 
618
+ `mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
619
+ and may be a batch; `searchQueryProps` normalises a search box's free text so
620
+ the same question aggregates as one row. Pass the request's headers and the
621
+ event joins whoever sent it, person or agent.
622
+
623
+ `digest(sql, { days })` reads a site back as one JSON object: visitors, page
624
+ views, bounce rate, visit length, sources (a link's `utm_source`, else the
625
+ referring host), top and entry pages, countries, devices, visitors by day, MCP
626
+ tool calls, and the searches that found nothing. A visit is a run of one
627
+ visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
628
+ key to every call and a `site` resolver to `collect`.
629
+
571
630
  Instrumenting the agent lane and not the human one is the easy mistake: it
572
631
  leaves a wiki able to say what every crawler asked for and nothing about what
573
- its readers asked for. The queries that return zero rows are the valuable ones —
574
- they name a gap in the corpus in the reader's own words.
632
+ its readers asked for. The queries that return zero rows are the valuable ones
633
+ – they name a gap in the corpus in the reader's own words.
575
634
 
576
635
  ## Base stylesheet
577
636
 
@@ -585,15 +644,23 @@ No view emits a Tailwind utility any more — `ColumnsView` states its gap and a
585
644
 
586
645
  ## API
587
646
 
588
- Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on.
589
- The emitted `.d.ts` files are the reference. There is no hand-maintained symbol list
590
- here, because the one that used to be here drifted from them.
647
+ Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on —
648
+ and there is no root export: `import … from 'wiki-formant'` does not resolve. A
649
+ barrel loads every module it names for the one symbol a route wanted, and had
650
+ already re-exported one module twice. The emitted `.d.ts` files are the reference.
651
+ There is no hand-maintained symbol list here, because the one that used to be here
652
+ drifted from them.
591
653
 
592
- The package root re-exports the runtime modules that need no peer dependency and no
593
- client boundary, so `import … from 'wiki-formant'` stays importable from a route
594
- handler with nothing else installed. Everything that reaches for React, a browser
595
- global, tiptap or a wallet — and the tooling modules, which no route imports — is
596
- subpath-only.
654
+ `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
655
+ `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
656
+ 5432 session pooler, whose fifteen-client cap a build's workers share.
657
+
658
+ `rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
659
+ caller already shown to be abusive: running dry blocks the key outright for that
660
+ long, across every bucket that names it, rather than letting the refill trickle
661
+ one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
662
+ and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
663
+ `x-real-ip` was set, since a proxy that appends hands the client that slot.
597
664
 
598
665
  ## A bin
599
666
 
@@ -605,6 +672,14 @@ npx check-classes --warn # report and exit 0
605
672
  npx check-classes --compositions # also fail on the inline compositions
606
673
  ```
607
674
 
675
+ `analytics-digest` prints a site's `digest` as JSON for a script outside the app. Run it from the app's root, where `pg` is installed and `.env` holds `DATABASE_URL`:
676
+
677
+ ```sh
678
+ npx analytics-digest --days 7
679
+ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
680
+ npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
681
+ ```
682
+
608
683
  ## Licence
609
684
 
610
685
  MIT.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // bin/analytics-digest.mjs – print one site's `digest` (`wiki-formant/analytics`)
3
+ // as JSON, for a script that reads traffic from outside the app.
4
+ //
5
+ // Run from the app's root, where `pg` is installed and `.env` holds
6
+ // DATABASE_URL (read from the environment first). The SQL is the package's, so
7
+ // a report reads the figures the app's own pages read.
8
+ //
9
+ // npx analytics-digest --days 7
10
+ // npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
11
+ // npx analytics-digest --days 7 --site <id> # a multi-tenant app's site
12
+ // npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds to the search gaps
13
+ import pg from 'pg';
14
+ import { digest, SEARCH_GAPS } from '../dist/analytics.js';
15
+
16
+ const args = process.argv.slice(2);
17
+ const arg = name => {
18
+ const i = args.indexOf(name);
19
+ return i >= 0 ? args[i + 1] : undefined;
20
+ };
21
+ const all = name => args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1]] : []));
22
+
23
+ if (!process.env.DATABASE_URL) {
24
+ try {
25
+ process.loadEnvFile('.env');
26
+ } catch {
27
+ // reported below
28
+ }
29
+ }
30
+ if (!process.env.DATABASE_URL) {
31
+ console.log(JSON.stringify({ error: 'no DATABASE_URL in the environment or ./.env' }));
32
+ process.exit(0);
33
+ }
34
+
35
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
36
+ try {
37
+ await client.connect();
38
+ const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
39
+ const result = await digest(sql, {
40
+ site: arg('--site') ?? '',
41
+ days: Number(arg('--days') ?? 7),
42
+ handle: arg('--handle'),
43
+ siblings: arg('--siblings')?.split(',').filter(Boolean) ?? [],
44
+ gaps: [
45
+ SEARCH_GAPS,
46
+ ...all('--gap').map(g => {
47
+ const [event, prop] = g.split(':');
48
+ return { event, prop };
49
+ }),
50
+ ],
51
+ });
52
+ console.log(JSON.stringify(result));
53
+ } catch (e) {
54
+ console.log(JSON.stringify({ error: e.message }));
55
+ } finally {
56
+ await client.end().catch(() => {});
57
+ }
@@ -1,36 +1,125 @@
1
- import type { HeaderReader } from './rate-limit.js';
2
- export interface PlausibleConfig {
3
- /** The hostname the Plausible property is registered under. */
4
- domain: string;
5
- /** Override for self-hosted Plausible. */
6
- endpoint?: string;
1
+ import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
2
+ /**
3
+ * Runs one statement with `$1, $2…` placeholders and resolves to its rows.
4
+ * Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
5
+ */
6
+ export type Sql = (query: string, ...values: unknown[]) => Promise<unknown>;
7
+ /** iPadOS reports itself as a Mac, so its views count as a computer's. */
8
+ export declare const deviceOf: (ua: string) => "phone" | "tablet" | "computer";
9
+ /**
10
+ * Where a visit came from: its `utm_source` when the link carried one, else the
11
+ * referring host, and null for a referrer that is this site or none.
12
+ */
13
+ export declare function sourceOf(referrer: unknown, utm: unknown, host: string): string | null;
14
+ /** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
15
+ export declare function normalisePath(path: string): string | null;
16
+ export interface ViewInput {
17
+ /** The beacon request's headers: the address, browser, host and country come from them. */
18
+ headers: HeaderReader;
19
+ path: string;
20
+ referrer?: unknown;
21
+ /** The page URL's `utm_source`. */
22
+ utm?: unknown;
23
+ site?: string;
24
+ }
25
+ /** Count one page view, unless a bot sent it. Never rejects. */
26
+ export declare function recordView(sql: Sql, { headers, path, referrer, utm, site }: ViewInput): Promise<void>;
27
+ export interface EventInput {
28
+ name: string;
29
+ props?: Record<string, string | number>;
30
+ /** The page it happened on, as a URL or a path. */
31
+ url?: string;
32
+ /** The request of whoever caused it, so the event joins their visitor. Omit for an event no visitor caused. */
33
+ headers?: HeaderReader;
34
+ site?: string;
35
+ }
36
+ /** Record one named event. Never rejects. */
37
+ export declare function recordEvent(sql: Sql, { name, props, url, headers, site }: EventInput): Promise<void>;
38
+ /** What a page sends: a view, or with `name` an event. */
39
+ export interface Beacon {
40
+ path: string;
41
+ referrer?: string;
42
+ utm?: string;
43
+ name?: string;
44
+ props?: Record<string, string | number>;
45
+ }
46
+ export interface CollectOptions {
47
+ sql: Sql;
48
+ /** `after` in a Next route: the write runs once the 204 has gone. */
49
+ defer: (task: () => Promise<unknown>) => void;
50
+ /** The site a beacon counts toward, or null to drop it. A single-site app omits it. */
51
+ site?: (request: Request, path: string) => string | null | Promise<string | null>;
52
+ rateLimit?: RateLimitOptions;
7
53
  }
8
- export interface PlausibleExtra {
9
- /** Revenue goal payload; accepted and ignored on plans without revenue goals. */
10
- revenue?: {
11
- currency: string;
12
- amount: number;
13
- };
54
+ /**
55
+ * The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
56
+ * Answers 204 whatever happens, before anything is written, so a reader never waits on it.
57
+ */
58
+ export declare function collect(request: Request, { sql, defer, site, rateLimit: limit }: CollectOptions): Promise<Response>;
59
+ export interface Gap {
60
+ event: string;
61
+ /** The prop holding the reader's words. */
62
+ prop: string;
63
+ /** Props the event must carry, e.g. `{ results: '0' }`. */
64
+ where?: Record<string, string>;
65
+ }
66
+ /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
67
+ export declare const SEARCH_GAPS: Gap;
68
+ export interface DigestOptions {
69
+ site?: string;
70
+ /** How far back, in whole days from now. */
71
+ days: number;
72
+ /** The site's own X handle: `follow_clicks` counts clicks on links to it. */
73
+ handle?: string;
74
+ /** Hosts of sibling sites: `sibling_referrals` counts the visits they sent. */
75
+ siblings?: string[];
76
+ gaps?: Gap[];
14
77
  /**
15
- * Send the visitor's own user agent so the event joins their session rather
16
- * than landing as the fixed bot-tracker pseudo-visitor. Right for an event
17
- * a person triggered; wrong for one a crawler did.
78
+ * MCP callers left out of `agent_tool_calls`, matched inside the user agent.
79
+ * The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
18
80
  */
19
- userAgent?: string;
81
+ ownAgents?: string[];
82
+ }
83
+ type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
84
+ export interface Digest {
85
+ visitors: number;
86
+ pageviews: number;
87
+ /** Percent of visits that saw one page; null with no visits. */
88
+ bounce_rate: number | null;
89
+ /** Mean seconds from a visit's first view to its last; null with no visits. */
90
+ visit_duration: number | null;
91
+ /** Visitors plus every agent that sent an event, such as an MCP call. */
92
+ visitors_incl_agents: number;
93
+ /** Visitors in the same number of days before. */
94
+ previous_visitors: number;
95
+ top_sources: Ranked<'source', 'visitors'>;
96
+ top_pages: Ranked<'page', 'visitors'>;
97
+ entry_pages: Ranked<'page', 'visitors'>;
98
+ /** Entry pages of visits from links tagged `utm_source=x`. */
99
+ from_posts: Ranked<'page', 'visitors'>;
100
+ sibling_referrals: Ranked<'source', 'visitors'>;
101
+ countries: Ranked<'country', 'visitors'>;
102
+ devices: Ranked<'device', 'visitors'>;
103
+ by_day: Ranked<'date', 'visitors'>;
104
+ follow_clicks: {
105
+ events: number;
106
+ visitors: number;
107
+ } | null;
108
+ agent_tool_calls: Ranked<'tool', 'events'>;
109
+ gaps: Ranked<'query', 'events'>;
20
110
  }
21
111
  /**
22
- * The exact request shape Plausible's events API expects.
23
- *
24
- * Never rejects: analytics must not be able to fail a response the reader is
25
- * waiting on.
112
+ * Everything a report needs about one site's last `days` days, in one query.
113
+ * Visitor figures count distinct visitors, so a person who comes back on
114
+ * another day counts once for each day.
26
115
  */
27
- export declare function plausibleEvent(config: PlausibleConfig, name: string, url: string, props: Record<string, string>, headers: HeaderReader, extra?: PlausibleExtra): Promise<unknown>;
116
+ export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
28
117
  /**
29
118
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
30
119
  *
31
120
  * The body is untrusted and may be a batch, a notification, or malformed
32
121
  * entirely, so every extraction is defensive and the result is always a
33
- * complete props object. A batch is attributed to its first member — the
122
+ * complete props object. A batch is attributed to its first member – the
34
123
  * alternative is one event per member, which would make a fan-out look like
35
124
  * traffic it is not.
36
125
  */
@@ -47,7 +136,7 @@ export interface SearchQueryInput {
47
136
  surface?: string;
48
137
  }
49
138
  /**
50
- * Props for a "Search Query" event — the human-side twin of `mcpCallProps`.
139
+ * Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
51
140
  *
52
141
  * A wiki instruments its agent surface and then counts every question an agent
53
142
  * asks while recording none of the questions a person asks. The queries that
@@ -56,9 +145,60 @@ export interface SearchQueryInput {
56
145
  *
57
146
  * Returns `null` for a query with no content, so an empty field cannot fire an
58
147
  * event. The text is untrusted, so it is collapsed, lowercased for aggregation
59
- * and truncated — the same 64-character bound search itself applies, which
148
+ * and truncated – the same 64-character bound search itself applies, which
60
149
  * keeps the prop and the query that produced it the same string.
61
150
  */
62
151
  export declare function searchQueryProps({ query, results, surface, }: SearchQueryInput): Record<string, string> | null;
63
- /** The hostname of a site URL, for the `domain` a Plausible property is filed under. */
64
- export declare function plausibleDomain(siteUrl: string | undefined, fallback: string): string;
152
+ export interface TrackerOptions {
153
+ sql: Sql;
154
+ /** `after` in Next: the write runs once the response has gone. */
155
+ defer: (task: () => Promise<unknown>) => void;
156
+ /**
157
+ * Whether anything is recorded. Default: only on a Vercel production
158
+ * deployment, because dev and previews write to production's database.
159
+ */
160
+ enabled?: boolean;
161
+ /** Where an event with no page of its own is filed: a search with no referer. Default `/`. */
162
+ siteUrl?: string;
163
+ }
164
+ /** Headers in hand, or a reader for them run inside the deferred write: Next's `headers`. */
165
+ export type HeaderSource = HeaderReader | (() => HeaderReader | Promise<HeaderReader>);
166
+ export interface Tracker {
167
+ /**
168
+ * Record one event. `headers` are the request of whoever caused it, so the
169
+ * event joins their visitor, an agent's included; omit them for an event no
170
+ * visitor caused. Never rejects, and the caller defers it: `event.waitUntil`
171
+ * in a proxy, `after` in a route.
172
+ */
173
+ trackEvent(name: string, url: string, props: Record<string, string | number>, headers?: HeaderReader): Promise<void>;
174
+ /**
175
+ * An "MCP Call" event, deferred. The signature is `McpServerConfig.onCall`'s
176
+ * plus a server name, so a one-server site passes it as `onCall` as it is.
177
+ */
178
+ trackMcpCall(request: Request, body: unknown, server?: string): void;
179
+ /**
180
+ * A "Search Query" event, deferred, filed against the page the reader
181
+ * searched from. A function for `headers` is called inside the deferred
182
+ * write, and a failure there drops the event rather than the response.
183
+ */
184
+ trackSearch(headers: HeaderSource, query: string, results: number, surface?: string): void;
185
+ /**
186
+ * The beacon route: `export const POST = tracker.viewRoute()`. `site` is
187
+ * `collect`'s resolver, for an app that serves many sites or drops a path.
188
+ */
189
+ viewRoute(opts?: Pick<CollectOptions, 'site' | 'rateLimit'>): (request: Request) => Promise<Response>;
190
+ }
191
+ /**
192
+ * Everything an app's `track.ts` had written around the functions above: the
193
+ * production gate, the deferral and the three named events. Four apps carried
194
+ * it, two with a search signature of their own.
195
+ *
196
+ * The tracker holds `sql`, so the module that builds it imports the app's
197
+ * database client. A proxy must keep reaching it through a dynamic import
198
+ * inside `waitUntil`, never a static one, or every request the proxy sees
199
+ * loads Prisma:
200
+ *
201
+ * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
202
+ */
203
+ export declare function createTracker({ sql, defer, enabled, siteUrl, }: TrackerOptions): Tracker;
204
+ export {};