webspresso 0.0.65 → 0.0.67

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,9 @@ 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)**.
20
+ - **Optional client runtime** (Alpine.js + [swup](https://swup.js.org/)): **`createApp({ clientRuntime: { alpine, swup } })`** serves scripts under **`/__webspresso/client-runtime/`** and exposes **`clientRuntime`** in Nunjucks; layouts can include **`views/partials/webspresso-client-runtime.njk`**. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`**. Demo: **`examples/alpine-swup-demo/`**. Details: **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
19
21
  - **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
22
 
21
23
  ## Installation
@@ -356,6 +358,9 @@ Creates and configures the Express app.
356
358
  - `false`: Disable Helmet
357
359
  - `Object`: Custom Helmet configuration (merged with defaults)
358
360
  - `middlewares` (optional): Named middleware registry for routes
361
+ - `clientRuntime` (optional): **`{ alpine?: boolean | object, swup?: boolean | object }`**. When either flag is enabled, mounts vendored Alpine 3 / swup 4 (+ Head + Scripts plugins) at **`/__webspresso/client-runtime/*`** and passes resolved **`{ alpine, swup }`** into SSR templates as **`clientRuntime`**. Override with env **`WEBSPRESSO_ALPINE`** / **`WEBSPRESSO_SWUP`** (`1` or `true`). Package exports **`resolveClientRuntime`** and **`CLIENT_RUNTIME_BASE`**. See **[Client runtime](#client-runtime)** below and **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
362
+ - `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)**.
363
+ - `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.; **`ctx.clientRuntime`** is **`{ alpine, swup }`**
359
364
 
360
365
  **Example with middlewares:**
361
366
 
@@ -399,6 +404,48 @@ module.exports = {
399
404
  };
400
405
  ```
401
406
 
407
+ **Parameterized named middleware:** entries in `middlewares` can be **factories** `(options) => (req, res, next) => …`. A bare string calls the factory with `{}`; a **tuple** passes options:
408
+
409
+ ```javascript
410
+ // pages/api/account.get.js
411
+ module.exports = {
412
+ middleware: [['auth', { api: true }], 'rateLimit'], // JSON 401 instead of redirect when using createApp({ auth })
413
+ handler: (req, res) => res.json({ ok: true }),
414
+ };
415
+
416
+ // server.js — custom factory
417
+ middlewares: {
418
+ oauth: (opts) => (req, res, next) => {
419
+ if (opts.google && !req.headers['x-google']) return res.status(401).end();
420
+ next();
421
+ },
422
+ };
423
+ // pages/example/index.js → middleware: [['oauth', { google: true }], 'auth']
424
+ ```
425
+
426
+ 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]`).
427
+
428
+ ### Client runtime
429
+
430
+ Alpine.js + swup — opt-in progressive enhancement for SSR pages:
431
+
432
+ - **`clientRuntime: { alpine: true, swup: true }`** (each can be toggled independently). Default is off; no scripts are injected when both are disabled.
433
+ - Include **`{% include "partials/webspresso-client-runtime.njk" %}`** in your layout (copy from the npm package’s **`views/partials/`** or the framework repo). When **swup** is on, wrap the main content in **`<main id="swup">…</main>`** so transitions replace the correct region.
434
+ - **swup** uses Head + Scripts plugins; **Alpine** is re-bound after each visit via **`Alpine.initTree`** on the container. Use **`data-no-swup`** on links for a full page load. Paths **`/_admin`** and **`/_webspresso`** are ignored by the default bootstrap; the admin panel and dev dashboard stay separate Mithril apps.
435
+ - Dynamic UI can call **`pages/api/*`** from Alpine with **`fetch`** (see **`examples/alpine-swup-demo/`**).
436
+ - **Helmet / CSP**: production **`script-src 'self'`** works for **`/__webspresso/client-runtime/`**; some Alpine builds may need **`unsafe-eval`** — validate for your version or use a stricter build.
437
+
438
+ ```javascript
439
+ const { createApp } = require('webspresso');
440
+
441
+ const { app } = createApp({
442
+ pagesDir: './pages',
443
+ viewsDir: './views',
444
+ publicDir: './public',
445
+ clientRuntime: { alpine: true, swup: true },
446
+ });
447
+ ```
448
+
402
449
  ### App context (`req.db`, `getDb`, `attachDbMiddleware`)
403
450
 
404
451
  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 +596,15 @@ Works with Vite and Webpack manifest formats:
549
596
  }
550
597
  ```
551
598
 
552
- **Returns:** `{ app, nunjucksEnv, pluginManager }`
599
+ **Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is `null` when `auth` was not passed.
600
+
601
+ ### Authentication (session)
602
+
603
+ 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.
604
+
605
+ The **admin panel** plugin uses its **own** session and `/api/auth/*` routes (`req.session.adminUser`) — it is separate from **`createApp({ auth })`**.
606
+
607
+ For integration patterns (remember-me table, `setupRoutes`, file-router ordering), see **[`doc/index.html#authentication`](doc/index.html#authentication)**.
553
608
 
554
609
  ## Plugin System
555
610
 
@@ -838,6 +893,23 @@ const { app } = createApp({
838
893
 
839
894
  Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
840
895
 
896
+ **ORM cache admin plugin:**
897
+ - Depends on **`adminPanelPlugin`** and a database instance created with **`createDatabase({ cache: true | { … } })`** so **`db.cache`** is non-null
898
+ - Registers an **ORM Cache** admin page (metrics, full purge, per-model invalidation, reset counters) backed by **`db.cache`**
899
+ - If `db.cache` is disabled, the plugin logs a warning and skips registration
900
+
901
+ ```javascript
902
+ const { adminPanelPlugin, ormCacheAdminPlugin } = require('webspresso');
903
+
904
+ const { app } = createApp({
905
+ pagesDir: './pages',
906
+ plugins: [
907
+ adminPanelPlugin({ db }),
908
+ ormCacheAdminPlugin({ db }),
909
+ ],
910
+ });
911
+ ```
912
+
841
913
  **reCAPTCHA plugin:**
842
914
  - 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
915
  - 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 +1634,52 @@ await db.transaction(async (trx) => {
1562
1634
  });
1563
1635
  ```
1564
1636
 
1637
+ **ORM reads inside `db.transaction()` always bypass the query cache** (Knex transaction client), so you never serve stale rows mid-transaction.
1638
+
1639
+ ### ORM query cache (optional)
1640
+
1641
+ 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`).
1642
+
1643
+ **Turn it on:**
1644
+
1645
+ ```javascript
1646
+ const db = createDatabase({
1647
+ client: 'pg',
1648
+ connection: process.env.DATABASE_URL,
1649
+ models: './models',
1650
+ cache: true, // same as { enabled: true, defaultStrategy: 'auto', memory provider }
1651
+ // cache: {
1652
+ // enabled: true,
1653
+ // defaultStrategy: 'auto', // or 'smart'
1654
+ // memory: { maxEntries: 50_000, defaultTtlMs: undefined },
1655
+ // // provider: myRedisLikeAdapter,
1656
+ // },
1657
+ });
1658
+ ```
1659
+
1660
+ When enabled, **`db.cache`** exposes **`purge()`**, **`invalidateTags(string[])`**, **`invalidateModel('ModelName')`**, **`getMetrics()`**, and **`resetMetrics()`**. When the cache is off, **`db.cache`** is **`null`**.
1661
+
1662
+ **Per-model opt-in / strategy** (inherits the database **`defaultStrategy`** when set to `true`):
1663
+
1664
+ ```javascript
1665
+ defineModel({
1666
+ name: 'User',
1667
+ table: 'users',
1668
+ schema: UserSchema,
1669
+ cache: true, // use db defaultStrategy
1670
+ // cache: 'auto', // invalidate all cached reads for this model on any row change
1671
+ // cache: 'smart', // finer-grained tags: PK reads vs list/collection queries
1672
+ // cache: { strategy: 'smart' },
1673
+ // cache: false, // never cache this model
1674
+ });
1675
+ ```
1676
+
1677
+ **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.
1678
+
1679
+ **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.
1680
+
1681
+ **Exports:** **`createMemoryCacheProvider`**, **`OrmCacheLayer`**, and **`createOrmCacheFromConfig`** are available from **`webspresso`** for advanced/testing setups.
1682
+
1565
1683
  ### Migrations
1566
1684
 
1567
1685
  **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
+ };