@actuate-media/cms-core 0.27.0 → 0.27.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__/api/stats-seo-cache.test.d.ts +2 -0
- package/dist/__tests__/api/stats-seo-cache.test.d.ts.map +1 -0
- package/dist/__tests__/api/stats-seo-cache.test.js +96 -0
- package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -0
- package/dist/api/handlers.d.ts +2 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +51 -12
- package/dist/api/handlers.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats-seo-cache.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/stats-seo-cache.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleActuateAPI } from '../../api/index.js';
|
|
3
|
+
import { __resetDashboardSeoCache } from '../../api/handlers.js';
|
|
4
|
+
import { createSession } from '../../auth/session.js';
|
|
5
|
+
import { initDB } from '../../db.js';
|
|
6
|
+
import { generateToken as generateCsrfToken } from '../../security/csrf.js';
|
|
7
|
+
/**
|
|
8
|
+
* `/stats` caches its average-SEO-score computation (a read + scan over
|
|
9
|
+
* up to 200 documents) per content-scope for a short TTL. These tests
|
|
10
|
+
* assert the cache is actually consulted: a second request within the
|
|
11
|
+
* window must NOT re-issue the 200-document SEO read, and clearing the
|
|
12
|
+
* cache forces a recompute.
|
|
13
|
+
*/
|
|
14
|
+
const SECRET = 'test-secret-for-stats-seo-cache-aaaaaaaaaaaaaaaa';
|
|
15
|
+
function createMockDB(rows) {
|
|
16
|
+
let seoReadCount = 0;
|
|
17
|
+
const allRows = rows.map((r, i) => ({
|
|
18
|
+
id: `d${i}`,
|
|
19
|
+
title: `Doc ${i}`,
|
|
20
|
+
status: 'PUBLISHED',
|
|
21
|
+
collection: 'posts',
|
|
22
|
+
updatedAt: new Date(),
|
|
23
|
+
createdById: 'u1',
|
|
24
|
+
createdBy: { name: 'Author', email: 'a@example.com' },
|
|
25
|
+
data: r.data,
|
|
26
|
+
}));
|
|
27
|
+
const db = {
|
|
28
|
+
session: { findUnique: async () => ({ revokedAt: null }) },
|
|
29
|
+
user: { count: async () => 0 },
|
|
30
|
+
media: { count: async () => 0 },
|
|
31
|
+
formSubmission: { count: async () => 0 },
|
|
32
|
+
webhookEndpoint: { count: async () => 0 },
|
|
33
|
+
document: {
|
|
34
|
+
count: async () => allRows.length,
|
|
35
|
+
// The SEO read is the only `findMany` with `take: 200` + `select.data`.
|
|
36
|
+
findMany: async ({ take, select }) => {
|
|
37
|
+
if (take === 200 && select?.data)
|
|
38
|
+
seoReadCount++;
|
|
39
|
+
return allRows;
|
|
40
|
+
},
|
|
41
|
+
groupBy: async () => [],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { db, getSeoReadCount: () => seoReadCount };
|
|
45
|
+
}
|
|
46
|
+
async function sessionCookie() {
|
|
47
|
+
const token = await createSession({ userId: 'user-1', role: 'ADMIN', sessionId: 'session-1' }, { secret: SECRET });
|
|
48
|
+
const csrf = await generateCsrfToken();
|
|
49
|
+
return `actuate_session=${token}; actuate_csrf=${csrf}`;
|
|
50
|
+
}
|
|
51
|
+
const POSTS_CONFIG = { collections: { posts: { slug: 'posts', fields: {} } } };
|
|
52
|
+
describe('GET /api/cms/stats — SEO score cache', () => {
|
|
53
|
+
const originalSecret = process.env.CMS_SECRET;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
process.env.CMS_SECRET = SECRET;
|
|
56
|
+
__resetDashboardSeoCache();
|
|
57
|
+
});
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
if (originalSecret === undefined)
|
|
60
|
+
delete process.env.CMS_SECRET;
|
|
61
|
+
else
|
|
62
|
+
process.env.CMS_SECRET = originalSecret;
|
|
63
|
+
delete globalThis.__actuateConfig;
|
|
64
|
+
__resetDashboardSeoCache();
|
|
65
|
+
});
|
|
66
|
+
it('computes the score once and serves repeat requests from cache', async () => {
|
|
67
|
+
const { db, getSeoReadCount } = createMockDB([
|
|
68
|
+
{ data: { seoTitle: 'a', metaDescription: 'b' } }, // 50
|
|
69
|
+
{ data: { seoTitle: 'a', metaDescription: 'b', canonical: 'c', schemaType: 'Article' } }, // 100
|
|
70
|
+
]);
|
|
71
|
+
initDB(db);
|
|
72
|
+
const handler = handleActuateAPI({ prismaClient: db, config: POSTS_CONFIG });
|
|
73
|
+
const cookie = await sessionCookie();
|
|
74
|
+
const call = () => handler(new Request('https://example.com/api/cms/stats', { headers: { cookie } }));
|
|
75
|
+
const first = (await (await call()).json());
|
|
76
|
+
expect(first.data.avgSeoScore).toBe(75);
|
|
77
|
+
expect(getSeoReadCount()).toBe(1);
|
|
78
|
+
const second = (await (await call()).json());
|
|
79
|
+
expect(second.data.avgSeoScore).toBe(75);
|
|
80
|
+
// Served from cache — no second 200-document read.
|
|
81
|
+
expect(getSeoReadCount()).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
it('recomputes after the cache is cleared', async () => {
|
|
84
|
+
const { db, getSeoReadCount } = createMockDB([{ data: { seoTitle: 'a' } }]); // 25
|
|
85
|
+
initDB(db);
|
|
86
|
+
const handler = handleActuateAPI({ prismaClient: db, config: POSTS_CONFIG });
|
|
87
|
+
const cookie = await sessionCookie();
|
|
88
|
+
const call = () => handler(new Request('https://example.com/api/cms/stats', { headers: { cookie } }));
|
|
89
|
+
await call();
|
|
90
|
+
expect(getSeoReadCount()).toBe(1);
|
|
91
|
+
__resetDashboardSeoCache();
|
|
92
|
+
await call();
|
|
93
|
+
expect(getSeoReadCount()).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
//# sourceMappingURL=stats-seo-cache.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats-seo-cache.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/stats-seo-cache.test.ts"],"names":[],"mappings":"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"}
|
package/dist/api/handlers.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ApiRouter } from './router.js';
|
|
2
|
+
/** Test-only: reset the SEO score cache so cases don't bleed into each other. */
|
|
3
|
+
export declare function __resetDashboardSeoCache(): void;
|
|
2
4
|
export declare function parseCookieHeader(cookieHeader: string): Record<string, string>;
|
|
3
5
|
export declare function registerCMSRoutes(router: ApiRouter): void;
|
|
4
6
|
//# sourceMappingURL=handlers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAogB5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAiQD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA8kPzD"}
|
package/dist/api/handlers.js
CHANGED
|
@@ -386,6 +386,25 @@ async function safeFindMany(model, args) {
|
|
|
386
386
|
return [];
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Short-TTL cache for the dashboard's average SEO score.
|
|
391
|
+
*
|
|
392
|
+
* `/stats` otherwise reads the `data` JSON of up to 200 published
|
|
393
|
+
* documents and scores each one on every load — the single heaviest
|
|
394
|
+
* part of the endpoint. The score barely moves minute-to-minute, so we
|
|
395
|
+
* cache it per content-scope (the JSON-stringified Prisma `where`, which
|
|
396
|
+
* encodes the registered collection allowlist) for a short window.
|
|
397
|
+
*
|
|
398
|
+
* Module-level so it survives across requests within a warm isolate;
|
|
399
|
+
* a cold start simply recomputes once. Keyed by scope so multi-config
|
|
400
|
+
* isolates never read each other's score.
|
|
401
|
+
*/
|
|
402
|
+
let dashboardSeoCache = null;
|
|
403
|
+
const DASHBOARD_SEO_CACHE_TTL_MS = 60_000;
|
|
404
|
+
/** Test-only: reset the SEO score cache so cases don't bleed into each other. */
|
|
405
|
+
export function __resetDashboardSeoCache() {
|
|
406
|
+
dashboardSeoCache = null;
|
|
407
|
+
}
|
|
389
408
|
function isAllowedStorageUrl(url) {
|
|
390
409
|
try {
|
|
391
410
|
const parsed = new URL(url);
|
|
@@ -2953,6 +2972,33 @@ export function registerCMSRoutes(router) {
|
|
|
2953
2972
|
score += 25;
|
|
2954
2973
|
return score;
|
|
2955
2974
|
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Average SEO score across published content, cached per content-scope
|
|
2977
|
+
* for `DASHBOARD_SEO_CACHE_TTL_MS`. On a cache hit this returns without
|
|
2978
|
+
* touching the database — avoiding the 200-document `data` read + scan
|
|
2979
|
+
* that dominates `/stats`. Failures from `safeFindMany` surface as an
|
|
2980
|
+
* empty list (score 0) and are not cached, so a transient DB blip
|
|
2981
|
+
* doesn't pin a stale 0.
|
|
2982
|
+
*/
|
|
2983
|
+
async function getCachedAvgSeoScore(model, where) {
|
|
2984
|
+
const key = JSON.stringify(where);
|
|
2985
|
+
const now = Date.now();
|
|
2986
|
+
if (dashboardSeoCache && dashboardSeoCache.key === key && dashboardSeoCache.expires > now) {
|
|
2987
|
+
return dashboardSeoCache.value;
|
|
2988
|
+
}
|
|
2989
|
+
const docs = await safeFindMany(model, {
|
|
2990
|
+
where,
|
|
2991
|
+
select: { data: true },
|
|
2992
|
+
take: 200,
|
|
2993
|
+
});
|
|
2994
|
+
let value = 0;
|
|
2995
|
+
if (docs.length > 0) {
|
|
2996
|
+
const total = docs.reduce((sum, doc) => sum + computeLightSeoScore(doc.data ?? {}), 0);
|
|
2997
|
+
value = Math.round(total / docs.length);
|
|
2998
|
+
}
|
|
2999
|
+
dashboardSeoCache = { key, value, expires: now + DASHBOARD_SEO_CACHE_TTL_MS };
|
|
3000
|
+
return value;
|
|
3001
|
+
}
|
|
2956
3002
|
/**
|
|
2957
3003
|
* Collection slugs that are written by the benchmark suite and a few
|
|
2958
3004
|
* other internal/test fixtures. Documents in these collections are
|
|
@@ -3002,7 +3048,7 @@ export function registerCMSRoutes(router) {
|
|
|
3002
3048
|
// the helper above for the allowlist-vs-denylist behaviour.
|
|
3003
3049
|
const contentWhere = userContentWhere();
|
|
3004
3050
|
const publishedContentWhere = userContentWhere({ status: 'PUBLISHED' });
|
|
3005
|
-
const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult,
|
|
3051
|
+
const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoScoreResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
|
|
3006
3052
|
safeCount(d.document, contentWhere),
|
|
3007
3053
|
safeCount(d.media),
|
|
3008
3054
|
safeCount(d.user),
|
|
@@ -3029,11 +3075,9 @@ export function registerCMSRoutes(router) {
|
|
|
3029
3075
|
safeGroupBy(d.document, ['collection'], contentWhere),
|
|
3030
3076
|
safeGroupBy(d.document, ['status'], contentWhere),
|
|
3031
3077
|
safeCount(d.formSubmission),
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
take: 200,
|
|
3036
|
-
}),
|
|
3078
|
+
// Cached per content-scope so the 200-doc SEO read+scan runs at
|
|
3079
|
+
// most once per TTL window across warm requests.
|
|
3080
|
+
getCachedAvgSeoScore(d.document, publishedContentWhere),
|
|
3037
3081
|
// Cheap counts so the dashboard's "Content Delivery" tile doesn't need
|
|
3038
3082
|
// a second round-trip. `webhookEndpoint` may not exist on every host,
|
|
3039
3083
|
// so `safeCount` swallows missing-model errors and returns 0.
|
|
@@ -3050,12 +3094,7 @@ export function registerCMSRoutes(router) {
|
|
|
3050
3094
|
for (const g of statusGroupResult.value)
|
|
3051
3095
|
statusCounts[g.status] = g._count._all;
|
|
3052
3096
|
}
|
|
3053
|
-
|
|
3054
|
-
if (seoDocsResult.status === 'fulfilled' && seoDocsResult.value.length > 0) {
|
|
3055
|
-
const docs = seoDocsResult.value;
|
|
3056
|
-
const total = docs.reduce((sum, doc) => sum + computeLightSeoScore(doc.data ?? {}), 0);
|
|
3057
|
-
avgSeoScore = Math.round(total / docs.length);
|
|
3058
|
-
}
|
|
3097
|
+
const avgSeoScore = seoScoreResult.status === 'fulfilled' ? seoScoreResult.value : 0;
|
|
3059
3098
|
const recentDocs = recentResult.status === 'fulfilled'
|
|
3060
3099
|
? recentResult.value.map((doc) => ({
|
|
3061
3100
|
id: doc.id,
|