@commonpub/layer 0.61.0 → 0.62.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,15 +54,15 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
- "@commonpub/editor": "0.7.11",
58
57
  "@commonpub/config": "0.19.0",
58
+ "@commonpub/explainer": "0.7.15",
59
+ "@commonpub/docs": "0.6.3",
59
60
  "@commonpub/protocol": "0.13.0",
61
+ "@commonpub/server": "2.82.0",
62
+ "@commonpub/editor": "0.7.11",
60
63
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/server": "2.81.0",
62
64
  "@commonpub/ui": "0.9.2",
63
- "@commonpub/explainer": "0.7.15",
64
- "@commonpub/schema": "0.34.0",
65
- "@commonpub/docs": "0.6.3"
65
+ "@commonpub/schema": "0.35.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,41 @@
1
+ import { getMetricsTimeseries, TIMESERIES_METRICS } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
5
+ const DAY_MS = 86_400_000;
6
+
7
+ const querySchema = z.object({
8
+ metric: z.enum(Object.keys(TIMESERIES_METRICS) as [string, ...string[]]),
9
+ interval: z.enum(['day', 'week', 'month']).default('day'),
10
+ from: z.string().regex(DAY).optional(),
11
+ to: z.string().regex(DAY).optional(),
12
+ });
13
+
14
+ /**
15
+ * GET /api/public/v1/metrics/timeseries
16
+ *
17
+ * Scope: read:analytics. Daily time-series from the `metrics_daily` rollups.
18
+ * `metric` is one of the registered series (users.total/new, content.total/new/
19
+ * views/likes/comments); `interval` buckets day|week|month. Defaults to the last
20
+ * 90 days. Engagement (views/likes/comments) series begin at the first rollup,
21
+ * surfaced via `since`.
22
+ */
23
+ export default defineEventHandler(async (event) => {
24
+ requireApiScope(event, 'read:analytics');
25
+ const parsed = querySchema.safeParse(getQuery(event));
26
+ if (!parsed.success) {
27
+ throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters', data: parsed.error.flatten() });
28
+ }
29
+ const { metric, interval } = parsed.data;
30
+
31
+ // Default to the last 90 days; clamp the span to 2 years to bound the scan.
32
+ const todayMs = Date.now();
33
+ const to = parsed.data.to ?? new Date(todayMs).toISOString().slice(0, 10);
34
+ let from = parsed.data.from ?? new Date(todayMs - 90 * DAY_MS).toISOString().slice(0, 10);
35
+ if (from > to) from = to;
36
+ const minFrom = new Date(new Date(`${to}T00:00:00Z`).getTime() - 730 * DAY_MS).toISOString().slice(0, 10);
37
+ if (from < minFrom) from = minFrom;
38
+
39
+ const db = useDB();
40
+ return await getMetricsTimeseries(db, { metric, interval, from, to });
41
+ });
@@ -379,6 +379,9 @@ export default defineEventHandler((event) => {
379
379
  '/metrics/engagement': {
380
380
  get: { summary: 'Aggregate engagement ratios and funnels', security: [{ bearer: ['read:analytics'] }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } },
381
381
  },
382
+ '/metrics/timeseries': {
383
+ get: { summary: 'Daily time-series from rollups', security: [{ bearer: ['read:analytics'] }], parameters: [{ name: 'metric', in: 'query', required: true, schema: { type: 'string', enum: ['users.total', 'users.new', 'content.total', 'content.new', 'content.views', 'content.likes', 'content.comments'] } }, { name: 'interval', in: 'query', schema: { type: 'string', enum: ['day', 'week', 'month'] } }, { name: 'from', in: 'query', schema: { type: 'string', format: 'date' } }, { name: 'to', in: 'query', schema: { type: 'string', format: 'date' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } } } },
384
+ },
382
385
  '/metrics/federation': {
383
386
  get: { summary: 'Federation reach (opt-in; read:federation)', security: [{ bearer: ['read:federation'] }], parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }], responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'object' } } } }, '404': { description: 'Federation reach metrics not enabled', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } } } },
384
387
  },
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Daily analytics rollup worker (Phase 3).
3
+ *
4
+ * When `features.publicApi` is on, snapshots aggregate metrics into
5
+ * `metrics_daily` so `GET /api/public/v1/metrics/timeseries` has history. On the
6
+ * first ever run (empty table) it backfills the deterministic count-based series
7
+ * from timestamps. Runs are idempotent (upsert per day), so the periodic
8
+ * interval just refreshes today's row. Aggregates only — no per-user data.
9
+ */
10
+ import { runDailyRollup, backfillMetricsDaily } from '@commonpub/server';
11
+ import { metricsDaily } from '@commonpub/schema';
12
+
13
+ const ROLLUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6h; refreshes today's snapshot
14
+
15
+ export default defineNitroPlugin((nitro) => {
16
+ if (process.env.NODE_ENV === 'test') return;
17
+
18
+ let interval: ReturnType<typeof setInterval> | null = null;
19
+
20
+ const startupTimer = setTimeout(() => {
21
+ try {
22
+ const config = useConfig();
23
+ if (!config.features.publicApi) return; // no public API ⇒ no rollups needed
24
+ console.log(`[metrics-rollup] worker started (interval: ${ROLLUP_INTERVAL_MS}ms)`);
25
+ runRollup();
26
+ interval = setInterval(runRollup, ROLLUP_INTERVAL_MS);
27
+ } catch (err) {
28
+ console.error('[metrics-rollup] failed to start:', err instanceof Error ? err.message : err);
29
+ }
30
+ }, 15_000); // stagger after federation/registry workers
31
+
32
+ async function runRollup(): Promise<void> {
33
+ try {
34
+ const db = useDB();
35
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
36
+ const [existing] = await db.select({ id: metricsDaily.id }).from(metricsDaily).limit(1);
37
+ if (!existing) {
38
+ const n = await backfillMetricsDaily(db);
39
+ console.log(`[metrics-rollup] backfilled ${n} historical rows`);
40
+ }
41
+ await runDailyRollup(db, today);
42
+ } catch (err) {
43
+ console.error('[metrics-rollup] run error:', err instanceof Error ? err.message : err);
44
+ }
45
+ }
46
+
47
+ nitro.hooks.hook('close', () => {
48
+ clearTimeout(startupTimer);
49
+ if (interval) {
50
+ clearInterval(interval);
51
+ interval = null;
52
+ }
53
+ });
54
+ });