@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.
- package/README.md +53 -9
- package/agent/integration.md +48 -19
- package/agent/manifest.json +13 -7
- package/agent/manifest.schema.json +18 -8
- package/dist/client/federation.d.ts +18 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +7 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/identity.d.ts +11 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +123 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/index.d.ts +257 -7
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +110 -4
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +92 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +37 -24
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/people.d.ts +79 -0
- package/dist/component/people.d.ts.map +1 -0
- package/dist/component/people.js +249 -0
- package/dist/component/people.js.map +1 -0
- package/dist/component/reports.d.ts +13 -4
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +25 -10
- package/dist/component/reports.js.map +1 -1
- package/dist/component/sanitize.d.ts +3 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +30 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +42 -1
- package/dist/component/schema.js +30 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts +4 -0
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +13 -2
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +53 -1
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +28 -0
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +38 -2
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js +162 -42
- package/dist/component/visitors.js.map +1 -1
- package/dist/react/index.d.ts +9 -5
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +36 -5
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +6 -6
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +6 -6
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +2 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +41 -2
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation.md +24 -0
- package/docs/identity.md +307 -0
- package/docs/upgrading.md +85 -20
- package/llms.txt +6 -2
- package/package.json +5 -3
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +104 -0
- package/src/component/ingest.ts +54 -30
- package/src/component/people.ts +321 -0
- package/src/component/reports.ts +34 -9
- package/src/component/sanitize.ts +46 -1
- package/src/component/schema.ts +35 -0
- package/src/component/sites.ts +14 -1
- package/src/component/validators.ts +50 -0
- package/src/component/visitors.ts +232 -55
- 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",
|
package/src/component/ingest.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
1933
|
-
const
|
|
1934
|
-
.
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
.
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
|
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:
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
+
});
|