@iann29/rastro 0.5.0 → 0.6.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.
Files changed (70) hide show
  1. package/README.md +26 -9
  2. package/agent/integration.md +40 -19
  3. package/agent/manifest.json +13 -7
  4. package/agent/manifest.schema.json +18 -8
  5. package/dist/client/federation.d.ts +18 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +7 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/identity.d.ts +11 -0
  10. package/dist/client/identity.d.ts.map +1 -0
  11. package/dist/client/identity.js +123 -0
  12. package/dist/client/identity.js.map +1 -0
  13. package/dist/client/index.d.ts +242 -5
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +102 -3
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +2 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +87 -0
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/ingest.d.ts.map +1 -1
  23. package/dist/component/ingest.js +30 -19
  24. package/dist/component/ingest.js.map +1 -1
  25. package/dist/component/people.d.ts +79 -0
  26. package/dist/component/people.d.ts.map +1 -0
  27. package/dist/component/people.js +249 -0
  28. package/dist/component/people.js.map +1 -0
  29. package/dist/component/reports.d.ts +10 -4
  30. package/dist/component/reports.d.ts.map +1 -1
  31. package/dist/component/reports.js +9 -5
  32. package/dist/component/reports.js.map +1 -1
  33. package/dist/component/schema.d.ts +39 -0
  34. package/dist/component/schema.js +30 -1
  35. package/dist/component/schema.js.map +1 -1
  36. package/dist/component/validators.d.ts +50 -0
  37. package/dist/component/validators.d.ts.map +1 -1
  38. package/dist/component/validators.js +27 -0
  39. package/dist/component/validators.js.map +1 -1
  40. package/dist/component/visitors.d.ts +38 -2
  41. package/dist/component/visitors.d.ts.map +1 -1
  42. package/dist/component/visitors.js +162 -42
  43. package/dist/component/visitors.js.map +1 -1
  44. package/dist/react/index.d.ts +9 -5
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +36 -5
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/tracker/generated.d.ts +6 -6
  49. package/dist/tracker/generated.d.ts.map +1 -1
  50. package/dist/tracker/generated.js +6 -6
  51. package/dist/tracker/generated.js.map +1 -1
  52. package/dist/tracker/tracker.d.ts +2 -1
  53. package/dist/tracker/tracker.d.ts.map +1 -1
  54. package/dist/tracker/tracker.js +41 -2
  55. package/dist/tracker/tracker.js.map +1 -1
  56. package/dist/tracker.min.js +1 -1
  57. package/docs/federation.md +24 -0
  58. package/docs/identity.md +307 -0
  59. package/docs/upgrading.md +58 -20
  60. package/llms.txt +6 -2
  61. package/package.json +5 -3
  62. package/src/component/_generated/api.ts +2 -0
  63. package/src/component/_generated/component.ts +99 -0
  64. package/src/component/ingest.ts +46 -27
  65. package/src/component/people.ts +321 -0
  66. package/src/component/reports.ts +11 -4
  67. package/src/component/schema.ts +35 -0
  68. package/src/component/validators.ts +49 -0
  69. package/src/component/visitors.ts +232 -55
  70. package/src/tracker/generated.ts +6 -6
package/docs/upgrading.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Upgrading Amage Rastro
2
2
 
3
+ ## People, attributes and identity continuity (0.6.0)
4
+
5
+ Install `@iann29/rastro@0.6.0` and deploy the host through its normal workflow.
6
+
7
+ - Additive `visitorProfiles` and `visitorAttributes` storage; no event/session
8
+ rewrite.
9
+ - Attributes are optional project-defined strings, numbers and booleans. Name,
10
+ email and attribute searches use indexes with bounded pagination.
11
+ - `identifyVisitor`, `identifyVisitorMutation` and authenticated
12
+ `identifyVisitorHttpAction` support any backend authentication. Existing
13
+ `linkVisitor` remains available.
14
+ - Re-export `getVisitorProfile` and `listPeople` from federation and refresh the
15
+ connection to advertise `visitorProfiles` and `people`. The dashboard
16
+ tolerates older hosts. Its Pessoas view lists profiles, filters attributes and
17
+ opens history.
18
+ - For profiles created before the directory, call the owner-authorized
19
+ `backfillVisitorProfiles({siteId})` until `isDone` is true (at most 64
20
+ profiles per call). This initializes search fields and attribute indexes;
21
+ timestamps and event history are preserved. Fresh installations need no
22
+ backfill.
23
+ - Funnels continue across linked pre-login and account IDs, including queued
24
+ alias events. Historical aggregates are not recomputed; steps must still
25
+ arrive in order.
26
+ - Map connections between routes in the same column bend around intermediate
27
+ nodes instead of drawing through them.
28
+ - Tracker `identify`/`reset` rotate sessions on login/logout without a reload;
29
+ `context` now includes `identified`. `RastroScript` synchronizes identity
30
+ changes and forwards native load/error events.
31
+ - The reference example's auth entrypoint is now
32
+ `analyticsIdentity:identifyVisitor`; configure its server-owned
33
+ `RASTRO_IDENTITY_SITE_ID`. Its optional HTTP route also requires
34
+ `RASTRO_IDENTITY_SERVER_KEY`.
35
+ - The tracker budget is now below 1,700 bytes gzip including identity switching.
36
+
37
+ See [the complete identity guide](identity.md) for integration and deployment
38
+ order.
39
+
3
40
  Pin exact versions, read the changelog, and rehearse upgrades against a
4
41
  non-production snapshot before changing a maintained deployment. `0.1.0` is the
5
42
  first release without a prerelease tag; the alphas below it stay documented for
@@ -7,26 +44,27 @@ hosts still on them.
7
44
 
8
45
  ## Release matrix
9
46
 
10
- | Current state | Required path | Success condition |
11
- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
12
- | Fresh deployment | Install the intended release directly | Component deploys and `/rastro/health` succeeds |
13
- | `0.1.0-alpha.0` with maintained data | Install `0.1.0-alpha.1` first and wait for staged visitor-index backfills | Every staged index reports complete before installing `alpha.2` |
14
- | `0.1.0-alpha.1` | Wait for staged visitor-index backfills, then install `alpha.2` | Backfills complete before active readers deploy |
15
- | `0.1.0-alpha.2` with legacy `events` rows | Stay on `alpha.2` and run/resume migration | Durable success plus empty source |
16
- | `0.1.0-alpha.3` fresh/canonical deployment | Rehearse the next alpha normally | Schema, reports, tracker, and retention checks pass |
17
- | `0.1.0-alpha.3` through `alpha.5` with canonical data | Install `0.1.0-alpha.6` directly; Web Vitals tables are additive | Schema deploys and `/rastro/health` reports `webVitals: "optIn"` |
18
- | `0.1.0-alpha.6` | Install `0.1.0-alpha.7` directly; no schema change | `GET /rastro/tracker.js` contains `sentAt` |
19
- | `0.1.0-alpha.7` | Install `0.1.0-alpha.8` directly; site map tables are additive; re-export `siteMap` | `/rastro/health` reports `siteMap: "dailyRollup"` |
20
- | `0.1.0-alpha.8` | Install `0.1.0-alpha.10` directly; no schema change; the tracker changes | `/rastro/health` reports `liveDepartures: "leaveBeacon"` |
21
- | `0.1.0-alpha.9` | Install `0.1.0-alpha.10` directly; `alpha.9` is deprecated — it rejected its own leave beacons | The final page-close `POST /rastro/events` returns 200 |
22
- | `0.1.0-alpha.10` | Install `0.1.0-alpha.12` directly; new tables and fields are additive; re-export the configure functions to opt in | `/rastro/health` reports `tracker.version: "0.1.0-alpha.12"` |
23
- | `0.1.0-alpha.11` | Install `0.1.0-alpha.12` directly; same code, the package now ships `src/tracker/generated.ts` | A `convex/` test importing `@iann29/rastro/test` typechecks on push |
24
- | `0.1.0-alpha.12` | Install `0.1.0` directly; every schema change is an optional field or a new table; add `analytics:public` to a grant only to allow public links | `/rastro/health` reports `tracker.version: "0.1.0"` |
25
- | `0.1.0` | Install `0.2.0` directly; add the optional host callback and grant version to enable transfers | Health reports `0.2.0`; opted-in connectors advertise `transfer` |
26
- | `0.2.0` | Install `0.3.0` directly; no new table; export `getSession` and `revenueSummary` from the federation module | Health reports `0.3.0`; the manifest lists `sessionDetails` |
27
- | `0.3.0` | Install `0.4.0` directly; no re-export; the deploy rebuilds the `reportRollupQueue` index and the tracker changes | Health reports `0.4.0`; a hidden tab leaves **Ao vivo** in ~10 s |
28
- | `0.4.0` | Install `0.5.0` directly; every schema change is an optional field or a new table; export `listTrackedLinks`, `createTrackedLink`, `updateTrackedLink` and `deleteTrackedLink` from the federation module; reverify — the manifest advertises `trackedLinks` | Health reports `0.5.0`; the manifest lists `trackedLinks` |
29
- | Any release whose packed declarations lack federation | Stop and request an eligible exact registry release | Packed declarations contain the federation exports |
47
+ | Current state | Required path | Success condition |
48
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
49
+ | Fresh deployment | Install the intended release directly | Component deploys and `/rastro/health` succeeds |
50
+ | `0.1.0-alpha.0` with maintained data | Install `0.1.0-alpha.1` first and wait for staged visitor-index backfills | Every staged index reports complete before installing `alpha.2` |
51
+ | `0.1.0-alpha.1` | Wait for staged visitor-index backfills, then install `alpha.2` | Backfills complete before active readers deploy |
52
+ | `0.1.0-alpha.2` with legacy `events` rows | Stay on `alpha.2` and run/resume migration | Durable success plus empty source |
53
+ | `0.1.0-alpha.3` fresh/canonical deployment | Rehearse the next alpha normally | Schema, reports, tracker, and retention checks pass |
54
+ | `0.1.0-alpha.3` through `alpha.5` with canonical data | Install `0.1.0-alpha.6` directly; Web Vitals tables are additive | Schema deploys and `/rastro/health` reports `webVitals: "optIn"` |
55
+ | `0.1.0-alpha.6` | Install `0.1.0-alpha.7` directly; no schema change | `GET /rastro/tracker.js` contains `sentAt` |
56
+ | `0.1.0-alpha.7` | Install `0.1.0-alpha.8` directly; site map tables are additive; re-export `siteMap` | `/rastro/health` reports `siteMap: "dailyRollup"` |
57
+ | `0.1.0-alpha.8` | Install `0.1.0-alpha.10` directly; no schema change; the tracker changes | `/rastro/health` reports `liveDepartures: "leaveBeacon"` |
58
+ | `0.1.0-alpha.9` | Install `0.1.0-alpha.10` directly; `alpha.9` is deprecated — it rejected its own leave beacons | The final page-close `POST /rastro/events` returns 200 |
59
+ | `0.1.0-alpha.10` | Install `0.1.0-alpha.12` directly; new tables and fields are additive; re-export the configure functions to opt in | `/rastro/health` reports `tracker.version: "0.1.0-alpha.12"` |
60
+ | `0.1.0-alpha.11` | Install `0.1.0-alpha.12` directly; same code, the package now ships `src/tracker/generated.ts` | A `convex/` test importing `@iann29/rastro/test` typechecks on push |
61
+ | `0.1.0-alpha.12` | Install `0.1.0` directly; every schema change is an optional field or a new table; add `analytics:public` to a grant only to allow public links | `/rastro/health` reports `tracker.version: "0.1.0"` |
62
+ | `0.1.0` | Install `0.2.0` directly; add the optional host callback and grant version to enable transfers | Health reports `0.2.0`; opted-in connectors advertise `transfer` |
63
+ | `0.2.0` | Install `0.3.0` directly; no new table; export `getSession` and `revenueSummary` from the federation module | Health reports `0.3.0`; the manifest lists `sessionDetails` |
64
+ | `0.3.0` | Install `0.4.0` directly; no re-export; the deploy rebuilds the `reportRollupQueue` index and the tracker changes | Health reports `0.4.0`; a hidden tab leaves **Ao vivo** in ~10 s |
65
+ | `0.4.0` | Install `0.5.0` directly; every schema change is an optional field or a new table; export `listTrackedLinks`, `createTrackedLink`, `updateTrackedLink` and `deleteTrackedLink` from the federation module; reverify — the manifest advertises `trackedLinks` | Health reports `0.5.0`; the manifest lists `trackedLinks` |
66
+ | `0.5.0` | Install `0.6.0`; export `listPeople` and `getVisitorProfile`, deploy, and reverify. Use the host identity resolver to populate profiles. | Health reports `0.6.0`; the manifest lists `people` and `visitorProfiles` |
67
+ | Any release whose packed declarations lack federation | Stop and request an eligible exact registry release | Packed declarations contain the federation exports |
30
68
 
31
69
  Do not upgrade a populated legacy deployment directly to a release that removes
32
70
  the `events` table. Schema acceptance alone does not prove that telemetry was
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Amage Rastro
2
2
 
3
- > Privacy-first, real-time web analytics for Convex. Telemetry stays in each product's Convex or Synapse deployment; the central dashboard reads only an explicitly granted report surface, and configures a site only when its host's grant opts into the configure scope.
3
+ > Real-time product and web analytics: identified people, journeys, conversions and retention on Convex. Telemetry stays in each product's Convex or Synapse deployment; the central dashboard reads only an explicitly granted report surface, and configures a site only when its host's grant opts into the configure scope.
4
4
 
5
5
  Canonical documentation: https://www.amagerastro.com/docs/
6
6
  Machine manifest: https://www.amagerastro.com/agent/manifest.json
@@ -16,7 +16,7 @@ Do not infer federation availability from this website, a dist-tag, or a source
16
16
  ## Architecture
17
17
 
18
18
  - Tracker and telemetry: browser -> customer HTTP-actions URL -> customer Rastro component.
19
- - The tracker is 2,594 bytes raw and 1,395 gzip. It sends the click-ID key **names** of the landing URL in `clid` (`gclid`, `fbclid`, `ttclid`…), at most eight, never a value, and the referrer as scheme and host, so an `android-app://<package>` referrer keeps its package.
19
+ - The tracker is 2,999 bytes raw and 1,565 gzip. It sends the click-ID key **names** of the landing URL in `clid` (`gclid`, `fbclid`, `ttclid`…), at most eight, never a value, and the referrer as scheme and host, so an `android-app://<package>` referrer keeps its package.
20
20
  - Tracked links (0.5.0+): browser -> customer HTTP-actions URL `/rastro/l/<slug>` -> `302` (`Cache-Control: no-store`) to a destination on one of the site's domains, with `utm_source` (platform), `utm_medium` (channel), `utm_campaign` (campaign, or the slug) and `utm_content` (slug) appended; the destination's own query and `utm_*` keys stay. Unknown or disabled slug: `404`. Clicks count in four-shard daily counters; self-declared bots are redirected and counted apart.
21
21
  - Optional field Web Vitals (alpha.6+): a second browser module at `/rastro/vitals.js` reports LCP, CLS, INP, FCP, and TTFB to the same events endpoint, into bounded daily histograms; it needs the base tracker on the page.
22
22
  - Reactive dashboard reads: amagerastro.com browser -> customer functions URL -> `rastroFederation:*`.
@@ -76,3 +76,7 @@ Do not infer federation availability from this website, a dist-tag, or a source
76
76
  - `dataCoverage` echoes the authorized site ID but returns no event, visitor, or session IDs.
77
77
  - `vitalsReport` returns p75, averages, and exact good/needs-improvement/poor counts per metric, page, and device — never individual measurements.
78
78
  - `siteMap` returns per-route totals (pageviews, estimated visitors, entries, derived exits and bounces, time on page) and route-to-route transition counts, with numeric, UUID, and long hexadecimal segments collapsed to `:id` — never sessions, visitor IDs, or query strings. `liveVisitors` adds `previousPath` and `movedAt` when a visitor moves between routes, and since 0.1.0 the intent facts of the session — `entryPath`, `returning`, `utmCampaign`, `affiliateSlug`, `conversionCount`, and the furthest `funnel` step — which `visitorHeat` from the package turns into the dashboard's hot/warm/cold score.
79
+
80
+ ## People and identity (0.6.0+)
81
+
82
+ Use the [identity guide](https://www.amagerastro.com/docs/reference/identity.md) for Better Auth, custom Convex auth and authenticated HTTP from other backends. `Rastro.identifyVisitorMutation` resolves the current user on the host; `identifyVisitorHttpAction` authorizes external servers. `getVisitorProfile` requires reader access and is advertised as `visitorProfiles`. `listPeople` adds the optional `people` directory with indexed name/email/ID prefix search and exact filters on project-defined string, number or boolean attributes. Existing profiles need the bounded host-authorized search backfill described in the guide. The browser applies the returned ID with `identify` and calls `reset` on logout.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@iann29/rastro",
3
- "description": "Privacy-first, real-time web analytics for Convex: live visitor atlas, journeys, revenue, goals, funnels, affiliates, and a 1.4 KB tracker.",
3
+ "description": "Real-time product and web analytics for Convex: identified people, journeys, revenue, goals, funnels, affiliates, and a lightweight browser tracker.",
4
4
  "repository": "github:amageweb/amage-rastro",
5
5
  "homepage": "https://github.com/amageweb/amage-rastro#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/amageweb/amage-rastro/issues"
8
8
  },
9
- "version": "0.5.0",
9
+ "version": "0.6.0",
10
10
  "license": "Apache-2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -16,7 +16,7 @@
16
16
  "convex-component",
17
17
  "analytics",
18
18
  "realtime",
19
- "privacy",
19
+ "product-analytics",
20
20
  "cookieless",
21
21
  "session-journey",
22
22
  "conversion-tracking",
@@ -46,6 +46,7 @@
46
46
  "typecheck": "tsc --noEmit && tsc -p control-plane && tsc -p control-plane/convex && tsc -p example/convex",
47
47
  "lint": "eslint .",
48
48
  "test": "vitest run --typecheck",
49
+ "test:layout": "playwright test -c control-plane/playwright.layout.config.ts",
49
50
  "test:watch": "vitest --typecheck --clearScreen false",
50
51
  "test:debug": "vitest --inspect-brk --no-file-parallelism",
51
52
  "test:coverage": "vitest run --coverage --coverage.reporter=text",
@@ -68,6 +69,7 @@
68
69
  "docs/federation.md",
69
70
  "docs/federation-setup.md",
70
71
  "docs/upgrading.md",
72
+ "docs/identity.md",
71
73
  "docs/benchmarks/*.md",
72
74
  "assets/rastro-live-atlas.png"
73
75
  ],
@@ -25,6 +25,7 @@ import type * as ingest from "../ingest.js";
25
25
  import type * as live from "../live.js";
26
26
  import type * as localTime from "../localTime.js";
27
27
  import type * as origin from "../origin.js";
28
+ import type * as people from "../people.js";
28
29
  import type * as platforms from "../platforms.js";
29
30
  import type * as reports from "../reports.js";
30
31
  import type * as retention from "../retention.js";
@@ -63,6 +64,7 @@ const fullApi: ApiFromModules<{
63
64
  live: typeof live;
64
65
  localTime: typeof localTime;
65
66
  origin: typeof origin;
67
+ people: typeof people;
66
68
  platforms: typeof platforms;
67
69
  reports: typeof reports;
68
70
  retention: typeof retention;
@@ -241,6 +241,46 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
241
241
  Name
242
242
  >;
243
243
  };
244
+ people: {
245
+ backfillSearch: FunctionReference<
246
+ "mutation",
247
+ "internal",
248
+ { siteId: string },
249
+ { isDone: boolean; updated: number },
250
+ Name
251
+ >;
252
+ list: FunctionReference<
253
+ "query",
254
+ "internal",
255
+ {
256
+ filter?:
257
+ | { field: "name" | "email" | "visitorId"; prefix: string }
258
+ | {
259
+ field: "attribute";
260
+ key: string;
261
+ value: string | number | boolean;
262
+ };
263
+ paginationOpts: { cursor: string | null; numItems: number };
264
+ siteId: string;
265
+ },
266
+ {
267
+ continueCursor: string;
268
+ isDone: boolean;
269
+ page: Array<{
270
+ _creationTime: number;
271
+ _id: string;
272
+ attributes?: Record<string, string | number | boolean>;
273
+ email?: string;
274
+ identifiedAt: number;
275
+ name?: string;
276
+ siteId: string;
277
+ updatedAt: number;
278
+ visitorId: string;
279
+ }>;
280
+ },
281
+ Name
282
+ >;
283
+ };
244
284
  reports: {
245
285
  affiliatesReport: FunctionReference<
246
286
  "query",
@@ -415,6 +455,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
415
455
  os: string;
416
456
  pageviewCount: number;
417
457
  platform?: string;
458
+ profile?: {
459
+ attributes?: Record<string, string | number | boolean>;
460
+ email?: string;
461
+ identifiedAt: number;
462
+ name?: string;
463
+ updatedAt: number;
464
+ visitorId: string;
465
+ };
418
466
  referrer?: string;
419
467
  revenueCents: number;
420
468
  sessionId: string;
@@ -575,6 +623,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
575
623
  os: string;
576
624
  pageviewCount: number;
577
625
  platform?: string;
626
+ profile?: {
627
+ attributes?: Record<string, string | number | boolean>;
628
+ email?: string;
629
+ identifiedAt: number;
630
+ name?: string;
631
+ updatedAt: number;
632
+ visitorId: string;
633
+ };
578
634
  referrer?: string;
579
635
  revenueCents: number;
580
636
  sessionId: string;
@@ -617,6 +673,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
617
673
  pageviewCount: number;
618
674
  path: string;
619
675
  previousPath?: string;
676
+ profile?: {
677
+ attributes?: Record<string, string | number | boolean>;
678
+ email?: string;
679
+ identifiedAt: number;
680
+ name?: string;
681
+ updatedAt: number;
682
+ visitorId: string;
683
+ };
620
684
  returning?: boolean;
621
685
  sessionId: string;
622
686
  siteId: string;
@@ -1292,6 +1356,41 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1292
1356
  >;
1293
1357
  };
1294
1358
  visitors: {
1359
+ getProfile: FunctionReference<
1360
+ "query",
1361
+ "internal",
1362
+ { siteId: string; visitorId: string },
1363
+ {
1364
+ attributes?: Record<string, string | number | boolean>;
1365
+ email?: string;
1366
+ identifiedAt: number;
1367
+ name?: string;
1368
+ updatedAt: number;
1369
+ visitorId: string;
1370
+ } | null,
1371
+ Name
1372
+ >;
1373
+ identify: FunctionReference<
1374
+ "mutation",
1375
+ "internal",
1376
+ {
1377
+ attributes?: Record<string, string | number | boolean | null>;
1378
+ email?: string | null;
1379
+ name?: string | null;
1380
+ previousVisitorId?: string;
1381
+ siteId: string;
1382
+ visitorId: string;
1383
+ },
1384
+ {
1385
+ attributes?: Record<string, string | number | boolean>;
1386
+ email?: string;
1387
+ identifiedAt: number;
1388
+ name?: string;
1389
+ updatedAt: number;
1390
+ visitorId: string;
1391
+ },
1392
+ Name
1393
+ >;
1295
1394
  link: FunctionReference<
1296
1395
  "mutation",
1297
1396
  "internal",
@@ -89,6 +89,7 @@ import {
89
89
  } from "./rollupStore.js";
90
90
  import { localDayStart, localDayTimezone } from "./localTime.js";
91
91
  import { classifyOrigin, platformDimensionValue } from "./origin.js";
92
+ import { resolveVisitorIdentities } from "./visitors.js";
92
93
 
93
94
  type DimensionType =
94
95
  | "source"
@@ -217,6 +218,7 @@ type BatchState = {
217
218
  CachedRow<CachedDocument<"visitorAttributions">>
218
219
  >;
219
220
  funnelProgress: Map<string, CachedRow<CachedDocument<"funnelProgress">>>;
221
+ funnelIdentities: Map<string, string[]>;
220
222
  eventBatches: EventBatchBuffer;
221
223
  liveSweepSites: Set<string>;
222
224
  };
@@ -286,6 +288,7 @@ export const ingestBatch = mutation({
286
288
  liveSessions: new Map(),
287
289
  visitorAttributions: new Map(),
288
290
  funnelProgress: new Map(),
291
+ funnelIdentities: new Map(),
289
292
  eventBatches: new Map(),
290
293
  liveSweepSites: new Set(),
291
294
  };
@@ -1927,46 +1930,62 @@ async function advanceFunnel(
1927
1930
  reportRollupDeltas: ReportRollupDeltas,
1928
1931
  batchState?: BatchState,
1929
1932
  ): Promise<FunnelReach | undefined> {
1930
- const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1933
+ let identities = batchState?.funnelIdentities.get(event.visitorId);
1934
+ if (!identities) {
1935
+ identities = await resolveVisitorIdentities(ctx, siteId, event.visitorId);
1936
+ for (const id of identities)
1937
+ batchState?.funnelIdentities.set(id, identities);
1938
+ }
1939
+ const cacheKey = JSON.stringify([funnel._id, identities[0]]);
1931
1940
  let progressState = batchState?.funnelProgress.get(cacheKey);
1932
- if (batchState && !progressState) {
1933
- const current = await ctx.db
1934
- .query("funnelProgress")
1935
- .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1936
- range
1937
- .eq("siteId", siteId)
1938
- .eq("funnelId", funnel._id)
1939
- .eq("visitorId", event.visitorId),
1940
- )
1941
- .unique();
1941
+ if (!progressState) {
1942
+ const candidates = await Promise.all(
1943
+ identities.map((visitorId) =>
1944
+ ctx.db
1945
+ .query("funnelProgress")
1946
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1947
+ range
1948
+ .eq("siteId", siteId)
1949
+ .eq("funnelId", funnel._id)
1950
+ .eq("visitorId", visitorId),
1951
+ )
1952
+ .unique(),
1953
+ ),
1954
+ );
1955
+ // Reuse the furthest attempt still inside its window. Already-counted
1956
+ // attempts are not replayed or merged into historical aggregates.
1957
+ const current =
1958
+ candidates
1959
+ .filter((row) => row !== null)
1960
+ .sort((a, b) => {
1961
+ const activeA =
1962
+ event.timestamp - a.startedAt <= funnel.conversionWindowMs;
1963
+ const activeB =
1964
+ event.timestamp - b.startedAt <= funnel.conversionWindowMs;
1965
+ return (
1966
+ Number(activeB) - Number(activeA) ||
1967
+ (activeA ? b.currentStep - a.currentStep : 0) ||
1968
+ b.lastStepAt - a.lastStepAt
1969
+ );
1970
+ })[0] ?? null;
1942
1971
  progressState = { current, dirty: false };
1943
- batchState.funnelProgress.set(cacheKey, progressState);
1972
+ batchState?.funnelProgress.set(cacheKey, progressState);
1944
1973
  }
1945
- const progress = progressState
1946
- ? progressState.current
1947
- : await ctx.db
1948
- .query("funnelProgress")
1949
- .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1950
- range
1951
- .eq("siteId", siteId)
1952
- .eq("funnelId", funnel._id)
1953
- .eq("visitorId", event.visitorId),
1954
- )
1955
- .unique();
1974
+ const progress = progressState.current;
1956
1975
  const matchesFirst = stepMatches(funnel.steps[0], event);
1957
1976
  if (!progress) {
1958
1977
  if (!matchesFirst) return;
1959
1978
  const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
1960
1979
  siteId,
1961
1980
  funnelId: funnel._id,
1962
- visitorId: event.visitorId,
1981
+ visitorId: identities[0],
1963
1982
  sessionId: event.sessionId,
1964
1983
  currentStep: 1,
1965
1984
  startedAt: event.timestamp,
1966
1985
  lastStepAt: event.timestamp,
1967
1986
  };
1968
1987
  const progressId = await ctx.db.insert("funnelProgress", fields);
1969
- if (progressState) {
1988
+ if (batchState) {
1970
1989
  progressState.current = {
1971
1990
  ...fields,
1972
1991
  _id: progressId,
@@ -1997,7 +2016,7 @@ async function advanceFunnel(
1997
2016
  lastStepAt: event.timestamp,
1998
2017
  };
1999
2018
  delete updated.completedAt;
2000
- if (progressState) {
2019
+ if (batchState) {
2001
2020
  progressState.current = updated;
2002
2021
  progressState.dirty = true;
2003
2022
  } else {
@@ -2031,7 +2050,7 @@ async function advanceFunnel(
2031
2050
  lastStepAt: event.timestamp,
2032
2051
  ...(completed ? { completedAt: event.timestamp } : {}),
2033
2052
  };
2034
- if (progressState) {
2053
+ if (batchState) {
2035
2054
  progressState.current = { ...progress, ...updates };
2036
2055
  progressState.dirty = true;
2037
2056
  } else {