@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
@@ -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
+ });
@@ -64,8 +64,9 @@ import {
64
64
  goalFieldsValidator,
65
65
  sessionFieldsValidator,
66
66
  vitalMetricValidator,
67
+ visitorProfileValidator,
67
68
  } from "./validators.js";
68
- import { resolveVisitorIdentities } from "./visitors.js";
69
+ import { resolveVisitorIdentities, withVisitorProfiles } from "./visitors.js";
69
70
  import {
70
71
  mergeVitalHistograms,
71
72
  VITAL_ALL,
@@ -76,6 +77,7 @@ import {
76
77
  } from "./vitals.js";
77
78
 
78
79
  const sessionDocumentValidator = sessionFieldsValidator.extend({
80
+ profile: v.optional(visitorProfileValidator),
79
81
  _id: v.id("sessions"),
80
82
  _creationTime: v.number(),
81
83
  });
@@ -84,6 +86,7 @@ const eventDocumentValidator = eventFieldsValidator.extend({
84
86
  _creationTime: v.number(),
85
87
  });
86
88
  const liveSessionDocumentValidator = v.object({
89
+ profile: v.optional(visitorProfileValidator),
87
90
  _id: v.id("liveSessions"),
88
91
  _creationTime: v.number(),
89
92
  siteId: v.id("sites"),
@@ -628,7 +631,7 @@ export const liveVisitors = query({
628
631
  .take(limit)),
629
632
  );
630
633
  }
631
- return rows
634
+ const visible = rows
632
635
  .sort((left, right) => right.lastSeenAt - left.lastSeenAt)
633
636
  .slice(0, limit)
634
637
  .map((row) => ({
@@ -662,6 +665,7 @@ export const liveVisitors = query({
662
665
  : {}),
663
666
  ...(row.funnel ? { funnel: row.funnel } : {}),
664
667
  }));
668
+ return withVisitorProfiles(ctx, visible);
665
669
  },
666
670
  });
667
671
 
@@ -1003,7 +1007,9 @@ export const getSession = query({
1003
1007
  ...result
1004
1008
  } = session;
1005
1009
  const site = await ctx.db.get("sites", args.siteId);
1006
- return withOrigin(result, site?.domains ?? []);
1010
+ return (
1011
+ await withVisitorProfiles(ctx, [withOrigin(result, site?.domains ?? [])])
1012
+ )[0];
1007
1013
  },
1008
1014
  });
1009
1015
 
@@ -1150,10 +1156,11 @@ export const listSessions = query({
1150
1156
  ...row
1151
1157
  }) => withOrigin(row, site?.domains ?? []),
1152
1158
  );
1153
- return keysetPaginationResult(publicRows, pagination, (row) => ({
1159
+ const result = keysetPaginationResult(publicRows, pagination, (row) => ({
1154
1160
  timestamp: row.lastSeenAt,
1155
1161
  creationTime: row._creationTime,
1156
1162
  }));
1163
+ return { ...result, page: await withVisitorProfiles(ctx, result.page) };
1157
1164
  },
1158
1165
  });
1159
1166
 
@@ -10,6 +10,8 @@ import {
10
10
  siteFieldsValidator,
11
11
  trackedLinkFieldsValidator,
12
12
  visitorSketchValidator,
13
+ visitorProfileValidator,
14
+ personAttributeValueValidator,
13
15
  vitalMetricValidator,
14
16
  } from "./validators.js";
15
17
  import {
@@ -395,6 +397,39 @@ export default defineSchema({
395
397
  createdAt: v.number(),
396
398
  }).index("by_key", ["key"]),
397
399
 
400
+ visitorProfiles: defineTable(
401
+ visitorProfileValidator.extend({
402
+ siteId: v.id("sites"),
403
+ searchName: v.optional(v.string()),
404
+ searchEmail: v.optional(v.string()),
405
+ }),
406
+ )
407
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"])
408
+ .index("by_siteId_and_searchName_and_visitorId", [
409
+ "siteId",
410
+ "searchName",
411
+ "visitorId",
412
+ ])
413
+ .index("by_siteId_and_searchEmail_and_visitorId", [
414
+ "siteId",
415
+ "searchEmail",
416
+ "visitorId",
417
+ ]),
418
+
419
+ visitorAttributes: defineTable({
420
+ siteId: v.id("sites"),
421
+ visitorId: v.string(),
422
+ key: v.string(),
423
+ value: personAttributeValueValidator,
424
+ })
425
+ .index("by_siteId_and_key_and_value_and_visitorId", [
426
+ "siteId",
427
+ "key",
428
+ "value",
429
+ "visitorId",
430
+ ])
431
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"]),
432
+
398
433
  visitorAliases: defineTable({
399
434
  siteId: v.id("sites"),
400
435
  visitorId: v.string(),
@@ -1,5 +1,54 @@
1
1
  import { v, type Infer } from "convex/values";
2
2
 
3
+ export const personAttributeValueValidator = v.union(
4
+ v.string(),
5
+ v.number(),
6
+ v.boolean(),
7
+ );
8
+ export const personAttributesValidator = v.record(
9
+ v.string(),
10
+ personAttributeValueValidator,
11
+ );
12
+ export type PersonAttributes = Infer<typeof personAttributesValidator>;
13
+ export const personFilterValidator = v.union(
14
+ v.object({
15
+ field: v.union(
16
+ v.literal("name"),
17
+ v.literal("email"),
18
+ v.literal("visitorId"),
19
+ ),
20
+ prefix: v.string(),
21
+ }),
22
+ v.object({
23
+ field: v.literal("attribute"),
24
+ key: v.string(),
25
+ value: personAttributeValueValidator,
26
+ }),
27
+ );
28
+ export type PersonFilter = Infer<typeof personFilterValidator>;
29
+
30
+ /** A profile supplied by the product's trusted backend, shared by its visits. */
31
+ export const visitorProfileValidator = v.object({
32
+ visitorId: v.string(),
33
+ name: v.optional(v.string()),
34
+ email: v.optional(v.string()),
35
+ attributes: v.optional(personAttributesValidator),
36
+ identifiedAt: v.number(),
37
+ updatedAt: v.number(),
38
+ });
39
+ export type VisitorProfile = Infer<typeof visitorProfileValidator>;
40
+
41
+ export const identifyVisitorFields = {
42
+ visitorId: v.string(),
43
+ previousVisitorId: v.optional(v.string()),
44
+ // Omission preserves the value; null explicitly removes it.
45
+ name: v.optional(v.union(v.string(), v.null())),
46
+ email: v.optional(v.union(v.string(), v.null())),
47
+ attributes: v.optional(
48
+ v.record(v.string(), v.union(personAttributeValueValidator, v.null())),
49
+ ),
50
+ };
51
+
3
52
  export const eventTypeValidator = v.union(
4
53
  v.literal("pageview"),
5
54
  v.literal("click"),