@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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgresSubscriptionStore / PostgresPurchaseStore against a real database.
|
|
3
|
+
*
|
|
4
|
+
* Until now nothing executed this SQL. `schema.test.ts` compares the embedded DDL
|
|
5
|
+
* strings to `sql/schema.sql` as text, which catches the two drifting apart but
|
|
6
|
+
* says nothing about whether either one works, and every other suite runs on the
|
|
7
|
+
* in-memory store. So the store that production actually uses was the least
|
|
8
|
+
* verified code in the package — and two things shipped on the strength of
|
|
9
|
+
* reading it: `getPurchasesForProduct`, and the claim in ARCHITECTURE.md that the
|
|
10
|
+
* partial unique index is "the atomic guarantee" behind non-consumable dedup.
|
|
11
|
+
*
|
|
12
|
+
* Skipped unless `DATABASE_URL` is set, so a local `npm test` without a database
|
|
13
|
+
* still passes. CI provides one through a service container.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=postgres-store.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-store.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/postgres-store.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG"}
|
package/dist/index.cjs
CHANGED
|
@@ -2549,33 +2549,44 @@ function isEndedSubscription(sub) {
|
|
|
2549
2549
|
function isNonConsumable(purchase) {
|
|
2550
2550
|
return purchase.type === shared.PURCHASE_TYPE.NON_CONSUMABLE;
|
|
2551
2551
|
}
|
|
2552
|
-
function
|
|
2552
|
+
function aggregateActiveSubscriptions(subs, nowMs) {
|
|
2553
2553
|
const byProduct = {};
|
|
2554
|
-
const byProductPurchases = {};
|
|
2555
2554
|
const byPlatform = {};
|
|
2556
|
-
let
|
|
2557
|
-
let
|
|
2558
|
-
let nonConsumablePurchases = 0;
|
|
2555
|
+
let active = 0;
|
|
2556
|
+
let gracePeriod = 0;
|
|
2559
2557
|
for (const sub of subs) {
|
|
2560
2558
|
if (!isActiveSubscription(sub, nowMs)) continue;
|
|
2561
|
-
|
|
2562
|
-
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD)
|
|
2559
|
+
active++;
|
|
2560
|
+
if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod++;
|
|
2563
2561
|
bump(byProduct, sub.productId);
|
|
2564
2562
|
bump(byPlatform, sub.platform);
|
|
2565
2563
|
}
|
|
2564
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
2565
|
+
}
|
|
2566
|
+
function aggregateNonConsumablePurchases(purchases) {
|
|
2567
|
+
const byProduct = {};
|
|
2568
|
+
const byPlatform = {};
|
|
2569
|
+
let total = 0;
|
|
2566
2570
|
for (const purchase of purchases) {
|
|
2567
2571
|
if (!isNonConsumable(purchase)) continue;
|
|
2568
|
-
|
|
2569
|
-
bump(
|
|
2572
|
+
total++;
|
|
2573
|
+
bump(byProduct, purchase.productId);
|
|
2570
2574
|
bump(byPlatform, purchase.platform);
|
|
2571
2575
|
}
|
|
2576
|
+
return { total, byProduct, byPlatform };
|
|
2577
|
+
}
|
|
2578
|
+
function composeActiveResponse(subs, purchases) {
|
|
2579
|
+
const byPlatform = { ...subs.byPlatform };
|
|
2580
|
+
for (const [platform, count] of Object.entries(purchases.byPlatform)) {
|
|
2581
|
+
byPlatform[platform] = (byPlatform[platform] ?? 0) + count;
|
|
2582
|
+
}
|
|
2572
2583
|
return {
|
|
2573
|
-
total:
|
|
2574
|
-
activeSubscriptions,
|
|
2575
|
-
gracePeriodSubscriptions,
|
|
2576
|
-
nonConsumablePurchases,
|
|
2577
|
-
byProduct,
|
|
2578
|
-
byProductPurchases,
|
|
2584
|
+
total: subs.active + purchases.total,
|
|
2585
|
+
activeSubscriptions: subs.active,
|
|
2586
|
+
gracePeriodSubscriptions: subs.gracePeriod,
|
|
2587
|
+
nonConsumablePurchases: purchases.total,
|
|
2588
|
+
byProduct: subs.byProduct,
|
|
2589
|
+
byProductPurchases: purchases.byProduct,
|
|
2579
2590
|
byPlatform
|
|
2580
2591
|
};
|
|
2581
2592
|
}
|
|
@@ -2638,8 +2649,12 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2638
2649
|
const response = await metricsCache.resolve(
|
|
2639
2650
|
["active", metricsCache.quantizeToWindow(Date.now())],
|
|
2640
2651
|
async () => {
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2652
|
+
const now = /* @__PURE__ */ new Date();
|
|
2653
|
+
const [subsAgg, purchasesAgg] = await Promise.all([
|
|
2654
|
+
store.aggregateActive ? store.aggregateActive(now) : store.listAll().then((subs) => aggregateActiveSubscriptions(subs, now.getTime())),
|
|
2655
|
+
purchaseStore.aggregateNonConsumable ? purchaseStore.aggregateNonConsumable() : purchaseStore.listAll().then(aggregateNonConsumablePurchases)
|
|
2656
|
+
]);
|
|
2657
|
+
return composeActiveResponse(subsAgg, purchasesAgg);
|
|
2643
2658
|
}
|
|
2644
2659
|
);
|
|
2645
2660
|
res.status(200).json(response);
|
|
@@ -2672,7 +2687,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2672
2687
|
}
|
|
2673
2688
|
return { fromMs, toMs, groupBy };
|
|
2674
2689
|
}
|
|
2675
|
-
function windowed(name, load, anchor, include) {
|
|
2690
|
+
function windowed(name, sqlAggregate, load, anchor, include) {
|
|
2676
2691
|
return async (req, res) => {
|
|
2677
2692
|
const range = parseRange(req);
|
|
2678
2693
|
if ("error" in range) {
|
|
@@ -2687,13 +2702,22 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2687
2702
|
metricsCache.quantizeToWindow(range.toMs),
|
|
2688
2703
|
range.groupBy
|
|
2689
2704
|
],
|
|
2690
|
-
async () =>
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2705
|
+
async () => {
|
|
2706
|
+
if (sqlAggregate) {
|
|
2707
|
+
return sqlAggregate({
|
|
2708
|
+
from: new Date(range.fromMs),
|
|
2709
|
+
to: new Date(range.toMs),
|
|
2710
|
+
groupBy: range.groupBy
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
return aggregateRange(await load(), {
|
|
2714
|
+
fromMs: range.fromMs,
|
|
2715
|
+
toMs: range.toMs,
|
|
2716
|
+
groupBy: range.groupBy,
|
|
2717
|
+
anchor,
|
|
2718
|
+
...include ? { include } : {}
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2697
2721
|
);
|
|
2698
2722
|
const response = {
|
|
2699
2723
|
from: req.query["from"],
|
|
@@ -2709,12 +2733,18 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2709
2733
|
}
|
|
2710
2734
|
router.get(
|
|
2711
2735
|
shared.ROUTES.METRICS_STARTED,
|
|
2712
|
-
windowed(
|
|
2736
|
+
windowed(
|
|
2737
|
+
"started",
|
|
2738
|
+
store.aggregateStarted?.bind(store),
|
|
2739
|
+
() => store.listAll(),
|
|
2740
|
+
(sub) => sub.purchasedAt
|
|
2741
|
+
)
|
|
2713
2742
|
);
|
|
2714
2743
|
router.get(
|
|
2715
2744
|
shared.ROUTES.METRICS_EXPIRED,
|
|
2716
2745
|
windowed(
|
|
2717
2746
|
"expired",
|
|
2747
|
+
store.aggregateExpired?.bind(store),
|
|
2718
2748
|
() => store.listAll(),
|
|
2719
2749
|
(sub) => sub.expiresAt,
|
|
2720
2750
|
isEndedSubscription
|
|
@@ -2724,6 +2754,7 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2724
2754
|
shared.ROUTES.METRICS_PURCHASES_STARTED,
|
|
2725
2755
|
windowed(
|
|
2726
2756
|
"purchases-started",
|
|
2757
|
+
purchaseStore.aggregateStarted?.bind(purchaseStore),
|
|
2727
2758
|
() => purchaseStore.listAll(),
|
|
2728
2759
|
(purchase) => purchase.purchasedAt,
|
|
2729
2760
|
isNonConsumable
|
|
@@ -2999,6 +3030,52 @@ var PostgresSubscriptionStore = class {
|
|
|
2999
3030
|
offset
|
|
3000
3031
|
};
|
|
3001
3032
|
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Counts of subscriptions currently granting entitlement.
|
|
3035
|
+
*
|
|
3036
|
+
* One `GROUP BY` instead of reading every row into the process. Grouping by
|
|
3037
|
+
* status as well as product/platform is what lets the grace-period subset come
|
|
3038
|
+
* out of the same query.
|
|
3039
|
+
*/
|
|
3040
|
+
async aggregateActive(now) {
|
|
3041
|
+
const pool = await this.getPool();
|
|
3042
|
+
const result = await pool.query(
|
|
3043
|
+
`SELECT product_id, platform, status, COUNT(*)::text AS n
|
|
3044
|
+
FROM onesub_subscriptions
|
|
3045
|
+
WHERE status IN ($1, $2) AND expires_at > $3
|
|
3046
|
+
GROUP BY product_id, platform, status`,
|
|
3047
|
+
[shared.SUBSCRIPTION_STATUS.ACTIVE, shared.SUBSCRIPTION_STATUS.GRACE_PERIOD, now]
|
|
3048
|
+
);
|
|
3049
|
+
const byProduct = {};
|
|
3050
|
+
const byPlatform = {};
|
|
3051
|
+
let active = 0;
|
|
3052
|
+
let gracePeriod = 0;
|
|
3053
|
+
for (const row of result.rows) {
|
|
3054
|
+
const n = parseInt(row.n, 10);
|
|
3055
|
+
active += n;
|
|
3056
|
+
if (row.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) gracePeriod += n;
|
|
3057
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3058
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3059
|
+
}
|
|
3060
|
+
return { active, gracePeriod, byProduct, byPlatform };
|
|
3061
|
+
}
|
|
3062
|
+
/** Cohort-start counts: subscriptions whose `purchased_at` is in the window. */
|
|
3063
|
+
async aggregateStarted(query) {
|
|
3064
|
+
const pool = await this.getPool();
|
|
3065
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "purchased_at", query);
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Subscriptions that have ended AND whose `expires_at` is in the window. A
|
|
3069
|
+
* still-active record does not count even if its expiry lands inside it — see
|
|
3070
|
+
* `isEndedSubscription`, which this mirrors.
|
|
3071
|
+
*/
|
|
3072
|
+
async aggregateExpired(query) {
|
|
3073
|
+
const pool = await this.getPool();
|
|
3074
|
+
return aggregateViaSql(pool, "onesub_subscriptions", "expires_at", query, {
|
|
3075
|
+
column: "status",
|
|
3076
|
+
values: [shared.SUBSCRIPTION_STATUS.EXPIRED, shared.SUBSCRIPTION_STATUS.CANCELED]
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3002
3079
|
/** Gracefully close the underlying connection pool. */
|
|
3003
3080
|
async close() {
|
|
3004
3081
|
if (this.poolPromise) {
|
|
@@ -3139,6 +3216,35 @@ var PostgresPurchaseStore = class {
|
|
|
3139
3216
|
const result = await pool.query(`SELECT * FROM onesub_purchases`);
|
|
3140
3217
|
return result.rows.map(rowToPurchaseInfo);
|
|
3141
3218
|
}
|
|
3219
|
+
/** Counts of non-consumable purchases. Consumables are excluded. */
|
|
3220
|
+
async aggregateNonConsumable() {
|
|
3221
|
+
const pool = await this.getPool();
|
|
3222
|
+
const result = await pool.query(
|
|
3223
|
+
`SELECT product_id, platform, COUNT(*)::text AS n
|
|
3224
|
+
FROM onesub_purchases
|
|
3225
|
+
WHERE type = $1
|
|
3226
|
+
GROUP BY product_id, platform`,
|
|
3227
|
+
[shared.PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3228
|
+
);
|
|
3229
|
+
const byProduct = {};
|
|
3230
|
+
const byPlatform = {};
|
|
3231
|
+
let total = 0;
|
|
3232
|
+
for (const row of result.rows) {
|
|
3233
|
+
const n = parseInt(row.n, 10);
|
|
3234
|
+
total += n;
|
|
3235
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3236
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3237
|
+
}
|
|
3238
|
+
return { total, byProduct, byPlatform };
|
|
3239
|
+
}
|
|
3240
|
+
/** Non-consumable purchases whose `purchased_at` is in the window. */
|
|
3241
|
+
async aggregateStarted(query) {
|
|
3242
|
+
const pool = await this.getPool();
|
|
3243
|
+
return aggregateViaSql(pool, "onesub_purchases", "purchased_at", query, {
|
|
3244
|
+
column: "type",
|
|
3245
|
+
values: [shared.PURCHASE_TYPE.NON_CONSUMABLE]
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3142
3248
|
/** Gracefully close the underlying connection pool. */
|
|
3143
3249
|
async close() {
|
|
3144
3250
|
if (this.poolPromise) {
|
|
@@ -3148,6 +3254,42 @@ var PostgresPurchaseStore = class {
|
|
|
3148
3254
|
}
|
|
3149
3255
|
}
|
|
3150
3256
|
};
|
|
3257
|
+
async function aggregateViaSql(pool, table, anchorColumn, query, filter) {
|
|
3258
|
+
const params = [query.from, query.to];
|
|
3259
|
+
let filterClause = "";
|
|
3260
|
+
if (filter) {
|
|
3261
|
+
const placeholders = filter.values.map((_, i) => `$${params.length + i + 1}`).join(", ");
|
|
3262
|
+
filterClause = `AND ${filter.column} IN (${placeholders})`;
|
|
3263
|
+
params.push(...filter.values);
|
|
3264
|
+
}
|
|
3265
|
+
const bucketing = query.groupBy === "day";
|
|
3266
|
+
const dayExpr = `to_char(date_trunc('day', ${anchorColumn} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`;
|
|
3267
|
+
const result = await pool.query(
|
|
3268
|
+
`SELECT product_id, platform,${bucketing ? ` ${dayExpr} AS day,` : ""}
|
|
3269
|
+
COUNT(*)::text AS n
|
|
3270
|
+
FROM ${table}
|
|
3271
|
+
WHERE ${anchorColumn} >= $1 AND ${anchorColumn} <= $2 ${filterClause}
|
|
3272
|
+
GROUP BY product_id, platform${bucketing ? ", day" : ""}`,
|
|
3273
|
+
params
|
|
3274
|
+
);
|
|
3275
|
+
const byProduct = {};
|
|
3276
|
+
const byPlatform = {};
|
|
3277
|
+
const perDay = /* @__PURE__ */ new Map();
|
|
3278
|
+
let total = 0;
|
|
3279
|
+
for (const row of result.rows) {
|
|
3280
|
+
const n = parseInt(row.n, 10);
|
|
3281
|
+
total += n;
|
|
3282
|
+
byProduct[row.product_id] = (byProduct[row.product_id] ?? 0) + n;
|
|
3283
|
+
byPlatform[row.platform] = (byPlatform[row.platform] ?? 0) + n;
|
|
3284
|
+
if (bucketing && row.day) perDay.set(row.day, (perDay.get(row.day) ?? 0) + n);
|
|
3285
|
+
}
|
|
3286
|
+
if (!bucketing) return { total, byProduct, byPlatform };
|
|
3287
|
+
const buckets = emptyDailyBuckets(query.from.getTime(), query.to.getTime()).map((b) => ({
|
|
3288
|
+
date: b.date,
|
|
3289
|
+
count: perDay.get(b.date) ?? 0
|
|
3290
|
+
}));
|
|
3291
|
+
return { total, byProduct, byPlatform, buckets };
|
|
3292
|
+
}
|
|
3151
3293
|
function rowToSubscriptionInfo(row) {
|
|
3152
3294
|
return {
|
|
3153
3295
|
originalTransactionId: row.original_transaction_id,
|