@onesub/server 0.22.0 → 0.23.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.
package/dist/index.js CHANGED
@@ -168,10 +168,20 @@ var current = console;
168
168
  function setLogger(logger) {
169
169
  if (logger) current = logger;
170
170
  }
171
+ function escapeLineBreaks(value) {
172
+ return value.replace(/[\r\n\u2028\u2029]/g, (ch) => {
173
+ if (ch === "\n") return "\\n";
174
+ if (ch === "\r") return "\\r";
175
+ return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
176
+ });
177
+ }
178
+ function scrub(args) {
179
+ return args.map((arg) => typeof arg === "string" ? escapeLineBreaks(arg) : arg);
180
+ }
171
181
  var log = {
172
- info: (...args) => current.info(...args),
173
- warn: (...args) => current.warn(...args),
174
- error: (...args) => current.error(...args)
182
+ info: (...args) => current.info(...scrub(args)),
183
+ warn: (...args) => current.warn(...scrub(args)),
184
+ error: (...args) => current.error(...scrub(args))
175
185
  };
176
186
 
177
187
  // src/http.ts
@@ -2542,33 +2552,44 @@ function isEndedSubscription(sub) {
2542
2552
  function isNonConsumable(purchase) {
2543
2553
  return purchase.type === PURCHASE_TYPE.NON_CONSUMABLE;
2544
2554
  }
2545
- function aggregateActive(subs, purchases, nowMs) {
2555
+ function aggregateActiveSubscriptions(subs, nowMs) {
2546
2556
  const byProduct = {};
2547
- const byProductPurchases = {};
2548
2557
  const byPlatform = {};
2549
- let activeSubscriptions = 0;
2550
- let gracePeriodSubscriptions = 0;
2551
- let nonConsumablePurchases = 0;
2558
+ let active = 0;
2559
+ let gracePeriod = 0;
2552
2560
  for (const sub of subs) {
2553
2561
  if (!isActiveSubscription(sub, nowMs)) continue;
2554
- activeSubscriptions++;
2555
- if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriodSubscriptions++;
2562
+ active++;
2563
+ if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod++;
2556
2564
  bump(byProduct, sub.productId);
2557
2565
  bump(byPlatform, sub.platform);
2558
2566
  }
2567
+ return { active, gracePeriod, byProduct, byPlatform };
2568
+ }
2569
+ function aggregateNonConsumablePurchases(purchases) {
2570
+ const byProduct = {};
2571
+ const byPlatform = {};
2572
+ let total = 0;
2559
2573
  for (const purchase of purchases) {
2560
2574
  if (!isNonConsumable(purchase)) continue;
2561
- nonConsumablePurchases++;
2562
- bump(byProductPurchases, purchase.productId);
2575
+ total++;
2576
+ bump(byProduct, purchase.productId);
2563
2577
  bump(byPlatform, purchase.platform);
2564
2578
  }
2579
+ return { total, byProduct, byPlatform };
2580
+ }
2581
+ function composeActiveResponse(subs, purchases) {
2582
+ const byPlatform = { ...subs.byPlatform };
2583
+ for (const [platform, count] of Object.entries(purchases.byPlatform)) {
2584
+ byPlatform[platform] = (byPlatform[platform] ?? 0) + count;
2585
+ }
2565
2586
  return {
2566
- total: activeSubscriptions + nonConsumablePurchases,
2567
- activeSubscriptions,
2568
- gracePeriodSubscriptions,
2569
- nonConsumablePurchases,
2570
- byProduct,
2571
- byProductPurchases,
2587
+ total: subs.active + purchases.total,
2588
+ activeSubscriptions: subs.active,
2589
+ gracePeriodSubscriptions: subs.gracePeriod,
2590
+ nonConsumablePurchases: purchases.total,
2591
+ byProduct: subs.byProduct,
2592
+ byProductPurchases: purchases.byProduct,
2572
2593
  byPlatform
2573
2594
  };
2574
2595
  }
@@ -2631,8 +2652,12 @@ function createMetricsRouter(config, store, purchaseStore) {
2631
2652
  const response = await metricsCache.resolve(
2632
2653
  ["active", metricsCache.quantizeToWindow(Date.now())],
2633
2654
  async () => {
2634
- const [subs, purchases] = await Promise.all([store.listAll(), purchaseStore.listAll()]);
2635
- return aggregateActive(subs, purchases, Date.now());
2655
+ const now = /* @__PURE__ */ new Date();
2656
+ const [subsAgg, purchasesAgg] = await Promise.all([
2657
+ store.aggregateActive ? store.aggregateActive(now) : store.listAll().then((subs) => aggregateActiveSubscriptions(subs, now.getTime())),
2658
+ purchaseStore.aggregateNonConsumable ? purchaseStore.aggregateNonConsumable() : purchaseStore.listAll().then(aggregateNonConsumablePurchases)
2659
+ ]);
2660
+ return composeActiveResponse(subsAgg, purchasesAgg);
2636
2661
  }
2637
2662
  );
2638
2663
  res.status(200).json(response);
@@ -2665,7 +2690,7 @@ function createMetricsRouter(config, store, purchaseStore) {
2665
2690
  }
2666
2691
  return { fromMs, toMs, groupBy };
2667
2692
  }
2668
- function windowed(name, load, anchor, include) {
2693
+ function windowed(name, sqlAggregate, load, anchor, include) {
2669
2694
  return async (req, res) => {
2670
2695
  const range = parseRange(req);
2671
2696
  if ("error" in range) {
@@ -2680,13 +2705,22 @@ function createMetricsRouter(config, store, purchaseStore) {
2680
2705
  metricsCache.quantizeToWindow(range.toMs),
2681
2706
  range.groupBy
2682
2707
  ],
2683
- async () => aggregateRange(await load(), {
2684
- fromMs: range.fromMs,
2685
- toMs: range.toMs,
2686
- groupBy: range.groupBy,
2687
- anchor,
2688
- ...include ? { include } : {}
2689
- })
2708
+ async () => {
2709
+ if (sqlAggregate) {
2710
+ return sqlAggregate({
2711
+ from: new Date(range.fromMs),
2712
+ to: new Date(range.toMs),
2713
+ groupBy: range.groupBy
2714
+ });
2715
+ }
2716
+ return aggregateRange(await load(), {
2717
+ fromMs: range.fromMs,
2718
+ toMs: range.toMs,
2719
+ groupBy: range.groupBy,
2720
+ anchor,
2721
+ ...include ? { include } : {}
2722
+ });
2723
+ }
2690
2724
  );
2691
2725
  const response = {
2692
2726
  from: req.query["from"],
@@ -2702,12 +2736,18 @@ function createMetricsRouter(config, store, purchaseStore) {
2702
2736
  }
2703
2737
  router.get(
2704
2738
  ROUTES.METRICS_STARTED,
2705
- windowed("started", () => store.listAll(), (sub) => sub.purchasedAt)
2739
+ windowed(
2740
+ "started",
2741
+ store.aggregateStarted?.bind(store),
2742
+ () => store.listAll(),
2743
+ (sub) => sub.purchasedAt
2744
+ )
2706
2745
  );
2707
2746
  router.get(
2708
2747
  ROUTES.METRICS_EXPIRED,
2709
2748
  windowed(
2710
2749
  "expired",
2750
+ store.aggregateExpired?.bind(store),
2711
2751
  () => store.listAll(),
2712
2752
  (sub) => sub.expiresAt,
2713
2753
  isEndedSubscription
@@ -2717,6 +2757,7 @@ function createMetricsRouter(config, store, purchaseStore) {
2717
2757
  ROUTES.METRICS_PURCHASES_STARTED,
2718
2758
  windowed(
2719
2759
  "purchases-started",
2760
+ purchaseStore.aggregateStarted?.bind(purchaseStore),
2720
2761
  () => purchaseStore.listAll(),
2721
2762
  (purchase) => purchase.purchasedAt,
2722
2763
  isNonConsumable
@@ -2992,6 +3033,52 @@ var PostgresSubscriptionStore = class {
2992
3033
  offset
2993
3034
  };
2994
3035
  }
3036
+ /**
3037
+ * Counts of subscriptions currently granting entitlement.
3038
+ *
3039
+ * One `GROUP BY` instead of reading every row into the process. Grouping by
3040
+ * status as well as product/platform is what lets the grace-period subset come
3041
+ * out of the same query.
3042
+ */
3043
+ async aggregateActive(now) {
3044
+ const pool = await this.getPool();
3045
+ const result = await pool.query(
3046
+ `SELECT product_id, platform, status, COUNT(*)::text AS n
3047
+ FROM onesub_subscriptions
3048
+ WHERE status IN ($1, $2) AND expires_at > $3
3049
+ GROUP BY product_id, platform, status`,
3050
+ [SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.GRACE_PERIOD, now]
3051
+ );
3052
+ const byProduct = {};
3053
+ const byPlatform = {};
3054
+ let active = 0;
3055
+ let gracePeriod = 0;
3056
+ for (const row of result.rows) {
3057
+ const n = parseInt(row.n, 10);
3058
+ active += n;
3059
+ if (row.status === SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod += n;
3060
+ byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
3061
+ byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
3062
+ }
3063
+ return { active, gracePeriod, byProduct, byPlatform };
3064
+ }
3065
+ /** Cohort-start counts: subscriptions whose `purchased_at` is in the window. */
3066
+ async aggregateStarted(query) {
3067
+ const pool = await this.getPool();
3068
+ return aggregateViaSql(pool, "onesub_subscriptions", "purchased_at", query);
3069
+ }
3070
+ /**
3071
+ * Subscriptions that have ended AND whose `expires_at` is in the window. A
3072
+ * still-active record does not count even if its expiry lands inside it — see
3073
+ * `isEndedSubscription`, which this mirrors.
3074
+ */
3075
+ async aggregateExpired(query) {
3076
+ const pool = await this.getPool();
3077
+ return aggregateViaSql(pool, "onesub_subscriptions", "expires_at", query, {
3078
+ column: "status",
3079
+ values: [SUBSCRIPTION_STATUS.EXPIRED, SUBSCRIPTION_STATUS.CANCELED]
3080
+ });
3081
+ }
2995
3082
  /** Gracefully close the underlying connection pool. */
2996
3083
  async close() {
2997
3084
  if (this.poolPromise) {
@@ -3132,6 +3219,35 @@ var PostgresPurchaseStore = class {
3132
3219
  const result = await pool.query(`SELECT * FROM onesub_purchases`);
3133
3220
  return result.rows.map(rowToPurchaseInfo);
3134
3221
  }
3222
+ /** Counts of non-consumable purchases. Consumables are excluded. */
3223
+ async aggregateNonConsumable() {
3224
+ const pool = await this.getPool();
3225
+ const result = await pool.query(
3226
+ `SELECT product_id, platform, COUNT(*)::text AS n
3227
+ FROM onesub_purchases
3228
+ WHERE type = $1
3229
+ GROUP BY product_id, platform`,
3230
+ [PURCHASE_TYPE.NON_CONSUMABLE]
3231
+ );
3232
+ const byProduct = {};
3233
+ const byPlatform = {};
3234
+ let total = 0;
3235
+ for (const row of result.rows) {
3236
+ const n = parseInt(row.n, 10);
3237
+ total += n;
3238
+ byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
3239
+ byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
3240
+ }
3241
+ return { total, byProduct, byPlatform };
3242
+ }
3243
+ /** Non-consumable purchases whose `purchased_at` is in the window. */
3244
+ async aggregateStarted(query) {
3245
+ const pool = await this.getPool();
3246
+ return aggregateViaSql(pool, "onesub_purchases", "purchased_at", query, {
3247
+ column: "type",
3248
+ values: [PURCHASE_TYPE.NON_CONSUMABLE]
3249
+ });
3250
+ }
3135
3251
  /** Gracefully close the underlying connection pool. */
3136
3252
  async close() {
3137
3253
  if (this.poolPromise) {
@@ -3141,6 +3257,42 @@ var PostgresPurchaseStore = class {
3141
3257
  }
3142
3258
  }
3143
3259
  };
3260
+ async function aggregateViaSql(pool, table, anchorColumn, query, filter) {
3261
+ const params = [query.from, query.to];
3262
+ let filterClause = "";
3263
+ if (filter) {
3264
+ const placeholders = filter.values.map((_, i) => `$${params.length + i + 1}`).join(", ");
3265
+ filterClause = `AND ${filter.column} IN (${placeholders})`;
3266
+ params.push(...filter.values);
3267
+ }
3268
+ const bucketing = query.groupBy === "day";
3269
+ const dayExpr = `to_char(date_trunc('day', ${anchorColumn} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`;
3270
+ const result = await pool.query(
3271
+ `SELECT product_id, platform,${bucketing ? ` ${dayExpr} AS day,` : ""}
3272
+ COUNT(*)::text AS n
3273
+ FROM ${table}
3274
+ WHERE ${anchorColumn} >= $1 AND ${anchorColumn} <= $2 ${filterClause}
3275
+ GROUP BY product_id, platform${bucketing ? ", day" : ""}`,
3276
+ params
3277
+ );
3278
+ const byProduct = {};
3279
+ const byPlatform = {};
3280
+ const perDay = /* @__PURE__ */ new Map();
3281
+ let total = 0;
3282
+ for (const row of result.rows) {
3283
+ const n = parseInt(row.n, 10);
3284
+ total += n;
3285
+ byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
3286
+ byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
3287
+ if (bucketing && row.day) perDay.set(row.day, (perDay.get(row.day) ?? 0) + n);
3288
+ }
3289
+ if (!bucketing) return { total, byProduct, byPlatform };
3290
+ const buckets = emptyDailyBuckets(query.from.getTime(), query.to.getTime()).map((b) => ({
3291
+ date: b.date,
3292
+ count: perDay.get(b.date) ?? 0
3293
+ }));
3294
+ return { total, byProduct, byPlatform, buckets };
3295
+ }
3144
3296
  function rowToSubscriptionInfo(row) {
3145
3297
  return {
3146
3298
  originalTransactionId: row.original_transaction_id,