@onesub/server 0.22.0 → 0.23.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.js CHANGED
@@ -2542,33 +2542,44 @@ function isEndedSubscription(sub) {
2542
2542
  function isNonConsumable(purchase) {
2543
2543
  return purchase.type === PURCHASE_TYPE.NON_CONSUMABLE;
2544
2544
  }
2545
- function aggregateActive(subs, purchases, nowMs) {
2545
+ function aggregateActiveSubscriptions(subs, nowMs) {
2546
2546
  const byProduct = {};
2547
- const byProductPurchases = {};
2548
2547
  const byPlatform = {};
2549
- let activeSubscriptions = 0;
2550
- let gracePeriodSubscriptions = 0;
2551
- let nonConsumablePurchases = 0;
2548
+ let active = 0;
2549
+ let gracePeriod = 0;
2552
2550
  for (const sub of subs) {
2553
2551
  if (!isActiveSubscription(sub, nowMs)) continue;
2554
- activeSubscriptions++;
2555
- if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
2552
+ active++;
2553
+ if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod++;
2556
2554
  bump(byProduct, sub.productId);
2557
2555
  bump(byPlatform, sub.platform);
2558
2556
  }
2557
+ return { active, gracePeriod, byProduct, byPlatform };
2558
+ }
2559
+ function aggregateNonConsumablePurchases(purchases) {
2560
+ const byProduct = {};
2561
+ const byPlatform = {};
2562
+ let total = 0;
2559
2563
  for (const purchase of purchases) {
2560
2564
  if (!isNonConsumable(purchase)) continue;
2561
- nonConsumablePurchases++;
2562
- bump(byProductPurchases, purchase.productId);
2565
+ total++;
2566
+ bump(byProduct, purchase.productId);
2563
2567
  bump(byPlatform, purchase.platform);
2564
2568
  }
2569
+ return { total, byProduct, byPlatform };
2570
+ }
2571
+ function composeActiveResponse(subs, purchases) {
2572
+ const byPlatform = { ...subs.byPlatform };
2573
+ for (const [platform, count] of Object.entries(purchases.byPlatform)) {
2574
+ byPlatform[platform] = (byPlatform[platform] ?? 0) + count;
2575
+ }
2565
2576
  return {
2566
- total: activeSubscriptions + nonConsumablePurchases,
2567
- activeSubscriptions,
2568
- gracePeriodSubscriptions,
2569
- nonConsumablePurchases,
2570
- byProduct,
2571
- byProductPurchases,
2577
+ total: subs.active + purchases.total,
2578
+ activeSubscriptions: subs.active,
2579
+ gracePeriodSubscriptions: subs.gracePeriod,
2580
+ nonConsumablePurchases: purchases.total,
2581
+ byProduct: subs.byProduct,
2582
+ byProductPurchases: purchases.byProduct,
2572
2583
  byPlatform
2573
2584
  };
2574
2585
  }
@@ -2631,8 +2642,12 @@ function createMetricsRouter(config, store, purchaseStore) {
2631
2642
  const response = await metricsCache.resolve(
2632
2643
  ["active", metricsCache.quantizeToWindow(Date.now())],
2633
2644
  async () => {
2634
- const [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2635
- return aggregateActive(subs, purchases, Date.now());
2645
+ const now = /* @__PURE__ */ new Date();
2646
+ const [subsAgg, purchasesAgg] = await Promise.all([
2647
+ store.aggregateActive ? store.aggregateActive(now) : store.listAll().then((subs) => aggregateActiveSubscriptions(subs, now.getTime())),
2648
+ purchaseStore.aggregateNonConsumable ? purchaseStore.aggregateNonConsumable() : purchaseStore.listAll().then(aggregateNonConsumablePurchases)
2649
+ ]);
2650
+ return composeActiveResponse(subsAgg, purchasesAgg);
2636
2651
  }
2637
2652
  );
2638
2653
  res.status(200).json(response);
@@ -2665,7 +2680,7 @@ function createMetricsRouter(config, store, purchaseStore) {
2665
2680
  }
2666
2681
  return { fromMs, toMs, groupBy };
2667
2682
  }
2668
- function windowed(name, load, anchor, include) {
2683
+ function windowed(name, sqlAggregate, load, anchor, include) {
2669
2684
  return async (req, res) => {
2670
2685
  const range = parseRange(req);
2671
2686
  if ("error" in range) {
@@ -2680,13 +2695,22 @@ function createMetricsRouter(config, store, purchaseStore) {
2680
2695
  metricsCache.quantizeToWindow(range.toMs),
2681
2696
  range.groupBy
2682
2697
  ],
2683
- async () => aggregateRange(await load(), {
2684
- fromMs: range.fromMs,
2685
- toMs: range.toMs,
2686
- groupBy: range.groupBy,
2687
- anchor,
2688
- ...include ? { include } : {}
2689
- })
2698
+ async () => {
2699
+ if (sqlAggregate) {
2700
+ return sqlAggregate({
2701
+ from: new Date(range.fromMs),
2702
+ to: new Date(range.toMs),
2703
+ groupBy: range.groupBy
2704
+ });
2705
+ }
2706
+ return aggregateRange(await load(), {
2707
+ fromMs: range.fromMs,
2708
+ toMs: range.toMs,
2709
+ groupBy: range.groupBy,
2710
+ anchor,
2711
+ ...include ? { include } : {}
2712
+ });
2713
+ }
2690
2714
  );
2691
2715
  const response = {
2692
2716
  from: req.query["from"],
@@ -2702,12 +2726,18 @@ function createMetricsRouter(config, store, purchaseStore) {
2702
2726
  }
2703
2727
  router.get(
2704
2728
  ROUTES.METRICS_STARTED,
2705
- windowed("started", () => store.listAll(), (sub) => sub.purchasedAt)
2729
+ windowed(
2730
+ "started",
2731
+ store.aggregateStarted?.bind(store),
2732
+ () => store.listAll(),
2733
+ (sub) => sub.purchasedAt
2734
+ )
2706
2735
  );
2707
2736
  router.get(
2708
2737
  ROUTES.METRICS_EXPIRED,
2709
2738
  windowed(
2710
2739
  "expired",
2740
+ store.aggregateExpired?.bind(store),
2711
2741
  () => store.listAll(),
2712
2742
  (sub) => sub.expiresAt,
2713
2743
  isEndedSubscription
@@ -2717,6 +2747,7 @@ function createMetricsRouter(config, store, purchaseStore) {
2717
2747
  ROUTES.METRICS_PURCHASES_STARTED,
2718
2748
  windowed(
2719
2749
  "purchases-started",
2750
+ purchaseStore.aggregateStarted?.bind(purchaseStore),
2720
2751
  () => purchaseStore.listAll(),
2721
2752
  (purchase) => purchase.purchasedAt,
2722
2753
  isNonConsumable
@@ -2992,6 +3023,52 @@ var PostgresSubscriptionStore = class {
2992
3023
  offset
2993
3024
  };
2994
3025
  }
3026
+ /**
3027
+ * Counts of subscriptions currently granting entitlement.
3028
+ *
3029
+ * One `GROUP BY` instead of reading every row into the process. Grouping by
3030
+ * status as well as product/platform is what lets the grace-period subset come
3031
+ * out of the same query.
3032
+ */
3033
+ async aggregateActive(now) {
3034
+ const pool = await this.getPool();
3035
+ const result = await pool.query(
3036
+ `SELECT product_id, platform, status, COUNT(*)::text AS n
3037
+ FROM onesub_subscriptions
3038
+ WHERE status IN ($1, $2) AND expires_at > $3
3039
+ GROUP BY product_id, platform, status`,
3040
+ [SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.GRACE_PERIOD, now]
3041
+ );
3042
+ const byProduct = {};
3043
+ const byPlatform = {};
3044
+ let active = 0;
3045
+ let gracePeriod = 0;
3046
+ for (const row of result.rows) {
3047
+ const n = parseInt(row.n, 10);
3048
+ active += n;
3049
+ if (row.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod += n;
3050
+ byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
3051
+ byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
3052
+ }
3053
+ return { active, gracePeriod, byProduct, byPlatform };
3054
+ }
3055
+ /** Cohort-start counts: subscriptions whose `purchased_at` is in the window. */
3056
+ async aggregateStarted(query) {
3057
+ const pool = await this.getPool();
3058
+ return aggregateViaSql(pool, "onesub_subscriptions", "purchased_at", query);
3059
+ }
3060
+ /**
3061
+ * Subscriptions that have ended AND whose `expires_at` is in the window. A
3062
+ * still-active record does not count even if its expiry lands inside it — see
3063
+ * `isEndedSubscription`, which this mirrors.
3064
+ */
3065
+ async aggregateExpired(query) {
3066
+ const pool = await this.getPool();
3067
+ return aggregateViaSql(pool, "onesub_subscriptions", "expires_at", query, {
3068
+ column: "status",
3069
+ values: [SUBSCRIPTION_STATUS.EXPIRED, SUBSCRIPTION_STATUS.CANCELED]
3070
+ });
3071
+ }
2995
3072
  /** Gracefully close the underlying connection pool. */
2996
3073
  async close() {
2997
3074
  if (this.poolPromise) {
@@ -3132,6 +3209,35 @@ var PostgresPurchaseStore = class {
3132
3209
  const result = await pool.query(`SELECT * FROM onesub_purchases`);
3133
3210
  return result.rows.map(rowToPurchaseInfo);
3134
3211
  }
3212
+ /** Counts of non-consumable purchases. Consumables are excluded. */
3213
+ async aggregateNonConsumable() {
3214
+ const pool = await this.getPool();
3215
+ const result = await pool.query(
3216
+ `SELECT product_id, platform, COUNT(*)::text AS n
3217
+ FROM onesub_purchases
3218
+ WHERE type = $1
3219
+ GROUP BY product_id, platform`,
3220
+ [PURCHASE_TYPE.NON_CONSUMABLE]
3221
+ );
3222
+ const byProduct = {};
3223
+ const byPlatform = {};
3224
+ let total = 0;
3225
+ for (const row of result.rows) {
3226
+ const n = parseInt(row.n, 10);
3227
+ total += n;
3228
+ byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
3229
+ byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
3230
+ }
3231
+ return { total, byProduct, byPlatform };
3232
+ }
3233
+ /** Non-consumable purchases whose `purchased_at` is in the window. */
3234
+ async aggregateStarted(query) {
3235
+ const pool = await this.getPool();
3236
+ return aggregateViaSql(pool, "onesub_purchases", "purchased_at", query, {
3237
+ column: "type",
3238
+ values: [PURCHASE_TYPE.NON_CONSUMABLE]
3239
+ });
3240
+ }
3135
3241
  /** Gracefully close the underlying connection pool. */
3136
3242
  async close() {
3137
3243
  if (this.poolPromise) {
@@ -3141,6 +3247,42 @@ var PostgresPurchaseStore = class {
3141
3247
  }
3142
3248
  }
3143
3249
  };
3250
+ async function aggregateViaSql(pool, table, anchorColumn, query, filter) {
3251
+ const params = [query.from, query.to];
3252
+ let filterClause = "";
3253
+ if (filter) {
3254
+ const placeholders = filter.values.map((_, i) => `$${params.length + i + 1}`).join(", ");
3255
+ filterClause = `AND ${filter.column} IN (${placeholders})`;
3256
+ params.push(...filter.values);
3257
+ }
3258
+ const bucketing = query.groupBy === "day";
3259
+ const dayExpr = `to_char(date_trunc('day', ${anchorColumn} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`;
3260
+ const result = await pool.query(
3261
+ `SELECT product_id, platform,${bucketing ? ` ${dayExpr} AS day,` : ""}
3262
+ COUNT(*)::text AS n
3263
+ FROM ${table}
3264
+ WHERE ${anchorColumn} >= $1 AND ${anchorColumn} <= $2 ${filterClause}
3265
+ GROUP BY product_id, platform${bucketing ? ", day" : ""}`,
3266
+ params
3267
+ );
3268
+ const byProduct = {};
3269
+ const byPlatform = {};
3270
+ const perDay = /* @__PURE__ */ new Map();
3271
+ let total = 0;
3272
+ for (const row of result.rows) {
3273
+ const n = parseInt(row.n, 10);
3274
+ total += n;
3275
+ byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
3276
+ byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
3277
+ if (bucketing && row.day) perDay.set(row.day, (perDay.get(row.day) ?? 0) + n);
3278
+ }
3279
+ if (!bucketing) return { total, byProduct, byPlatform };
3280
+ const buckets = emptyDailyBuckets(query.from.getTime(), query.to.getTime()).map((b) => ({
3281
+ date: b.date,
3282
+ count: perDay.get(b.date) ?? 0
3283
+ }));
3284
+ return { total, byProduct, byPlatform, buckets };
3285
+ }
3144
3286
  function rowToSubscriptionInfo(row) {
3145
3287
  return {
3146
3288
  originalTransactionId: row.original_transaction_id,