@iann29/rastro 0.5.0 → 0.7.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 (80) hide show
  1. package/README.md +53 -9
  2. package/agent/integration.md +48 -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 +257 -7
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +110 -4
  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 +92 -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 +37 -24
  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 +13 -4
  30. package/dist/component/reports.d.ts.map +1 -1
  31. package/dist/component/reports.js +25 -10
  32. package/dist/component/reports.js.map +1 -1
  33. package/dist/component/sanitize.d.ts +3 -1
  34. package/dist/component/sanitize.d.ts.map +1 -1
  35. package/dist/component/sanitize.js +30 -1
  36. package/dist/component/sanitize.js.map +1 -1
  37. package/dist/component/schema.d.ts +42 -1
  38. package/dist/component/schema.js +30 -1
  39. package/dist/component/schema.js.map +1 -1
  40. package/dist/component/sites.d.ts +4 -0
  41. package/dist/component/sites.d.ts.map +1 -1
  42. package/dist/component/sites.js +13 -2
  43. package/dist/component/sites.js.map +1 -1
  44. package/dist/component/validators.d.ts +53 -1
  45. package/dist/component/validators.d.ts.map +1 -1
  46. package/dist/component/validators.js +28 -0
  47. package/dist/component/validators.js.map +1 -1
  48. package/dist/component/visitors.d.ts +38 -2
  49. package/dist/component/visitors.d.ts.map +1 -1
  50. package/dist/component/visitors.js +162 -42
  51. package/dist/component/visitors.js.map +1 -1
  52. package/dist/react/index.d.ts +9 -5
  53. package/dist/react/index.d.ts.map +1 -1
  54. package/dist/react/index.js +36 -5
  55. package/dist/react/index.js.map +1 -1
  56. package/dist/tracker/generated.d.ts +6 -6
  57. package/dist/tracker/generated.d.ts.map +1 -1
  58. package/dist/tracker/generated.js +6 -6
  59. package/dist/tracker/generated.js.map +1 -1
  60. package/dist/tracker/tracker.d.ts +2 -1
  61. package/dist/tracker/tracker.d.ts.map +1 -1
  62. package/dist/tracker/tracker.js +41 -2
  63. package/dist/tracker/tracker.js.map +1 -1
  64. package/dist/tracker.min.js +1 -1
  65. package/docs/federation.md +24 -0
  66. package/docs/identity.md +307 -0
  67. package/docs/upgrading.md +85 -20
  68. package/llms.txt +6 -2
  69. package/package.json +5 -3
  70. package/src/component/_generated/api.ts +2 -0
  71. package/src/component/_generated/component.ts +104 -0
  72. package/src/component/ingest.ts +54 -30
  73. package/src/component/people.ts +321 -0
  74. package/src/component/reports.ts +34 -9
  75. package/src/component/sanitize.ts +46 -1
  76. package/src/component/schema.ts +35 -0
  77. package/src/component/sites.ts +14 -1
  78. package/src/component/validators.ts +50 -0
  79. package/src/component/visitors.ts +232 -55
  80. package/src/tracker/generated.ts +6 -6
@@ -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;
@@ -878,6 +942,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
878
942
  };
879
943
  visitors: { basis: "estimatedUnique"; exact: boolean };
880
944
  };
945
+ routePatterns?: Record<string, Array<string>>;
881
946
  routes: Array<{
882
947
  bounces: number;
883
948
  daily: Array<{ bucketStart: number; pageviews: number }>;
@@ -1139,6 +1204,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1139
1204
  name: string;
1140
1205
  networkId?: string;
1141
1206
  ownerId: string;
1207
+ routePatterns?: Array<string>;
1142
1208
  timezone?: string;
1143
1209
  },
1144
1210
  string,
@@ -1158,6 +1224,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1158
1224
  name: string;
1159
1225
  networkId?: string;
1160
1226
  ownerId: string;
1227
+ routePatterns?: Array<string>;
1161
1228
  timezone?: string;
1162
1229
  updatedAt: number;
1163
1230
  } | null,
@@ -1177,6 +1244,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1177
1244
  name: string;
1178
1245
  networkId?: string;
1179
1246
  ownerId: string;
1247
+ routePatterns?: Array<string>;
1180
1248
  timezone?: string;
1181
1249
  updatedAt: number;
1182
1250
  }>,
@@ -1198,6 +1266,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1198
1266
  domains?: Array<string>;
1199
1267
  name?: string;
1200
1268
  networkId?: string | null;
1269
+ routePatterns?: Array<string>;
1201
1270
  siteId: string;
1202
1271
  timezone?: string | null;
1203
1272
  },
@@ -1292,6 +1361,41 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1292
1361
  >;
1293
1362
  };
1294
1363
  visitors: {
1364
+ getProfile: FunctionReference<
1365
+ "query",
1366
+ "internal",
1367
+ { siteId: string; visitorId: string },
1368
+ {
1369
+ attributes?: Record<string, string | number | boolean>;
1370
+ email?: string;
1371
+ identifiedAt: number;
1372
+ name?: string;
1373
+ updatedAt: number;
1374
+ visitorId: string;
1375
+ } | null,
1376
+ Name
1377
+ >;
1378
+ identify: FunctionReference<
1379
+ "mutation",
1380
+ "internal",
1381
+ {
1382
+ attributes?: Record<string, string | number | boolean | null>;
1383
+ email?: string | null;
1384
+ name?: string | null;
1385
+ previousVisitorId?: string;
1386
+ siteId: string;
1387
+ visitorId: string;
1388
+ },
1389
+ {
1390
+ attributes?: Record<string, string | number | boolean>;
1391
+ email?: string;
1392
+ identifiedAt: number;
1393
+ name?: string;
1394
+ updatedAt: number;
1395
+ visitorId: string;
1396
+ },
1397
+ Name
1398
+ >;
1295
1399
  link: FunctionReference<
1296
1400
  "mutation",
1297
1401
  "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
  };
@@ -579,6 +582,7 @@ export const ingestBatch = mutation({
579
582
  recordTelemetry: true,
580
583
  timezone,
581
584
  siteDomains: site.domains,
585
+ routePatterns: site.routePatterns,
582
586
  financialConversion: false,
583
587
  trustedGoalKey: undefined,
584
588
  now,
@@ -710,6 +714,7 @@ export const trackConversion = mutation({
710
714
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
711
715
  timezone: localDayTimezone(site.timezone),
712
716
  siteDomains: site.domains,
717
+ routePatterns: site.routePatterns,
713
718
  financialConversion: true,
714
719
  trustedGoalKey,
715
720
  now: Date.now(),
@@ -850,6 +855,7 @@ async function accountEvent(
850
855
  timezone?: string;
851
856
  /** The site's own domains: a referrer on them is not an origin. */
852
857
  siteDomains: string[];
858
+ routePatterns?: string[];
853
859
  financialConversion: boolean;
854
860
  trustedGoalKey: string | undefined;
855
861
  now: number;
@@ -879,6 +885,7 @@ async function accountEvent(
879
885
  event,
880
886
  session,
881
887
  input.existingSession,
888
+ input.routePatterns,
882
889
  );
883
890
  }
884
891
 
@@ -1927,46 +1934,62 @@ async function advanceFunnel(
1927
1934
  reportRollupDeltas: ReportRollupDeltas,
1928
1935
  batchState?: BatchState,
1929
1936
  ): Promise<FunnelReach | undefined> {
1930
- const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1937
+ let identities = batchState?.funnelIdentities.get(event.visitorId);
1938
+ if (!identities) {
1939
+ identities = await resolveVisitorIdentities(ctx, siteId, event.visitorId);
1940
+ for (const id of identities)
1941
+ batchState?.funnelIdentities.set(id, identities);
1942
+ }
1943
+ const cacheKey = JSON.stringify([funnel._id, identities[0]]);
1931
1944
  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();
1945
+ if (!progressState) {
1946
+ const candidates = await Promise.all(
1947
+ identities.map((visitorId) =>
1948
+ ctx.db
1949
+ .query("funnelProgress")
1950
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1951
+ range
1952
+ .eq("siteId", siteId)
1953
+ .eq("funnelId", funnel._id)
1954
+ .eq("visitorId", visitorId),
1955
+ )
1956
+ .unique(),
1957
+ ),
1958
+ );
1959
+ // Reuse the furthest attempt still inside its window. Already-counted
1960
+ // attempts are not replayed or merged into historical aggregates.
1961
+ const current =
1962
+ candidates
1963
+ .filter((row) => row !== null)
1964
+ .sort((a, b) => {
1965
+ const activeA =
1966
+ event.timestamp - a.startedAt <= funnel.conversionWindowMs;
1967
+ const activeB =
1968
+ event.timestamp - b.startedAt <= funnel.conversionWindowMs;
1969
+ return (
1970
+ Number(activeB) - Number(activeA) ||
1971
+ (activeA ? b.currentStep - a.currentStep : 0) ||
1972
+ b.lastStepAt - a.lastStepAt
1973
+ );
1974
+ })[0] ?? null;
1942
1975
  progressState = { current, dirty: false };
1943
- batchState.funnelProgress.set(cacheKey, progressState);
1976
+ batchState?.funnelProgress.set(cacheKey, progressState);
1944
1977
  }
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();
1978
+ const progress = progressState.current;
1956
1979
  const matchesFirst = stepMatches(funnel.steps[0], event);
1957
1980
  if (!progress) {
1958
1981
  if (!matchesFirst) return;
1959
1982
  const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
1960
1983
  siteId,
1961
1984
  funnelId: funnel._id,
1962
- visitorId: event.visitorId,
1985
+ visitorId: identities[0],
1963
1986
  sessionId: event.sessionId,
1964
1987
  currentStep: 1,
1965
1988
  startedAt: event.timestamp,
1966
1989
  lastStepAt: event.timestamp,
1967
1990
  };
1968
1991
  const progressId = await ctx.db.insert("funnelProgress", fields);
1969
- if (progressState) {
1992
+ if (batchState) {
1970
1993
  progressState.current = {
1971
1994
  ...fields,
1972
1995
  _id: progressId,
@@ -1997,7 +2020,7 @@ async function advanceFunnel(
1997
2020
  lastStepAt: event.timestamp,
1998
2021
  };
1999
2022
  delete updated.completedAt;
2000
- if (progressState) {
2023
+ if (batchState) {
2001
2024
  progressState.current = updated;
2002
2025
  progressState.dirty = true;
2003
2026
  } else {
@@ -2031,7 +2054,7 @@ async function advanceFunnel(
2031
2054
  lastStepAt: event.timestamp,
2032
2055
  ...(completed ? { completedAt: event.timestamp } : {}),
2033
2056
  };
2034
- if (progressState) {
2057
+ if (batchState) {
2035
2058
  progressState.current = { ...progress, ...updates };
2036
2059
  progressState.dirty = true;
2037
2060
  } else {
@@ -2388,8 +2411,9 @@ function foldRouteRollups(
2388
2411
  event: TrackerEvent,
2389
2412
  session: SessionState,
2390
2413
  existing: CachedDocument<"sessions"> | null,
2414
+ patterns?: string[],
2391
2415
  ) {
2392
- const route = normalizeRoute(event.path);
2416
+ const route = normalizeRoute(event.path, patterns);
2393
2417
  const priorPageviews = existing?.pageviewCount ?? 0;
2394
2418
  const firstPageview = existing === null || priorPageviews === 0;
2395
2419
  foldRouteDelta(
@@ -2407,7 +2431,7 @@ function foldRouteRollups(
2407
2431
  // Out-of-order telemetry keeps its view but cannot say where it came from.
2408
2432
  if (existing === null || firstPageview) return;
2409
2433
  if (event.timestamp < existing.lastSeenAt) return;
2410
- const previous = normalizeRoute(existing.exitPath);
2434
+ const previous = normalizeRoute(existing.exitPath, patterns);
2411
2435
  const lastPageviewAt = existing.lastPageviewAt;
2412
2436
  foldRouteDelta(
2413
2437
  deltas,
@@ -2427,7 +2451,7 @@ function foldRouteRollups(
2427
2451
  foldRouteDelta(
2428
2452
  deltas,
2429
2453
  siteId,
2430
- normalizeRoute(existing.entryPath),
2454
+ normalizeRoute(existing.entryPath, patterns),
2431
2455
  existing.startedAt,
2432
2456
  event.visitorId,
2433
2457
  (delta) => {
@@ -0,0 +1,321 @@
1
+ import { v } from "convex/values";
2
+ import { query, mutation, type MutationCtx } from "./_generated/server.js";
3
+ import type { Doc, Id } from "./_generated/dataModel.js";
4
+ import { fail } from "./errors.js";
5
+ import {
6
+ personFilterValidator,
7
+ visitorProfileValidator,
8
+ type PersonAttributes,
9
+ type PersonFilter,
10
+ } from "./validators.js";
11
+
12
+ export const personDocumentValidator = visitorProfileValidator.extend({
13
+ _id: v.id("visitorProfiles"),
14
+ _creationTime: v.number(),
15
+ siteId: v.id("sites"),
16
+ });
17
+
18
+ export function profileSearchFields(name?: string, email?: string) {
19
+ return {
20
+ searchName: normalizeSearch(name ?? ""),
21
+ searchEmail: normalizeSearch(email ?? ""),
22
+ };
23
+ }
24
+
25
+ function normalizeSearch(value: string) {
26
+ return value.normalize("NFKC").trim().toLowerCase();
27
+ }
28
+
29
+ export function mergePersonAttributes(
30
+ previous: PersonAttributes = {},
31
+ patch?: Record<string, string | number | boolean | null>,
32
+ ): PersonAttributes {
33
+ const attributes = { ...previous };
34
+ for (const [key, value] of Object.entries(patch ?? {})) {
35
+ validateAttributeKey(key);
36
+ if (value === null) delete attributes[key];
37
+ else {
38
+ validateAttributeValue(value);
39
+ attributes[key] = value;
40
+ }
41
+ }
42
+ if (
43
+ Object.keys(attributes).length > 32 ||
44
+ new TextEncoder().encode(JSON.stringify(attributes)).length > 4096
45
+ )
46
+ fail(
47
+ "INVALID_ARGUMENT",
48
+ "attributes allow at most 32 keys and 4096 UTF-8 bytes",
49
+ );
50
+ return Object.fromEntries(
51
+ Object.entries(attributes).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
52
+ );
53
+ }
54
+
55
+ function validateAttributeKey(key: string) {
56
+ if (
57
+ !/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key) ||
58
+ ["__proto__", "constructor", "prototype"].includes(key)
59
+ )
60
+ fail(
61
+ "INVALID_ARGUMENT",
62
+ "attribute keys must start with a letter and contain up to 64 ASCII letters, digits, dots, underscores or hyphens",
63
+ );
64
+ }
65
+
66
+ function validateAttributeValue(value: string | number | boolean) {
67
+ if (
68
+ (typeof value === "string" && value.length > 512) ||
69
+ (typeof value === "number" && !Number.isFinite(value))
70
+ )
71
+ fail(
72
+ "INVALID_ARGUMENT",
73
+ "attribute values require finite numbers or strings up to 512 characters",
74
+ );
75
+ }
76
+
77
+ /** Index each current attribute once; ordinary reads never scan all profiles. */
78
+ export async function syncPersonAttributes(
79
+ ctx: MutationCtx,
80
+ siteId: Id<"sites">,
81
+ visitorId: string,
82
+ attributes: PersonAttributes,
83
+ ) {
84
+ const rows = await ctx.db
85
+ .query("visitorAttributes")
86
+ .withIndex("by_siteId_and_visitorId", (q) =>
87
+ q.eq("siteId", siteId).eq("visitorId", visitorId),
88
+ )
89
+ .take(33);
90
+ const present = new Set<string>();
91
+ for (const row of rows) {
92
+ present.add(row.key);
93
+ if (!Object.hasOwn(attributes, row.key))
94
+ await ctx.db.delete("visitorAttributes", row._id);
95
+ else if (attributes[row.key] !== row.value)
96
+ await ctx.db.patch("visitorAttributes", row._id, {
97
+ value: attributes[row.key],
98
+ });
99
+ }
100
+ for (const [key, value] of Object.entries(attributes)) {
101
+ if (!present.has(key))
102
+ await ctx.db.insert("visitorAttributes", {
103
+ siteId,
104
+ visitorId,
105
+ key,
106
+ value,
107
+ });
108
+ }
109
+ }
110
+
111
+ export function publicPerson(row: Doc<"visitorProfiles">) {
112
+ const { searchName: _name, searchEmail: _email, ...person } = row;
113
+ return person;
114
+ }
115
+
116
+ /** An optional, bounded upgrade for profiles created before the directory. */
117
+ export const backfillSearch = mutation({
118
+ args: { siteId: v.id("sites") },
119
+ returns: v.object({ updated: v.number(), isDone: v.boolean() }),
120
+ handler: async (ctx, { siteId }) => {
121
+ if (!(await ctx.db.get("sites", siteId)))
122
+ fail("NOT_FOUND", "site not found");
123
+ const rows = await ctx.db
124
+ .query("visitorProfiles")
125
+ .withIndex("by_siteId_and_searchName_and_visitorId", (q) =>
126
+ q.eq("siteId", siteId).eq("searchName", undefined),
127
+ )
128
+ .take(65);
129
+ for (const row of rows.slice(0, 64)) {
130
+ await ctx.db.patch(
131
+ "visitorProfiles",
132
+ row._id,
133
+ profileSearchFields(row.name, row.email),
134
+ );
135
+ await syncPersonAttributes(
136
+ ctx,
137
+ siteId,
138
+ row.visitorId,
139
+ row.attributes ?? {},
140
+ );
141
+ }
142
+ return { updated: Math.min(64, rows.length), isDone: rows.length <= 64 };
143
+ },
144
+ });
145
+
146
+ /** Exclusive upper bound for a Unicode prefix, including supplementary characters. */
147
+ function afterPrefix(prefix: string) {
148
+ const points = Array.from(prefix);
149
+ for (let i = points.length - 1; i >= 0; i--) {
150
+ const code = points[i].codePointAt(0)!;
151
+ if (code < 0x10ffff)
152
+ return (
153
+ points.slice(0, i).join("") +
154
+ String.fromCodePoint(code + 1 === 0xd800 ? 0xe000 : code + 1)
155
+ );
156
+ }
157
+ return undefined;
158
+ }
159
+
160
+ function normalizedFilter(filter?: PersonFilter): PersonFilter | undefined {
161
+ if (!filter) return undefined;
162
+ if (filter.field === "attribute") {
163
+ validateAttributeKey(filter.key);
164
+ validateAttributeValue(filter.value);
165
+ return { field: "attribute", key: filter.key, value: filter.value };
166
+ }
167
+ if (filter.prefix.length > 320)
168
+ fail("INVALID_ARGUMENT", "search prefix exceeds 320 characters");
169
+ const prefix =
170
+ filter.field === "visitorId"
171
+ ? filter.prefix.trim()
172
+ : normalizeSearch(filter.prefix);
173
+ return prefix ? { field: filter.field, prefix } : undefined;
174
+ }
175
+
176
+ export const list = query({
177
+ args: {
178
+ siteId: v.id("sites"),
179
+ filter: v.optional(personFilterValidator),
180
+ paginationOpts: v.object({
181
+ cursor: v.union(v.string(), v.null()),
182
+ numItems: v.number(),
183
+ }),
184
+ },
185
+ returns: v.object({
186
+ page: v.array(personDocumentValidator),
187
+ isDone: v.boolean(),
188
+ continueCursor: v.string(),
189
+ }),
190
+ handler: async (ctx, args) => {
191
+ const filter = normalizedFilter(args.filter);
192
+ const scope = JSON.stringify([args.siteId, filter ?? null]);
193
+ const limit = args.paginationOpts.numItems;
194
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
195
+ fail("INVALID_ARGUMENT", "numItems must be an integer between 1 and 50");
196
+ let after: { value: string; visitorId: string } | undefined;
197
+ if (args.paginationOpts.cursor !== null) {
198
+ try {
199
+ // NFKC may expand one character into several, in both scope and value.
200
+ if (args.paginationOpts.cursor.length > 16_384) throw new Error();
201
+ const cursor = JSON.parse(args.paginationOpts.cursor) as Record<
202
+ string,
203
+ unknown
204
+ >;
205
+ if (
206
+ cursor.v !== 1 ||
207
+ cursor.scope !== scope ||
208
+ typeof cursor.value !== "string" ||
209
+ typeof cursor.visitorId !== "string"
210
+ )
211
+ throw new Error();
212
+ after = { value: cursor.value, visitorId: cursor.visitorId };
213
+ if (
214
+ filter &&
215
+ filter.field !== "attribute" &&
216
+ !after.value.startsWith(filter.prefix)
217
+ )
218
+ throw new Error();
219
+ } catch {
220
+ fail("INVALID_ARGUMENT", "cursor does not belong to this people query");
221
+ }
222
+ }
223
+ let rows: Doc<"visitorProfiles">[];
224
+ const take = limit + 1;
225
+ if (filter?.field === "attribute") {
226
+ const matches = await ctx.db
227
+ .query("visitorAttributes")
228
+ .withIndex("by_siteId_and_key_and_value_and_visitorId", (q) =>
229
+ q
230
+ .eq("siteId", args.siteId)
231
+ .eq("key", filter.key)
232
+ .eq("value", filter.value)
233
+ .gt("visitorId", after?.visitorId ?? ""),
234
+ )
235
+ .take(take);
236
+ rows = await Promise.all(
237
+ matches.map(async (match) => {
238
+ const row = await ctx.db
239
+ .query("visitorProfiles")
240
+ .withIndex("by_siteId_and_visitorId", (q) =>
241
+ q.eq("siteId", args.siteId).eq("visitorId", match.visitorId),
242
+ )
243
+ .unique();
244
+ if (!row) fail("NOT_FOUND", "person attribute index has no profile");
245
+ return row;
246
+ }),
247
+ );
248
+ } else if (filter && filter.field !== "visitorId") {
249
+ const pending = await ctx.db
250
+ .query("visitorProfiles")
251
+ .withIndex("by_siteId_and_searchName_and_visitorId", (q) =>
252
+ q.eq("siteId", args.siteId).eq("searchName", undefined),
253
+ )
254
+ .first();
255
+ if (pending)
256
+ fail(
257
+ "CONFLICT",
258
+ "Run backfillVisitorProfiles for this site before searching existing profiles",
259
+ );
260
+ const field = filter.field === "name" ? "searchName" : "searchEmail";
261
+ const index =
262
+ filter.field === "name"
263
+ ? "by_siteId_and_searchName_and_visitorId"
264
+ : "by_siteId_and_searchEmail_and_visitorId";
265
+ rows = after
266
+ ? await ctx.db
267
+ .query("visitorProfiles")
268
+ .withIndex(index, (q) =>
269
+ q
270
+ .eq("siteId", args.siteId)
271
+ .eq(field, after.value)
272
+ .gt("visitorId", after.visitorId),
273
+ )
274
+ .take(take)
275
+ : [];
276
+ if (rows.length < take) {
277
+ const upper = afterPrefix(filter.prefix);
278
+ const rest = await ctx.db
279
+ .query("visitorProfiles")
280
+ .withIndex(index, (q) => {
281
+ const lower = after
282
+ ? q.eq("siteId", args.siteId).gt(field, after.value)
283
+ : q.eq("siteId", args.siteId).gte(field, filter.prefix);
284
+ return upper === undefined ? lower : lower.lt(field, upper);
285
+ })
286
+ .take(take - rows.length);
287
+ rows.push(...rest);
288
+ }
289
+ } else {
290
+ const prefix = filter?.prefix ?? "";
291
+ const upper = afterPrefix(prefix);
292
+ rows = await ctx.db
293
+ .query("visitorProfiles")
294
+ .withIndex("by_siteId_and_visitorId", (q) => {
295
+ const lower = after
296
+ ? q.eq("siteId", args.siteId).gt("visitorId", after.visitorId)
297
+ : q.eq("siteId", args.siteId).gte("visitorId", prefix);
298
+ return upper === undefined ? lower : lower.lt("visitorId", upper);
299
+ })
300
+ .take(take);
301
+ }
302
+ const page = rows.slice(0, limit);
303
+ const last = page.at(-1);
304
+ return {
305
+ page: page.map(publicPerson),
306
+ isDone: rows.length <= limit,
307
+ continueCursor: JSON.stringify({
308
+ v: 1,
309
+ scope,
310
+ visitorId: last?.visitorId ?? after?.visitorId ?? "",
311
+ value: last
312
+ ? filter?.field === "name"
313
+ ? last.searchName
314
+ : filter?.field === "email"
315
+ ? last.searchEmail
316
+ : last.visitorId
317
+ : (after?.value ?? ""),
318
+ }),
319
+ };
320
+ },
321
+ });