@iann29/rastro 0.1.0 → 0.3.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/docs/upgrading.md CHANGED
@@ -22,12 +22,35 @@ hosts still on them.
22
22
  | `0.1.0-alpha.10` | Install `0.1.0-alpha.12` directly; new tables and fields are additive; re-export the configure functions to opt in | `/rastro/health` reports `tracker.version: "0.1.0-alpha.12"` |
23
23
  | `0.1.0-alpha.11` | Install `0.1.0-alpha.12` directly; same code, the package now ships `src/tracker/generated.ts` | A `convex/` test importing `@iann29/rastro/test` typechecks on push |
24
24
  | `0.1.0-alpha.12` | Install `0.1.0` directly; every schema change is an optional field or a new table; add `analytics:public` to a grant only to allow public links | `/rastro/health` reports `tracker.version: "0.1.0"` |
25
+ | `0.1.0` | Install `0.2.0` directly; add the optional host callback and grant version to enable transfers | Health reports `0.2.0`; opted-in connectors advertise `transfer` |
25
26
  | Any release whose packed declarations lack federation | Stop and request an eligible exact registry release | Packed declarations contain the federation exports |
26
27
 
27
28
  Do not upgrade a populated legacy deployment directly to a release that removes
28
29
  the `events` table. Schema acceptance alone does not prove that telemetry was
29
30
  migrated.
30
31
 
32
+ ## Organization transfers (0.2.0)
33
+
34
+ Install `@iann29/rastro@0.2.0` after rehearsing on a non-production snapshot.
35
+ The analytics component has no schema change or telemetry migration in this
36
+ release. The tracker reports the new package version; its behavior is unchanged.
37
+
38
+ To enable **Conexões → Transferir organização**, follow
39
+ [the host changes in the federation setup guide](federation-setup.md#transfer-a-connection-to-another-organization):
40
+ add optional `version` to the host's grant table, return it from
41
+ `resolveConnection`, persist the destination organization and incremented
42
+ version in `transferConnection`, and export that mutation. Keep existing
43
+ versions when provisioning grants. Transfers also require `analytics:configure`
44
+ in the local grant; read-only grants remain read-only after the package upgrade.
45
+
46
+ Reverify the connection in **Conexões** after deploying so the control plane
47
+ refreshes its capabilities and permissions. Acceptance: `/rastro/health` reports
48
+ `tracker.version: "0.2.0"`, the opted-in connector manifest includes `transfer`,
49
+ and a rehearsal transfer preserves site IDs and reports while rejecting the
50
+ source organization's old token. If a response is lost, resume the same transfer
51
+ through the dashboard; do not reset grant versions or clear the pending intent
52
+ manually.
53
+
31
54
  ## Legacy event migration
32
55
 
33
56
  `0.1.0-alpha.2` is the published migration bridge. A DEV or snapshot deployment
@@ -384,3 +407,31 @@ control-plane issuer.
384
407
 
385
408
  Record the package version, deployment target, migration output, and verifier in
386
409
  the project's release evidence.
410
+
411
+ ### Session details and complete revenue lists
412
+
413
+ After upgrading the package, export `getSession` and `revenueSummary` from the
414
+ object returned by `exposeFederatedAnalyticsApi` in
415
+ `convex/rastroFederation.ts`, then deploy the host and reverify its dashboard
416
+ connection. The manifest advertises `sessionDetails` and `revenueSummary`;
417
+ advertising them without exporting the functions leaves an incomplete host
418
+ surface.
419
+
420
+ `getSession({siteId, sessionId})` returns the retained session metadata (or
421
+ null). The dashboard pairs it with the existing paginated `sessionJourney` so a
422
+ copied session link keeps its duration, entry/exit and counters even after a
423
+ reload. Older hosts still provide scoped session events, but cannot restore all
424
+ metadata.
425
+
426
+ `revenueSummary({siteIds, from, to})` returns exact `commissionCents` for the
427
+ inclusive period. Revenue, conversion count, average order and currency reuse
428
+ `overview` totals. The commission report reads existing daily affiliate stats
429
+ for complete UTC days and trusted conversion records only for partial edge days;
430
+ renamed/deleted affiliate definitions do not remove historical commissions. It
431
+ rejects unavailable retained sources, mixed currencies and periods requiring
432
+ more than 5,000 source rows instead of presenting partial totals. No new table,
433
+ backfill or ingestion change is required. Older hosts show commissions as
434
+ unavailable, never as the sum of a loaded list page.
435
+
436
+ Session and conversion lists now expose per-site keyset continuation, merge rows
437
+ in chronological order and label search as filtering only loaded records.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "bugs": {
7
7
  "url": "https://github.com/amageweb/amage-rastro/issues"
8
8
  },
9
- "version": "0.1.0",
9
+ "version": "0.3.0",
10
10
  "license": "Apache-2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -362,6 +362,47 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
362
362
  }>,
363
363
  Name
364
364
  >;
365
+ getSession: FunctionReference<
366
+ "query",
367
+ "internal",
368
+ { sessionId: string; siteId: string },
369
+ {
370
+ _creationTime: number;
371
+ _id: string;
372
+ affiliateAttributedAt?: number;
373
+ affiliateExpiresAt?: number;
374
+ affiliateId?: string;
375
+ affiliateSlug?: string;
376
+ browser: string;
377
+ city?: string;
378
+ conversionCount: number;
379
+ country?: string;
380
+ currency?: string;
381
+ device: string;
382
+ durationMs: number;
383
+ entryPath: string;
384
+ eventCount: number;
385
+ exitPath: string;
386
+ lastSeenAt: number;
387
+ latitude?: number;
388
+ longitude?: number;
389
+ newVisitor?: boolean;
390
+ os: string;
391
+ pageviewCount: number;
392
+ referrer?: string;
393
+ revenueCents: number;
394
+ sessionId: string;
395
+ siteId: string;
396
+ source: string;
397
+ startedAt: number;
398
+ utmCampaign?: string;
399
+ utmMedium?: string;
400
+ utmSource?: string;
401
+ visitorId: string;
402
+ visitorKey?: string;
403
+ } | null,
404
+ Name
405
+ >;
365
406
  goalsReport: FunctionReference<
366
407
  "query",
367
408
  "internal",
@@ -676,6 +717,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
676
717
  },
677
718
  Name
678
719
  >;
720
+ revenueSummary: FunctionReference<
721
+ "query",
722
+ "internal",
723
+ { from: number; siteIds: Array<string>; to: number },
724
+ { commissionCents: number },
725
+ Name
726
+ >;
679
727
  sessionJourney: FunctionReference<
680
728
  "query",
681
729
  "internal",
@@ -413,10 +413,8 @@ export const ingestBatch = mutation({
413
413
  }
414
414
 
415
415
  if (event.type === "leave") {
416
- // A departure only shortens live presence: it stores no telemetry,
417
- // touches no aggregates, and never extends anything, so a replayed
418
- // or late leave is inert. The read side filters by expiresAt and the
419
- // sweep deletes the row, so the dashboard sees the exit in seconds.
416
+ // A departure closes the final duration interval without telemetry
417
+ // or new presence. Older departures cannot retire a bfcache return.
420
418
  if (!existingSession) {
421
419
  rejected += 1;
422
420
  continue;
@@ -441,7 +439,33 @@ export const ingestBatch = mutation({
441
439
  batchState,
442
440
  );
443
441
  const liveSession = liveState.current;
444
- if (liveSession) {
442
+ if (liveSession && event.timestamp >= liveSession.lastSeenAt) {
443
+ // Only the still-covered tail is trusted; a delayed departure
444
+ // cannot lengthen an expired visit or move its start backwards.
445
+ if (
446
+ event.timestamp > existingSession.lastSeenAt &&
447
+ event.timestamp >= now - LIVE_SESSION_TTL_MS &&
448
+ event.timestamp <= liveSession.expiresAt
449
+ ) {
450
+ const durationMs = Math.max(
451
+ existingSession.durationMs,
452
+ event.timestamp - existingSession.startedAt,
453
+ );
454
+ sessionRow.current = {
455
+ ...existingSession,
456
+ lastSeenAt: event.timestamp,
457
+ exitPath: event.path,
458
+ durationMs,
459
+ };
460
+ sessionRow.dirty = true;
461
+ foldSessionDuration(
462
+ aggregateDeltas,
463
+ args.siteId,
464
+ event,
465
+ durationMs - existingSession.durationMs,
466
+ timezone,
467
+ );
468
+ }
445
469
  const expiresAt = Math.min(
446
470
  liveSession.expiresAt,
447
471
  now + LIVE_LEAVE_GRACE_MS,
@@ -2404,7 +2428,7 @@ function aggregateBucketStart(
2404
2428
  return Math.floor(timestamp / interval) * interval;
2405
2429
  }
2406
2430
 
2407
- /** Folds only session duration growth, for heartbeats. */
2431
+ /** Folds session duration growth without telemetry, for heartbeats and leaves. */
2408
2432
  function foldSessionDuration(
2409
2433
  aggregateDeltas: AggregateDeltas,
2410
2434
  siteId: Id<"sites">,
@@ -932,6 +932,102 @@ async function readRouteRollups(
932
932
  return { rows, completeFrom: cutAt + DAY_MS };
933
933
  }
934
934
 
935
+ /** Metadata for a selected session, independent of list pagination. */
936
+ export const getSession = query({
937
+ args: { siteId: v.id("sites"), sessionId: v.string() },
938
+ returns: v.union(sessionDocumentValidator, v.null()),
939
+ handler: async (ctx, args) => {
940
+ const session = await ctx.db
941
+ .query("sessions")
942
+ .withIndex("by_siteId_and_sessionId", (q) =>
943
+ q.eq("siteId", args.siteId).eq("sessionId", args.sessionId),
944
+ )
945
+ .unique();
946
+ if (!session) return null;
947
+ const {
948
+ geoLookupAttemptedAt: _geo,
949
+ lastPageviewAt: _pageview,
950
+ ...result
951
+ } = session;
952
+ return result;
953
+ },
954
+ });
955
+
956
+ /** Exact commissions alongside overview's period revenue/conversion totals. */
957
+ export const revenueSummary = query({
958
+ args: { siteIds: v.array(v.id("sites")), from: v.number(), to: v.number() },
959
+ returns: v.object({ commissionCents: v.number() }),
960
+ handler: async (ctx, args) => {
961
+ const siteIds = validateSiteIds(args.siteIds);
962
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
963
+ const fullFrom = Math.ceil(args.from / DAY_MS) * DAY_MS;
964
+ const fullTo = Math.floor((args.to + 1) / DAY_MS) * DAY_MS;
965
+ let commissionCents = 0;
966
+ let rowsRead = 0;
967
+ const currencies = new Set<string>();
968
+ for (const siteId of siteIds) {
969
+ const site = await ctx.db.get("sites", siteId);
970
+ if (!site) fail("NOT_FOUND", "site not found");
971
+ currencies.add(site.currency ?? "USD");
972
+ if (currencies.size > 1)
973
+ fail("CURRENCY_MISMATCH", "revenue sites must use the same currency");
974
+ if (fullFrom < fullTo) {
975
+ await assertSourceAvailable(
976
+ ctx,
977
+ [siteId],
978
+ ["affiliateStats"],
979
+ fullFrom,
980
+ );
981
+ const stats = await ctx.db
982
+ .query("affiliateDailyStats")
983
+ .withIndex("by_siteId_and_bucketStart", (q) =>
984
+ q
985
+ .eq("siteId", siteId)
986
+ .gte("bucketStart", fullFrom)
987
+ .lt("bucketStart", fullTo),
988
+ )
989
+ .take(MAX_REPORT_BUCKETS - rowsRead + 1);
990
+ rowsRead += stats.length;
991
+ failIfIncomplete(
992
+ stats,
993
+ MAX_REPORT_BUCKETS - (rowsRead - stats.length),
994
+ "commission buckets",
995
+ );
996
+ for (const row of stats) commissionCents += row.commissionCents;
997
+ }
998
+ // ponytail: boundary scans cap at MAX_REPORT_BUCKETS; add hourly commission
999
+ // rollups if busy partial days exceed this explicit report limit.
1000
+ // Daily stats cannot answer partial days (24h and local-day windows).
1001
+ // Read only those edges from the trusted ledger, never round the period.
1002
+ const edges: Array<[number, number]> =
1003
+ fullFrom < fullTo
1004
+ ? [
1005
+ [args.from, fullFrom - 1],
1006
+ [fullTo, args.to],
1007
+ ]
1008
+ : [[args.from, args.to]];
1009
+ for (const [from, to] of edges) {
1010
+ if (from > to) continue;
1011
+ await assertSourceAvailable(ctx, [siteId], ["conversions"], from);
1012
+ const rows = await ctx.db
1013
+ .query("conversions")
1014
+ .withIndex("by_siteId_and_timestamp", (q) =>
1015
+ q.eq("siteId", siteId).gte("timestamp", from).lte("timestamp", to),
1016
+ )
1017
+ .take(MAX_REPORT_BUCKETS - rowsRead + 1);
1018
+ rowsRead += rows.length;
1019
+ failIfIncomplete(
1020
+ rows,
1021
+ MAX_REPORT_BUCKETS - (rowsRead - rows.length),
1022
+ "commission boundary conversions",
1023
+ );
1024
+ for (const row of rows) commissionCents += row.commissionCents;
1025
+ }
1026
+ }
1027
+ return { commissionCents };
1028
+ },
1029
+ });
1030
+
935
1031
  export const listSessions = query({
936
1032
  args: {
937
1033
  siteId: v.id("sites"),
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/build-tracker.mjs. Do not edit.
2
- export const RASTRO_VERSION = "0.1.0";
2
+ export const RASTRO_VERSION = "0.3.0";
3
3
  export const TRACKER_SOURCE = "(()=>{const e=document.currentScript,t=e?.dataset.site,s=e?.dataset.endpoint||e?.src&&new URL(\"events\",e.src).href;if(!t||!s)return;const o=()=>crypto.randomUUID?.()||Math.random()+\"\",n=new URLSearchParams(location.search),r=n.get(\"ref\")?.slice(0,64);let a,i,c;for(const e of[\"source\",\"medium\",\"campaign\",\"term\",\"content\"]){const t=n.get(\"utm_\"+e)?.slice(0,64);t&&((a??={})[\"utm_\"+e]=t)}try{i=sessionStorage._r||o(),sessionStorage._r=i,c=r||sessionStorage._a,c&&(sessionStorage._a=c),a||=sessionStorage._u&&JSON.parse(sessionStorage._u),a&&(sessionStorage._u=JSON.stringify(a))}catch{i=o(),c=r}let d=e.dataset.visitor;try{d||=localStorage._rv||=o()}catch{}d||=i;let l,p=0;const g=[],h=document.referrer?new URL(document.referrer).origin:void 0,u=e=>{const o=g.splice(0,50);if(!o.length)return;const n=()=>new Blob([JSON.stringify({siteId:t,events:o,sentAt:Date.now()})]);let r=n();for(;r.size>64e3&&o.length>1;)g.unshift(o.pop()),r=n();e&&navigator.sendBeacon(s,r)||fetch(s,{method:\"POST\",body:r,keepalive:1}).catch(()=>{}),g.length&&u(e)},m=(e,t,s)=>{const o=Date.now();g.push({eventId:`${i}.${o.toString(36)}.${p}`,sessionId:i,visitorId:d,type:e,name:t,path:location.pathname.slice(0,256),referrer:h,timestamp:o,sequence:p++,affiliateSlug:c,...s}),clearTimeout(l),l=setTimeout(u,1200)};let v;const f=()=>{const e=location.pathname;e!==v&&(v=e,m(\"pageview\",void 0,a&&{properties:a}))};window.rastro=(e,t,s)=>{if(\"context\"===e)return{sessionId:i,visitorId:d};\"event\"===e&&m(\"custom\",`${t||\"event\"}`.slice(0,80),s&&JSON.stringify(s).length>2e4?void 0:{properties:s})},document.addEventListener(\"click\",e=>{const t=e.target?.closest(\"a,button,[data-rastro-event]\");if(!t)return;const s=t.dataset.rastroEvent,o=t.href,n=o&&new URL(o),r=/^https?:$/.test(n?.protocol),a=r&&n.origin!==location.origin;m(s?\"custom\":a?\"outbound\":\"click\",s,{target:(t.dataset.rastroLabel||t.innerText||t.tagName).slice(0,64),href:r?((a?n.origin:\"\")+n.pathname).slice(0,256):void 0})},1);const S=e=>{const t=history[e];history[e]=(...e)=>{t.apply(history,e),f()}};S(\"pushState\"),S(\"replaceState\"),addEventListener(\"popstate\",f),addEventListener(\"pagehide\",()=>{m(\"leave\"),u(1)}),addEventListener(\"pageshow\",e=>{e.persisted&&(m(\"heartbeat\"),u(1))}),setInterval(()=>{m(\"heartbeat\"),u()},2e4),f()})();";
4
4
  export const TRACKER_GZIP_BASE64 = "H4sIAAAAAAACA3VV227cNhD9FZswBBKeMLbjGIUEZtEgfXDhJkE3eTLcmKZGEhGJVMnROtuV/r2QVuv1JXmTZsi5nDlnyLlQ7zbGu0gHqHJvugYdSdOFgI6WJtiWgBQuZK5JRyQZLSHExxZ0eeuto77HhYzBJInD+4Ovf19xhit0FBngaBeyClhktuCH1PeHUQSkLrhsm92rsRQT1i15GbTLffP16+WHheSi7//SVM1GLo4ZA6fmHEvUwVSfddBN5LU3mqx3Mk5WAUE5WSJxFrBgYiFjbQ3yE7g4F1mNdKDBgskKH/gMwYEvrln0XTDIgDWY265hwIxuWm1Lx4ARhsniHaEjdiNm9GiXqqPmGzvGZ9koSTjXi4XaDOJ6d+ZGkRgorDdWRYzRerckH3SJ8lvoe88FvDArC0aFvn/u0GCShL+wKiNA9/2L8F2S/Ln89FG2OkR8ca0ToH8SrVPTnUjButIWa66FGIwmU22sGqs1KgwjrLnCB3asbLTkQza2mfe9GmdU7/tZ9f14dQ4zjCfsNJoaWnUyc6NU1zdQ7ekZsMAQMCx2RHvhEdIHW1qXrrzND06gU/jAc69KGdt5Nm9PxMRIL2t0JVVPSekmUo5Z3tf+jl8/638ziuEyTwm2RE89RHT0O6UfNKF0/p6LQdxsuRaU42LiWhZktP/hu4tzfJMku8zvTjNRys7FyhbEvWx9y8VEYS4yTBKnV7bU5IOM6PL3qI13PEIQfV8gmYpH2DRIlc9T9vnT8guDO5+v0wDfEVtd2xWmp4OQE9B8kv0goJyTJ0nHUQzQKI5AEMUjtPa9ZKVsu1jxzdTuZZ7eHm3sII82XpJfTrDwNxditLTD7Y68l3lqYabBZZ7mQOsWUwSnG0wJWk1V+qDc8W90PIjn7O2FgN1Y0wrINhhJN+0E9r8dOoNpe3wMuihsbTXhsu7K1ICUMg4CTI06fLEN+o54LaBWEWn338Hp2cmJGKYBreahF+rJUnxRWoaHSq2ShK8UQsNZq0tcWbxnMJNNJ8mmDb7FQBZjqgchhuzeutzfy6AjBf8IZVvw7TL5QUwphTMBN78Ab8i2S3U6myQNZ6aL5BsGt0cb6vvZO9w+APjbiYA4y31P3Sh2tDvD88W28PRx1XEQAzzoSuf5H2PgKxsJHQbOTG3NdwZ7WZFCSTqUSAtpah8xEmca7joi7+B63Aevtt2/mmq8YVvl0VPJRUUPu2N7fEoMXtH0foBTfv/C+FEgr/+piNq4SI9eSxrTuoVsgydvfC1Aq5Akbt4Hh2o/z60la3hc7EBM9YL5ju5853KW7nqMsNk2lvLntV3pO6z7nqR1DsMX/EHjD+nyo25QPH4BYCw+DYvxFdjlThkTx3tiiSekn1fXOIZTMYOzVI/xrmwkH9bXeJPtPxWXUuLILJK6bes1n32AAgouhiFbcjbKeEmakAlYjs9jW2uDO8vLabe+jZMTip+6dYmVzZHBJJ2Gsxr1agzV8VMx/OpKrPz9lkIoWwxxdOZJwhvOKtSB7lDTHGMMEpEuHWFY6Zrv8jw9JwY4w/Ntn4KL7H/zTKaM3wgAAA==";
5
5
  export const TRACKER_RAW_BYTES = 2271;