webspresso 0.0.64 → 0.0.66

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/README.md CHANGED
@@ -15,7 +15,8 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
15
15
  - **Lifecycle Hooks**: Global and route-level hooks for request processing
16
16
  - **Template Helpers**: Laravel-inspired helper functions available in templates
17
17
  - **Plugin System**: Extensible architecture with version control and inter-plugin communication
18
- - **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models
18
+ - **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models, optional admin UI for ORM query cache metrics and purge
19
+ - **Session authentication** (optional): `createAuth` / `quickAuth` in **`webspresso/core/auth`** — pass the manager to **`createApp({ auth })`** for `express-session`, `req.user` / `req.auth`, remember-me tokens, and policy-style authorization. Full walkthrough: **[`doc/index.html#authentication`](doc/index.html#authentication)**.
19
20
  - **TypeScript**: Published **`index.d.ts`** (via `package.json` `"types"`) for `createApp`, ORM, plugins, and router helpers — use from TS/JS with IDE autocomplete; runtime stays CommonJS
20
21
 
21
22
  ## Installation
@@ -356,6 +357,8 @@ Creates and configures the Express app.
356
357
  - `false`: Disable Helmet
357
358
  - `Object`: Custom Helmet configuration (merged with defaults)
358
359
  - `middlewares` (optional): Named middleware registry for routes
360
+ - `auth` (optional): `AuthManager` from **`createAuth()`** / **`quickAuth()`** (`require('webspresso/core/auth')`). Registers session + cookie parsing, attaches **`req.auth`** / **`req.user`**, and injects named route middleware **`auth`** and **`guest`** (do not reuse those names for custom handlers if you pass `auth`). See **[`doc/index.html#authentication`](doc/index.html#authentication)**.
361
+ - `setupRoutes(app, ctx)` (optional): Register custom Express routes after file routes and plugin `onRoutesReady`, before the 404 handler — use for login/logout handlers when using the auth module; **`ctx.authMiddleware`** exposes `requireAuth`, `requireGuest`, etc.
359
362
 
360
363
  **Example with middlewares:**
361
364
 
@@ -399,6 +402,27 @@ module.exports = {
399
402
  };
400
403
  ```
401
404
 
405
+ **Parameterized named middleware:** entries in `middlewares` can be **factories** `(options) => (req, res, next) => …`. A bare string calls the factory with `{}`; a **tuple** passes options:
406
+
407
+ ```javascript
408
+ // pages/api/account.get.js
409
+ module.exports = {
410
+ middleware: [['auth', { api: true }], 'rateLimit'], // JSON 401 instead of redirect when using createApp({ auth })
411
+ handler: (req, res) => res.json({ ok: true }),
412
+ };
413
+
414
+ // server.js — custom factory
415
+ middlewares: {
416
+ oauth: (opts) => (req, res, next) => {
417
+ if (opts.google && !req.headers['x-google']) return res.status(401).end();
418
+ next();
419
+ },
420
+ };
421
+ // pages/example/index.js → middleware: [['oauth', { google: true }], 'auth']
422
+ ```
423
+
424
+ Plain `(req, res, next) => …` handlers still work as today. Tuple form **requires** a factory for that name (you get a clear error if you mix a plain handler with `['name', opts]`).
425
+
402
426
  ### App context (`req.db`, `getDb`, `attachDbMiddleware`)
403
427
 
404
428
  With **`createApp({ db })`**, file-based **API** routes (`pages/api/*.js`) get the same ORM instance on **`req.db`** before your **`middleware`** array and the handler run — no extra `require` in the handler:
@@ -549,7 +573,15 @@ Works with Vite and Webpack manifest formats:
549
573
  }
550
574
  ```
551
575
 
552
- **Returns:** `{ app, nunjucksEnv, pluginManager }`
576
+ **Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is `null` when `auth` was not passed.
577
+
578
+ ### Authentication (session)
579
+
580
+ Optional **session-based** auth lives under **`webspresso/core/auth`**: **`createAuth`**, **`quickAuth`**, **`hash`** / **`verify`**, **`setupAuthMiddleware`**, **`createRememberTokensTable`**, and policy helpers. Pass the manager to **`createApp({ auth })`** so routes can use **`middleware: ['auth']`** or **`['guest']`** and handlers can call **`req.auth.attempt()`**, **`req.auth.logout()`**, **`req.auth.can()`**, etc.
581
+
582
+ The **admin panel** plugin uses its **own** session and `/api/auth/*` routes (`req.session.adminUser`) — it is separate from **`createApp({ auth })`**.
583
+
584
+ For integration patterns (remember-me table, `setupRoutes`, file-router ordering), see **[`doc/index.html#authentication`](doc/index.html#authentication)**.
553
585
 
554
586
  ## Plugin System
555
587
 
@@ -838,6 +870,23 @@ const { app } = createApp({
838
870
 
839
871
  Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
840
872
 
873
+ **ORM cache admin plugin:**
874
+ - Depends on **`adminPanelPlugin`** and a database instance created with **`createDatabase({ cache: true | { … } })`** so **`db.cache`** is non-null
875
+ - Registers an **ORM Cache** admin page (metrics, full purge, per-model invalidation, reset counters) backed by **`db.cache`**
876
+ - If `db.cache` is disabled, the plugin logs a warning and skips registration
877
+
878
+ ```javascript
879
+ const { adminPanelPlugin, ormCacheAdminPlugin } = require('webspresso');
880
+
881
+ const { app } = createApp({
882
+ pagesDir: './pages',
883
+ plugins: [
884
+ adminPanelPlugin({ db }),
885
+ ormCacheAdminPlugin({ db }),
886
+ ],
887
+ });
888
+ ```
889
+
841
890
  **reCAPTCHA plugin:**
842
891
  - Google reCAPTCHA **v2** (checkbox) or **v3** (score): server verification via `https://www.google.com/recaptcha/api/siteverify` (no extra npm dependency; Node 18+ `fetch`)
843
892
  - Registers CSP entries for Google scripts/iframes; Nunjucks helpers: `recaptchaScript`, `recaptchaWidget` (v2), `recaptchaV3Token` (hidden input + execute for v3 — use with `version: 'v3'` and `recaptchaScript` loads `api.js?render=siteKey`)
@@ -1562,6 +1611,52 @@ await db.transaction(async (trx) => {
1562
1611
  });
1563
1612
  ```
1564
1613
 
1614
+ **ORM reads inside `db.transaction()` always bypass the query cache** (Knex transaction client), so you never serve stale rows mid-transaction.
1615
+
1616
+ ### ORM query cache (optional)
1617
+
1618
+ Enable an in-process, tag-based cache for common read paths. Default provider is in-memory (`createMemoryCacheProvider`); you can pass a custom **`provider`** with the same shape (`get`, `set` with optional `tags` / `ttlMs`, `invalidateTags`, `clear`, `getSizeStats`).
1619
+
1620
+ **Turn it on:**
1621
+
1622
+ ```javascript
1623
+ const db = createDatabase({
1624
+ client: 'pg',
1625
+ connection: process.env.DATABASE_URL,
1626
+ models: './models',
1627
+ cache: true, // same as { enabled: true, defaultStrategy: 'auto', memory provider }
1628
+ // cache: {
1629
+ // enabled: true,
1630
+ // defaultStrategy: 'auto', // or 'smart'
1631
+ // memory: { maxEntries: 50_000, defaultTtlMs: undefined },
1632
+ // // provider: myRedisLikeAdapter,
1633
+ // },
1634
+ });
1635
+ ```
1636
+
1637
+ When enabled, **`db.cache`** exposes **`purge()`**, **`invalidateTags(string[])`**, **`invalidateModel('ModelName')`**, **`getMetrics()`**, and **`resetMetrics()`**. When the cache is off, **`db.cache`** is **`null`**.
1638
+
1639
+ **Per-model opt-in / strategy** (inherits the database **`defaultStrategy`** when set to `true`):
1640
+
1641
+ ```javascript
1642
+ defineModel({
1643
+ name: 'User',
1644
+ table: 'users',
1645
+ schema: UserSchema,
1646
+ cache: true, // use db defaultStrategy
1647
+ // cache: 'auto', // invalidate all cached reads for this model on any row change
1648
+ // cache: 'smart', // finer-grained tags: PK reads vs list/collection queries
1649
+ // cache: { strategy: 'smart' },
1650
+ // cache: false, // never cache this model
1651
+ });
1652
+ ```
1653
+
1654
+ **What gets cached:** **`findById`**, **`findOne`**, **`findAll`**, and query builder **`first`**, **`list`**, **`count`**, **`paginate`** — only when the model participates in caching and the read is not on a transaction connection. Some query shapes are never cached (e.g. raw `where` fragments); the layer increments a **`bypassed`** metric for those.
1655
+
1656
+ **Invalidation:** Hooks on **`afterCreate`**, **`afterUpdate`**, **`afterDelete`**, and **`afterRestore`** schedule tag invalidation after the Knex transaction commits (when applicable). With **`auto`**, every mutation clears all cache entries tagged for that model. With **`smart`**, creates invalidate collection query tags; updates/deletes target the row’s primary key tag plus collection tags when the record exposes an id (falls back to full-model invalidation if the id is missing). Bulk **`updateWhere`** / query-builder **`update`** / **`delete`** that affect rows call **`invalidateModelAll`** for safety.
1657
+
1658
+ **Exports:** **`createMemoryCacheProvider`**, **`OrmCacheLayer`**, and **`createOrmCacheFromConfig`** are available from **`webspresso`** for advanced/testing setups.
1659
+
1565
1660
  ### Migrations
1566
1661
 
1567
1662
  **CLI Commands:**
@@ -223,9 +223,9 @@ function createAuthMiddleware(authManager) {
223
223
  // Utility
224
224
  parseMiddlewareString,
225
225
 
226
- // Aliases for route config
227
- auth: requireAuth(),
228
- guest: requireGuest(),
226
+ // Aliases for route config (factories — file-router calls requireAuth(opts) / requireGuest(opts))
227
+ auth: requireAuth,
228
+ guest: requireGuest,
229
229
  };
230
230
  }
231
231
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Stable cache key fingerprint for ORM reads
3
+ * @module core/orm/cache/fingerprint
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+
8
+ /**
9
+ * @param {*} v
10
+ * @returns {*}
11
+ */
12
+ function stableValue(v) {
13
+ if (v === null || v === undefined) return v;
14
+ if (typeof v !== 'object') return v;
15
+ if (Array.isArray(v)) return v.map(stableValue);
16
+ const keys = Object.keys(v).sort();
17
+ const out = {};
18
+ for (const k of keys) out[k] = stableValue(v[k]);
19
+ return out;
20
+ }
21
+
22
+ /**
23
+ * @param {import('../types').ScopeContext} scopeContext
24
+ */
25
+ function scopeFingerprint(scopeContext) {
26
+ return {
27
+ tenantId: scopeContext.tenantId,
28
+ withTrashed: !!scopeContext.withTrashed,
29
+ onlyTrashed: !!scopeContext.onlyTrashed,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * @param {import('../types').QueryState} state
35
+ */
36
+ function serializeWheres(wheres) {
37
+ return wheres.map((w) => {
38
+ if (w.raw) {
39
+ return { raw: true, sql: w.sql, bindings: w.bindings, boolean: w.boolean };
40
+ }
41
+ return {
42
+ column: w.column,
43
+ operator: w.operator,
44
+ value: w.value,
45
+ boolean: w.boolean,
46
+ };
47
+ });
48
+ }
49
+
50
+ /**
51
+ * @param {import('../model').ModelDefinition} model
52
+ * @param {import('../types').ScopeContext} scopeContext
53
+ * @param {object} parts
54
+ * @returns {string} hex key
55
+ */
56
+ function hashKey(model, scopeContext, parts) {
57
+ const payload = stableValue({
58
+ model: model.name,
59
+ table: model.table,
60
+ pk: model.primaryKey,
61
+ scope: scopeFingerprint(scopeContext),
62
+ ...parts,
63
+ });
64
+ const json = JSON.stringify(payload);
65
+ return crypto.createHash('sha256').update(json).digest('hex').slice(0, 40);
66
+ }
67
+
68
+ module.exports = {
69
+ stableValue,
70
+ scopeFingerprint,
71
+ serializeWheres,
72
+ hashKey,
73
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * ORM query cache (memory provider + tag invalidation)
3
+ * @module core/orm/cache
4
+ */
5
+
6
+ const { createMemoryCacheProvider } = require('./memory-provider');
7
+ const { OrmCacheLayer } = require('./layer');
8
+ const { registerOrmCacheListeners, unregisterOrmCacheListeners } = require('./listeners');
9
+
10
+ /**
11
+ * @param {boolean|object} cacheConfig - true | { enabled, defaultStrategy, provider, memory }
12
+ * @returns {{ layer: OrmCacheLayer|null, publicApi: object|null }}
13
+ */
14
+ function createOrmCacheFromConfig(cacheConfig) {
15
+ const normalized = normalizeCacheConfig(cacheConfig);
16
+ if (!normalized) {
17
+ return { layer: null, publicApi: null };
18
+ }
19
+
20
+ const provider =
21
+ normalized.provider || createMemoryCacheProvider(normalized.memory || {});
22
+
23
+ const layer = new OrmCacheLayer(provider, {
24
+ defaultStrategy: normalized.defaultStrategy,
25
+ providerKind: normalized.provider ? 'custom' : 'memory',
26
+ });
27
+
28
+ registerOrmCacheListeners(layer);
29
+
30
+ const publicApi = {
31
+ purge: () => layer.purge(),
32
+ invalidateTags: (tags) => layer.invalidateTags(tags),
33
+ invalidateModel: (modelName) => {
34
+ const { getModel } = require('../model');
35
+ const model = getModel(modelName);
36
+ if (model) layer.invalidateModelAll(model);
37
+ },
38
+ getMetrics: () => ({
39
+ ...layer.getMetrics(),
40
+ providerKind: layer.providerKind || 'memory',
41
+ }),
42
+ resetMetrics: () => layer.resetMetrics(),
43
+ };
44
+
45
+ return { layer, publicApi };
46
+ }
47
+
48
+ /**
49
+ * @param {boolean|object|undefined} cacheConfig
50
+ */
51
+ function normalizeCacheConfig(cacheConfig) {
52
+ if (cacheConfig === true) {
53
+ return { enabled: true, defaultStrategy: 'auto', provider: null, memory: {} };
54
+ }
55
+ if (!cacheConfig || cacheConfig.enabled === false) {
56
+ return null;
57
+ }
58
+ return {
59
+ enabled: true,
60
+ defaultStrategy: cacheConfig.defaultStrategy === 'smart' ? 'smart' : 'auto',
61
+ provider: cacheConfig.provider || null,
62
+ memory: cacheConfig.memory || {},
63
+ };
64
+ }
65
+
66
+ module.exports = {
67
+ createOrmCacheFromConfig,
68
+ normalizeCacheConfig,
69
+ createMemoryCacheProvider,
70
+ OrmCacheLayer,
71
+ registerOrmCacheListeners,
72
+ unregisterOrmCacheListeners,
73
+ };
@@ -0,0 +1,314 @@
1
+ /**
2
+ * ORM cache orchestration: metrics, strategy, read wrapper, invalidation helpers
3
+ * @module core/orm/cache/layer
4
+ */
5
+
6
+ const { hashKey, serializeWheres } = require('./fingerprint');
7
+
8
+ /**
9
+ * @param {*} knex
10
+ */
11
+ function isTransactionKnex(knex) {
12
+ return !!(knex && knex.isTransaction);
13
+ }
14
+
15
+ /**
16
+ * @param {import('../model').ModelDefinition} model
17
+ * @returns {'auto'|'smart'|null}
18
+ */
19
+ function modelCacheStrategy(model, defaultStrategy) {
20
+ const c = model.cache;
21
+ if (c === false || c === undefined || c === null) return null;
22
+ if (c === true) return defaultStrategy;
23
+ if (c === 'auto' || c === 'smart') return c;
24
+ if (typeof c === 'object' && c.strategy) {
25
+ return c.strategy === 'smart' ? 'smart' : 'auto';
26
+ }
27
+ return defaultStrategy;
28
+ }
29
+
30
+ function cloneForCache(value) {
31
+ if (value === undefined) return undefined;
32
+ if (typeof structuredClone === 'function') return structuredClone(value);
33
+ return JSON.parse(JSON.stringify(value));
34
+ }
35
+
36
+ class OrmCacheLayer {
37
+ /**
38
+ * @param {import('./types').CacheProvider} provider
39
+ * @param {object} [options]
40
+ * @param {'auto'|'smart'} [options.defaultStrategy='auto']
41
+ */
42
+ constructor(provider, options = {}) {
43
+ this.provider = provider;
44
+ this.providerKind = options.providerKind || 'memory';
45
+ this.defaultStrategy = options.defaultStrategy || 'auto';
46
+ this.metrics = {
47
+ hits: 0,
48
+ misses: 0,
49
+ sets: 0,
50
+ invalidations: 0,
51
+ bypassed: 0,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param {import('../model').ModelDefinition} model
57
+ */
58
+ strategyFor(model) {
59
+ return modelCacheStrategy(model, this.defaultStrategy);
60
+ }
61
+
62
+ /**
63
+ * @param {import('../model').ModelDefinition} model
64
+ * @param {*} knex
65
+ */
66
+ shouldBypassRead(model, knex) {
67
+ if (!this.provider) return true;
68
+ if (!this.strategyFor(model)) return true;
69
+ if (isTransactionKnex(knex)) return true;
70
+ return false;
71
+ }
72
+
73
+ resetMetrics() {
74
+ this.metrics.hits = 0;
75
+ this.metrics.misses = 0;
76
+ this.metrics.sets = 0;
77
+ this.metrics.invalidations = 0;
78
+ this.metrics.bypassed = 0;
79
+ }
80
+
81
+ getMetrics() {
82
+ const { entries, tags } = this.provider.getSizeStats();
83
+ const { hits, misses, sets, invalidations, bypassed } = this.metrics;
84
+ const lookups = hits + misses;
85
+ const hitRate = lookups > 0 ? hits / lookups : null;
86
+ return {
87
+ hits,
88
+ misses,
89
+ sets,
90
+ invalidations,
91
+ bypassed,
92
+ hitRate,
93
+ approxKeys: entries,
94
+ approxTags: tags,
95
+ enabled: true,
96
+ providerKind: this.providerKind,
97
+ };
98
+ }
99
+
100
+ invalidateTags(tags) {
101
+ const uniq = [...new Set(tags.filter(Boolean))];
102
+ if (uniq.length === 0) return;
103
+ this.provider.invalidateTags(uniq);
104
+ this.metrics.invalidations += 1;
105
+ }
106
+
107
+ purge() {
108
+ this.provider.clear();
109
+ }
110
+
111
+ /**
112
+ * @param {import('../model').ModelDefinition} model
113
+ * @param {import('../types').ScopeContext} scopeContext
114
+ * @param {string} fingerprintKey
115
+ * @param {string[]} tags
116
+ * @param {() => Promise<*>} executor
117
+ * @param {(*)=>boolean} [shouldCache]
118
+ */
119
+ async wrapRead(model, knex, scopeContext, fingerprintKey, tags, executor, shouldCache = () => true) {
120
+ if (this.shouldBypassRead(model, knex)) {
121
+ this.metrics.bypassed += 1;
122
+ return executor();
123
+ }
124
+
125
+ const cached = this.provider.get(fingerprintKey);
126
+ if (cached !== undefined) {
127
+ this.metrics.hits += 1;
128
+ return cloneForCache(cached);
129
+ }
130
+
131
+ this.metrics.misses += 1;
132
+ const result = await executor();
133
+
134
+ if (shouldCache(result)) {
135
+ this.provider.set(fingerprintKey, cloneForCache(result), { tags });
136
+ this.metrics.sets += 1;
137
+ }
138
+
139
+ return result;
140
+ }
141
+
142
+ /**
143
+ * Deferred invalidation after Knex transaction commit
144
+ * @param {*} ctxTrx - createEventContext trx
145
+ * @param {string[]} tags
146
+ */
147
+ scheduleInvalidate(ctxTrx, tags) {
148
+ const uniq = [...new Set(tags.filter(Boolean))];
149
+ if (uniq.length === 0) return;
150
+
151
+ if (!ctxTrx) {
152
+ this.invalidateTags(uniq);
153
+ return;
154
+ }
155
+
156
+ const p = ctxTrx.executionPromise;
157
+ if (p && typeof p.then === 'function') {
158
+ p.then(() => {
159
+ this.invalidateTags(uniq);
160
+ }).catch(() => {});
161
+ } else {
162
+ this.invalidateTags(uniq);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build tags for a read operation
168
+ * @param {import('../model').ModelDefinition} model
169
+ * @param {'auto'|'smart'} strategy
170
+ * @param {'pk'|'collection'} kind
171
+ * @param {string|number|null} [pkValue]
172
+ */
173
+ buildReadTags(model, strategy, kind, pkValue) {
174
+ const base = [`model:${model.name}`, `table:${model.table}`];
175
+ if (strategy === 'auto') return base;
176
+ if (kind === 'pk' && pkValue != null) {
177
+ return [...base, `pk:${model.name}:${pkValue}`];
178
+ }
179
+ return [...base, `q:${model.name}`];
180
+ }
181
+
182
+ /**
183
+ * Classify query builder state for smart caching
184
+ * @param {import('../model').ModelDefinition} model
185
+ * @param {import('../types').QueryState} state
186
+ * @param {'first'|'list'|'count'|'paginate'} op
187
+ */
188
+ classifyQueryBuilder(model, state, op) {
189
+ const hasRaw = state.wheres.some((w) => w.raw);
190
+ if (hasRaw) return { cacheable: false, kind: 'collection' };
191
+
192
+ if (state.withs.length > 0) return { cacheable: true, kind: 'collection' };
193
+
194
+ if (op === 'count' || op === 'paginate' || op === 'list') {
195
+ return { cacheable: true, kind: 'collection' };
196
+ }
197
+
198
+ // first()
199
+ if (state.wheres.length !== 1) return { cacheable: true, kind: 'collection' };
200
+
201
+ const w = state.wheres[0];
202
+ if (w.raw || w.boolean === 'or') return { cacheable: true, kind: 'collection' };
203
+ if (w.column !== model.primaryKey || w.operator !== '=') {
204
+ return { cacheable: true, kind: 'collection' };
205
+ }
206
+
207
+ return { cacheable: true, kind: 'pk', pkValue: w.value };
208
+ }
209
+
210
+ /**
211
+ * @param {import('../model').ModelDefinition} model
212
+ * @param {import('../types').ScopeContext} scopeContext
213
+ * @param {import('../types').QueryState} state
214
+ * @param {'first'|'list'|'count'|'paginate'} op
215
+ * @param {object} [extra] e.g. { page, perPage } for paginate
216
+ */
217
+ queryBuilderFingerprint(model, scopeContext, state, op, extra = {}) {
218
+ return hashKey(model, scopeContext, {
219
+ op,
220
+ selects: [...state.selects].sort(),
221
+ wheres: serializeWheres(state.wheres),
222
+ orderBys: state.orderBys,
223
+ limit: state.limitValue,
224
+ offset: state.offsetValue,
225
+ withs: [...state.withs].sort(),
226
+ extra,
227
+ });
228
+ }
229
+
230
+ /**
231
+ * @param {import('../model').ModelDefinition} model
232
+ * @param {import('../types').ScopeContext} scopeContext
233
+ * @param {string|number} id
234
+ * @param {string[]} [select]
235
+ * @param {string[]} [withs]
236
+ */
237
+ findByIdFingerprint(model, scopeContext, id, select = [], withs = []) {
238
+ return hashKey(model, scopeContext, {
239
+ op: 'findById',
240
+ id: String(id),
241
+ select: [...select].sort(),
242
+ withs: [...withs].sort(),
243
+ });
244
+ }
245
+
246
+ /**
247
+ * @param {import('../model').ModelDefinition} model
248
+ * @param {import('../types').ScopeContext} scopeContext
249
+ * @param {Record<string, *>} conditions
250
+ * @param {string[]} [select]
251
+ * @param {string[]} [withs]
252
+ */
253
+ findOneFingerprint(model, scopeContext, conditions, select = [], withs = []) {
254
+ return hashKey(model, scopeContext, {
255
+ op: 'findOne',
256
+ conditions: Object.keys(conditions)
257
+ .sort()
258
+ .reduce((acc, k) => {
259
+ acc[k] = conditions[k];
260
+ return acc;
261
+ }, {}),
262
+ select: [...select].sort(),
263
+ withs: [...withs].sort(),
264
+ });
265
+ }
266
+
267
+ /**
268
+ * @param {import('../model').ModelDefinition} model
269
+ * @param {import('../types').ScopeContext} scopeContext
270
+ * @param {string[]} [select]
271
+ */
272
+ findAllFingerprint(model, scopeContext, select = []) {
273
+ return hashKey(model, scopeContext, {
274
+ op: 'findAll',
275
+ select: [...select].sort(),
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Conservative invalidation for a model (auto-equivalent)
281
+ * @param {import('../model').ModelDefinition} model
282
+ */
283
+ invalidateModelAll(model) {
284
+ this.invalidateTags([`model:${model.name}`, `table:${model.table}`]);
285
+ }
286
+
287
+ /**
288
+ * Smart / auto write tags
289
+ * @param {import('../model').ModelDefinition} model
290
+ * @param {'auto'|'smart'} strategy
291
+ * @param {'create'|'update'|'delete'|'restore'} op
292
+ * @param {*} [record] for pk
293
+ */
294
+ tagsForMutation(model, strategy, op, record) {
295
+ const name = model.name;
296
+ const table = model.table;
297
+ if (strategy === 'auto') {
298
+ return [`model:${name}`, `table:${table}`];
299
+ }
300
+ // smart: avoid invalidating `model:` / `table:` on every row write (would flush all PK rows)
301
+ if (op === 'create') {
302
+ return [`q:${name}`];
303
+ }
304
+ const id = record && record[model.primaryKey];
305
+ if (id == null) return [`model:${name}`, `table:${table}`];
306
+ return [`pk:${name}:${id}`, `q:${name}`];
307
+ }
308
+ }
309
+
310
+ module.exports = {
311
+ OrmCacheLayer,
312
+ modelCacheStrategy,
313
+ isTransactionKnex,
314
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Wire ModelEvents to ORM cache invalidation
3
+ * Supports multiple DB/cache layers (e.g. tests); each layer receives mutation events.
4
+ * @module core/orm/cache/listeners
5
+ */
6
+
7
+ const { ModelEvents, Hooks } = require('../events');
8
+ const { getModel } = require('../model');
9
+
10
+ /** @type {Set<import('./layer').OrmCacheLayer>} */
11
+ const layers = new Set();
12
+ let hooked = false;
13
+
14
+ /**
15
+ * @param {import('./layer').OrmCacheLayer} cacheLayer
16
+ */
17
+ function registerOrmCacheListeners(cacheLayer) {
18
+ layers.add(cacheLayer);
19
+ if (hooked) return;
20
+ hooked = true;
21
+
22
+ /**
23
+ * @param {*} record
24
+ * @param {import('../events').EventContext} ctx
25
+ * @param {'create'|'update'|'delete'|'restore'} op
26
+ */
27
+ function handleMutation(record, ctx, op) {
28
+ const modelName = ctx.model;
29
+ const model = getModel(modelName);
30
+ if (!model) return;
31
+
32
+ for (const layer of layers) {
33
+ if (!layer.strategyFor(model)) continue;
34
+ const strat = layer.strategyFor(model);
35
+ const tags = layer.tagsForMutation(model, strat, op, record);
36
+ layer.scheduleInvalidate(ctx.trx, tags);
37
+ }
38
+ }
39
+
40
+ ModelEvents.on(`*.${Hooks.AFTER_CREATE}`, (record, ctx) => {
41
+ handleMutation(record, ctx, 'create');
42
+ });
43
+
44
+ ModelEvents.on(`*.${Hooks.AFTER_UPDATE}`, (record, ctx) => {
45
+ handleMutation(record, ctx, 'update');
46
+ });
47
+
48
+ ModelEvents.on(`*.${Hooks.AFTER_DELETE}`, (record, ctx) => {
49
+ handleMutation(record, ctx, 'delete');
50
+ });
51
+
52
+ ModelEvents.on(`*.${Hooks.AFTER_RESTORE}`, (record, ctx) => {
53
+ handleMutation(record, ctx, 'restore');
54
+ });
55
+ }
56
+
57
+ /**
58
+ * @param {import('./layer').OrmCacheLayer} cacheLayer
59
+ */
60
+ function unregisterOrmCacheListeners(cacheLayer) {
61
+ layers.delete(cacheLayer);
62
+ }
63
+
64
+ module.exports = {
65
+ registerOrmCacheListeners,
66
+ unregisterOrmCacheListeners,
67
+ };