@beesolve/analytics-processor 0.3.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -363,6 +363,18 @@ interface RollupModels {
363
363
  readonly markers: Pick<AppliedMarkers, "isApplied" | "buildMarkerPut">;
364
364
  }
365
365
  type View = Grain | "year";
366
+ /**
367
+ * How a bucket's value was obtained, so the UI can tell "no traffic" apart from
368
+ * "not calculated yet":
369
+ * - `sealed`: a finalized, rolled-up period read from its stored aggregate. A
370
+ * zero-hit sealed bucket is genuine "no traffic".
371
+ * - `partial`: an in-progress period computed live from the finer grain; the
372
+ * value is real but may still grow.
373
+ * - `pending`: a period past its expected processing time with no stored data
374
+ * and nothing computable yet — awaiting processing.
375
+ */
376
+ declare const bucketStates: readonly ["sealed", "partial", "pending"];
377
+ type BucketState = (typeof bucketStates)[number];
366
378
  interface ResolveModels {
367
379
  readonly totals: Pick<Totals, "queryWindow" | "getOne">;
368
380
  readonly breakdowns: Pick<Breakdowns, "getForPeriod">;
@@ -370,15 +382,21 @@ interface ResolveModels {
370
382
  }
371
383
  /**
372
384
  * Resolves one bucket's totals, computing an in-progress period on the fly from
373
- * the grain below it. A past period whose rollup marker is applied is read
374
- * directly from the stored aggregate ("sealed"); the current period, and any
375
- * past period still missing its marker, is summed from its children — the
376
- * current month folds down through the current day to the hours ingested so far.
385
+ * exactly one grain finer, following only the current edge deeper.
386
+ *
387
+ * A sealed period (strictly past, rollup marker applied) is read from its stored
388
+ * aggregate. Otherwise the period is the sum of its children at the next finer
389
+ * grain, where every already-complete child is taken from its stored aggregate
390
+ * and only the single in-progress child is resolved recursively:
377
391
  *
378
- * Each level reads its children with a single ranged query (never a get per
379
- * child): a day sums its hours via one hourly `queryWindow`; a month sums its
380
- * days, and the one in-progress day among them is resolved from hours. `hour`
381
- * is terminal.
392
+ * - partial day = stored/queried hours `00..currentHour`
393
+ * - partial month = stored days `01..currentDay-1` + the partial current day
394
+ * - partial year = stored months `01..currentMonth-1` + the partial current month
395
+ *
396
+ * So a partial month never fans out to hours for its finished days (it trusts
397
+ * their day aggregate) — only the one current day drills into hours. Each level
398
+ * is one ranged query, keeping the read count bounded (a year touches ~12
399
+ * months + ~31 days + ~24 hours) and the request sub-second.
382
400
  */
383
401
  declare function resolveTotalsLive({ models, grain, periodKey, now }: {
384
402
  readonly models: ResolveModels;
@@ -393,6 +411,20 @@ declare function resolveBreakdownLive({ models, dimension, grain, periodKey, now
393
411
  readonly periodKey: string;
394
412
  readonly now: Date;
395
413
  }): Promise<Array<BreakdownRow>>;
414
+ /**
415
+ * Classifies a bucket as sealed / partial / pending without recomputing its
416
+ * value. A sealed bucket is a finalized rollup (its marker is applied), so a
417
+ * zero-hit sealed bucket is genuine "no traffic". A non-sealed bucket that
418
+ * yielded data is in-progress (`partial`); one with nothing to show is awaiting
419
+ * processing (`pending`).
420
+ */
421
+ declare function classifyBucketState({ models, grain, periodKey, now, hasData }: {
422
+ readonly models: ResolveModels;
423
+ readonly grain: Grain;
424
+ readonly periodKey: string;
425
+ readonly now: Date;
426
+ readonly hasData: boolean;
427
+ }): Promise<BucketState>;
396
428
  declare function yearKey(date: Date): string;
397
429
  declare function monthKey2(date: Date): string;
398
430
  declare function dayKey2(date: Date): string;
@@ -423,4 +455,4 @@ declare function hoursBetween({ from, to }: {
423
455
  from: Date;
424
456
  to: Date;
425
457
  }): Array<string>;
426
- export { AnalyticsConfig, AppliedMarker, AppliedMarkers, AppliedSource, AthenaColumnMap, BreakdownInput, BreakdownItem, BreakdownRow, Breakdowns, Config, DeviceBucket, Dimension, Grain, HourlyAthenaResult, HourlyAthenaTotals, ProcessHourAthena, ProcessHourModels, ProcessHourOptions, ProcessHourResult, ResolveModels, SummedTotals, Totals, TotalsInput, TotalsItem, TransactItem, View, appliedMarkerSchema, appliedSources, applyDailyRollup, applyMonthlyRollup, breakdownSchema, buildHourlyQuery, chunkTransactItems, classifyDevice, columnMaps, configSchema, dayKey2 as dayKey, dimensions, grainForPeriodKey, grains, hourKey2 as hourKey, hourKeyParts, hoursBetween, maxWindowFor, monthKey2 as monthKey, parsePeriodKey, processHour, requiredRecordFields, resolveBreakdownLive, resolveTotalsLive, runHourlyAthenaQuery, shiftAnchor, sumBreakdown, sumTotals, topNWithOther, totalsSchema, windowKeys, yearKey };
458
+ export { AnalyticsConfig, AppliedMarker, AppliedMarkers, AppliedSource, AthenaColumnMap, BreakdownInput, BreakdownItem, BreakdownRow, Breakdowns, BucketState, Config, DeviceBucket, Dimension, Grain, HourlyAthenaResult, HourlyAthenaTotals, ProcessHourAthena, ProcessHourModels, ProcessHourOptions, ProcessHourResult, ResolveModels, SummedTotals, Totals, TotalsInput, TotalsItem, TransactItem, View, appliedMarkerSchema, appliedSources, applyDailyRollup, applyMonthlyRollup, breakdownSchema, bucketStates, buildHourlyQuery, chunkTransactItems, classifyBucketState, classifyDevice, columnMaps, configSchema, dayKey2 as dayKey, dimensions, grainForPeriodKey, grains, hourKey2 as hourKey, hourKeyParts, hoursBetween, maxWindowFor, monthKey2 as monthKey, parsePeriodKey, processHour, requiredRecordFields, resolveBreakdownLive, resolveTotalsLive, runHourlyAthenaQuery, shiftAnchor, sumBreakdown, sumTotals, topNWithOther, totalsSchema, windowKeys, yearKey };
package/dist/index.js CHANGED
@@ -837,6 +837,7 @@ function toBreakdownRow(item) {
837
837
  return { value: item.value, hits: item.hits, bytes: item.bytes };
838
838
  }
839
839
  // packages/analytics-processor/src/resolveLive.ts
840
+ var bucketStates = ["sealed", "partial", "pending"];
840
841
  var emptyTotals = {
841
842
  hits: 0,
842
843
  success: 0,
@@ -870,21 +871,22 @@ async function resolveTotalsLive({
870
871
  });
871
872
  return sumTotals(hours);
872
873
  }
873
- const dayKeys = dayKeysInProgress({ periodKey, now });
874
- const sealedDays = await models.totals.queryWindow({
874
+ const storedDays = await models.totals.queryWindow({
875
875
  grain: "day",
876
876
  startKey: `${periodKey}-01`,
877
877
  endKey: `${periodKey}-31`
878
878
  });
879
- const sealedByKey = indexByTimeKey(sealedDays);
880
- const resolvedDays = await Promise.all(dayKeys.map(async (dayKey) => {
881
- const stored = sealedByKey[dayKey];
882
- if (stored != null && !isCurrentPeriod({ grain: "day", periodKey: dayKey, now })) {
883
- return pickTotals(stored);
884
- }
885
- return resolveTotalsLive({ models, grain: "day", periodKey: dayKey, now });
886
- }));
887
- return sumTotals(resolvedDays);
879
+ const base = sumTotals(storedDays.filter((item) => !isCurrentPeriod({ grain: "day", periodKey: item.timeKey, now })));
880
+ const currentDayKey = currentChildKey({ parentGrain: "month", periodKey, now });
881
+ if (currentDayKey == null)
882
+ return base;
883
+ const currentDay = await resolveTotalsLive({
884
+ models,
885
+ grain: "day",
886
+ periodKey: currentDayKey,
887
+ now
888
+ });
889
+ return sumTotals([base, currentDay]);
888
890
  }
889
891
  async function resolveBreakdownLive({
890
892
  models,
@@ -901,10 +903,39 @@ async function resolveBreakdownLive({
901
903
  if (stored.length > 0)
902
904
  return toRows(stored);
903
905
  }
904
- const finer = finerGrain(grain);
905
- const childKeys = grain === "day" ? hourKeysInProgress({ periodKey, now }) : dayKeysInProgress({ periodKey, now });
906
- const children = await Promise.all(childKeys.map((childKey) => resolveBreakdownLive({ models, dimension, grain: finer, periodKey: childKey, now })));
907
- return sumBreakdown(children.flat());
906
+ if (grain === "day") {
907
+ const hourKeys = hourKeysInProgress({ periodKey, now });
908
+ const hourRows = await Promise.all(hourKeys.map((hourKey) => models.breakdowns.getForPeriod({ dimension, grain: "hour", periodKey: hourKey })));
909
+ return sumBreakdown(hourRows.flat().map(toRow));
910
+ }
911
+ const storedDayKeys = pastDayKeys({ periodKey, now });
912
+ const storedDayRows = await Promise.all(storedDayKeys.map((dayKey) => models.breakdowns.getForPeriod({ dimension, grain: "day", periodKey: dayKey })));
913
+ const rows = storedDayRows.flat().map(toRow);
914
+ const currentDayKey = currentChildKey({ parentGrain: "month", periodKey, now });
915
+ if (currentDayKey != null) {
916
+ const currentDay = await resolveBreakdownLive({
917
+ models,
918
+ dimension,
919
+ grain: "day",
920
+ periodKey: currentDayKey,
921
+ now
922
+ });
923
+ rows.push(...currentDay);
924
+ }
925
+ return sumBreakdown(rows);
926
+ }
927
+ async function classifyBucketState({
928
+ models,
929
+ grain,
930
+ periodKey,
931
+ now,
932
+ hasData
933
+ }) {
934
+ if (await isSealed({ models, grain, periodKey, now }))
935
+ return "sealed";
936
+ if (hasData)
937
+ return "partial";
938
+ return "pending";
908
939
  }
909
940
  async function isSealed({
910
941
  models,
@@ -912,7 +943,7 @@ async function isSealed({
912
943
  periodKey,
913
944
  now
914
945
  }) {
915
- if (isCurrentPeriod({ grain, periodKey, now }))
946
+ if (!isPastPeriod({ grain, periodKey, now }))
916
947
  return false;
917
948
  return models.markers.isApplied({ grain, periodKey });
918
949
  }
@@ -923,6 +954,13 @@ function isCurrentPeriod({
923
954
  }) {
924
955
  return periodKey === currentKeyFor({ grain, now });
925
956
  }
957
+ function isPastPeriod({
958
+ grain,
959
+ periodKey,
960
+ now
961
+ }) {
962
+ return periodKey < currentKeyFor({ grain, now });
963
+ }
926
964
  function currentKeyFor({ grain, now }) {
927
965
  const year = pad4(now.getUTCFullYear());
928
966
  const month = pad2(now.getUTCMonth() + 1);
@@ -934,8 +972,14 @@ function currentKeyFor({ grain, now }) {
934
972
  return `${year}-${month}-${day}`;
935
973
  return `${year}-${month}-${day}-${hour}`;
936
974
  }
937
- function finerGrain(grain) {
938
- return grain === "month" ? "day" : "hour";
975
+ function currentChildKey({
976
+ parentGrain,
977
+ periodKey,
978
+ now
979
+ }) {
980
+ if (!isCurrentPeriod({ grain: parentGrain, periodKey, now }))
981
+ return null;
982
+ return currentKeyFor({ grain: "day", now });
939
983
  }
940
984
  function hourKeysInProgress({
941
985
  periodKey,
@@ -948,11 +992,12 @@ function hourKeysInProgress({
948
992
  }
949
993
  return keys;
950
994
  }
951
- function dayKeysInProgress({
995
+ function pastDayKeys({
952
996
  periodKey,
953
997
  now
954
998
  }) {
955
- const lastDay = isCurrentPeriod({ grain: "month", periodKey, now }) ? now.getUTCDate() : daysInMonth(periodKey);
999
+ const isCurrentMonth = isCurrentPeriod({ grain: "month", periodKey, now });
1000
+ const lastDay = isCurrentMonth ? now.getUTCDate() - 1 : daysInMonth(periodKey);
956
1001
  const keys = [];
957
1002
  for (let day = 1;day <= lastDay; day += 1) {
958
1003
  keys.push(`${periodKey}-${pad2(day)}`);
@@ -963,15 +1008,11 @@ function daysInMonth(monthKey) {
963
1008
  const [year, month] = monthKey.split("-").map(Number);
964
1009
  return new Date(Date.UTC(year ?? 1970, month ?? 1, 0)).getUTCDate();
965
1010
  }
966
- function indexByTimeKey(items) {
967
- const byKey = {};
968
- for (const item of items) {
969
- byKey[item.timeKey] = item;
970
- }
971
- return byKey;
1011
+ function toRow(item) {
1012
+ return { value: item.value, hits: item.hits, bytes: item.bytes };
972
1013
  }
973
1014
  function toRows(items) {
974
- return items.map((item) => ({ value: item.value, hits: item.hits, bytes: item.bytes }));
1015
+ return items.map(toRow);
975
1016
  }
976
1017
  function pickTotals(item) {
977
1018
  return {
@@ -1223,8 +1264,10 @@ export {
1223
1264
  applyDailyRollup,
1224
1265
  applyMonthlyRollup,
1225
1266
  breakdownSchema,
1267
+ bucketStates,
1226
1268
  buildHourlyQuery,
1227
1269
  chunkTransactItems,
1270
+ classifyBucketState,
1228
1271
  classifyDevice,
1229
1272
  columnMaps,
1230
1273
  configSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beesolve/analytics-processor",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CloudFront analytics processor — Athena-to-DynamoDB hourly totals, breakdowns, and rollups",
5
5
  "homepage": "https://github.com/BeeSolve/packages/tree/main/packages/analytics-processor#readme",
6
6
  "license": "MIT",