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 +97 -2
- package/core/auth/middleware.js +3 -3
- package/core/orm/cache/fingerprint.js +73 -0
- package/core/orm/cache/index.js +73 -0
- package/core/orm/cache/layer.js +314 -0
- package/core/orm/cache/listeners.js +67 -0
- package/core/orm/cache/memory-provider.js +109 -0
- package/core/orm/cache/types.js +27 -0
- package/core/orm/index.js +19 -6
- package/core/orm/model.js +2 -0
- package/core/orm/query-builder.js +206 -59
- package/core/orm/repository.js +134 -75
- package/core/orm/types.js +21 -0
- package/index.d.ts +46 -1
- package/index.js +2 -1
- package/package.json +1 -1
- package/plugins/index.js +2 -0
- package/plugins/orm-cache-admin/admin-component.js +146 -0
- package/plugins/orm-cache-admin/api-handlers.js +78 -0
- package/plugins/orm-cache-admin/index.js +72 -0
- package/plugins/site-analytics/admin-component.js +34 -4
- package/plugins/site-analytics/api-handlers.js +74 -1
- package/plugins/site-analytics/index.js +1 -0
- package/src/file-router.js +61 -12
- package/templates/skills/webspresso-usage/SKILL.md +3 -0
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:**
|
package/core/auth/middleware.js
CHANGED
|
@@ -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
|
+
};
|