@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/__tests__/logger.test.d.ts +11 -0
- package/dist/__tests__/logger.test.d.ts.map +1 -0
- 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 +181 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +181 -29
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts.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
|
@@ -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
|
|
2555
|
+
function aggregateActiveSubscriptions(subs, nowMs) {
|
|
2546
2556
|
const byProduct = {};
|
|
2547
|
-
const byProductPurchases = {};
|
|
2548
2557
|
const byPlatform = {};
|
|
2549
|
-
let
|
|
2550
|
-
let
|
|
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
|
-
|
|
2555
|
-
if (sub.status === SUBSCRIPTION_STATUS.GRACE_PERIOD)
|
|
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
|
-
|
|
2562
|
-
bump(
|
|
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:
|
|
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
|
|
2635
|
-
|
|
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 () =>
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
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(
|
|
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,
|