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 +120 -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 +52 -1
- package/index.js +6 -1
- package/package.json +9 -4
- 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/src/client-runtime/bootstrap-alpine-swup.js +34 -0
- package/src/client-runtime/bootstrap-swup.js +26 -0
- package/src/client-runtime/mount.js +65 -0
- package/src/client-runtime/resolve.js +40 -0
- package/src/file-router.js +77 -14
- package/src/server.js +11 -2
- package/templates/skills/webspresso-usage/SKILL.md +5 -1
- package/views/partials/webspresso-client-runtime.njk +15 -0
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:**
|
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
|
+
};
|