domma-cms 0.17.0 → 0.21.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/CLAUDE.md +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +41 -36
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +88 -7
- package/server/server.js +54 -3
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +19 -4
- package/server/templates/page.html +135 -130
- /package/config/{navigation.json → navigation.json.bak} +0 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
|
+
import * as cache from './cache/index.js';
|
|
9
10
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
|
|
@@ -257,9 +258,12 @@ export async function listBlocks() {
|
|
|
257
258
|
const name = file.slice(0, -5);
|
|
258
259
|
const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
|
|
259
260
|
let bundled = false;
|
|
261
|
+
let meta = null;
|
|
260
262
|
try {
|
|
261
|
-
const
|
|
262
|
-
bundled = !!
|
|
263
|
+
const m = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
|
|
264
|
+
bundled = !!m.bundled;
|
|
265
|
+
// Surface the full sidecar so callers can filter by meta.project etc.
|
|
266
|
+
meta = m;
|
|
263
267
|
} catch { /* no meta file */
|
|
264
268
|
}
|
|
265
269
|
blocks.push({
|
|
@@ -267,6 +271,7 @@ export async function listBlocks() {
|
|
|
267
271
|
size: fileStat?.size ?? 0,
|
|
268
272
|
updatedAt: fileStat?.mtime?.toISOString() ?? null,
|
|
269
273
|
bundled,
|
|
274
|
+
...(meta && {meta}),
|
|
270
275
|
});
|
|
271
276
|
}
|
|
272
277
|
return blocks.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -284,9 +289,12 @@ export async function getBlock(name) {
|
|
|
284
289
|
try {
|
|
285
290
|
const content = await fs.readFile(blockFilePath(name), 'utf8');
|
|
286
291
|
let bundled = false;
|
|
292
|
+
let meta = null;
|
|
287
293
|
try {
|
|
288
|
-
const
|
|
289
|
-
bundled = !!
|
|
294
|
+
const metaFile = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8'));
|
|
295
|
+
bundled = !!metaFile.bundled;
|
|
296
|
+
// Preserve the full meta sidecar so callers can read meta.project etc.
|
|
297
|
+
meta = metaFile;
|
|
290
298
|
} catch { /* no meta file */
|
|
291
299
|
}
|
|
292
300
|
let css = '';
|
|
@@ -294,7 +302,7 @@ export async function getBlock(name) {
|
|
|
294
302
|
css = await fs.readFile(blockCssPath(name), 'utf8');
|
|
295
303
|
} catch { /* no CSS file — treat as empty */
|
|
296
304
|
}
|
|
297
|
-
return {name, content, css, bundled};
|
|
305
|
+
return {name, content, css, bundled, ...(meta && {meta})};
|
|
298
306
|
} catch (err) {
|
|
299
307
|
if (err.code === 'ENOENT') {
|
|
300
308
|
const notFound = new Error('Block not found');
|
|
@@ -316,7 +324,7 @@ export async function getBlock(name) {
|
|
|
316
324
|
* @returns {Promise<{success: boolean, name: string}>}
|
|
317
325
|
* @throws {Error} With code INVALID_NAME on bad name, or CSS_TOO_LARGE when CSS exceeds cap
|
|
318
326
|
*/
|
|
319
|
-
export async function saveBlock(name, content, {bundled, css} = {}) {
|
|
327
|
+
export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
320
328
|
assertValidName(name);
|
|
321
329
|
if (typeof css === 'string' && css.length > MAX_CSS_SIZE) {
|
|
322
330
|
const err = new Error(`CSS exceeds ${MAX_CSS_SIZE} byte limit`);
|
|
@@ -326,8 +334,13 @@ export async function saveBlock(name, content, {bundled, css} = {}) {
|
|
|
326
334
|
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
327
335
|
await fs.writeFile(blockFilePath(name), content, 'utf8');
|
|
328
336
|
const metaPath = blockMetaPath(name);
|
|
329
|
-
|
|
330
|
-
|
|
337
|
+
// Merge sidecar meta — bundled flag plus any caller-supplied fields (eg meta.project).
|
|
338
|
+
const sidecar = {
|
|
339
|
+
...(meta && typeof meta === 'object' ? meta : {}),
|
|
340
|
+
...(bundled ? {bundled: true} : {})
|
|
341
|
+
};
|
|
342
|
+
if (Object.keys(sidecar).length > 0) {
|
|
343
|
+
await fs.writeFile(metaPath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
|
|
331
344
|
} else {
|
|
332
345
|
await fs.unlink(metaPath).catch(() => {
|
|
333
346
|
});
|
|
@@ -341,6 +354,7 @@ export async function saveBlock(name, content, {bundled, css} = {}) {
|
|
|
341
354
|
});
|
|
342
355
|
}
|
|
343
356
|
}
|
|
357
|
+
await cache.invalidateTags([`block:${name}`]);
|
|
344
358
|
return {success: true, name};
|
|
345
359
|
}
|
|
346
360
|
|
|
@@ -368,6 +382,7 @@ export async function deleteBlock(name) {
|
|
|
368
382
|
});
|
|
369
383
|
await fs.unlink(blockMetaPath(name)).catch(() => {
|
|
370
384
|
});
|
|
385
|
+
await cache.invalidateTags([`block:${name}`]);
|
|
371
386
|
}
|
|
372
387
|
|
|
373
388
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {LRU} from '../lru.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory cache driver with TTL and tag invalidation.
|
|
5
|
+
*
|
|
6
|
+
* Each entry is stored as {value, expiresAt, tags}. TTL is lazy — entries
|
|
7
|
+
* past their expiresAt are returned as undefined on the next get and dropped.
|
|
8
|
+
*
|
|
9
|
+
* Tag index: a Map<tag, Set<key>>. set() with {tags} adds the key to each
|
|
10
|
+
* tag's set; invalidateTags() iterates the listed tags and deletes every
|
|
11
|
+
* key in their sets. The forward index (entry.tags) lets us clean up old
|
|
12
|
+
* links when a key is re-set with a different tag list.
|
|
13
|
+
*/
|
|
14
|
+
export class MemoryDriver {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{maxItems?: number, defaultTtlSeconds?: number}} options
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
const maxItems = options.maxItems ?? 1000;
|
|
20
|
+
this._defaultTtlSeconds = options.defaultTtlSeconds ?? null;
|
|
21
|
+
this._lru = new LRU(maxItems);
|
|
22
|
+
/** @type {Map<string, Set<string>>} */
|
|
23
|
+
this._tagIndex = new Map();
|
|
24
|
+
this._now = () => Date.now();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async get(key) {
|
|
28
|
+
const entry = this._lru.get(key);
|
|
29
|
+
if (!entry) return undefined;
|
|
30
|
+
if (entry.expiresAt !== null && entry.expiresAt <= this._now()) {
|
|
31
|
+
this._removeKey(key);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return entry.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async set(key, value, opts = {}) {
|
|
38
|
+
const existing = this._lru.get(key);
|
|
39
|
+
if (existing && existing.tags) {
|
|
40
|
+
for (const t of existing.tags) {
|
|
41
|
+
const set = this._tagIndex.get(t);
|
|
42
|
+
if (set) {
|
|
43
|
+
set.delete(key);
|
|
44
|
+
if (set.size === 0) this._tagIndex.delete(t);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ttl = opts.ttlSeconds ?? this._defaultTtlSeconds;
|
|
50
|
+
const expiresAt = ttl ? this._now() + ttl * 1000 : null;
|
|
51
|
+
const tags = opts.tags && opts.tags.length ? [...opts.tags] : null;
|
|
52
|
+
|
|
53
|
+
this._lru.set(key, {value, expiresAt, tags});
|
|
54
|
+
|
|
55
|
+
if (tags) {
|
|
56
|
+
for (const t of tags) {
|
|
57
|
+
let set = this._tagIndex.get(t);
|
|
58
|
+
if (!set) {
|
|
59
|
+
set = new Set();
|
|
60
|
+
this._tagIndex.set(t, set);
|
|
61
|
+
}
|
|
62
|
+
set.add(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async invalidateTags(tags) {
|
|
68
|
+
for (const t of tags) {
|
|
69
|
+
const set = this._tagIndex.get(t);
|
|
70
|
+
if (!set) continue;
|
|
71
|
+
for (const key of [...set]) {
|
|
72
|
+
this._removeKey(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async clear() {
|
|
78
|
+
this._lru.clear();
|
|
79
|
+
this._tagIndex.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Current entry count (sync — for the dashboard widget). */
|
|
83
|
+
size() {
|
|
84
|
+
return this._lru.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Snapshot the currently cached entries as `{key, tags, expiresAt}`.
|
|
89
|
+
* No values — they can be megabytes of HTML. Uses `entries()` (non-mutating)
|
|
90
|
+
* so the LRU order is preserved through the iteration.
|
|
91
|
+
*/
|
|
92
|
+
listEntries() {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const [key, entry] of this._lru.entries()) {
|
|
95
|
+
if (!entry) continue;
|
|
96
|
+
out.push({
|
|
97
|
+
key,
|
|
98
|
+
tags: entry.tags ? [...entry.tags] : [],
|
|
99
|
+
expiresAt: entry.expiresAt
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_removeKey(key) {
|
|
106
|
+
const entry = this._lru.get(key);
|
|
107
|
+
if (entry && entry.tags) {
|
|
108
|
+
for (const t of entry.tags) {
|
|
109
|
+
const set = this._tagIndex.get(t);
|
|
110
|
+
if (set) {
|
|
111
|
+
set.delete(key);
|
|
112
|
+
if (set.size === 0) this._tagIndex.delete(t);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this._lru.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Null driver — used when the cache is disabled.
|
|
3
|
+
* Every read misses; every write is dropped on the floor.
|
|
4
|
+
*/
|
|
5
|
+
export class NoneDriver {
|
|
6
|
+
async get() { return undefined; }
|
|
7
|
+
async set() { /* no-op */ }
|
|
8
|
+
async invalidateTags() { /* no-op */ }
|
|
9
|
+
async clear() { /* no-op */ }
|
|
10
|
+
size() { return 0; }
|
|
11
|
+
listEntries() { return []; }
|
|
12
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {MemoryDriver} from './drivers/MemoryDriver.js';
|
|
2
|
+
import {NoneDriver} from './drivers/NoneDriver.js';
|
|
3
|
+
|
|
4
|
+
const KEY_VERSION = 'v1:';
|
|
5
|
+
|
|
6
|
+
let _driver = new NoneDriver();
|
|
7
|
+
let _enabled = false;
|
|
8
|
+
let _lastConfig = null;
|
|
9
|
+
/** @type {Map<string, Promise<*>>} */
|
|
10
|
+
const _inflight = new Map();
|
|
11
|
+
|
|
12
|
+
const _stats = {
|
|
13
|
+
hits: 0,
|
|
14
|
+
misses: 0,
|
|
15
|
+
writes: 0,
|
|
16
|
+
invalidations: 0,
|
|
17
|
+
lastClearedAt: null,
|
|
18
|
+
lastInvalidationAt: null,
|
|
19
|
+
initialisedAt: null
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{
|
|
24
|
+
* enabled?: boolean,
|
|
25
|
+
* driver?: 'memory' | 'none' | 'redis',
|
|
26
|
+
* memory?: {maxItems?: number, defaultTtlSeconds?: number},
|
|
27
|
+
* redis?: {url: string, keyPrefix?: string}
|
|
28
|
+
* }} config
|
|
29
|
+
*/
|
|
30
|
+
export async function initCache(config = {}) {
|
|
31
|
+
_lastConfig = {...config};
|
|
32
|
+
_stats.initialisedAt = new Date().toISOString();
|
|
33
|
+
if (config.enabled === false) {
|
|
34
|
+
_driver = new NoneDriver();
|
|
35
|
+
_enabled = false;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const name = config.driver ?? 'memory';
|
|
39
|
+
if (name === 'memory') {
|
|
40
|
+
_driver = new MemoryDriver(config.memory ?? {});
|
|
41
|
+
_enabled = true;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (name === 'none') {
|
|
45
|
+
_driver = new NoneDriver();
|
|
46
|
+
_enabled = false;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (name === 'redis') {
|
|
50
|
+
const {RedisDriver} = await import('./drivers/RedisDriver.js');
|
|
51
|
+
_driver = new RedisDriver(config.redis ?? {});
|
|
52
|
+
await _driver.connect();
|
|
53
|
+
_enabled = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Unknown cache driver: ${name}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Flip the cache on or off at runtime.
|
|
61
|
+
*
|
|
62
|
+
* When turning on, re-initialise with the last config (or sensible defaults)
|
|
63
|
+
* so a previously disabled cache gets a real driver. When turning off, keep
|
|
64
|
+
* the driver in place but short-circuit reads/writes — preserves cached data
|
|
65
|
+
* if the user toggles back on. The persistence of this choice across restarts
|
|
66
|
+
* is the caller's responsibility (the route handler writes to config/cache.json).
|
|
67
|
+
*/
|
|
68
|
+
export async function setEnabled(enabled) {
|
|
69
|
+
if (Boolean(enabled) === _enabled) return;
|
|
70
|
+
if (enabled) {
|
|
71
|
+
if (_driver instanceof NoneDriver) {
|
|
72
|
+
// No usable driver yet — bootstrap one from last-known config.
|
|
73
|
+
const cfg = {...(_lastConfig || {}), enabled: true};
|
|
74
|
+
if (!cfg.driver || cfg.driver === 'none') cfg.driver = 'memory';
|
|
75
|
+
await initCache(cfg);
|
|
76
|
+
} else {
|
|
77
|
+
// Memory/Redis driver still live — flip the flag and preserve data.
|
|
78
|
+
_enabled = true;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
_enabled = false;
|
|
82
|
+
_inflight.clear();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isEnabled() {
|
|
87
|
+
return _enabled;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function get(key) {
|
|
91
|
+
return _driver.get(_vk(key));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function set(key, value, opts = {}) {
|
|
95
|
+
return _driver.set(_vk(key), value, opts);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function invalidateTags(tags) {
|
|
99
|
+
if (!Array.isArray(tags) || tags.length === 0) return;
|
|
100
|
+
_stats.invalidations++;
|
|
101
|
+
_stats.lastInvalidationAt = new Date().toISOString();
|
|
102
|
+
return _driver.invalidateTags(tags);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function clear() {
|
|
106
|
+
_inflight.clear();
|
|
107
|
+
_stats.lastClearedAt = new Date().toISOString();
|
|
108
|
+
return _driver.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List the cache's keys with their tags and expiry. Values are deliberately
|
|
113
|
+
* excluded — cached entries can be megabytes of HTML.
|
|
114
|
+
* @returns {{key: string, tags: string[], expiresAt: number|null}[]}
|
|
115
|
+
*/
|
|
116
|
+
export function listEntries() {
|
|
117
|
+
if (typeof _driver.listEntries !== 'function') return [];
|
|
118
|
+
return _driver.listEntries().map(e => ({
|
|
119
|
+
...e,
|
|
120
|
+
// Strip the internal v1: version prefix for display.
|
|
121
|
+
key: e.key.startsWith(KEY_VERSION) ? e.key.slice(KEY_VERSION.length) : e.key
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Snapshot of cache health for the dashboard widget.
|
|
127
|
+
* Counts are process-lifetime; restart resets them.
|
|
128
|
+
*/
|
|
129
|
+
export function getStats() {
|
|
130
|
+
const total = _stats.hits + _stats.misses;
|
|
131
|
+
return {
|
|
132
|
+
enabled: _enabled,
|
|
133
|
+
driver: _enabled ? (_lastConfig?.driver || 'memory') : 'disabled',
|
|
134
|
+
size: typeof _driver.size === 'function' ? _driver.size() : null,
|
|
135
|
+
maxItems: _lastConfig?.memory?.maxItems ?? null,
|
|
136
|
+
hits: _stats.hits,
|
|
137
|
+
misses: _stats.misses,
|
|
138
|
+
writes: _stats.writes,
|
|
139
|
+
invalidations: _stats.invalidations,
|
|
140
|
+
hitRate: total > 0 ? _stats.hits / total : null,
|
|
141
|
+
lastClearedAt: _stats.lastClearedAt,
|
|
142
|
+
lastInvalidationAt: _stats.lastInvalidationAt,
|
|
143
|
+
initialisedAt: _stats.initialisedAt
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get-or-set with in-flight coalescing.
|
|
149
|
+
*
|
|
150
|
+
* Producer may return either a raw value, or an object with a `_cacheTags`
|
|
151
|
+
* array — those tags are merged into opts.tags before storing, and the
|
|
152
|
+
* `_cacheTags` field is stripped from both the cached and returned value.
|
|
153
|
+
* This lets producers report tags discovered during rendering (e.g. the
|
|
154
|
+
* markdown pipeline noting embedded collection slugs) without the caller
|
|
155
|
+
* needing to know them up front.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} key
|
|
158
|
+
* @param {() => Promise<*>} producer
|
|
159
|
+
* @param {{ttlSeconds?: number, tags?: string[]}} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export async function wrap(key, producer, opts = {}) {
|
|
162
|
+
if (!_enabled) {
|
|
163
|
+
const raw = await producer();
|
|
164
|
+
return _stripCacheTags(raw);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const vkey = _vk(key);
|
|
168
|
+
const hit = await _driver.get(vkey);
|
|
169
|
+
if (hit !== undefined) {
|
|
170
|
+
_stats.hits++;
|
|
171
|
+
return hit;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (_inflight.has(vkey)) return _inflight.get(vkey);
|
|
175
|
+
|
|
176
|
+
_stats.misses++;
|
|
177
|
+
const promise = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
const raw = await producer();
|
|
180
|
+
const {value, extraTags} = _extractCacheTags(raw);
|
|
181
|
+
const setOpts = {...opts};
|
|
182
|
+
if (extraTags) {
|
|
183
|
+
setOpts.tags = [...(opts.tags ?? []), ...extraTags];
|
|
184
|
+
}
|
|
185
|
+
await _driver.set(vkey, value, setOpts);
|
|
186
|
+
_stats.writes++;
|
|
187
|
+
return value;
|
|
188
|
+
} finally {
|
|
189
|
+
_inflight.delete(vkey);
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
|
|
193
|
+
_inflight.set(vkey, promise);
|
|
194
|
+
return promise;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _vk(key) {
|
|
198
|
+
return KEY_VERSION + key;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _extractCacheTags(raw) {
|
|
202
|
+
if (raw && typeof raw === 'object' && Array.isArray(raw._cacheTags)) {
|
|
203
|
+
const {_cacheTags, ...value} = raw;
|
|
204
|
+
return {value, extraTags: _cacheTags};
|
|
205
|
+
}
|
|
206
|
+
return {value: raw, extraTags: null};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _stripCacheTags(raw) {
|
|
210
|
+
if (raw && typeof raw === 'object' && Array.isArray(raw._cacheTags)) {
|
|
211
|
+
const {_cacheTags, ...value} = raw;
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
return raw;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function _resetForTests() {
|
|
218
|
+
_driver = new NoneDriver();
|
|
219
|
+
_enabled = false;
|
|
220
|
+
_lastConfig = null;
|
|
221
|
+
_inflight.clear();
|
|
222
|
+
_stats.hits = 0;
|
|
223
|
+
_stats.misses = 0;
|
|
224
|
+
_stats.writes = 0;
|
|
225
|
+
_stats.invalidations = 0;
|
|
226
|
+
_stats.lastClearedAt = null;
|
|
227
|
+
_stats.lastInvalidationAt = null;
|
|
228
|
+
_stats.initialisedAt = null;
|
|
229
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded LRU built on the insertion-order property of Map.
|
|
3
|
+
* On hit, delete + re-set the entry so it becomes the most-recent.
|
|
4
|
+
* On set when at capacity, delete the first (oldest) key.
|
|
5
|
+
*/
|
|
6
|
+
export class LRU {
|
|
7
|
+
/**
|
|
8
|
+
* @param {number} maxItems
|
|
9
|
+
*/
|
|
10
|
+
constructor(maxItems) {
|
|
11
|
+
if (!Number.isInteger(maxItems) || maxItems < 1) {
|
|
12
|
+
throw new TypeError('LRU: maxItems must be a positive integer');
|
|
13
|
+
}
|
|
14
|
+
this._max = maxItems;
|
|
15
|
+
this._map = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get size() {
|
|
19
|
+
return this._map.size;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get(key) {
|
|
23
|
+
if (!this._map.has(key)) return undefined;
|
|
24
|
+
const value = this._map.get(key);
|
|
25
|
+
this._map.delete(key);
|
|
26
|
+
this._map.set(key, value);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read without promoting. Use for snapshots / iteration. */
|
|
31
|
+
peek(key) {
|
|
32
|
+
return this._map.get(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Iterate `[key, value]` pairs in oldest-to-newest order, no mutation. */
|
|
36
|
+
entries() {
|
|
37
|
+
return this._map.entries();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
set(key, value) {
|
|
41
|
+
if (this._map.has(key)) {
|
|
42
|
+
this._map.delete(key);
|
|
43
|
+
} else if (this._map.size >= this._max) {
|
|
44
|
+
const oldest = this._map.keys().next().value;
|
|
45
|
+
this._map.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
this._map.set(key, value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
delete(key) {
|
|
51
|
+
return this._map.delete(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clear() {
|
|
55
|
+
this._map.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
keys() {
|
|
59
|
+
return this._map.keys();
|
|
60
|
+
}
|
|
61
|
+
}
|