@drakkar.software/sunglasses-core 0.5.0 → 0.7.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.d.mts +32 -26
- package/dist/index.d.ts +32 -26
- package/dist/index.js +50 -110
- package/dist/index.mjs +50 -110
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -399,6 +399,20 @@ interface StarfishAdapterConfig {
|
|
|
399
399
|
* Can be the same adapter as `SunglassesConfig.storage`.
|
|
400
400
|
*/
|
|
401
401
|
pathStorage?: IStorageAdapter;
|
|
402
|
+
/**
|
|
403
|
+
* When `true`, events are pushed directly without a prior pull.
|
|
404
|
+
* No merge, no optimistic locking, no conflict detection.
|
|
405
|
+
*
|
|
406
|
+
* Use this for Starfish collections configured with `queueOnly: true` —
|
|
407
|
+
* the server ignores `baseHash` and returns no stored data on pull,
|
|
408
|
+
* so a pull round-trip is always wasted.
|
|
409
|
+
*
|
|
410
|
+
* On push failure the adapter **throws**, allowing SunglassesCore to keep
|
|
411
|
+
* events in the local queue and retry on the next flush interval.
|
|
412
|
+
*
|
|
413
|
+
* Cannot be combined with `rotatePathOnSuccess`.
|
|
414
|
+
*/
|
|
415
|
+
pushOnly?: boolean;
|
|
402
416
|
}
|
|
403
417
|
/**
|
|
404
418
|
* In-memory + persisted session state.
|
|
@@ -653,15 +667,11 @@ declare class MiddlewarePipeline {
|
|
|
653
667
|
/**
|
|
654
668
|
* Persists per-event counts bucketed by time period (daily, weekly, monthly, all-time).
|
|
655
669
|
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
* where `bucket` is:
|
|
660
|
-
* - daily: "2024-01-15"
|
|
661
|
-
* - weekly: "2024-W03" (ISO week)
|
|
662
|
-
* - monthly: "2024-01"
|
|
663
|
-
* - all-time: "all"
|
|
670
|
+
* All counts are stored in a single `sg:counts` JSON blob, keyed internally by
|
|
671
|
+
* `{period}:{bucket}:{eventName}` within the object:
|
|
672
|
+
* `{ "daily:2024-01-15:click": 3, "weekly:2024-W03:click": 7, ... }`
|
|
664
673
|
*
|
|
674
|
+
* One storage key regardless of how many event types are tracked.
|
|
665
675
|
* Counts survive app restarts (persisted to IStorageAdapter).
|
|
666
676
|
* Designed to be written from the enqueue hot path — must not throw.
|
|
667
677
|
*/
|
|
@@ -669,17 +679,12 @@ declare class EventCounter implements IEventCounter {
|
|
|
669
679
|
private readonly storage;
|
|
670
680
|
private readonly logger;
|
|
671
681
|
/**
|
|
672
|
-
* In-memory cache of counts keyed by
|
|
673
|
-
* Updated synchronously on increment so getCount() can return immediately
|
|
674
|
-
* without waiting for storage writes to complete.
|
|
682
|
+
* In-memory cache of counts keyed by sub-key (`{period}:{bucket}:{eventName}`).
|
|
683
|
+
* Updated synchronously on increment so getCount() can return immediately.
|
|
675
684
|
*/
|
|
676
685
|
private readonly cache;
|
|
677
|
-
/**
|
|
678
|
-
|
|
679
|
-
* Used to reliably clear an event's timed buckets on reset(),
|
|
680
|
-
* even when they fall outside the 90-day sweep window.
|
|
681
|
-
*/
|
|
682
|
-
private readonly writtenKeys;
|
|
686
|
+
/** Whether the counts blob has been loaded from storage into cache. */
|
|
687
|
+
private loaded;
|
|
683
688
|
constructor(storage: IStorageAdapter, logger: Logger);
|
|
684
689
|
/**
|
|
685
690
|
* Increment the count for `eventName` across all four periods for `date`.
|
|
@@ -688,20 +693,21 @@ declare class EventCounter implements IEventCounter {
|
|
|
688
693
|
getCount(eventName: string, period: EventCountPeriod, date?: Date): Promise<number>;
|
|
689
694
|
/**
|
|
690
695
|
* Reset counts for a specific event (all periods), or all events if omitted.
|
|
691
|
-
*
|
|
692
|
-
* Note: resetting all events requires listing storage keys. This is a best-effort
|
|
693
|
-
* operation — adapters that don't support key enumeration will only clear
|
|
694
|
-
* the provided event name's keys.
|
|
695
696
|
*/
|
|
696
697
|
reset(eventName?: string): Promise<void>;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
698
|
+
/**
|
|
699
|
+
* Load the counts blob from storage into the in-memory cache.
|
|
700
|
+
* Only runs once; subsequent calls are no-ops.
|
|
701
|
+
*/
|
|
702
|
+
private loadIfNeeded;
|
|
703
|
+
/** Serialize the full in-memory cache to `sg:counts`. */
|
|
704
|
+
private persist;
|
|
705
|
+
/** Build the sub-key within the counts blob for a given event, period, and date. */
|
|
706
|
+
private subKey;
|
|
701
707
|
/**
|
|
702
708
|
* Return the time-bucket string for a given period and date.
|
|
703
709
|
* - daily: "2024-01-15"
|
|
704
|
-
* - weekly: "2024-W03"
|
|
710
|
+
* - weekly: "2024-W03" (ISO week)
|
|
705
711
|
* - monthly: "2024-01"
|
|
706
712
|
* - all-time: "all"
|
|
707
713
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -399,6 +399,20 @@ interface StarfishAdapterConfig {
|
|
|
399
399
|
* Can be the same adapter as `SunglassesConfig.storage`.
|
|
400
400
|
*/
|
|
401
401
|
pathStorage?: IStorageAdapter;
|
|
402
|
+
/**
|
|
403
|
+
* When `true`, events are pushed directly without a prior pull.
|
|
404
|
+
* No merge, no optimistic locking, no conflict detection.
|
|
405
|
+
*
|
|
406
|
+
* Use this for Starfish collections configured with `queueOnly: true` —
|
|
407
|
+
* the server ignores `baseHash` and returns no stored data on pull,
|
|
408
|
+
* so a pull round-trip is always wasted.
|
|
409
|
+
*
|
|
410
|
+
* On push failure the adapter **throws**, allowing SunglassesCore to keep
|
|
411
|
+
* events in the local queue and retry on the next flush interval.
|
|
412
|
+
*
|
|
413
|
+
* Cannot be combined with `rotatePathOnSuccess`.
|
|
414
|
+
*/
|
|
415
|
+
pushOnly?: boolean;
|
|
402
416
|
}
|
|
403
417
|
/**
|
|
404
418
|
* In-memory + persisted session state.
|
|
@@ -653,15 +667,11 @@ declare class MiddlewarePipeline {
|
|
|
653
667
|
/**
|
|
654
668
|
* Persists per-event counts bucketed by time period (daily, weekly, monthly, all-time).
|
|
655
669
|
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
* where `bucket` is:
|
|
660
|
-
* - daily: "2024-01-15"
|
|
661
|
-
* - weekly: "2024-W03" (ISO week)
|
|
662
|
-
* - monthly: "2024-01"
|
|
663
|
-
* - all-time: "all"
|
|
670
|
+
* All counts are stored in a single `sg:counts` JSON blob, keyed internally by
|
|
671
|
+
* `{period}:{bucket}:{eventName}` within the object:
|
|
672
|
+
* `{ "daily:2024-01-15:click": 3, "weekly:2024-W03:click": 7, ... }`
|
|
664
673
|
*
|
|
674
|
+
* One storage key regardless of how many event types are tracked.
|
|
665
675
|
* Counts survive app restarts (persisted to IStorageAdapter).
|
|
666
676
|
* Designed to be written from the enqueue hot path — must not throw.
|
|
667
677
|
*/
|
|
@@ -669,17 +679,12 @@ declare class EventCounter implements IEventCounter {
|
|
|
669
679
|
private readonly storage;
|
|
670
680
|
private readonly logger;
|
|
671
681
|
/**
|
|
672
|
-
* In-memory cache of counts keyed by
|
|
673
|
-
* Updated synchronously on increment so getCount() can return immediately
|
|
674
|
-
* without waiting for storage writes to complete.
|
|
682
|
+
* In-memory cache of counts keyed by sub-key (`{period}:{bucket}:{eventName}`).
|
|
683
|
+
* Updated synchronously on increment so getCount() can return immediately.
|
|
675
684
|
*/
|
|
676
685
|
private readonly cache;
|
|
677
|
-
/**
|
|
678
|
-
|
|
679
|
-
* Used to reliably clear an event's timed buckets on reset(),
|
|
680
|
-
* even when they fall outside the 90-day sweep window.
|
|
681
|
-
*/
|
|
682
|
-
private readonly writtenKeys;
|
|
686
|
+
/** Whether the counts blob has been loaded from storage into cache. */
|
|
687
|
+
private loaded;
|
|
683
688
|
constructor(storage: IStorageAdapter, logger: Logger);
|
|
684
689
|
/**
|
|
685
690
|
* Increment the count for `eventName` across all four periods for `date`.
|
|
@@ -688,20 +693,21 @@ declare class EventCounter implements IEventCounter {
|
|
|
688
693
|
getCount(eventName: string, period: EventCountPeriod, date?: Date): Promise<number>;
|
|
689
694
|
/**
|
|
690
695
|
* Reset counts for a specific event (all periods), or all events if omitted.
|
|
691
|
-
*
|
|
692
|
-
* Note: resetting all events requires listing storage keys. This is a best-effort
|
|
693
|
-
* operation — adapters that don't support key enumeration will only clear
|
|
694
|
-
* the provided event name's keys.
|
|
695
696
|
*/
|
|
696
697
|
reset(eventName?: string): Promise<void>;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
698
|
+
/**
|
|
699
|
+
* Load the counts blob from storage into the in-memory cache.
|
|
700
|
+
* Only runs once; subsequent calls are no-ops.
|
|
701
|
+
*/
|
|
702
|
+
private loadIfNeeded;
|
|
703
|
+
/** Serialize the full in-memory cache to `sg:counts`. */
|
|
704
|
+
private persist;
|
|
705
|
+
/** Build the sub-key within the counts blob for a given event, period, and date. */
|
|
706
|
+
private subKey;
|
|
701
707
|
/**
|
|
702
708
|
* Return the time-bucket string for a given period and date.
|
|
703
709
|
* - daily: "2024-01-15"
|
|
704
|
-
* - weekly: "2024-W03"
|
|
710
|
+
* - weekly: "2024-W03" (ISO week)
|
|
705
711
|
* - monthly: "2024-01"
|
|
706
712
|
* - all-time: "all"
|
|
707
713
|
*/
|
package/dist/index.js
CHANGED
|
@@ -205,150 +205,91 @@ var ConsentManager = class {
|
|
|
205
205
|
};
|
|
206
206
|
|
|
207
207
|
// src/EventCounter.ts
|
|
208
|
-
var
|
|
208
|
+
var COUNTS_KEY = "sg:counts";
|
|
209
209
|
var EventCounter = class {
|
|
210
210
|
constructor(storage, logger) {
|
|
211
211
|
this.storage = storage;
|
|
212
212
|
this.logger = logger;
|
|
213
213
|
/**
|
|
214
|
-
* In-memory cache of counts keyed by
|
|
215
|
-
* Updated synchronously on increment so getCount() can return immediately
|
|
216
|
-
* without waiting for storage writes to complete.
|
|
214
|
+
* In-memory cache of counts keyed by sub-key (`{period}:{bucket}:{eventName}`).
|
|
215
|
+
* Updated synchronously on increment so getCount() can return immediately.
|
|
217
216
|
*/
|
|
218
217
|
this.cache = /* @__PURE__ */ new Map();
|
|
219
|
-
/**
|
|
220
|
-
|
|
221
|
-
* Used to reliably clear an event's timed buckets on reset(),
|
|
222
|
-
* even when they fall outside the 90-day sweep window.
|
|
223
|
-
*/
|
|
224
|
-
this.writtenKeys = /* @__PURE__ */ new Set();
|
|
218
|
+
/** Whether the counts blob has been loaded from storage into cache. */
|
|
219
|
+
this.loaded = false;
|
|
225
220
|
}
|
|
226
221
|
/**
|
|
227
222
|
* Increment the count for `eventName` across all four periods for `date`.
|
|
228
223
|
*/
|
|
229
224
|
async increment(eventName, date = /* @__PURE__ */ new Date()) {
|
|
225
|
+
await this.loadIfNeeded();
|
|
230
226
|
const periods = ["daily", "weekly", "monthly", "all-time"];
|
|
231
|
-
|
|
227
|
+
for (const period of periods) {
|
|
228
|
+
const key = this.subKey(eventName, period, date);
|
|
229
|
+
this.cache.set(key, (this.cache.get(key) ?? 0) + 1);
|
|
230
|
+
}
|
|
231
|
+
await this.persist();
|
|
232
232
|
}
|
|
233
233
|
async getCount(eventName, period, date = /* @__PURE__ */ new Date()) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (cached !== void 0) return cached;
|
|
237
|
-
try {
|
|
238
|
-
const raw = await this.storage.read(key);
|
|
239
|
-
if (raw === null) return 0;
|
|
240
|
-
const n = parseInt(raw, 10);
|
|
241
|
-
const count = Number.isNaN(n) ? 0 : n;
|
|
242
|
-
this.cache.set(key, count);
|
|
243
|
-
return count;
|
|
244
|
-
} catch (err) {
|
|
245
|
-
this.logger.warn("EventCounter: failed to read count", err);
|
|
246
|
-
return 0;
|
|
247
|
-
}
|
|
234
|
+
await this.loadIfNeeded();
|
|
235
|
+
return this.cache.get(this.subKey(eventName, period, date)) ?? 0;
|
|
248
236
|
}
|
|
249
237
|
/**
|
|
250
238
|
* Reset counts for a specific event (all periods), or all events if omitted.
|
|
251
|
-
*
|
|
252
|
-
* Note: resetting all events requires listing storage keys. This is a best-effort
|
|
253
|
-
* operation — adapters that don't support key enumeration will only clear
|
|
254
|
-
* the provided event name's keys.
|
|
255
239
|
*/
|
|
256
240
|
async reset(eventName) {
|
|
241
|
+
await this.loadIfNeeded();
|
|
257
242
|
if (eventName) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
await this.storage.delete(key);
|
|
264
|
-
} catch {
|
|
265
|
-
}
|
|
266
|
-
this.writtenKeys.delete(key);
|
|
267
|
-
this.cache.delete(key);
|
|
268
|
-
}
|
|
269
|
-
const now = /* @__PURE__ */ new Date();
|
|
270
|
-
const seenKeys = new Set(allKeys);
|
|
271
|
-
const periods = ["daily", "weekly", "monthly", "all-time"];
|
|
272
|
-
const eventNames = new Set(
|
|
273
|
-
allKeys.map((k) => k.split(":").pop()).filter(Boolean)
|
|
274
|
-
);
|
|
275
|
-
for (let i = 0; i < 90; i++) {
|
|
276
|
-
const d = new Date(now);
|
|
277
|
-
d.setDate(d.getDate() - i);
|
|
278
|
-
for (const name of eventNames) {
|
|
279
|
-
for (const period of periods) {
|
|
280
|
-
const key = this.storageKey(name, period, d);
|
|
281
|
-
if (!seenKeys.has(key)) {
|
|
282
|
-
seenKeys.add(key);
|
|
283
|
-
try {
|
|
284
|
-
await this.storage.delete(key);
|
|
285
|
-
} catch {
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
243
|
+
const safeEvent = eventName.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
244
|
+
for (const key of this.cache.keys()) {
|
|
245
|
+
if (key.endsWith(`:${safeEvent}`)) {
|
|
246
|
+
this.cache.delete(key);
|
|
289
247
|
}
|
|
290
248
|
}
|
|
291
|
-
|
|
249
|
+
} else {
|
|
250
|
+
this.cache.clear();
|
|
292
251
|
}
|
|
252
|
+
await this.persist();
|
|
253
|
+
this.logger.debug("EventCounter.reset(): cleared event counters");
|
|
293
254
|
}
|
|
294
255
|
// ── Private ──────────────────────────────────────────────────────────────
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
this.
|
|
256
|
+
/**
|
|
257
|
+
* Load the counts blob from storage into the in-memory cache.
|
|
258
|
+
* Only runs once; subsequent calls are no-ops.
|
|
259
|
+
*/
|
|
260
|
+
async loadIfNeeded() {
|
|
261
|
+
if (this.loaded) return;
|
|
262
|
+
this.loaded = true;
|
|
301
263
|
try {
|
|
302
|
-
await this.storage.
|
|
264
|
+
const raw = await this.storage.read(COUNTS_KEY);
|
|
265
|
+
if (!raw) return;
|
|
266
|
+
const parsed = JSON.parse(raw);
|
|
267
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
268
|
+
if (typeof v === "number") this.cache.set(k, v);
|
|
269
|
+
}
|
|
303
270
|
} catch (err) {
|
|
304
|
-
this.logger.warn("EventCounter: failed to
|
|
271
|
+
this.logger.warn("EventCounter: failed to load counts blob", err);
|
|
305
272
|
}
|
|
306
273
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
);
|
|
312
|
-
for (const key of sessionKeys) {
|
|
313
|
-
try {
|
|
314
|
-
await this.storage.delete(key);
|
|
315
|
-
} catch {
|
|
316
|
-
}
|
|
317
|
-
this.writtenKeys.delete(key);
|
|
318
|
-
this.cache.delete(key);
|
|
319
|
-
}
|
|
320
|
-
const now = /* @__PURE__ */ new Date();
|
|
321
|
-
const seenBuckets = new Set(sessionKeys);
|
|
322
|
-
for (let i = 0; i < 90; i++) {
|
|
323
|
-
const d = new Date(now);
|
|
324
|
-
d.setDate(d.getDate() - i);
|
|
325
|
-
const periods = ["daily", "weekly", "monthly"];
|
|
326
|
-
for (const period of periods) {
|
|
327
|
-
const key = this.storageKey(eventName, period, d);
|
|
328
|
-
if (!seenBuckets.has(key)) {
|
|
329
|
-
seenBuckets.add(key);
|
|
330
|
-
try {
|
|
331
|
-
await this.storage.delete(key);
|
|
332
|
-
} catch {
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
274
|
+
/** Serialize the full in-memory cache to `sg:counts`. */
|
|
275
|
+
async persist() {
|
|
276
|
+
const obj = {};
|
|
277
|
+
for (const [k, v] of this.cache) obj[k] = v;
|
|
337
278
|
try {
|
|
338
|
-
await this.storage.
|
|
339
|
-
} catch {
|
|
279
|
+
await this.storage.write(COUNTS_KEY, JSON.stringify(obj));
|
|
280
|
+
} catch (err) {
|
|
281
|
+
this.logger.warn("EventCounter: failed to persist counts blob", err);
|
|
340
282
|
}
|
|
341
283
|
}
|
|
342
|
-
/** Build the
|
|
343
|
-
|
|
344
|
-
const bucket = this.bucketFor(period, date);
|
|
284
|
+
/** Build the sub-key within the counts blob for a given event, period, and date. */
|
|
285
|
+
subKey(eventName, period, date) {
|
|
345
286
|
const safeEvent = eventName.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
346
|
-
return `${
|
|
287
|
+
return `${period}:${this.bucketFor(period, date)}:${safeEvent}`;
|
|
347
288
|
}
|
|
348
289
|
/**
|
|
349
290
|
* Return the time-bucket string for a given period and date.
|
|
350
291
|
* - daily: "2024-01-15"
|
|
351
|
-
* - weekly: "2024-W03"
|
|
292
|
+
* - weekly: "2024-W03" (ISO week)
|
|
352
293
|
* - monthly: "2024-01"
|
|
353
294
|
* - all-time: "all"
|
|
354
295
|
*/
|
|
@@ -357,10 +298,9 @@ var EventCounter = class {
|
|
|
357
298
|
case "daily":
|
|
358
299
|
return date.toISOString().slice(0, 10);
|
|
359
300
|
// "YYYY-MM-DD"
|
|
360
|
-
case "weekly":
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
301
|
+
case "weekly":
|
|
302
|
+
return toISOWeek(date);
|
|
303
|
+
// "YYYY-Www"
|
|
364
304
|
case "monthly":
|
|
365
305
|
return date.toISOString().slice(0, 7);
|
|
366
306
|
// "YYYY-MM"
|
package/dist/index.mjs
CHANGED
|
@@ -162,150 +162,91 @@ var ConsentManager = class {
|
|
|
162
162
|
};
|
|
163
163
|
|
|
164
164
|
// src/EventCounter.ts
|
|
165
|
-
var
|
|
165
|
+
var COUNTS_KEY = "sg:counts";
|
|
166
166
|
var EventCounter = class {
|
|
167
167
|
constructor(storage, logger) {
|
|
168
168
|
this.storage = storage;
|
|
169
169
|
this.logger = logger;
|
|
170
170
|
/**
|
|
171
|
-
* In-memory cache of counts keyed by
|
|
172
|
-
* Updated synchronously on increment so getCount() can return immediately
|
|
173
|
-
* without waiting for storage writes to complete.
|
|
171
|
+
* In-memory cache of counts keyed by sub-key (`{period}:{bucket}:{eventName}`).
|
|
172
|
+
* Updated synchronously on increment so getCount() can return immediately.
|
|
174
173
|
*/
|
|
175
174
|
this.cache = /* @__PURE__ */ new Map();
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
* Used to reliably clear an event's timed buckets on reset(),
|
|
179
|
-
* even when they fall outside the 90-day sweep window.
|
|
180
|
-
*/
|
|
181
|
-
this.writtenKeys = /* @__PURE__ */ new Set();
|
|
175
|
+
/** Whether the counts blob has been loaded from storage into cache. */
|
|
176
|
+
this.loaded = false;
|
|
182
177
|
}
|
|
183
178
|
/**
|
|
184
179
|
* Increment the count for `eventName` across all four periods for `date`.
|
|
185
180
|
*/
|
|
186
181
|
async increment(eventName, date = /* @__PURE__ */ new Date()) {
|
|
182
|
+
await this.loadIfNeeded();
|
|
187
183
|
const periods = ["daily", "weekly", "monthly", "all-time"];
|
|
188
|
-
|
|
184
|
+
for (const period of periods) {
|
|
185
|
+
const key = this.subKey(eventName, period, date);
|
|
186
|
+
this.cache.set(key, (this.cache.get(key) ?? 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
await this.persist();
|
|
189
189
|
}
|
|
190
190
|
async getCount(eventName, period, date = /* @__PURE__ */ new Date()) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (cached !== void 0) return cached;
|
|
194
|
-
try {
|
|
195
|
-
const raw = await this.storage.read(key);
|
|
196
|
-
if (raw === null) return 0;
|
|
197
|
-
const n = parseInt(raw, 10);
|
|
198
|
-
const count = Number.isNaN(n) ? 0 : n;
|
|
199
|
-
this.cache.set(key, count);
|
|
200
|
-
return count;
|
|
201
|
-
} catch (err) {
|
|
202
|
-
this.logger.warn("EventCounter: failed to read count", err);
|
|
203
|
-
return 0;
|
|
204
|
-
}
|
|
191
|
+
await this.loadIfNeeded();
|
|
192
|
+
return this.cache.get(this.subKey(eventName, period, date)) ?? 0;
|
|
205
193
|
}
|
|
206
194
|
/**
|
|
207
195
|
* Reset counts for a specific event (all periods), or all events if omitted.
|
|
208
|
-
*
|
|
209
|
-
* Note: resetting all events requires listing storage keys. This is a best-effort
|
|
210
|
-
* operation — adapters that don't support key enumeration will only clear
|
|
211
|
-
* the provided event name's keys.
|
|
212
196
|
*/
|
|
213
197
|
async reset(eventName) {
|
|
198
|
+
await this.loadIfNeeded();
|
|
214
199
|
if (eventName) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
await this.storage.delete(key);
|
|
221
|
-
} catch {
|
|
222
|
-
}
|
|
223
|
-
this.writtenKeys.delete(key);
|
|
224
|
-
this.cache.delete(key);
|
|
225
|
-
}
|
|
226
|
-
const now = /* @__PURE__ */ new Date();
|
|
227
|
-
const seenKeys = new Set(allKeys);
|
|
228
|
-
const periods = ["daily", "weekly", "monthly", "all-time"];
|
|
229
|
-
const eventNames = new Set(
|
|
230
|
-
allKeys.map((k) => k.split(":").pop()).filter(Boolean)
|
|
231
|
-
);
|
|
232
|
-
for (let i = 0; i < 90; i++) {
|
|
233
|
-
const d = new Date(now);
|
|
234
|
-
d.setDate(d.getDate() - i);
|
|
235
|
-
for (const name of eventNames) {
|
|
236
|
-
for (const period of periods) {
|
|
237
|
-
const key = this.storageKey(name, period, d);
|
|
238
|
-
if (!seenKeys.has(key)) {
|
|
239
|
-
seenKeys.add(key);
|
|
240
|
-
try {
|
|
241
|
-
await this.storage.delete(key);
|
|
242
|
-
} catch {
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
200
|
+
const safeEvent = eventName.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
201
|
+
for (const key of this.cache.keys()) {
|
|
202
|
+
if (key.endsWith(`:${safeEvent}`)) {
|
|
203
|
+
this.cache.delete(key);
|
|
246
204
|
}
|
|
247
205
|
}
|
|
248
|
-
|
|
206
|
+
} else {
|
|
207
|
+
this.cache.clear();
|
|
249
208
|
}
|
|
209
|
+
await this.persist();
|
|
210
|
+
this.logger.debug("EventCounter.reset(): cleared event counters");
|
|
250
211
|
}
|
|
251
212
|
// ── Private ──────────────────────────────────────────────────────────────
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
this.
|
|
213
|
+
/**
|
|
214
|
+
* Load the counts blob from storage into the in-memory cache.
|
|
215
|
+
* Only runs once; subsequent calls are no-ops.
|
|
216
|
+
*/
|
|
217
|
+
async loadIfNeeded() {
|
|
218
|
+
if (this.loaded) return;
|
|
219
|
+
this.loaded = true;
|
|
258
220
|
try {
|
|
259
|
-
await this.storage.
|
|
221
|
+
const raw = await this.storage.read(COUNTS_KEY);
|
|
222
|
+
if (!raw) return;
|
|
223
|
+
const parsed = JSON.parse(raw);
|
|
224
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
225
|
+
if (typeof v === "number") this.cache.set(k, v);
|
|
226
|
+
}
|
|
260
227
|
} catch (err) {
|
|
261
|
-
this.logger.warn("EventCounter: failed to
|
|
228
|
+
this.logger.warn("EventCounter: failed to load counts blob", err);
|
|
262
229
|
}
|
|
263
230
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
);
|
|
269
|
-
for (const key of sessionKeys) {
|
|
270
|
-
try {
|
|
271
|
-
await this.storage.delete(key);
|
|
272
|
-
} catch {
|
|
273
|
-
}
|
|
274
|
-
this.writtenKeys.delete(key);
|
|
275
|
-
this.cache.delete(key);
|
|
276
|
-
}
|
|
277
|
-
const now = /* @__PURE__ */ new Date();
|
|
278
|
-
const seenBuckets = new Set(sessionKeys);
|
|
279
|
-
for (let i = 0; i < 90; i++) {
|
|
280
|
-
const d = new Date(now);
|
|
281
|
-
d.setDate(d.getDate() - i);
|
|
282
|
-
const periods = ["daily", "weekly", "monthly"];
|
|
283
|
-
for (const period of periods) {
|
|
284
|
-
const key = this.storageKey(eventName, period, d);
|
|
285
|
-
if (!seenBuckets.has(key)) {
|
|
286
|
-
seenBuckets.add(key);
|
|
287
|
-
try {
|
|
288
|
-
await this.storage.delete(key);
|
|
289
|
-
} catch {
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
231
|
+
/** Serialize the full in-memory cache to `sg:counts`. */
|
|
232
|
+
async persist() {
|
|
233
|
+
const obj = {};
|
|
234
|
+
for (const [k, v] of this.cache) obj[k] = v;
|
|
294
235
|
try {
|
|
295
|
-
await this.storage.
|
|
296
|
-
} catch {
|
|
236
|
+
await this.storage.write(COUNTS_KEY, JSON.stringify(obj));
|
|
237
|
+
} catch (err) {
|
|
238
|
+
this.logger.warn("EventCounter: failed to persist counts blob", err);
|
|
297
239
|
}
|
|
298
240
|
}
|
|
299
|
-
/** Build the
|
|
300
|
-
|
|
301
|
-
const bucket = this.bucketFor(period, date);
|
|
241
|
+
/** Build the sub-key within the counts blob for a given event, period, and date. */
|
|
242
|
+
subKey(eventName, period, date) {
|
|
302
243
|
const safeEvent = eventName.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
303
|
-
return `${
|
|
244
|
+
return `${period}:${this.bucketFor(period, date)}:${safeEvent}`;
|
|
304
245
|
}
|
|
305
246
|
/**
|
|
306
247
|
* Return the time-bucket string for a given period and date.
|
|
307
248
|
* - daily: "2024-01-15"
|
|
308
|
-
* - weekly: "2024-W03"
|
|
249
|
+
* - weekly: "2024-W03" (ISO week)
|
|
309
250
|
* - monthly: "2024-01"
|
|
310
251
|
* - all-time: "all"
|
|
311
252
|
*/
|
|
@@ -314,10 +255,9 @@ var EventCounter = class {
|
|
|
314
255
|
case "daily":
|
|
315
256
|
return date.toISOString().slice(0, 10);
|
|
316
257
|
// "YYYY-MM-DD"
|
|
317
|
-
case "weekly":
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
258
|
+
case "weekly":
|
|
259
|
+
return toISOWeek(date);
|
|
260
|
+
// "YYYY-Www"
|
|
321
261
|
case "monthly":
|
|
322
262
|
return date.toISOString().slice(0, 7);
|
|
323
263
|
// "YYYY-MM"
|