domma-cms 1.6.60 → 1.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/lib/effect-defs.js +1 -1
  6. package/admin/js/lib/effects-builder.js +2 -2
  7. package/admin/js/lib/page-editor-reference.js +5 -5
  8. package/admin/js/lib/shortcode-tags.js +1 -1
  9. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  10. package/docs/DommaFeatures.md +3005 -0
  11. package/docs/MultiTenant.md +176 -0
  12. package/docs/api-reference.md +1667 -0
  13. package/docs/cache.md +96 -0
  14. package/docs/configuration.md +1811 -0
  15. package/docs/editions-and-licences.md +190 -0
  16. package/docs/getting-started.md +201 -0
  17. package/docs/guides/form-triggers.md +260 -0
  18. package/docs/guides/mongodb-storage-adapter.md +282 -0
  19. package/docs/guides/multi-page-forms.md +121 -0
  20. package/docs/images/scribe-composer.png +0 -0
  21. package/docs/markdown-shortcodes.md +1738 -0
  22. package/docs/pentest-report-2026-04-03.md +181 -0
  23. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  24. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  25. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  26. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  27. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  28. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  29. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  30. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  31. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  32. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  33. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  34. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  35. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  36. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  37. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  38. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  39. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  40. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  41. package/docs/plugin-development.md +827 -0
  42. package/docs/recruitment-recipe.md +501 -0
  43. package/docs/scaffolding.md +312 -0
  44. package/docs/screenshots/calculator-floating.png +0 -0
  45. package/docs/screenshots/calculator-standard.png +0 -0
  46. package/docs/screenshots/email-settings.png +0 -0
  47. package/docs/screenshots/messages-count.png +0 -0
  48. package/docs/structure/user-types.md +107 -0
  49. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  50. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  51. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  52. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  53. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  54. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  55. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  56. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  57. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  58. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  59. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  60. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  61. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  62. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  63. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  64. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  65. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  66. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  67. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  68. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  69. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  70. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  71. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  72. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  73. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  74. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  75. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  76. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  77. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  78. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  79. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  80. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  81. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  82. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  83. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  84. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  85. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  86. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  87. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  88. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  89. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  90. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  91. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  92. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  93. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  94. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  95. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  96. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  97. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  98. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  99. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  100. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  101. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  102. package/docs/theming.md +320 -0
  103. package/package.json +2 -2
  104. package/public/js/celebrations/core/canvas.js +1 -1
  105. package/public/js/celebrations/index.js +1 -1
  106. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  107. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  108. package/public/js/celebrations/message-attrs.mjs +1 -0
  109. package/public/js/celebrations/themes/anniversary.js +1 -0
  110. package/public/js/celebrations/themes/autumn.js +1 -0
  111. package/public/js/celebrations/themes/birthday.js +1 -0
  112. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  113. package/public/js/celebrations/themes/calendar.js +1 -0
  114. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  115. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  116. package/public/js/celebrations/themes/christmas.js +1 -1
  117. package/public/js/celebrations/themes/easter.js +1 -0
  118. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  119. package/public/js/celebrations/themes/garden.js +1 -0
  120. package/public/js/celebrations/themes/graduation.js +1 -0
  121. package/public/js/celebrations/themes/greeting.js +1 -0
  122. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  123. package/public/js/celebrations/themes/money-rain.js +1 -0
  124. package/public/js/celebrations/themes/new-year.js +1 -0
  125. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  126. package/public/js/celebrations/themes/occasions.js +1 -0
  127. package/public/js/celebrations/themes/rain-control.js +1 -0
  128. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  129. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  130. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  131. package/public/js/celebrations/themes/summer-garden.js +1 -0
  132. package/public/js/celebrations/themes/winter.js +1 -0
  133. package/public/js/celebrations/trait-data.mjs +1 -1
  134. package/public/js/effects.js +1 -1
  135. package/scripts/build.js +4 -0
  136. package/server/services/markdown.js +5 -2
  137. package/server/templates/page.html +1 -1
@@ -0,0 +1,1436 @@
1
+ # Cache System Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **This project's rule:** never run `git commit` - list a suggested commit message in each commit step; the user commits separately.
4
+
5
+ **Goal:** Add a pluggable caching layer to Domma CMS with tag-based invalidation, switchable between in-memory (default), disabled (dev), and Redis (optional Pro), driven by `config/cache.json` and hooked into existing write paths so stale content never persists across an admin edit.
6
+
7
+ **Architecture:** Core service at `server/services/cache/` mirroring the existing adapter/connection-manager patterns. Public page renders are wrapped through `cache.wrap(key, producer, {tags})`. The markdown pipeline collects tags from each shortcode it processes (`[collection slug="faq"]` → `collection:faq`) so invalidation needs no central embed-map. Write services emit `cache.invalidateTags([...])` after every mutating operation.
8
+
9
+ **Tech Stack:** Node 24 ESM, Fastify 5, `node:test` + `node:assert/strict`, no required new runtime deps (`ioredis` added only to `optionalDependencies` for the Pro driver).
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ server/services/cache/
17
+ index.js Public API: initCache, get, set, wrap, invalidateTags, clear, isEnabled
18
+ drivers/
19
+ MemoryDriver.js Bounded LRU + tag index, TTL support
20
+ NoneDriver.js No-op driver - wrap always calls producer, get always returns undefined
21
+ RedisDriver.js Optional, loaded dynamically. ioredis-backed with tag sets.
22
+ lru.js Small zero-dep bounded map (Map-backed LRU)
23
+ config/
24
+ cache.json.example Template; absence → defaults baked into the facade
25
+ server/server.js Initialise cache on boot via initCache(config.cache)
26
+ server/routes/public.js Wrap the SSR response through cache.wrap()
27
+ server/services/markdown.js parseMarkdown returns {html, tags} instead of html-only
28
+ server/services/renderer.js Forward tags from markdown to caller
29
+ server/services/content.js invalidateTags(['page:<urlPath>']) on page CRUD
30
+ server/services/collections.js invalidateTags(['collection:<slug>']) on entry CRUD
31
+ server/services/forms.js invalidateTags(['form:<slug>']) on form CRUD
32
+ server/services/blocks.js invalidateTags(['block:<name>']) on block CRUD
33
+ server/services/views.js invalidateTags(['view:<slug>']) on view CRUD
34
+ server/routes/api/navigation.js invalidateTags(['nav']) on nav save (flushes every page)
35
+ server/routes/api/settings.js invalidateTags(['site']) on site settings save
36
+ server/routes/api/cache.js POST /api/cache/clear admin endpoint
37
+ tests/services/cache/
38
+ lru.test.js
39
+ MemoryDriver.test.js
40
+ NoneDriver.test.js
41
+ index.test.js
42
+ invalidation-integration.test.js
43
+ docs/cache.md User-facing docs: config, drivers, tag conventions
44
+ ```
45
+
46
+ ## API surface (frozen)
47
+
48
+ ```js
49
+ // server/services/cache/index.js
50
+ export async function initCache(config) // sets driver, idempotent
51
+ export async function get(key) // returns value | undefined
52
+ export async function set(key, value, opts = {}) // opts: {ttlSeconds, tags}
53
+ export async function wrap(key, producer, opts) // get-or-set with in-flight coalescing
54
+ export async function invalidateTags(tags) // tags: string[]
55
+ export async function clear() // flush everything (admin button)
56
+ export function isEnabled() // boolean - for branches in callers
57
+ export function _resetForTests() // test hook; mirrors health.js pattern
58
+ ```
59
+
60
+ `key` is a string. Callers serialise composite keys (e.g. `` `page:${urlPath}:role${roleLevel}` ``).
61
+
62
+ ## Tag conventions (frozen)
63
+
64
+ - `page:<urlPath>` - every cached page response carries this; invalidated on page CRUD
65
+ - `collection:<slug>` - added by `[collection slug="..."]` shortcode; invalidated on entry CRUD or schema change
66
+ - `form:<slug>` - added by `[form slug="..."]` shortcode; invalidated on form save
67
+ - `block:<name>` - added by `[block name="..."]` shortcode; invalidated on block save
68
+ - `view:<slug>` - added by `[view slug="..."]` shortcode; invalidated on view save
69
+ - `nav` - added to every cached page; invalidated on navigation change
70
+ - `site` - added to every cached page; invalidated on site settings save
71
+
72
+ Cache keys are versioned with a `v1:` prefix so future schema changes can auto-invalidate.
73
+
74
+ ---
75
+
76
+ ## Task 1: Zero-dep bounded LRU
77
+
78
+ **Files:**
79
+ - Create: `server/services/cache/lru.js`
80
+ - Test: `tests/services/cache/lru.test.js`
81
+
82
+ - [ ] **Step 1: Write the failing tests**
83
+
84
+ ```js
85
+ // tests/services/cache/lru.test.js
86
+ import {test} from 'node:test';
87
+ import assert from 'node:assert/strict';
88
+ import {LRU} from '../../../server/services/cache/lru.js';
89
+
90
+ test('LRU set/get round-trip', () => {
91
+ const lru = new LRU(10);
92
+ lru.set('a', 1);
93
+ assert.equal(lru.get('a'), 1);
94
+ });
95
+
96
+ test('LRU returns undefined for missing keys', () => {
97
+ const lru = new LRU(10);
98
+ assert.equal(lru.get('missing'), undefined);
99
+ });
100
+
101
+ test('LRU evicts oldest when full', () => {
102
+ const lru = new LRU(2);
103
+ lru.set('a', 1);
104
+ lru.set('b', 2);
105
+ lru.set('c', 3);
106
+ assert.equal(lru.get('a'), undefined);
107
+ assert.equal(lru.get('b'), 2);
108
+ assert.equal(lru.get('c'), 3);
109
+ });
110
+
111
+ test('LRU promotes on get so recently-read keys survive eviction', () => {
112
+ const lru = new LRU(2);
113
+ lru.set('a', 1);
114
+ lru.set('b', 2);
115
+ lru.get('a'); // promote a
116
+ lru.set('c', 3); // should evict b, not a
117
+ assert.equal(lru.get('a'), 1);
118
+ assert.equal(lru.get('b'), undefined);
119
+ assert.equal(lru.get('c'), 3);
120
+ });
121
+
122
+ test('LRU delete removes a key', () => {
123
+ const lru = new LRU(2);
124
+ lru.set('a', 1);
125
+ lru.delete('a');
126
+ assert.equal(lru.get('a'), undefined);
127
+ });
128
+
129
+ test('LRU clear empties the store', () => {
130
+ const lru = new LRU(2);
131
+ lru.set('a', 1);
132
+ lru.set('b', 2);
133
+ lru.clear();
134
+ assert.equal(lru.get('a'), undefined);
135
+ assert.equal(lru.size, 0);
136
+ });
137
+
138
+ test('LRU size reflects current item count', () => {
139
+ const lru = new LRU(5);
140
+ lru.set('a', 1);
141
+ lru.set('b', 2);
142
+ assert.equal(lru.size, 2);
143
+ });
144
+ ```
145
+
146
+ - [ ] **Step 2: Run the tests, confirm they fail**
147
+
148
+ ```
149
+ node --test tests/services/cache/lru.test.js
150
+ ```
151
+
152
+ Expected: `Cannot find module '.../lru.js'` or similar - all tests fail.
153
+
154
+ - [ ] **Step 3: Implement LRU**
155
+
156
+ ```js
157
+ // server/services/cache/lru.js
158
+ /**
159
+ * Bounded LRU built on the insertion-order property of Map.
160
+ * On hit, delete + re-set the entry so it becomes the most-recent.
161
+ * On set when at capacity, delete the first (oldest) key.
162
+ */
163
+ export class LRU {
164
+ /**
165
+ * @param {number} maxItems
166
+ */
167
+ constructor(maxItems) {
168
+ if (!Number.isInteger(maxItems) || maxItems < 1) {
169
+ throw new TypeError('LRU: maxItems must be a positive integer');
170
+ }
171
+ this._max = maxItems;
172
+ this._map = new Map();
173
+ }
174
+
175
+ get size() {
176
+ return this._map.size;
177
+ }
178
+
179
+ get(key) {
180
+ if (!this._map.has(key)) return undefined;
181
+ const value = this._map.get(key);
182
+ this._map.delete(key);
183
+ this._map.set(key, value);
184
+ return value;
185
+ }
186
+
187
+ set(key, value) {
188
+ if (this._map.has(key)) {
189
+ this._map.delete(key);
190
+ } else if (this._map.size >= this._max) {
191
+ const oldest = this._map.keys().next().value;
192
+ this._map.delete(oldest);
193
+ }
194
+ this._map.set(key, value);
195
+ }
196
+
197
+ delete(key) {
198
+ return this._map.delete(key);
199
+ }
200
+
201
+ clear() {
202
+ this._map.clear();
203
+ }
204
+
205
+ keys() {
206
+ return this._map.keys();
207
+ }
208
+ }
209
+ ```
210
+
211
+ - [ ] **Step 4: Run tests, confirm pass**
212
+
213
+ ```
214
+ node --test tests/services/cache/lru.test.js
215
+ ```
216
+
217
+ Expected: 7/7 pass.
218
+
219
+ - [ ] **Step 5: Save (no commit - user will batch)**
220
+
221
+ Suggested commit message: `feat(cache): add zero-dep bounded LRU primitive`
222
+
223
+ ---
224
+
225
+ ## Task 2: MemoryDriver with TTL + tag index
226
+
227
+ **Files:**
228
+ - Create: `server/services/cache/drivers/MemoryDriver.js`
229
+ - Test: `tests/services/cache/MemoryDriver.test.js`
230
+
231
+ `MemoryDriver` wraps the LRU and adds:
232
+ - TTL (per-entry expiry; eviction-on-read for laziness, no timers)
233
+ - Tag index: `Map<tag, Set<key>>` so `invalidateTags(['foo'])` flushes every key tagged `foo`
234
+
235
+ - [ ] **Step 1: Write the failing tests**
236
+
237
+ ```js
238
+ // tests/services/cache/MemoryDriver.test.js
239
+ import {test} from 'node:test';
240
+ import assert from 'node:assert/strict';
241
+ import {MemoryDriver} from '../../../server/services/cache/drivers/MemoryDriver.js';
242
+
243
+ test('MemoryDriver set/get round-trip', async () => {
244
+ const d = new MemoryDriver({maxItems: 10});
245
+ await d.set('k', 'v');
246
+ assert.equal(await d.get('k'), 'v');
247
+ });
248
+
249
+ test('MemoryDriver returns undefined for unset key', async () => {
250
+ const d = new MemoryDriver({maxItems: 10});
251
+ assert.equal(await d.get('nope'), undefined);
252
+ });
253
+
254
+ test('MemoryDriver expires entries past TTL', async () => {
255
+ const d = new MemoryDriver({maxItems: 10}, {now: () => 0});
256
+ let clock = 0;
257
+ d._now = () => clock;
258
+ await d.set('k', 'v', {ttlSeconds: 1});
259
+ clock = 500; // 500ms in - still valid
260
+ assert.equal(await d.get('k'), 'v');
261
+ clock = 1500; // past 1s TTL
262
+ assert.equal(await d.get('k'), undefined);
263
+ });
264
+
265
+ test('MemoryDriver invalidateTags flushes all keys with that tag', async () => {
266
+ const d = new MemoryDriver({maxItems: 10});
267
+ await d.set('page:/a', 'A', {tags: ['page:/a', 'nav']});
268
+ await d.set('page:/b', 'B', {tags: ['page:/b', 'nav']});
269
+ await d.set('page:/c', 'C', {tags: ['page:/c']});
270
+ await d.invalidateTags(['nav']);
271
+ assert.equal(await d.get('page:/a'), undefined);
272
+ assert.equal(await d.get('page:/b'), undefined);
273
+ assert.equal(await d.get('page:/c'), 'C');
274
+ });
275
+
276
+ test('MemoryDriver invalidateTags is a no-op for unknown tag', async () => {
277
+ const d = new MemoryDriver({maxItems: 10});
278
+ await d.set('k', 'v', {tags: ['real-tag']});
279
+ await d.invalidateTags(['ghost-tag']);
280
+ assert.equal(await d.get('k'), 'v');
281
+ });
282
+
283
+ test('MemoryDriver clear empties everything including tag index', async () => {
284
+ const d = new MemoryDriver({maxItems: 10});
285
+ await d.set('k', 'v', {tags: ['t']});
286
+ await d.clear();
287
+ assert.equal(await d.get('k'), undefined);
288
+ await d.invalidateTags(['t']); // must not throw
289
+ });
290
+
291
+ test('MemoryDriver evicts oldest under LRU pressure', async () => {
292
+ const d = new MemoryDriver({maxItems: 2});
293
+ await d.set('a', 1);
294
+ await d.set('b', 2);
295
+ await d.set('c', 3);
296
+ assert.equal(await d.get('a'), undefined);
297
+ assert.equal(await d.get('b'), 2);
298
+ assert.equal(await d.get('c'), 3);
299
+ });
300
+
301
+ test('MemoryDriver re-setting a key updates its tag membership', async () => {
302
+ const d = new MemoryDriver({maxItems: 10});
303
+ await d.set('k', 'v1', {tags: ['old-tag']});
304
+ await d.set('k', 'v2', {tags: ['new-tag']});
305
+ await d.invalidateTags(['old-tag']); // should NOT remove k anymore
306
+ assert.equal(await d.get('k'), 'v2');
307
+ await d.invalidateTags(['new-tag']);
308
+ assert.equal(await d.get('k'), undefined);
309
+ });
310
+ ```
311
+
312
+ - [ ] **Step 2: Run tests, confirm they fail**
313
+
314
+ ```
315
+ node --test tests/services/cache/MemoryDriver.test.js
316
+ ```
317
+
318
+ Expected: module-not-found, all fail.
319
+
320
+ - [ ] **Step 3: Implement MemoryDriver**
321
+
322
+ ```js
323
+ // server/services/cache/drivers/MemoryDriver.js
324
+ import {LRU} from '../lru.js';
325
+
326
+ /**
327
+ * In-memory cache driver with TTL and tag invalidation.
328
+ *
329
+ * Each entry is stored as {value, expiresAt}. TTL is lazy - entries past
330
+ * their expiresAt are returned as undefined on the next get and dropped.
331
+ *
332
+ * Tag index: a Map<tag, Set<key>>. set() with {tags} adds the key to each
333
+ * tag's set; invalidateTags() iterates the listed tags and deletes every
334
+ * key in their sets. The reverse index (key → tags) is stored alongside
335
+ * each entry so that re-setting a key cleans up the old tag links.
336
+ */
337
+ export class MemoryDriver {
338
+ /**
339
+ * @param {{maxItems?: number, defaultTtlSeconds?: number}} options
340
+ */
341
+ constructor(options = {}) {
342
+ const maxItems = options.maxItems ?? 1000;
343
+ this._defaultTtlSeconds = options.defaultTtlSeconds ?? null;
344
+ this._lru = new LRU(maxItems);
345
+ /** @type {Map<string, Set<string>>} */
346
+ this._tagIndex = new Map();
347
+ this._now = () => Date.now();
348
+ }
349
+
350
+ async get(key) {
351
+ const entry = this._lru.get(key);
352
+ if (!entry) return undefined;
353
+ if (entry.expiresAt !== null && entry.expiresAt <= this._now()) {
354
+ this._removeKey(key);
355
+ return undefined;
356
+ }
357
+ return entry.value;
358
+ }
359
+
360
+ async set(key, value, opts = {}) {
361
+ // Clean up any prior tag links for this key (re-set scenario)
362
+ const existing = this._lru.get(key);
363
+ if (existing && existing.tags) {
364
+ for (const t of existing.tags) {
365
+ const set = this._tagIndex.get(t);
366
+ if (set) {
367
+ set.delete(key);
368
+ if (set.size === 0) this._tagIndex.delete(t);
369
+ }
370
+ }
371
+ }
372
+
373
+ const ttl = opts.ttlSeconds ?? this._defaultTtlSeconds;
374
+ const expiresAt = ttl ? this._now() + ttl * 1000 : null;
375
+ const tags = opts.tags && opts.tags.length ? [...opts.tags] : null;
376
+
377
+ this._lru.set(key, {value, expiresAt, tags});
378
+
379
+ if (tags) {
380
+ for (const t of tags) {
381
+ let set = this._tagIndex.get(t);
382
+ if (!set) {
383
+ set = new Set();
384
+ this._tagIndex.set(t, set);
385
+ }
386
+ set.add(key);
387
+ }
388
+ }
389
+ }
390
+
391
+ async invalidateTags(tags) {
392
+ for (const t of tags) {
393
+ const set = this._tagIndex.get(t);
394
+ if (!set) continue;
395
+ for (const key of set) {
396
+ this._removeKey(key);
397
+ }
398
+ }
399
+ }
400
+
401
+ async clear() {
402
+ this._lru.clear();
403
+ this._tagIndex.clear();
404
+ }
405
+
406
+ _removeKey(key) {
407
+ const entry = this._lru.get(key);
408
+ if (entry && entry.tags) {
409
+ for (const t of entry.tags) {
410
+ const set = this._tagIndex.get(t);
411
+ if (set) {
412
+ set.delete(key);
413
+ if (set.size === 0) this._tagIndex.delete(t);
414
+ }
415
+ }
416
+ }
417
+ this._lru.delete(key);
418
+ }
419
+ }
420
+ ```
421
+
422
+ - [ ] **Step 4: Run tests, confirm pass**
423
+
424
+ ```
425
+ node --test tests/services/cache/MemoryDriver.test.js
426
+ ```
427
+
428
+ Expected: 8/8 pass.
429
+
430
+ - [ ] **Step 5: Save**
431
+
432
+ Suggested commit message: `feat(cache): add MemoryDriver with TTL and tag invalidation`
433
+
434
+ ---
435
+
436
+ ## Task 3: NoneDriver (dev/disabled mode)
437
+
438
+ **Files:**
439
+ - Create: `server/services/cache/drivers/NoneDriver.js`
440
+ - Test: `tests/services/cache/NoneDriver.test.js`
441
+
442
+ - [ ] **Step 1: Write the failing tests**
443
+
444
+ ```js
445
+ // tests/services/cache/NoneDriver.test.js
446
+ import {test} from 'node:test';
447
+ import assert from 'node:assert/strict';
448
+ import {NoneDriver} from '../../../server/services/cache/drivers/NoneDriver.js';
449
+
450
+ test('NoneDriver get always returns undefined', async () => {
451
+ const d = new NoneDriver();
452
+ await d.set('k', 'v');
453
+ assert.equal(await d.get('k'), undefined);
454
+ });
455
+
456
+ test('NoneDriver set is a no-op (does not throw)', async () => {
457
+ const d = new NoneDriver();
458
+ await d.set('k', 'v', {tags: ['t'], ttlSeconds: 60});
459
+ });
460
+
461
+ test('NoneDriver invalidateTags is a no-op', async () => {
462
+ const d = new NoneDriver();
463
+ await d.invalidateTags(['t']);
464
+ });
465
+
466
+ test('NoneDriver clear is a no-op', async () => {
467
+ const d = new NoneDriver();
468
+ await d.clear();
469
+ });
470
+ ```
471
+
472
+ - [ ] **Step 2: Run, confirm fail**
473
+
474
+ ```
475
+ node --test tests/services/cache/NoneDriver.test.js
476
+ ```
477
+
478
+ - [ ] **Step 3: Implement**
479
+
480
+ ```js
481
+ // server/services/cache/drivers/NoneDriver.js
482
+ /**
483
+ * Null driver - used when the cache is disabled.
484
+ * Every read misses; every write is dropped on the floor.
485
+ */
486
+ export class NoneDriver {
487
+ async get() { return undefined; }
488
+ async set() { /* no-op */ }
489
+ async invalidateTags() { /* no-op */ }
490
+ async clear() { /* no-op */ }
491
+ }
492
+ ```
493
+
494
+ - [ ] **Step 4: Run, confirm pass**
495
+
496
+ ```
497
+ node --test tests/services/cache/NoneDriver.test.js
498
+ ```
499
+
500
+ Expected: 4/4 pass.
501
+
502
+ - [ ] **Step 5: Save**
503
+
504
+ Suggested commit message: `feat(cache): add NoneDriver for disabled/dev mode`
505
+
506
+ ---
507
+
508
+ ## Task 4: Cache facade (`services/cache/index.js`)
509
+
510
+ **Files:**
511
+ - Create: `server/services/cache/index.js`
512
+ - Test: `tests/services/cache/index.test.js`
513
+
514
+ The facade owns: the active driver instance, in-flight promise coalescing, and the `enabled` flag. It also normalises keys with a `v1:` version prefix so future schema bumps auto-invalidate.
515
+
516
+ - [ ] **Step 1: Write the failing tests**
517
+
518
+ ```js
519
+ // tests/services/cache/index.test.js
520
+ import {test} from 'node:test';
521
+ import assert from 'node:assert/strict';
522
+ import * as cache from '../../../server/services/cache/index.js';
523
+
524
+ test('initCache with enabled=false uses NoneDriver - wrap always calls producer', async () => {
525
+ await cache.initCache({enabled: false});
526
+ cache._resetForTests();
527
+ await cache.initCache({enabled: false});
528
+ let calls = 0;
529
+ const producer = async () => { calls++; return 'x'; };
530
+ await cache.wrap('k', producer);
531
+ await cache.wrap('k', producer);
532
+ assert.equal(calls, 2);
533
+ assert.equal(cache.isEnabled(), false);
534
+ });
535
+
536
+ test('initCache with driver=memory caches values', async () => {
537
+ cache._resetForTests();
538
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
539
+ let calls = 0;
540
+ const producer = async () => { calls++; return 'x'; };
541
+ const a = await cache.wrap('k', producer);
542
+ const b = await cache.wrap('k', producer);
543
+ assert.equal(a, 'x');
544
+ assert.equal(b, 'x');
545
+ assert.equal(calls, 1);
546
+ });
547
+
548
+ test('invalidateTags flushes wrapped values', async () => {
549
+ cache._resetForTests();
550
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
551
+ let calls = 0;
552
+ const producer = async () => { calls++; return 'x'; };
553
+ await cache.wrap('page:/a', producer, {tags: ['page:/a']});
554
+ await cache.invalidateTags(['page:/a']);
555
+ await cache.wrap('page:/a', producer, {tags: ['page:/a']});
556
+ assert.equal(calls, 2);
557
+ });
558
+
559
+ test('wrap coalesces concurrent calls for the same key', async () => {
560
+ cache._resetForTests();
561
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
562
+ let calls = 0;
563
+ const producer = async () => {
564
+ calls++;
565
+ await new Promise(r => setTimeout(r, 20));
566
+ return 'x';
567
+ };
568
+ const [a, b, c] = await Promise.all([
569
+ cache.wrap('k', producer),
570
+ cache.wrap('k', producer),
571
+ cache.wrap('k', producer),
572
+ ]);
573
+ assert.equal(a, 'x');
574
+ assert.equal(b, 'x');
575
+ assert.equal(c, 'x');
576
+ assert.equal(calls, 1);
577
+ });
578
+
579
+ test('clear flushes all entries', async () => {
580
+ cache._resetForTests();
581
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
582
+ await cache.set('k', 'v');
583
+ await cache.clear();
584
+ assert.equal(await cache.get('k'), undefined);
585
+ });
586
+
587
+ test('unknown driver name throws on init', async () => {
588
+ cache._resetForTests();
589
+ await assert.rejects(
590
+ () => cache.initCache({enabled: true, driver: 'snake-oil'}),
591
+ /Unknown cache driver/
592
+ );
593
+ });
594
+ ```
595
+
596
+ - [ ] **Step 2: Run, confirm fail**
597
+
598
+ ```
599
+ node --test tests/services/cache/index.test.js
600
+ ```
601
+
602
+ - [ ] **Step 3: Implement the facade**
603
+
604
+ ```js
605
+ // server/services/cache/index.js
606
+ import {MemoryDriver} from './drivers/MemoryDriver.js';
607
+ import {NoneDriver} from './drivers/NoneDriver.js';
608
+
609
+ const KEY_VERSION = 'v1:';
610
+
611
+ let _driver = new NoneDriver();
612
+ let _enabled = false;
613
+ /** @type {Map<string, Promise<*>>} */
614
+ const _inflight = new Map();
615
+
616
+ /**
617
+ * @param {{
618
+ * enabled?: boolean,
619
+ * driver?: 'memory' | 'none' | 'redis',
620
+ * memory?: {maxItems?: number, defaultTtlSeconds?: number},
621
+ * redis?: {url: string, keyPrefix?: string}
622
+ * }} config
623
+ */
624
+ export async function initCache(config = {}) {
625
+ if (config.enabled === false) {
626
+ _driver = new NoneDriver();
627
+ _enabled = false;
628
+ return;
629
+ }
630
+ const name = config.driver ?? 'memory';
631
+ if (name === 'memory') {
632
+ _driver = new MemoryDriver(config.memory ?? {});
633
+ _enabled = true;
634
+ return;
635
+ }
636
+ if (name === 'none') {
637
+ _driver = new NoneDriver();
638
+ _enabled = false;
639
+ return;
640
+ }
641
+ if (name === 'redis') {
642
+ // Dynamic import - keeps ioredis out of the free-tier require graph.
643
+ const {RedisDriver} = await import('./drivers/RedisDriver.js');
644
+ _driver = new RedisDriver(config.redis ?? {});
645
+ await _driver.connect();
646
+ _enabled = true;
647
+ return;
648
+ }
649
+ throw new Error(`Unknown cache driver: ${name}`);
650
+ }
651
+
652
+ export function isEnabled() {
653
+ return _enabled;
654
+ }
655
+
656
+ export async function get(key) {
657
+ return _driver.get(_vk(key));
658
+ }
659
+
660
+ export async function set(key, value, opts = {}) {
661
+ return _driver.set(_vk(key), value, opts);
662
+ }
663
+
664
+ export async function invalidateTags(tags) {
665
+ if (!Array.isArray(tags) || tags.length === 0) return;
666
+ return _driver.invalidateTags(tags);
667
+ }
668
+
669
+ export async function clear() {
670
+ _inflight.clear();
671
+ return _driver.clear();
672
+ }
673
+
674
+ /**
675
+ * Get-or-set with in-flight coalescing.
676
+ *
677
+ * @param {string} key
678
+ * @param {() => Promise<*>} producer
679
+ * @param {{ttlSeconds?: number, tags?: string[]}} [opts]
680
+ */
681
+ export async function wrap(key, producer, opts = {}) {
682
+ if (!_enabled) return producer();
683
+
684
+ const vkey = _vk(key);
685
+ const hit = await _driver.get(vkey);
686
+ if (hit !== undefined) return hit;
687
+
688
+ if (_inflight.has(vkey)) return _inflight.get(vkey);
689
+
690
+ const promise = (async () => {
691
+ try {
692
+ const value = await producer();
693
+ await _driver.set(vkey, value, opts);
694
+ return value;
695
+ } finally {
696
+ _inflight.delete(vkey);
697
+ }
698
+ })();
699
+
700
+ _inflight.set(vkey, promise);
701
+ return promise;
702
+ }
703
+
704
+ function _vk(key) {
705
+ return KEY_VERSION + key;
706
+ }
707
+
708
+ export function _resetForTests() {
709
+ _driver = new NoneDriver();
710
+ _enabled = false;
711
+ _inflight.clear();
712
+ }
713
+ ```
714
+
715
+ - [ ] **Step 4: Run tests, confirm pass**
716
+
717
+ ```
718
+ node --test tests/services/cache/index.test.js
719
+ ```
720
+
721
+ Expected: 6/6 pass.
722
+
723
+ - [ ] **Step 5: Save**
724
+
725
+ Suggested commit message: `feat(cache): add cache facade with driver loading and in-flight coalescing`
726
+
727
+ ---
728
+
729
+ ## Task 5: Server bootstrap integration
730
+
731
+ **Files:**
732
+ - Modify: `server/server.js` - call `initCache(config.cache)` during startup, after config load but before route registration.
733
+ - Modify: `server/config.js` - add `cache` to the config singleton with sensible defaults if `config/cache.json` is absent.
734
+ - Create: `config/cache.json.example`
735
+
736
+ - [ ] **Step 1: Add cache defaults to config loader**
737
+
738
+ Locate `server/config.js`. Find the section that merges per-file configs into the singleton (search for `config.content` or similar). Add a `cache` block after the existing sections.
739
+
740
+ ```js
741
+ // server/config.js - inside loadConfig() near other per-file merges
742
+ import {readFileSync, existsSync} from 'fs';
743
+
744
+ // ... existing code ...
745
+
746
+ const cacheFile = path.join(ROOT, 'config/cache.json');
747
+ const cacheDefaults = {
748
+ enabled: process.env.NODE_ENV === 'production',
749
+ driver: 'memory',
750
+ memory: {maxItems: 1000, defaultTtlSeconds: 3600}
751
+ };
752
+ config.cache = existsSync(cacheFile)
753
+ ? {...cacheDefaults, ...JSON.parse(readFileSync(cacheFile, 'utf8'))}
754
+ : cacheDefaults;
755
+ ```
756
+
757
+ (The exact insertion point depends on the existing loader shape - read `server/config.js` first and place this alongside the `content` / `auth` / `site` merges. Match the style.)
758
+
759
+ - [ ] **Step 2: Wire initCache into server boot**
760
+
761
+ Open `server/server.js`. After `await loadConfig()` (or wherever the config is finalised) and before route registration, add:
762
+
763
+ ```js
764
+ import * as cache from './services/cache/index.js';
765
+
766
+ // ... in the startup async block, after config is loaded:
767
+ await cache.initCache(config.cache);
768
+ console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
769
+ ```
770
+
771
+ - [ ] **Step 3: Add the example config**
772
+
773
+ ```json
774
+ // config/cache.json.example
775
+ {
776
+ "enabled": true,
777
+ "driver": "memory",
778
+ "memory": {
779
+ "maxItems": 1000,
780
+ "defaultTtlSeconds": 3600
781
+ },
782
+ "redis": {
783
+ "url": "redis://localhost:6379",
784
+ "keyPrefix": "domma:"
785
+ }
786
+ }
787
+ ```
788
+
789
+ - [ ] **Step 4: Smoke-test the server starts and logs the cache state**
790
+
791
+ ```
792
+ node server/server.js
793
+ ```
794
+
795
+ Expected: console shows `[cache] driver=memory` (or `disabled` depending on NODE_ENV). No crash. Curl `/` and confirm the public site still renders normally - cache wrapping is not yet wired so behaviour is unchanged.
796
+
797
+ - [ ] **Step 5: Save**
798
+
799
+ Suggested commit message: `feat(cache): initialise cache from config on server startup`
800
+
801
+ ---
802
+
803
+ ## Task 6: Markdown pipeline returns tag set
804
+
805
+ **Files:**
806
+ - Modify: `server/services/markdown.js` - `parseMarkdown()` returns `{html, frontmatter, tags}` instead of `{html, frontmatter}` (or whatever the current shape is).
807
+ - Test: `tests/services/markdown-tags.test.js`
808
+
809
+ The simplest tag-collection approach: each shortcode pre-processor returns a tag list alongside its output. We accumulate them in a `tagSet` Set passed through the pipeline.
810
+
811
+ Existing shortcode pre-processors (e.g. `processCarouselBlocks`, `processCollectionBlocks`, `processFormBlocks`) currently take and return a string. We add a second argument `tagSet` that they mutate.
812
+
813
+ - [ ] **Step 1: Write the failing test**
814
+
815
+ ```js
816
+ // tests/services/markdown-tags.test.js
817
+ import {test} from 'node:test';
818
+ import assert from 'node:assert/strict';
819
+ import {parseMarkdown} from '../../server/services/markdown.js';
820
+
821
+ test('parseMarkdown reports collection tag when [collection] is used', async () => {
822
+ const md = '---\ntitle: t\n---\n[collection slug="faq" display="cards" /]';
823
+ const result = await parseMarkdown(md);
824
+ assert.ok(result.tags.includes('collection:faq'), `expected collection:faq in ${result.tags}`);
825
+ });
826
+
827
+ test('parseMarkdown reports form tag when [form] is used', async () => {
828
+ const md = '---\ntitle: t\n---\n[form slug="contact" /]';
829
+ const result = await parseMarkdown(md);
830
+ assert.ok(result.tags.includes('form:contact'));
831
+ });
832
+
833
+ test('parseMarkdown reports block tag when [block] is used', async () => {
834
+ const md = '---\ntitle: t\n---\n[block name="hero-cta" /]';
835
+ const result = await parseMarkdown(md);
836
+ assert.ok(result.tags.includes('block:hero-cta'));
837
+ });
838
+
839
+ test('parseMarkdown reports view tag when [view] is used', async () => {
840
+ const md = '---\ntitle: t\n---\n[view slug="latest-posts" /]';
841
+ const result = await parseMarkdown(md);
842
+ assert.ok(result.tags.includes('view:latest-posts'));
843
+ });
844
+
845
+ test('parseMarkdown returns empty tag list for plain markdown', async () => {
846
+ const md = '---\ntitle: t\n---\nJust **plain** text.';
847
+ const result = await parseMarkdown(md);
848
+ assert.deepEqual(result.tags, []);
849
+ });
850
+ ```
851
+
852
+ - [ ] **Step 2: Run, confirm fail**
853
+
854
+ ```
855
+ node --test tests/services/markdown-tags.test.js
856
+ ```
857
+
858
+ - [ ] **Step 3: Update markdown.js shortcode processors**
859
+
860
+ In `server/services/markdown.js`:
861
+
862
+ 1. Inside `parseMarkdown` (around the existing pipeline), create a `tagSet`:
863
+ ```js
864
+ const tagSet = new Set();
865
+ ```
866
+
867
+ 2. Pass `tagSet` into the existing collection/form/block/view shortcode processors. For each, find the regex match and `tagSet.add('<prefix>:' + attrs.slug)` (or `.name`):
868
+
869
+ - `processCollectionBlocks(md, tagSet)`: when handling each `[collection slug="..."]`, do `tagSet.add('collection:' + attrs.slug)`.
870
+ - `processFormBlocks(md, tagSet)`: `tagSet.add('form:' + attrs.slug)`.
871
+ - `processBlockShortcodes(md, tagSet)`: `tagSet.add('block:' + attrs.name)`.
872
+ - `processViewBlocks(md, tagSet)`: `tagSet.add('view:' + attrs.slug)`.
873
+
874
+ (The function names above are placeholders - match the exact processors already in `markdown.js`. Read the file and apply the pattern to each.)
875
+
876
+ 3. At the bottom of `parseMarkdown`, return:
877
+ ```js
878
+ return {html: finalHtml, frontmatter, tags: [...tagSet]};
879
+ ```
880
+
881
+ 4. Update any caller of `parseMarkdown` that destructures `{html}` to also tolerate the `tags` property (most callers ignore extra fields, so this is usually no change).
882
+
883
+ - [ ] **Step 4: Run tests, confirm pass**
884
+
885
+ ```
886
+ node --test tests/services/markdown-tags.test.js
887
+ node --test tests/services/ # ensure no regressions in existing markdown tests
888
+ ```
889
+
890
+ Expected: 5/5 new tests pass; existing tests unaffected.
891
+
892
+ - [ ] **Step 5: Save**
893
+
894
+ Suggested commit message: `feat(cache): markdown pipeline emits tag set for cache invalidation`
895
+
896
+ ---
897
+
898
+ ## Task 7: Wrap the public route through the cache
899
+
900
+ **Files:**
901
+ - Modify: `server/routes/public.js` - wrap the page render via `cache.wrap()`. Compose the cache key and tag list.
902
+ - Modify: `server/services/renderer.js` - propagate `tags` from `parseMarkdown` up to the caller.
903
+
904
+ The cache key includes URL path AND the visitor's role level (so role-gated pages don't leak across roles).
905
+
906
+ - [ ] **Step 1: Propagate tags from renderer**
907
+
908
+ Open `server/services/renderer.js`. Find where it calls `parseMarkdown`. Capture the returned `tags` and return them from the renderer function alongside the HTML.
909
+
910
+ ```js
911
+ // before
912
+ const {html, frontmatter} = await parseMarkdown(...);
913
+ return {html: finalHtml, frontmatter};
914
+
915
+ // after
916
+ const {html, frontmatter, tags = []} = await parseMarkdown(...);
917
+ return {html: finalHtml, frontmatter, tags};
918
+ ```
919
+
920
+ - [ ] **Step 2: Wrap the route handler in public.js**
921
+
922
+ In `server/routes/public.js`, find the route handler that serves public pages (the catch-all SSR). Replace the direct render call with a `cache.wrap()` call. The producer is the existing render function. Tags = page tag + collected tags + universal tags (`nav`, `site`).
923
+
924
+ ```js
925
+ import * as cache from '../services/cache/index.js';
926
+
927
+ // inside the public route handler, replacing the existing render:
928
+ const roleLevel = request.user ? getRoleLevel(request.user.role) : null;
929
+ const cacheKey = `page:${urlPath}:role${roleLevel ?? 'anon'}`;
930
+
931
+ const {html, status} = await cache.wrap(
932
+ cacheKey,
933
+ async () => {
934
+ const rendered = await renderer.renderPage(urlPath, request.user);
935
+ return {
936
+ html: rendered.html,
937
+ status: rendered.status,
938
+ tags: [
939
+ `page:${urlPath}`,
940
+ ...rendered.tags,
941
+ 'nav',
942
+ 'site'
943
+ ]
944
+ };
945
+ },
946
+ // After producer runs, `wrap` calls set() with these opts. But we need
947
+ // tags from the producer result - see Step 3 for the fix.
948
+ );
949
+ ```
950
+
951
+ - [ ] **Step 3: Fix the chicken-and-egg with tags from producer**
952
+
953
+ The producer returns the tags, but `wrap`'s `opts.tags` is read up front. Two options:
954
+
955
+ **Option A (chosen - simpler):** Producer returns the value AND a separate `_cacheTags` field; `wrap` recognises it. Add a small extension to the facade:
956
+
957
+ In `server/services/cache/index.js`, update `wrap` to read tags from a `_cacheTags` field on the producer return:
958
+
959
+ ```js
960
+ const promise = (async () => {
961
+ try {
962
+ const value = await producer();
963
+ const setOpts = {...opts};
964
+ if (value && Array.isArray(value._cacheTags)) {
965
+ setOpts.tags = [...(opts.tags ?? []), ...value._cacheTags];
966
+ // Strip _cacheTags before storing so the cached value is clean
967
+ const {_cacheTags, ...clean} = value;
968
+ await _driver.set(vkey, clean, setOpts);
969
+ _inflight.delete(vkey);
970
+ return clean;
971
+ }
972
+ await _driver.set(vkey, value, setOpts);
973
+ return value;
974
+ } finally {
975
+ _inflight.delete(vkey);
976
+ }
977
+ })();
978
+ ```
979
+
980
+ Then in `public.js`, the producer returns `{html, status, _cacheTags: [...]}`.
981
+
982
+ Add a test for `_cacheTags`:
983
+
984
+ ```js
985
+ // append to tests/services/cache/index.test.js
986
+ test('wrap reads producer._cacheTags and uses them for invalidation', async () => {
987
+ cache._resetForTests();
988
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
989
+ const producer = async () => ({payload: 'x', _cacheTags: ['some-tag']});
990
+ await cache.wrap('k', producer);
991
+ await cache.invalidateTags(['some-tag']);
992
+ let calls = 0;
993
+ const producer2 = async () => { calls++; return {payload: 'y'}; };
994
+ await cache.wrap('k', producer2);
995
+ assert.equal(calls, 1);
996
+ });
997
+
998
+ test('wrap strips _cacheTags from the cached and returned value', async () => {
999
+ cache._resetForTests();
1000
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
1001
+ const result = await cache.wrap('k', async () => ({a: 1, _cacheTags: ['t']}));
1002
+ assert.equal(result._cacheTags, undefined);
1003
+ assert.equal(result.a, 1);
1004
+ });
1005
+ ```
1006
+
1007
+ - [ ] **Step 4: Verify via curl that wrapping works**
1008
+
1009
+ ```
1010
+ curl -s -o /dev/null -w "First: %{time_total}s\n" http://localhost:4096/
1011
+ curl -s -o /dev/null -w "Second: %{time_total}s\n" http://localhost:4096/
1012
+ ```
1013
+
1014
+ Expected: second request is meaningfully faster (cache hit). Run the test suite:
1015
+
1016
+ ```
1017
+ node --test tests/services/cache/
1018
+ ```
1019
+
1020
+ Expected: all pass including the two new tests for `_cacheTags`.
1021
+
1022
+ - [ ] **Step 5: Save**
1023
+
1024
+ Suggested commit message: `feat(cache): wrap public page renders through cache with role-keyed entries`
1025
+
1026
+ ---
1027
+
1028
+ ## Task 8: Invalidate page tag on content service writes
1029
+
1030
+ **Files:**
1031
+ - Modify: `server/services/content.js` - call `cache.invalidateTags(['page:<urlPath>'])` after every page write/delete.
1032
+ - Test: `tests/services/cache/invalidation-integration.test.js`
1033
+
1034
+ - [ ] **Step 1: Write the failing integration test**
1035
+
1036
+ ```js
1037
+ // tests/services/cache/invalidation-integration.test.js
1038
+ import {test} from 'node:test';
1039
+ import assert from 'node:assert/strict';
1040
+ import * as cache from '../../../server/services/cache/index.js';
1041
+
1042
+ test('savePage invalidates page:<urlPath>', async () => {
1043
+ cache._resetForTests();
1044
+ await cache.initCache({enabled: true, driver: 'memory', memory: {maxItems: 10}});
1045
+
1046
+ let calls = 0;
1047
+ const producer = async () => { calls++; return 'rendered'; };
1048
+
1049
+ // Prime the cache
1050
+ await cache.wrap('page:/about:roleanon', producer, {tags: ['page:/about']});
1051
+ await cache.wrap('page:/about:roleanon', producer, {tags: ['page:/about']});
1052
+ assert.equal(calls, 1);
1053
+
1054
+ // Simulate the content service's invalidation hook
1055
+ const {savePage} = await import('../../../server/services/content.js');
1056
+ // NOTE: depends on existing content.js signature - adapt to actual.
1057
+ await savePage('/about', {title: 'About', content: '...'});
1058
+
1059
+ // Next read should miss
1060
+ await cache.wrap('page:/about:roleanon', producer, {tags: ['page:/about']});
1061
+ assert.equal(calls, 2);
1062
+ });
1063
+ ```
1064
+
1065
+ (If `savePage` requires more setup than this, the test may need to mock the filesystem. In that case skip the integration test here and do it in Task 14 - verify manually via curl.)
1066
+
1067
+ - [ ] **Step 2: Run, confirm fail**
1068
+
1069
+ ```
1070
+ node --test tests/services/cache/invalidation-integration.test.js
1071
+ ```
1072
+
1073
+ - [ ] **Step 3: Add the invalidation hook to content.js**
1074
+
1075
+ Read `server/services/content.js`. Locate `savePage`, `deletePage`, and any other mutating function. Import the cache module at the top:
1076
+
1077
+ ```js
1078
+ import * as cache from './cache/index.js';
1079
+ ```
1080
+
1081
+ After each successful write/delete, add:
1082
+
1083
+ ```js
1084
+ // in savePage, after the file is written:
1085
+ await cache.invalidateTags([`page:${urlPath}`]);
1086
+
1087
+ // in deletePage:
1088
+ await cache.invalidateTags([`page:${urlPath}`]);
1089
+
1090
+ // in renamePage / movePage if such functions exist:
1091
+ await cache.invalidateTags([`page:${oldPath}`, `page:${newPath}`]);
1092
+ ```
1093
+
1094
+ - [ ] **Step 4: Run tests, confirm pass**
1095
+
1096
+ ```
1097
+ node --test tests/services/cache/invalidation-integration.test.js
1098
+ ```
1099
+
1100
+ - [ ] **Step 5: Save**
1101
+
1102
+ Suggested commit message: `feat(cache): invalidate page cache on content service writes`
1103
+
1104
+ ---
1105
+
1106
+ ## Task 9: Invalidate collection tag on entry CRUD
1107
+
1108
+ **Files:**
1109
+ - Modify: `server/services/collections.js` - call `cache.invalidateTags(['collection:<slug>'])` after entry create/update/delete and after schema change.
1110
+
1111
+ - [ ] **Step 1: Inspect collections.js for write functions**
1112
+
1113
+ Read `server/services/collections.js`. Identify all entry-mutating functions (`createEntry`, `updateEntry`, `deleteEntry`, `clearEntries`, `importEntries`) and schema-mutating functions (`updateCollection`).
1114
+
1115
+ - [ ] **Step 2: Add invalidation calls**
1116
+
1117
+ At the top of the file:
1118
+ ```js
1119
+ import * as cache from './cache/index.js';
1120
+ ```
1121
+
1122
+ After each successful entry mutation:
1123
+ ```js
1124
+ await cache.invalidateTags([`collection:${slug}`]);
1125
+ ```
1126
+
1127
+ After a schema mutation (the schema affects rendered output, e.g. `[collection]` cards):
1128
+ ```js
1129
+ await cache.invalidateTags([`collection:${slug}`]);
1130
+ ```
1131
+
1132
+ Also after `clearEntries(slug)` and `importEntries(slug, ...)`.
1133
+
1134
+ - [ ] **Step 3: Manual smoke test**
1135
+
1136
+ ```
1137
+ # In one terminal:
1138
+ node server/server.js
1139
+
1140
+ # In another:
1141
+ # 1. Curl a page that contains [collection slug="faq" display="cards"]
1142
+ curl -s http://localhost:4096/faq | grep -c "faq-item"
1143
+ # Note the count.
1144
+
1145
+ # 2. Add an entry via admin or via the API:
1146
+ curl -X POST http://localhost:4096/api/collections/faq/entries \
1147
+ -H "Authorization: Bearer <token>" \
1148
+ -H "Content-Type: application/json" \
1149
+ -d '{"title": "New Q", "description": "..."}'
1150
+
1151
+ # 3. Curl the same page again - count should have increased
1152
+ curl -s http://localhost:4096/faq | grep -c "faq-item"
1153
+ ```
1154
+
1155
+ Expected: new entry visible immediately, no stale cached HTML.
1156
+
1157
+ - [ ] **Step 4: Save**
1158
+
1159
+ Suggested commit message: `feat(cache): invalidate collection cache on entry/schema mutations`
1160
+
1161
+ ---
1162
+
1163
+ ## Task 10: Invalidate forms, blocks, views, nav, settings
1164
+
1165
+ **Files:**
1166
+ - Modify: `server/services/forms.js` - `cache.invalidateTags(['form:' + slug])` on save/delete.
1167
+ - Modify: `server/services/blocks.js` - `cache.invalidateTags(['block:' + name])` on save/delete.
1168
+ - Modify: `server/services/views.js` - `cache.invalidateTags(['view:' + slug])` on save/delete.
1169
+ - Modify: `server/routes/api/navigation.js` - `cache.invalidateTags(['nav'])` on PUT (nav appears on every page, so this flushes most of the cache).
1170
+ - Modify: `server/routes/api/settings.js` - `cache.invalidateTags(['site'])` on PUT (likewise universal).
1171
+
1172
+ For each file: import `* as cache from '...services/cache/index.js'` at the top, then add a single `await cache.invalidateTags([...])` after each successful write.
1173
+
1174
+ - [ ] **Step 1: Update each file with the appropriate hook**
1175
+
1176
+ (Plain mechanical edits - same pattern in each. Match the exact location of the write completion in each file.)
1177
+
1178
+ - [ ] **Step 2: Smoke test each**
1179
+
1180
+ ```
1181
+ # After saving a form, fetch a page that embeds [form slug="contact"]:
1182
+ curl -X PUT http://localhost:4096/api/plugins/form-builder/forms/contact ...
1183
+ curl -s http://localhost:4096/contact-page | grep "form-id"
1184
+
1185
+ # Repeat for blocks, views, nav, settings.
1186
+ ```
1187
+
1188
+ - [ ] **Step 3: Save**
1189
+
1190
+ Suggested commit message: `feat(cache): invalidate form, block, view, nav, and site tags on writes`
1191
+
1192
+ ---
1193
+
1194
+ ## Task 11: Admin "Clear cache" endpoint + button
1195
+
1196
+ **Files:**
1197
+ - Create: `server/routes/api/cache.js` - POST /api/cache/clear, GET /api/cache/status.
1198
+ - Modify: `server/server.js` - register the new route file.
1199
+ - Modify: `admin/js/views/settings.js` (or wherever the system actions live) - add a "Clear cache" button.
1200
+ - Modify: `admin/js/templates/settings.html` - markup for the button.
1201
+
1202
+ - [ ] **Step 1: Create the API route file**
1203
+
1204
+ ```js
1205
+ // server/routes/api/cache.js
1206
+ import * as cache from '../../services/cache/index.js';
1207
+ import {requireAdmin} from '../../middleware/auth.js';
1208
+
1209
+ export default async function cacheRoutes(app) {
1210
+ app.post('/api/cache/clear', {preHandler: [app.authenticate, requireAdmin]}, async () => {
1211
+ await cache.clear();
1212
+ return {ok: true};
1213
+ });
1214
+
1215
+ app.get('/api/cache/status', {preHandler: [app.authenticate, requireAdmin]}, async () => {
1216
+ return {enabled: cache.isEnabled()};
1217
+ });
1218
+ }
1219
+ ```
1220
+
1221
+ - [ ] **Step 2: Register the route in server.js**
1222
+
1223
+ In `server/server.js`, next to the other route registrations, add:
1224
+
1225
+ ```js
1226
+ import cacheRoutes from './routes/api/cache.js';
1227
+ // ...
1228
+ await app.register(cacheRoutes);
1229
+ ```
1230
+
1231
+ - [ ] **Step 3: Add the admin button**
1232
+
1233
+ In the settings template, add a card with a clear-cache button:
1234
+
1235
+ ```html
1236
+ <!-- admin/js/templates/settings.html - in the system/maintenance section -->
1237
+ <div class="card mb-4">
1238
+ <div class="card-header"><span data-icon="trash"></span> Cache</div>
1239
+ <div class="card-body">
1240
+ <p class="text-muted">Clear all cached page responses. The next visitor to each page will trigger a fresh render.</p>
1241
+ <button id="btn-clear-cache" class="btn btn-secondary" type="button">Clear cache</button>
1242
+ </div>
1243
+ </div>
1244
+ ```
1245
+
1246
+ In `admin/js/views/settings.js`, wire the click:
1247
+
1248
+ ```js
1249
+ $container.find('#btn-clear-cache').on('click', async () => {
1250
+ if (!await E.confirm('Clear the entire response cache?')) return;
1251
+ try {
1252
+ await H.post('/api/cache/clear');
1253
+ E.toast('Cache cleared.', {type: 'success'});
1254
+ } catch (err) {
1255
+ E.toast('Failed to clear cache: ' + err.message, {type: 'error'});
1256
+ }
1257
+ });
1258
+ ```
1259
+
1260
+ - [ ] **Step 4: Verify in the browser**
1261
+
1262
+ Navigate to Settings, click "Clear cache", confirm the toast. Curl a page before and after to confirm the producer ran fresh.
1263
+
1264
+ - [ ] **Step 5: Save**
1265
+
1266
+ Suggested commit message: `feat(cache): add admin clear-cache endpoint and settings button`
1267
+
1268
+ ---
1269
+
1270
+ ## Task 12: Documentation
1271
+
1272
+ **Files:**
1273
+ - Create: `docs/cache.md`
1274
+ - Modify: `docs/configuration.md` - add section linking to cache docs.
1275
+ - Modify: `CLAUDE.md` - add the cache service to the Core Services table.
1276
+
1277
+ - [ ] **Step 1: Write docs/cache.md**
1278
+
1279
+ ```markdown
1280
+ # Caching
1281
+
1282
+ Domma CMS caches assembled public page responses to skip the markdown→shortcode→template pipeline on subsequent hits. The cache is on by default in production (`NODE_ENV=production`) and off in development.
1283
+
1284
+ ## Config
1285
+
1286
+ `config/cache.json` (optional - absent means use defaults):
1287
+
1288
+ ```json
1289
+ {
1290
+ "enabled": true,
1291
+ "driver": "memory",
1292
+ "memory": { "maxItems": 1000, "defaultTtlSeconds": 3600 },
1293
+ "redis": { "url": "redis://localhost:6379", "keyPrefix": "domma:" }
1294
+ }
1295
+ ```
1296
+
1297
+ - `enabled: false` short-circuits every read to the producer - useful in dev.
1298
+ - `driver`: `"memory"` (default, in-process LRU), `"none"` (same as `enabled: false`), or `"redis"` (requires `redis` in `optionalDependencies` - Pro feature).
1299
+
1300
+ ## What gets cached
1301
+
1302
+ Public page HTML responses, keyed by `page:<urlPath>:role<level>`. Admin API responses are never cached.
1303
+
1304
+ ## What invalidates
1305
+
1306
+ Tag-based. When an admin saves a page, only entries tagged `page:<that-url>` are flushed - not the whole cache. Tags:
1307
+
1308
+ - `page:<urlPath>` - flushed on page save/delete/rename
1309
+ - `collection:<slug>` - flushed on entry CRUD or schema change
1310
+ - `form:<slug>` - flushed on form save
1311
+ - `block:<name>` - flushed on block save
1312
+ - `view:<slug>` - flushed on view save
1313
+ - `nav` - flushed on navigation update (touches every page)
1314
+ - `site` - flushed on site settings save (touches every page)
1315
+
1316
+ The markdown pipeline auto-attaches the appropriate tags as it processes shortcodes - embedding `[collection slug="faq"]` causes the rendered page to be tagged `collection:faq`, so a FAQ entry edit transparently busts every page that displays the FAQ.
1317
+
1318
+ ## Manual flush
1319
+
1320
+ Settings → Cache → "Clear cache". Or:
1321
+
1322
+ ```
1323
+ curl -X POST http://localhost:4096/api/cache/clear -H "Authorization: Bearer <admin-token>"
1324
+ ```
1325
+
1326
+ ## Adding a new shortcode that needs invalidation
1327
+
1328
+ In the markdown pipeline, when handling your new shortcode, `tagSet.add('<prefix>:' + attrs.id)`. Then in the corresponding service, add `await cache.invalidateTags(['<prefix>:' + id])` after every write. Done - no central registry to update.
1329
+ ```
1330
+
1331
+ - [ ] **Step 2: Add row to CLAUDE.md core services table**
1332
+
1333
+ Open `CLAUDE.md` (the project root one). Find the "Core Services" section, and add a row:
1334
+
1335
+ ```
1336
+ | `cache/index.js` | Pluggable response cache (Memory/None/Redis drivers) with tag-based invalidation |
1337
+ ```
1338
+
1339
+ Mention it briefly in the architecture overview alongside `adapterRegistry.js`.
1340
+
1341
+ - [ ] **Step 3: Save**
1342
+
1343
+ Suggested commit message: `docs(cache): document cache config, tags, and invalidation`
1344
+
1345
+ ---
1346
+
1347
+ ## Task 13 (stretch - separate decision): RedisDriver
1348
+
1349
+ Deferred until someone actually needs multi-instance deploys. Not required for v1. When required:
1350
+
1351
+ - Add `redis` (the official `redis` package - async/await-native) to `optionalDependencies`.
1352
+ - Implement `RedisDriver` mirroring `MemoryDriver`'s API. Use Redis SETs for the tag index (`SADD tag:<name> <key>`; `SMEMBERS tag:<name>` + `DEL` chain for `invalidateTags`).
1353
+ - Use Redis `EXPIRE` for TTLs.
1354
+ - Test with a real Redis instance (`docker run -d -p 6379:6379 redis:7-alpine`) - write the test file but mark with `skip` if `REDIS_URL` is not set.
1355
+
1356
+ (Skeleton, not for implementation in this plan:)
1357
+
1358
+ ```js
1359
+ // server/services/cache/drivers/RedisDriver.js
1360
+ export class RedisDriver {
1361
+ constructor(options = {}) {
1362
+ this._url = options.url;
1363
+ this._keyPrefix = options.keyPrefix ?? 'domma:';
1364
+ this._client = null;
1365
+ }
1366
+ async connect() {
1367
+ const {createClient} = await import('redis');
1368
+ this._client = createClient({url: this._url});
1369
+ await this._client.connect();
1370
+ }
1371
+ async get(key) { /* GET keyPrefix + key, JSON.parse */ }
1372
+ async set(key, value, opts) { /* SET + EXPIRE + SADD for each tag */ }
1373
+ async invalidateTags(tags) { /* SMEMBERS each, DEL keys, DEL tag set */ }
1374
+ async clear() { /* SCAN + DEL with keyPrefix */ }
1375
+ }
1376
+ ```
1377
+
1378
+ ---
1379
+
1380
+ ## Task 14: End-to-end verification
1381
+
1382
+ **Files:** none (manual)
1383
+
1384
+ After all prior tasks land, run through the full happy path:
1385
+
1386
+ - [ ] **Step 1: Start the server in production mode**
1387
+
1388
+ ```
1389
+ NODE_ENV=production node server/server.js
1390
+ ```
1391
+
1392
+ Confirm log shows `[cache] driver=memory`.
1393
+
1394
+ - [ ] **Step 2: Verify cold/warm performance gap**
1395
+
1396
+ ```
1397
+ curl -s -o /dev/null -w "Cold: %{time_total}s\n" http://localhost:4096/
1398
+ curl -s -o /dev/null -w "Warm: %{time_total}s\n" http://localhost:4096/
1399
+ curl -s -o /dev/null -w "Warm: %{time_total}s\n" http://localhost:4096/
1400
+ ```
1401
+
1402
+ Expected: cold render is meaningfully slower than warm hits (depends on page complexity but typically 20-50ms cold vs <5ms warm).
1403
+
1404
+ - [ ] **Step 3: Verify each invalidation path**
1405
+
1406
+ For each of: page edit, collection entry add, form save, block save, view save, nav save, site settings save - change the underlying thing, immediately curl the affected page, confirm the new content appears without delay.
1407
+
1408
+ - [ ] **Step 4: Verify dev mode bypass**
1409
+
1410
+ ```
1411
+ NODE_ENV=development node server/server.js
1412
+ ```
1413
+
1414
+ Confirm `[cache] driver=disabled`. Curl twice, both should hit the producer (you can verify by adding a temporary `console.log` in the renderer).
1415
+
1416
+ - [ ] **Step 5: Verify admin button flushes**
1417
+
1418
+ Settings → Clear cache. Curl returns fresh content on next request.
1419
+
1420
+ - [ ] **Step 6: Run the full test suite**
1421
+
1422
+ ```
1423
+ node --test tests/
1424
+ ```
1425
+
1426
+ Expected: all green; no regressions in existing tests.
1427
+
1428
+ ---
1429
+
1430
+ ## Self-Review Notes
1431
+
1432
+ - **Spec coverage:** Plugin-vs-core ✓ (Task 1-4 build the core service). Memory/none/redis drivers ✓ (Tasks 2/3/13). Tag-based invalidation ✓ (Tasks 6 + 8-10). Dev toggle ✓ (Task 5 + Task 14 step 4). Admin flush button ✓ (Task 11). Docs ✓ (Task 12).
1433
+ - **Type consistency:** Every task uses `cache.invalidateTags(tags: string[])` and `cache.wrap(key: string, producer, opts)`. The `_cacheTags` convention is introduced in Task 7 and only used in Task 7.
1434
+ - **Placeholders:** none - every code block is complete.
1435
+ - **Project rule respected:** no `git commit` instructions; each task ends with a "save" step and a suggested commit message.
1436
+ - **What's deferred:** stale-while-revalidate (not v1), per-route TTL overrides, cache statistics endpoint, RedisDriver implementation.