@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
@@ -56,7 +56,7 @@ import {
56
56
  } from "./eventStore.js";
57
57
  import { isPlainRecord } from "./guards.js";
58
58
  import { classifyOrigin } from "./origin.js";
59
- import { externalReferrer } from "./sanitize.js";
59
+ import { externalReferrer, normalizeRoute } from "./sanitize.js";
60
60
  import {
61
61
  affiliateFieldsValidator,
62
62
  eventFieldsValidator,
@@ -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
 
@@ -707,6 +711,7 @@ export const siteMap = query({
707
711
  window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
708
712
  routes: v.array(siteMapRouteValidator),
709
713
  transitions: v.array(siteMapTransitionValidator),
714
+ routePatterns: v.optional(v.record(v.string(), v.array(v.string()))),
710
715
  truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
711
716
  metadata: reportMetadataValidator.extend({
712
717
  visitors: v.object({
@@ -724,6 +729,17 @@ export const siteMap = query({
724
729
  const siteIds = validateSiteIds(args.siteIds);
725
730
  validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
726
731
  requireAlignedRange(args.from, args.to, DAY_MS, "site map");
732
+ const routePatterns = Object.fromEntries(
733
+ await Promise.all(
734
+ siteIds.map(
735
+ async (siteId) =>
736
+ [
737
+ siteId,
738
+ (await ctx.db.get("sites", siteId))?.routePatterns ?? [],
739
+ ] as const,
740
+ ),
741
+ ),
742
+ );
727
743
  const controls = await Promise.all(
728
744
  siteIds.map((siteId) =>
729
745
  ctx.db
@@ -771,6 +787,7 @@ export const siteMap = query({
771
787
  window: null,
772
788
  routes: [],
773
789
  transitions: [],
790
+ routePatterns,
774
791
  truncated: { routes: false, transitions: false },
775
792
  metadata: metadata("unavailable"),
776
793
  };
@@ -838,7 +855,10 @@ export const siteMap = query({
838
855
  >();
839
856
  for (const row of rows) {
840
857
  if (row.bucketStart < completeFrom) continue;
841
- const current = totalsFor(row.route);
858
+ // Apply current patterns to retained rollups too, before merging sketches.
859
+ const patterns = routePatterns[row.siteId];
860
+ const route = normalizeRoute(row.route, patterns);
861
+ const current = totalsFor(route);
842
862
  current.pageviews += row.pageviews;
843
863
  current.entries += row.entries;
844
864
  current.continued += row.continued;
@@ -850,10 +870,11 @@ export const siteMap = query({
850
870
  (current.daily.get(row.bucketStart) ?? 0) + row.pageviews,
851
871
  );
852
872
  for (const target of row.outgoing) {
853
- const key = `${row.route}\u0000${target.route}`;
873
+ const to = normalizeRoute(target.route, patterns);
874
+ const key = `${route}\u0000${to}`;
854
875
  const edge = edges.get(key) ?? {
855
- from: row.route,
856
- to: target.route,
876
+ from: route,
877
+ to,
857
878
  count: 0,
858
879
  };
859
880
  edge.count += target.count;
@@ -909,6 +930,7 @@ export const siteMap = query({
909
930
  from: args.from,
910
931
  to: args.to,
911
932
  window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
933
+ routePatterns,
912
934
  routes: routeRows,
913
935
  transitions: transitionRows,
914
936
  truncated: {
@@ -1003,7 +1025,9 @@ export const getSession = query({
1003
1025
  ...result
1004
1026
  } = session;
1005
1027
  const site = await ctx.db.get("sites", args.siteId);
1006
- return withOrigin(result, site?.domains ?? []);
1028
+ return (
1029
+ await withVisitorProfiles(ctx, [withOrigin(result, site?.domains ?? [])])
1030
+ )[0];
1007
1031
  },
1008
1032
  });
1009
1033
 
@@ -1150,10 +1174,11 @@ export const listSessions = query({
1150
1174
  ...row
1151
1175
  }) => withOrigin(row, site?.domains ?? []),
1152
1176
  );
1153
- return keysetPaginationResult(publicRows, pagination, (row) => ({
1177
+ const result = keysetPaginationResult(publicRows, pagination, (row) => ({
1154
1178
  timestamp: row.lastSeenAt,
1155
1179
  creationTime: row._creationTime,
1156
1180
  }));
1181
+ return { ...result, page: await withVisitorProfiles(ctx, result.page) };
1157
1182
  },
1158
1183
  });
1159
1184
 
@@ -82,6 +82,36 @@ const NUMERIC_SEGMENT = /^\d+$/;
82
82
  const UUID_SEGMENT =
83
83
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
84
84
  const HEX_SEGMENT = /^[0-9a-f]{16,}$/i;
85
+ const ROUTE_PARAMETER = /^:[A-Za-z][A-Za-z0-9_]*$/;
86
+
87
+ /** Ordered, full-path patterns; no regex or wildcard execution from settings. */
88
+ export function validateRoutePatterns(patterns: readonly string[]): string[] {
89
+ if (patterns.length > 32)
90
+ throw new Error("At most 32 route patterns are allowed");
91
+ return [
92
+ ...new Set(
93
+ patterns.map((value) => {
94
+ const pattern = value.trim().replace(/\/$/, "");
95
+ const segments = pattern.slice(1).split("/");
96
+ if (
97
+ !pattern.startsWith("/") ||
98
+ pattern.length > 256 ||
99
+ /[\s?#*\\]/.test(pattern) ||
100
+ !segments.some((segment) => ROUTE_PARAMETER.test(segment)) ||
101
+ segments.some(
102
+ (segment) =>
103
+ !segment ||
104
+ segment === "." ||
105
+ segment === ".." ||
106
+ (segment.includes(":") && !ROUTE_PARAMETER.test(segment)),
107
+ )
108
+ )
109
+ throw new Error("Use full route patterns such as /pedido/:codigo");
110
+ return pattern;
111
+ }),
112
+ ),
113
+ ];
114
+ }
85
115
 
86
116
  /**
87
117
  * Collapses the identifiers that unmistakably vary per record — numbers,
@@ -91,10 +121,25 @@ const HEX_SEGMENT = /^[0-9a-f]{16,}$/i;
91
121
  * spellings serve the same page. Only the map uses this; every other report
92
122
  * keeps the sanitized path exactly as the tracker sent it.
93
123
  */
94
- export function normalizeRoute(path: string): string {
124
+ export function normalizeRoute(
125
+ path: string,
126
+ patterns: readonly string[] = [],
127
+ ): string {
95
128
  const trimmed =
96
129
  path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
97
130
  const segments = trimmed.split("/");
131
+ for (const pattern of patterns) {
132
+ const parts = pattern.split("/");
133
+ if (
134
+ parts.length === segments.length &&
135
+ parts.every(
136
+ (part, index) =>
137
+ part === segments[index] ||
138
+ (ROUTE_PARAMETER.test(part) && Boolean(segments[index])),
139
+ )
140
+ )
141
+ return pattern;
142
+ }
98
143
  for (let index = 1; index < segments.length; index += 1) {
99
144
  const segment = segments[index] ?? "";
100
145
  if (
@@ -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(),
@@ -12,6 +12,7 @@ import {
12
12
  sanitizeCurrency,
13
13
  sanitizeOpaqueId,
14
14
  validateTimezone,
15
+ validateRoutePatterns,
15
16
  } from "./sanitize.js";
16
17
  import { siteFieldsValidator } from "./validators.js";
17
18
 
@@ -29,6 +30,7 @@ export const create = mutation({
29
30
  timezone: v.optional(v.string()),
30
31
  currency: v.optional(v.string()),
31
32
  cookieless: v.optional(v.boolean()),
33
+ routePatterns: v.optional(v.array(v.string())),
32
34
  },
33
35
  returns: v.id("sites"),
34
36
  handler: async (ctx, args) => {
@@ -41,6 +43,7 @@ export const create = mutation({
41
43
  let timezone: string | undefined;
42
44
  let networkId: string | undefined;
43
45
  let currency: string;
46
+ let routePatterns: string[];
44
47
  try {
45
48
  domains = normalizeDomains(args.domains);
46
49
  timezone = validateTimezone(args.timezone);
@@ -48,6 +51,7 @@ export const create = mutation({
48
51
  ? sanitizeOpaqueId(args.networkId, "networkId")
49
52
  : undefined;
50
53
  currency = sanitizeCurrency(args.currency ?? "USD");
54
+ routePatterns = validateRoutePatterns(args.routePatterns ?? []);
51
55
  } catch (error) {
52
56
  fail(
53
57
  "INVALID_SITE",
@@ -69,7 +73,10 @@ export const create = mutation({
69
73
  existing.networkId !== networkId ||
70
74
  existing.timezone !== timezone ||
71
75
  (existing.currency ?? "USD") !== currency ||
72
- existing.cookieless !== cookieless
76
+ existing.cookieless !== cookieless ||
77
+ (args.routePatterns !== undefined &&
78
+ JSON.stringify(existing.routePatterns ?? []) !==
79
+ JSON.stringify(routePatterns))
73
80
  ) {
74
81
  fail(
75
82
  "CONFLICT",
@@ -99,6 +106,7 @@ export const create = mutation({
99
106
  timezone,
100
107
  currency,
101
108
  cookieless,
109
+ routePatterns,
102
110
  createdAt: now,
103
111
  updatedAt: now,
104
112
  });
@@ -116,6 +124,7 @@ export const update = mutation({
116
124
  timezone: v.optional(v.union(v.string(), v.null())),
117
125
  currency: v.optional(v.string()),
118
126
  cookieless: v.optional(v.boolean()),
127
+ routePatterns: v.optional(v.array(v.string())),
119
128
  },
120
129
  returns: v.null(),
121
130
  handler: async (ctx, args) => {
@@ -129,6 +138,7 @@ export const update = mutation({
129
138
  timezone?: string | undefined;
130
139
  currency?: string;
131
140
  cookieless?: boolean;
141
+ routePatterns?: string[];
132
142
  updatedAt: number;
133
143
  } = { updatedAt: Date.now() };
134
144
  if (args.name !== undefined) {
@@ -161,6 +171,9 @@ export const update = mutation({
161
171
  if (args.currency !== undefined) {
162
172
  patch.currency = sanitizeCurrency(args.currency);
163
173
  }
174
+ if (args.routePatterns !== undefined) {
175
+ patch.routePatterns = validateRoutePatterns(args.routePatterns);
176
+ }
164
177
  } catch (error) {
165
178
  fail(
166
179
  "INVALID_SITE",
@@ -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"),
@@ -112,6 +161,7 @@ export const siteFieldsValidator = v.object({
112
161
  timezone: v.optional(v.string()),
113
162
  currency: v.optional(v.string()),
114
163
  cookieless: v.boolean(),
164
+ routePatterns: v.optional(v.array(v.string())),
115
165
  createdAt: v.number(),
116
166
  updatedAt: v.number(),
117
167
  });