@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/__tests__/postgres-store.test.d.ts +16 -0
- package/dist/__tests__/postgres-store.test.d.ts.map +1 -0
- package/dist/index.cjs +168 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +168 -26
- package/dist/index.js.map +1 -1
- package/dist/metrics-aggregate.d.ts +27 -8
- package/dist/metrics-aggregate.d.ts.map +1 -1
- package/dist/routes/metrics.d.ts +18 -6
- package/dist/routes/metrics.d.ts.map +1 -1
- package/dist/store.d.ts +65 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/postgres.d.ts +21 -1
- package/dist/stores/postgres.d.ts.map +1 -1
- package/package.json +2 -1
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
|
|
2545
|
+
function aggregateActiveSubscriptions(subs, nowMs) {
|
|
2546
2546
|
const byProduct = {};
|
|
2547
|
-
const byProductPurchases = {};
|
|
2548
2547
|
const byPlatform = {};
|
|
2549
|
-
let
|
|
2550
|
-
let
|
|
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
|
-
|
|
2555
|
-
if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD)
|
|
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
|
-
|
|
2562
|
-
bump(
|
|
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:
|
|
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
|
|
2635
|
-
|
|
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 () =>
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
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(
|
|
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,
|