@iann29/rastro 0.7.0 → 0.8.1

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 (48) hide show
  1. package/README.md +63 -0
  2. package/agent/integration.md +11 -0
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +34 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +14 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +350 -3
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +90 -5
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +2 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +158 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/constants.d.ts +5 -0
  18. package/dist/component/constants.d.ts.map +1 -1
  19. package/dist/component/constants.js +8 -0
  20. package/dist/component/constants.js.map +1 -1
  21. package/dist/component/leads.d.ts +190 -0
  22. package/dist/component/leads.d.ts.map +1 -0
  23. package/dist/component/leads.js +689 -0
  24. package/dist/component/leads.js.map +1 -0
  25. package/dist/component/reports.d.ts.map +1 -1
  26. package/dist/component/reports.js +56 -31
  27. package/dist/component/reports.js.map +1 -1
  28. package/dist/component/schema.d.ts +119 -0
  29. package/dist/component/schema.js +39 -1
  30. package/dist/component/schema.js.map +1 -1
  31. package/dist/component/validators.d.ts +693 -0
  32. package/dist/component/validators.d.ts.map +1 -1
  33. package/dist/component/validators.js +129 -0
  34. package/dist/component/validators.js.map +1 -1
  35. package/dist/tracker/generated.d.ts +1 -1
  36. package/dist/tracker/generated.js +1 -1
  37. package/docs/federation-setup.md +29 -0
  38. package/docs/upgrading.md +42 -0
  39. package/llms.txt +2 -0
  40. package/package.json +6 -6
  41. package/src/component/_generated/api.ts +2 -0
  42. package/src/component/_generated/component.ts +181 -0
  43. package/src/component/constants.ts +8 -0
  44. package/src/component/leads.ts +823 -0
  45. package/src/component/reports.ts +84 -40
  46. package/src/component/schema.ts +45 -0
  47. package/src/component/validators.ts +164 -0
  48. package/src/tracker/generated.ts +1 -1
@@ -397,7 +397,17 @@ export const overview = query({
397
397
  ? ("overviewDay" as const)
398
398
  : ("overviewLocalDay" as const);
399
399
 
400
- await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
400
+ // Buckets before a retention boundary are gone: read from it, and the
401
+ // coverage below reports the range as partial.
402
+ const readFrom = await readableFrom(ctx, siteIds, [overviewDataset], from);
403
+ // Cleanup keeps whole the bucket that holds the boundary (a local day
404
+ // around a UTC cutoff), so reading starts at that bucket, not the instant.
405
+ const firstReadBucket = Math.max(
406
+ firstBucket,
407
+ localDays
408
+ ? localDayStart(readFrom, siteTimezone!)
409
+ : Math.floor(readFrom / intervalMs) * intervalMs,
410
+ );
401
411
 
402
412
  for (const siteId of siteIds) {
403
413
  // Iterated rather than taken whole: within the row budget a busy range
@@ -412,7 +422,7 @@ export const overview = query({
412
422
  range
413
423
  .eq("siteId", siteId)
414
424
  .eq("granularity", granularity)
415
- .gte("bucketStart", firstBucket)
425
+ .gte("bucketStart", firstReadBucket)
416
426
  .lte("bucketStart", lastBucket),
417
427
  )) {
418
428
  buckets.push(bucket);
@@ -1116,7 +1126,12 @@ export const listSessions = query({
1116
1126
  returns: paginationResultValidator(sessionDocumentValidator),
1117
1127
  handler: async (ctx, args) => {
1118
1128
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1119
- await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
1129
+ const readFrom = await readableFrom(
1130
+ ctx,
1131
+ [args.siteId],
1132
+ ["sessions"],
1133
+ args.from,
1134
+ );
1120
1135
  const pagination = parseKeysetPagination(args.paginationOpts, {
1121
1136
  kind: "sessions",
1122
1137
  siteId: String(args.siteId),
@@ -1145,7 +1160,7 @@ export const listSessions = query({
1145
1160
  .withIndex("by_siteId_and_lastSeenAt", (range) =>
1146
1161
  range
1147
1162
  .eq("siteId", args.siteId)
1148
- .gte("lastSeenAt", args.from)
1163
+ .gte("lastSeenAt", readFrom)
1149
1164
  .lt("lastSeenAt", pagination.cursor!.timestamp),
1150
1165
  )
1151
1166
  .order("desc")
@@ -1159,7 +1174,7 @@ export const listSessions = query({
1159
1174
  .withIndex("by_siteId_and_lastSeenAt", (range) =>
1160
1175
  range
1161
1176
  .eq("siteId", args.siteId)
1162
- .gte("lastSeenAt", args.from)
1177
+ .gte("lastSeenAt", readFrom)
1163
1178
  .lte("lastSeenAt", args.to),
1164
1179
  )
1165
1180
  .order("desc")
@@ -1193,7 +1208,12 @@ export const sessionJourney = query({
1193
1208
  returns: paginationResultValidator(eventDocumentValidator),
1194
1209
  handler: async (ctx, args) => {
1195
1210
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1196
- await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
1211
+ const readFrom = await readableFrom(
1212
+ ctx,
1213
+ [args.siteId],
1214
+ ["events"],
1215
+ args.from,
1216
+ );
1197
1217
  const pagination = parseJourneyPagination(args.paginationOpts, {
1198
1218
  kind: "events",
1199
1219
  siteId: String(args.siteId),
@@ -1204,7 +1224,7 @@ export const sessionJourney = query({
1204
1224
  const rows = await loadSessionJourneyEvents(ctx, {
1205
1225
  siteId: args.siteId,
1206
1226
  sessionId: args.sessionId,
1207
- from: args.from,
1227
+ from: readFrom,
1208
1228
  to: args.to,
1209
1229
  cursor: pagination.cursor,
1210
1230
  resultLimit: pagination.numItems + 1,
@@ -1224,7 +1244,12 @@ export const listConversions = query({
1224
1244
  returns: paginationResultValidator(conversionDocumentValidator),
1225
1245
  handler: async (ctx, args) => {
1226
1246
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1227
- await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
1247
+ const readFrom = await readableFrom(
1248
+ ctx,
1249
+ [args.siteId],
1250
+ ["conversions"],
1251
+ args.from,
1252
+ );
1228
1253
  const pagination = parseKeysetPagination(args.paginationOpts, {
1229
1254
  kind: "conversions",
1230
1255
  siteId: String(args.siteId),
@@ -1253,7 +1278,7 @@ export const listConversions = query({
1253
1278
  .withIndex("by_siteId_and_timestamp", (range) =>
1254
1279
  range
1255
1280
  .eq("siteId", args.siteId)
1256
- .gte("timestamp", args.from)
1281
+ .gte("timestamp", readFrom)
1257
1282
  .lt("timestamp", pagination.cursor!.timestamp),
1258
1283
  )
1259
1284
  .order("desc")
@@ -1267,7 +1292,7 @@ export const listConversions = query({
1267
1292
  .withIndex("by_siteId_and_timestamp", (range) =>
1268
1293
  range
1269
1294
  .eq("siteId", args.siteId)
1270
- .gte("timestamp", args.from)
1295
+ .gte("timestamp", readFrom)
1271
1296
  .lte("timestamp", args.to),
1272
1297
  )
1273
1298
  .order("desc")
@@ -1293,7 +1318,7 @@ export const visitorJourney = query({
1293
1318
  handler: async (ctx, args) => {
1294
1319
  const siteIds = validateSiteIds(args.siteIds);
1295
1320
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1296
- await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
1321
+ const readFrom = await readableFrom(ctx, siteIds, ["events"], args.from);
1297
1322
  const limit = args.limit ?? 200;
1298
1323
  if (
1299
1324
  !Number.isSafeInteger(limit) ||
@@ -1318,7 +1343,7 @@ export const visitorJourney = query({
1318
1343
  ...(await loadVisitorJourneyEvents(ctx, {
1319
1344
  siteId,
1320
1345
  visitorId,
1321
- from: args.from,
1346
+ from: readFrom,
1322
1347
  to: cutoff,
1323
1348
  resultLimit: limit,
1324
1349
  })),
@@ -1382,7 +1407,7 @@ export const goalsReport = query({
1382
1407
  totals.set(key, total);
1383
1408
  }
1384
1409
  } else {
1385
- await assertSourceAvailable(
1410
+ const readFrom = await readableFrom(
1386
1411
  ctx,
1387
1412
  [args.siteId],
1388
1413
  ["goalCompletions"],
@@ -1393,7 +1418,7 @@ export const goalsReport = query({
1393
1418
  .withIndex("by_siteId_and_timestamp", (range) =>
1394
1419
  range
1395
1420
  .eq("siteId", args.siteId)
1396
- .gte("timestamp", args.from)
1421
+ .gte("timestamp", readFrom)
1397
1422
  .lte("timestamp", args.to),
1398
1423
  )
1399
1424
  .take(MAX_REPORT_BUCKETS + 1);
@@ -1474,14 +1499,9 @@ export const funnelsReport = query({
1474
1499
  args.to,
1475
1500
  )
1476
1501
  : null;
1477
- if (!rollups) {
1478
- await assertSourceAvailable(
1479
- ctx,
1480
- [args.siteId],
1481
- ["funnelSteps"],
1482
- args.from,
1483
- );
1484
- }
1502
+ const stepsFrom = rollups
1503
+ ? args.from
1504
+ : await readableFrom(ctx, [args.siteId], ["funnelSteps"], args.from);
1485
1505
  const reportCoverage = await coverageForRange(
1486
1506
  ctx,
1487
1507
  [args.siteId],
@@ -1511,7 +1531,7 @@ export const funnelsReport = query({
1511
1531
  range
1512
1532
  .eq("siteId", args.siteId)
1513
1533
  .eq("funnelId", funnel._id)
1514
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
1534
+ .gte("bucketStart", Math.floor(stepsFrom / DAY_MS) * DAY_MS)
1515
1535
  .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
1516
1536
  )
1517
1537
  .take(MAX_REPORT_BUCKETS + 1);
@@ -1635,7 +1655,7 @@ export const affiliatesReport = query({
1635
1655
  totals.set(key, total);
1636
1656
  }
1637
1657
  } else {
1638
- await assertSourceAvailable(
1658
+ const readFrom = await readableFrom(
1639
1659
  ctx,
1640
1660
  [args.siteId],
1641
1661
  ["affiliateStats", "sessions"],
@@ -1646,7 +1666,7 @@ export const affiliatesReport = query({
1646
1666
  .withIndex("by_siteId_and_bucketStart", (range) =>
1647
1667
  range
1648
1668
  .eq("siteId", args.siteId)
1649
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
1669
+ .gte("bucketStart", Math.floor(readFrom / DAY_MS) * DAY_MS)
1650
1670
  .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
1651
1671
  )
1652
1672
  .take(MAX_REPORT_BUCKETS + 1);
@@ -1655,7 +1675,7 @@ export const affiliatesReport = query({
1655
1675
  .withIndex("by_siteId_and_startedAt", (range) =>
1656
1676
  range
1657
1677
  .eq("siteId", args.siteId)
1658
- .gte("startedAt", args.from)
1678
+ .gte("startedAt", readFrom)
1659
1679
  .lte("startedAt", args.to),
1660
1680
  )
1661
1681
  .take(MAX_REPORT_BUCKETS + 1);
@@ -1763,7 +1783,7 @@ export const vitalsReport = query({
1763
1783
  handler: async (ctx, args) => {
1764
1784
  validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
1765
1785
  requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
1766
- await assertSourceAvailable(
1786
+ const readFrom = await readableFrom(
1767
1787
  ctx,
1768
1788
  [args.siteId],
1769
1789
  ["vitalRollups"],
@@ -1774,7 +1794,7 @@ export const vitalsReport = query({
1774
1794
  .withIndex("by_siteId_and_bucketStart", (range) =>
1775
1795
  range
1776
1796
  .eq("siteId", args.siteId)
1777
- .gte("bucketStart", args.from)
1797
+ .gte("bucketStart", readFrom)
1778
1798
  .lte("bucketStart", args.to),
1779
1799
  )
1780
1800
  .take(MAX_VITALS_REPORT_ROWS + 1);
@@ -2188,12 +2208,44 @@ async function coverageForRange(
2188
2208
  };
2189
2209
  }
2190
2210
 
2211
+ /**
2212
+ * Where a report may start reading `sources` for these sites: `from`, or the
2213
+ * latest retention boundary when a cleanup or a policy already purged the data
2214
+ * before it. Reports read from here and let `coverageForRange` say `partial`
2215
+ * (or `unavailable` when the whole range is gone) instead of failing: a range
2216
+ * that begins before the retained data is a question with a partial answer,
2217
+ * not an error. Rows before the boundary are deleted anyway; clamping keeps a
2218
+ * cleanup still in progress from leaking a half-purged slice into the answer.
2219
+ */
2220
+ /**
2221
+ * Money is the exception. The commission summary has no coverage to say
2222
+ * "partial", and a retained period summed as zero would read as money that
2223
+ * never came in: it refuses instead.
2224
+ */
2191
2225
  async function assertSourceAvailable(
2192
2226
  ctx: QueryCtx,
2193
2227
  siteIds: Id<"sites">[],
2194
2228
  sources: CoverageSource[],
2195
2229
  from: number,
2196
2230
  ) {
2231
+ const readFrom = await readableFrom(ctx, siteIds, sources, from);
2232
+ if (readFrom > from) {
2233
+ fail(
2234
+ "REPORT_INCOMPLETE",
2235
+ "requested report range intersects retained data",
2236
+ {
2237
+ retainedBefore: readFrom,
2238
+ },
2239
+ );
2240
+ }
2241
+ }
2242
+
2243
+ async function readableFrom(
2244
+ ctx: QueryCtx,
2245
+ siteIds: Id<"sites">[],
2246
+ sources: CoverageSource[],
2247
+ from: number,
2248
+ ): Promise<number> {
2197
2249
  const rows = await Promise.all(
2198
2250
  siteIds.map((siteId) =>
2199
2251
  ctx.db
@@ -2202,23 +2254,15 @@ async function assertSourceAvailable(
2202
2254
  .unique(),
2203
2255
  ),
2204
2256
  );
2205
- const cutoff = rows.reduce<number | null>((latest, row) => {
2257
+ let cutoff = from;
2258
+ for (const row of rows) {
2206
2259
  for (const retained of row?.retained ?? []) {
2207
2260
  if (sources.some((source) => source === retained.dataset)) {
2208
- latest = Math.max(latest ?? 0, retained.before);
2261
+ cutoff = Math.max(cutoff, retained.before);
2209
2262
  }
2210
2263
  }
2211
- return latest;
2212
- }, null);
2213
- if (cutoff !== null && from < cutoff) {
2214
- fail(
2215
- "REPORT_INCOMPLETE",
2216
- "requested report range intersects retained data",
2217
- {
2218
- retainedBefore: cutoff,
2219
- },
2220
- );
2221
2264
  }
2265
+ return cutoff;
2222
2266
  }
2223
2267
 
2224
2268
  async function datasetCoverageForRange(
@@ -1,8 +1,11 @@
1
1
  import { defineSchema, defineTable } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
  import {
4
+ adCatalogFieldsValidator,
4
5
  affiliateFieldsValidator,
5
6
  aggregateFieldsValidator,
7
+ leadEventTypeValidator,
8
+ leadFieldsValidator,
6
9
  batchedEventValidator,
7
10
  funnelFieldsValidator,
8
11
  goalFieldsValidator,
@@ -391,6 +394,48 @@ export default defineSchema({
391
394
  bots: v.number(),
392
395
  }).index("by_linkId_and_dayStart_and_shard", ["linkId", "dayStart", "shard"]),
393
396
 
397
+ // Leads the host's server reports (see leads.ts), one per host conversation.
398
+ leads: defineTable(leadFieldsValidator)
399
+ .index("by_siteId_and_leadKey", ["siteId", "leadKey"])
400
+ .index("by_siteId_and_firstAt", ["siteId", "firstAt"])
401
+ .index("by_siteId_and_ad_and_firstAt", ["siteId", "ad.adId", "firstAt"]),
402
+
403
+ // What happened to a lead, append-only; `eventId` makes a retry a no-op.
404
+ leadEvents: defineTable({
405
+ siteId: v.id("sites"),
406
+ leadId: v.id("leads"),
407
+ eventId: v.string(),
408
+ type: leadEventTypeValidator,
409
+ at: v.number(),
410
+ revenueCents: v.optional(v.number()),
411
+ })
412
+ .index("by_siteId_and_eventId", ["siteId", "eventId"])
413
+ .index("by_leadId_and_at", ["leadId", "at"]),
414
+
415
+ // One row per site, UTC cohort day, dimension and key. Lead volume is human
416
+ // scale (a conversation each), so a row is never a write hot spot.
417
+ leadDailyRollups: defineTable({
418
+ siteId: v.id("sites"),
419
+ dayStart: v.number(),
420
+ dimension: v.union(v.literal("origin"), v.literal("ad")),
421
+ key: v.string(),
422
+ leads: v.number(),
423
+ qualified: v.number(),
424
+ won: v.number(),
425
+ revenueCents: v.number(),
426
+ }).index("by_siteId_and_dimension_and_dayStart_and_key", [
427
+ "siteId",
428
+ "dimension",
429
+ "dayStart",
430
+ "key",
431
+ ]),
432
+
433
+ // Names the host fetches from the Meta Marketing API for the ads it saw.
434
+ adCatalog: defineTable(adCatalogFieldsValidator).index("by_siteId_and_adId", [
435
+ "siteId",
436
+ "adId",
437
+ ]),
438
+
394
439
  visitorSecrets: defineTable({
395
440
  key: v.literal("current"),
396
441
  value: v.string(),
@@ -373,6 +373,79 @@ export const trackedLinkFieldsValidator = v.object({
373
373
  disabledAt: v.optional(v.number()),
374
374
  });
375
375
 
376
+ /**
377
+ * Leads (0.8.0): a conversation the host's server reports, with no web session
378
+ * behind it. Today the channel is WhatsApp; the stage follows the host's
379
+ * pipeline as `lead` → `qualified` → `won`, or `lost`.
380
+ */
381
+ export const leadChannelValidator = v.literal("whatsapp");
382
+ export const leadStageValidator = v.union(
383
+ v.literal("lead"),
384
+ v.literal("qualified"),
385
+ v.literal("won"),
386
+ v.literal("lost"),
387
+ );
388
+ /** What the host may move a lead to; `open` undoes a sale or a loss. */
389
+ export const leadStageChangeValidator = v.union(
390
+ v.literal("qualified"),
391
+ v.literal("won"),
392
+ v.literal("lost"),
393
+ v.literal("open"),
394
+ );
395
+ export const leadEventTypeValidator = v.union(
396
+ v.literal("created"),
397
+ v.literal("qualified"),
398
+ v.literal("won"),
399
+ v.literal("lost"),
400
+ v.literal("open"),
401
+ );
402
+
403
+ /** The Meta Click-to-WhatsApp ad a lead came from, as the message carried it. */
404
+ export const leadAdValidator = v.object({
405
+ adId: v.optional(v.string()),
406
+ sourceApp: v.optional(v.string()),
407
+ sourceUrl: v.optional(v.string()),
408
+ ctwaClid: v.optional(v.string()),
409
+ title: v.optional(v.string()),
410
+ });
411
+
412
+ export const leadFieldsValidator = v.object({
413
+ siteId: v.id("sites"),
414
+ /** The host's opaque id for the lead (never a phone number). */
415
+ leadKey: v.string(),
416
+ channel: leadChannelValidator,
417
+ /** The channel's raw entry point, as the host received it. */
418
+ entryPoint: v.optional(v.string()),
419
+ /** The bucket reports group by: `ctwa_ad` for any ad, else the entry point or `none`. */
420
+ originKey: v.string(),
421
+ ad: v.optional(leadAdValidator),
422
+ /** A short display name the host chooses to share, if any. */
423
+ label: v.optional(v.string()),
424
+ /** The web visitor this lead came from, when a tracked link bridged them. */
425
+ visitorId: v.optional(v.string()),
426
+ stage: leadStageValidator,
427
+ revenueCents: v.number(),
428
+ firstAt: v.number(),
429
+ /** UTC day of `firstAt`: the cohort every later stage is counted in. */
430
+ dayStart: v.number(),
431
+ stageAt: v.number(),
432
+ qualifiedAt: v.optional(v.number()),
433
+ wonAt: v.optional(v.number()),
434
+ updatedAt: v.number(),
435
+ });
436
+
437
+ export const adCatalogFieldsValidator = v.object({
438
+ siteId: v.id("sites"),
439
+ adId: v.string(),
440
+ adName: v.optional(v.string()),
441
+ adsetId: v.optional(v.string()),
442
+ adsetName: v.optional(v.string()),
443
+ campaignId: v.optional(v.string()),
444
+ campaignName: v.optional(v.string()),
445
+ thumbnailUrl: v.optional(v.string()),
446
+ updatedAt: v.number(),
447
+ });
448
+
376
449
  export const ingestResultValidator = v.object({
377
450
  accepted: v.number(),
378
451
  duplicates: v.number(),
@@ -399,3 +472,94 @@ export type FunnelStep = Infer<typeof funnelStepValidator>;
399
472
  export type EventType = Infer<typeof eventTypeValidator>;
400
473
  export type Channel = Infer<typeof channelValidator>;
401
474
  export type Evidence = Infer<typeof evidenceValidator>;
475
+
476
+ // Lead reports. Ids are plain strings so the host's own functions can return
477
+ // these shapes unchanged across the component boundary.
478
+ const leadCountersFields = {
479
+ leads: v.number(),
480
+ qualified: v.number(),
481
+ won: v.number(),
482
+ revenueCents: v.number(),
483
+ };
484
+
485
+ export const leadAdRowValidator = v.object({
486
+ adId: v.string(),
487
+ adName: v.union(v.string(), v.null()),
488
+ sourceApp: v.union(v.string(), v.null()),
489
+ sourceUrl: v.union(v.string(), v.null()),
490
+ title: v.union(v.string(), v.null()),
491
+ thumbnailUrl: v.union(v.string(), v.null()),
492
+ ...leadCountersFields,
493
+ });
494
+
495
+ export const leadAdsetRowValidator = v.object({
496
+ adsetId: v.union(v.string(), v.null()),
497
+ adsetName: v.union(v.string(), v.null()),
498
+ ...leadCountersFields,
499
+ ads: v.array(leadAdRowValidator),
500
+ });
501
+
502
+ export const leadCampaignRowValidator = v.object({
503
+ campaignId: v.union(v.string(), v.null()),
504
+ campaignName: v.union(v.string(), v.null()),
505
+ ...leadCountersFields,
506
+ adsets: v.array(leadAdsetRowValidator),
507
+ });
508
+
509
+ export const leadsReportValidator = v.object({
510
+ from: v.number(),
511
+ to: v.number(),
512
+ /** The sites' currency, or null when they differ (revenue then mixes). */
513
+ currency: v.union(v.string(), v.null()),
514
+ totals: v.object(leadCountersFields),
515
+ origins: v.array(v.object({ key: v.string(), ...leadCountersFields })),
516
+ /**
517
+ * Campaign → ad set → ad, with ads the catalog does not name yet grouped
518
+ * under a null campaign and a null ad set.
519
+ */
520
+ campaigns: v.array(leadCampaignRowValidator),
521
+ });
522
+
523
+ export const leadSummaryValidator = v.object({
524
+ leadId: v.string(),
525
+ siteId: v.string(),
526
+ label: v.union(v.string(), v.null()),
527
+ channel: leadChannelValidator,
528
+ originKey: v.string(),
529
+ entryPoint: v.union(v.string(), v.null()),
530
+ adId: v.union(v.string(), v.null()),
531
+ sourceApp: v.union(v.string(), v.null()),
532
+ stage: leadStageValidator,
533
+ revenueCents: v.number(),
534
+ firstAt: v.number(),
535
+ stageAt: v.number(),
536
+ /** Whether a web journey is linked, through a tracked link. */
537
+ hasVisitor: v.boolean(),
538
+ });
539
+
540
+ export const leadJourneyValidator = v.union(
541
+ v.null(),
542
+ v.object({
543
+ lead: leadSummaryValidator,
544
+ ad: v.union(
545
+ v.null(),
546
+ v.object({
547
+ adId: v.union(v.string(), v.null()),
548
+ adName: v.union(v.string(), v.null()),
549
+ adsetName: v.union(v.string(), v.null()),
550
+ campaignName: v.union(v.string(), v.null()),
551
+ sourceApp: v.union(v.string(), v.null()),
552
+ sourceUrl: v.union(v.string(), v.null()),
553
+ title: v.union(v.string(), v.null()),
554
+ }),
555
+ ),
556
+ visitorId: v.union(v.string(), v.null()),
557
+ events: v.array(
558
+ v.object({
559
+ type: leadEventTypeValidator,
560
+ at: v.number(),
561
+ revenueCents: v.union(v.number(), v.null()),
562
+ }),
563
+ ),
564
+ }),
565
+ );
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/build-tracker.mjs. Do not edit.
2
- export const RASTRO_VERSION = "0.7.0";
2
+ export const RASTRO_VERSION = "0.8.1";
3
3
  export const TRACKER_SOURCE = "(()=>{const t=document.currentScript,e=t?.dataset.site,i=t?.dataset.endpoint||t?.src&&new URL(\"events\",t.src).href;if(!e||!i)return;const n=()=>crypto.randomUUID?.()||Math.random()+\"\",r=new URLSearchParams(location.search),s=r.get(\"ref\")?.slice(0,64);let o;for(const t of[\"source\",\"medium\",\"campaign\",\"term\",\"content\"]){const e=r.get(\"utm_\"+t)?.slice(0,64);e&&((o??={})[\"utm_\"+t]=e)}const a=\"gclid gbraid wbraid msclkid ttclid twclid li_fat_id ScCid rdt_cid dclid epik srsltid mc_cid mc_eid _hsenc igsh igshid fbclid\".split(\" \").filter(t=>r.has(t)).slice(0,8);let c,d,l,h,f=t.dataset.visitor||\"\",p=Boolean(f);try{f||=localStorage._rv||=n()}catch{}try{d=sessionStorage,l=d._r,c=d._ri==f&&d._rt,h=s||d._a,h&&(d._a=h),o||=d._u&&JSON.parse(d._u),o&&(d._u=JSON.stringify(o))}catch{h=s}let v,g=0;const u=[];let m=document.referrer.split(\"/\",3).join(\"/\")||void 0;const _=t=>{const n=u.splice(0,50);if(!n.length)return;const r=()=>new Blob([JSON.stringify({siteId:e,events:n,sentAt:Date.now()})]);let s=r();for(;s.size>64e3&&n.length>1;)u.unshift(n.pop()),s=r();t&&navigator.sendBeacon(i,s)||fetch(i,{method:\"POST\",body:s,keepalive:1}).catch(()=>{}),u.length&&_(t)},y=(t,e,i)=>{const r=Date.now(),s=r-c;c=r,s<18e5||(l=n(),\"pageview\"!=t&&(S=\"\",w()));try{d._r=l,d._ri=f,d._rt=r}catch{}u.push({eventId:`${l}.${r.toString(36)}.${g}`,sessionId:l,visitorId:f||l,type:t,name:e,path:location.pathname.slice(0,256),referrer:m,timestamp:r,sequence:g++,affiliateSlug:h,...i}),clearTimeout(v),v=setTimeout(_,1200)};let S;const w=()=>{const t=location.pathname;t!=S&&(S=t,y(\"pageview\",void 0,{properties:o,clid:a.length?a:void 0}))};window.rastro=(e,i,r)=>{if(\"context\"==e)return{sessionId:l,visitorId:f||l,identified:p};if(\"identify\"==e||\"reset\"==e){if(\"identify\"==e&&(!i||i.length>128||!/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(i)))return;if(\"identify\"==e&&i==f)return;_();const r=\"reset\"==e||p;if(p=\"identify\"==e,f=\"identify\"==e?i:n(),t.dataset.visitor=\"identify\"==e?f:\"\",r){h=void 0,o=void 0,m=void 0,a.length=0;try{\"reset\"==e&&(localStorage._rv=f),d._a=\"\",d._u=\"\"}catch{}}c=0,g=0,S=\"\",w()}\"event\"==e&&y(\"custom\",`${i||\"event\"}`.slice(0,80),r&&JSON.stringify(r).length>2e4?void 0:{properties:r})},document.addEventListener(\"click\",t=>{const e=t.target?.closest(\"a,button,[data-rastro-event]\");if(!e)return;const i=e.dataset.rastroEvent,n=e.href,r=n&&new URL(n),s=/^https?:$/.test(r?.protocol),o=s&&r.origin!=location.origin;y(i?\"custom\":o?\"outbound\":\"click\",i,{target:(e.dataset.rastroLabel||e.innerText||e.tagName).slice(0,64),href:s?((o?r.origin:\"\")+r.pathname).slice(0,256):void 0})},1);for(const t of[\"pushState\",\"replaceState\"]){const e=history[t];history[t]=(...t)=>{e.apply(history,t),w()}}addEventListener(\"popstate\",w);let b=0;const I=()=>{y(\"heartbeat\"),_()},L=()=>{b&&(clearInterval(b),b=0,y(\"leave\"),_(1))},E=()=>{document.hidden?L():b||(I(),b=setInterval(I,2e4))};addEventListener(\"pagehide\",L),addEventListener(\"pageshow\",E),document.addEventListener(\"visibilitychange\",E),b=setInterval(I,2e4),w(),E()})();";
4
4
  export const TRACKER_GZIP_BASE64 = "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";
5
5
  export const TRACKER_RAW_BYTES = 2999;