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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API for ORM cache metrics and purge
|
|
3
|
+
* @module plugins/orm-cache-admin/api-handlers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {import('../../core/orm/types').DatabaseInstance} options.db
|
|
9
|
+
*/
|
|
10
|
+
function createOrmCacheAdminHandlers({ db }) {
|
|
11
|
+
function requireCache(res) {
|
|
12
|
+
if (!db.cache) {
|
|
13
|
+
res.status(503).json({ error: 'ORM cache is not enabled on this database' });
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return db.cache;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getStats(req, res) {
|
|
20
|
+
try {
|
|
21
|
+
const cache = requireCache(res);
|
|
22
|
+
if (!cache) return;
|
|
23
|
+
res.json(cache.getMetrics());
|
|
24
|
+
} catch (e) {
|
|
25
|
+
res.status(500).json({ error: e.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function postPurge(req, res) {
|
|
30
|
+
try {
|
|
31
|
+
const cache = requireCache(res);
|
|
32
|
+
if (!cache) return;
|
|
33
|
+
cache.purge();
|
|
34
|
+
res.json({ ok: true });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
res.status(500).json({ error: e.message });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function postInvalidate(req, res) {
|
|
41
|
+
try {
|
|
42
|
+
const cache = requireCache(res);
|
|
43
|
+
if (!cache) return;
|
|
44
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
45
|
+
if (Array.isArray(body.tags) && body.tags.length > 0) {
|
|
46
|
+
cache.invalidateTags(body.tags.map(String));
|
|
47
|
+
return res.json({ ok: true, mode: 'tags' });
|
|
48
|
+
}
|
|
49
|
+
if (typeof body.model === 'string' && body.model.trim()) {
|
|
50
|
+
cache.invalidateModel(body.model.trim());
|
|
51
|
+
return res.json({ ok: true, mode: 'model' });
|
|
52
|
+
}
|
|
53
|
+
res.status(400).json({ error: 'Provide { model: string } or { tags: string[] }' });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
res.status(500).json({ error: e.message });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function postResetMetrics(req, res) {
|
|
60
|
+
try {
|
|
61
|
+
const cache = requireCache(res);
|
|
62
|
+
if (!cache) return;
|
|
63
|
+
cache.resetMetrics();
|
|
64
|
+
res.json({ ok: true });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
res.status(500).json({ error: e.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
getStats,
|
|
72
|
+
postPurge,
|
|
73
|
+
postInvalidate,
|
|
74
|
+
postResetMetrics,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { createOrmCacheAdminHandlers };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORM Cache Admin — metrics, purge, invalidate (requires admin-panel + db.cache)
|
|
3
|
+
* @module plugins/orm-cache-admin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createOrmCacheAdminHandlers } = require('./api-handlers');
|
|
7
|
+
const { generateOrmCacheAdminComponent } = require('./admin-component');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {import('../../core/orm/types').DatabaseInstance} options.db - From createDatabase({ cache: true })
|
|
12
|
+
*/
|
|
13
|
+
function ormCacheAdminPlugin(options = {}) {
|
|
14
|
+
const { db } = options;
|
|
15
|
+
|
|
16
|
+
if (!db) {
|
|
17
|
+
throw new Error('orm-cache-admin plugin requires `db` (database instance from createDatabase)');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
name: 'orm-cache-admin',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
description: 'Admin UI for ORM query cache metrics and purge',
|
|
24
|
+
dependencies: { 'admin-panel': '*' },
|
|
25
|
+
|
|
26
|
+
register() {},
|
|
27
|
+
|
|
28
|
+
onRoutesReady(ctx) {
|
|
29
|
+
const adminApi = ctx.usePlugin('admin-panel');
|
|
30
|
+
if (!adminApi) {
|
|
31
|
+
console.warn('[orm-cache-admin] admin-panel not found, skipping registration');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!db.cache) {
|
|
36
|
+
console.warn('[orm-cache-admin] db.cache is disabled; enable createDatabase({ cache: true })');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handlers = createOrmCacheAdminHandlers({ db });
|
|
41
|
+
|
|
42
|
+
adminApi.registerModule({
|
|
43
|
+
id: 'orm-cache',
|
|
44
|
+
pages: [{
|
|
45
|
+
id: 'orm-cache',
|
|
46
|
+
title: 'ORM Cache',
|
|
47
|
+
path: '/orm-cache',
|
|
48
|
+
icon: 'database',
|
|
49
|
+
component: generateOrmCacheAdminComponent(),
|
|
50
|
+
}],
|
|
51
|
+
menu: [{
|
|
52
|
+
id: 'orm-cache',
|
|
53
|
+
label: 'ORM Cache',
|
|
54
|
+
path: '/orm-cache',
|
|
55
|
+
icon: 'database',
|
|
56
|
+
order: 15,
|
|
57
|
+
}],
|
|
58
|
+
api: {
|
|
59
|
+
prefix: '/orm-cache',
|
|
60
|
+
routes: [
|
|
61
|
+
{ method: 'get', path: '/stats', handler: handlers.getStats },
|
|
62
|
+
{ method: 'post', path: '/purge', handler: handlers.postPurge },
|
|
63
|
+
{ method: 'post', path: '/invalidate', handler: handlers.postInvalidate },
|
|
64
|
+
{ method: 'post', path: '/metrics/reset', handler: handlers.postResetMetrics },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = ormCacheAdminPlugin;
|
|
@@ -187,6 +187,7 @@ var AnalyticsPage = {
|
|
|
187
187
|
vnode.state.viewsOverTime = [];
|
|
188
188
|
vnode.state.topPages = [];
|
|
189
189
|
vnode.state.botActivity = [];
|
|
190
|
+
vnode.state.referrerSources = [];
|
|
190
191
|
vnode.state.countries = [];
|
|
191
192
|
vnode.state.recent = [];
|
|
192
193
|
vnode.state.chartLoaded = false;
|
|
@@ -217,6 +218,7 @@ var AnalyticsPage = {
|
|
|
217
218
|
analyticsApi('views-over-time', days),
|
|
218
219
|
analyticsApi('top-pages', days),
|
|
219
220
|
analyticsApi('bot-activity', days),
|
|
221
|
+
analyticsApi('referrer-sources', days),
|
|
220
222
|
analyticsApi('countries', days),
|
|
221
223
|
analyticsApi('client-errors', days),
|
|
222
224
|
analyticsApi('recent', days),
|
|
@@ -225,9 +227,10 @@ var AnalyticsPage = {
|
|
|
225
227
|
vnode.state.viewsOverTime = results[1];
|
|
226
228
|
vnode.state.topPages = results[2];
|
|
227
229
|
vnode.state.botActivity = results[3];
|
|
228
|
-
vnode.state.
|
|
229
|
-
vnode.state.
|
|
230
|
-
vnode.state.
|
|
230
|
+
vnode.state.referrerSources = results[4];
|
|
231
|
+
vnode.state.countries = results[5];
|
|
232
|
+
vnode.state.clientErrors = results[6];
|
|
233
|
+
vnode.state.recent = results[7];
|
|
231
234
|
vnode.state.loading = false;
|
|
232
235
|
|
|
233
236
|
if (chartInstance) {
|
|
@@ -279,11 +282,13 @@ var AnalyticsPage = {
|
|
|
279
282
|
? m('div.flex.justify-center.py-24', m(Spinner))
|
|
280
283
|
: [
|
|
281
284
|
// Stat cards
|
|
282
|
-
m('div.grid.grid-cols-
|
|
285
|
+
m('div.grid.grid-cols-2.sm:grid-cols-3.lg:grid-cols-3.xl:grid-cols-6.gap-4.mb-6', [
|
|
283
286
|
m(StatCard, { icon: '👁', label: 'Views', value: s.stats?.views, bgClass: 'bg-blue-100' }),
|
|
284
287
|
m(StatCard, { icon: '👤', label: 'Visitors', value: s.stats?.visitors, bgClass: 'bg-green-100' }),
|
|
285
288
|
m(StatCard, { icon: '📄', label: 'Unique Pages', value: s.stats?.uniquePages, bgClass: 'bg-yellow-100' }),
|
|
286
289
|
m(StatCard, { icon: '🔗', label: 'Sessions', value: s.stats?.sessions, bgClass: 'bg-purple-100' }),
|
|
290
|
+
m(StatCard, { icon: '🏠', label: 'Direct traffic', value: s.stats?.directViews, bgClass: 'bg-slate-100' }),
|
|
291
|
+
m(StatCard, { icon: '🌐', label: 'With referrer', value: s.stats?.referredViews, bgClass: 'bg-cyan-100' }),
|
|
287
292
|
]),
|
|
288
293
|
|
|
289
294
|
// Chart + Bot Activity row
|
|
@@ -413,6 +418,31 @@ var AnalyticsPage = {
|
|
|
413
418
|
]),
|
|
414
419
|
]),
|
|
415
420
|
|
|
421
|
+
// Referrer sources (hostname-level)
|
|
422
|
+
m('div.bg-white.rounded-lg.shadow.mb-6', [
|
|
423
|
+
m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
|
|
424
|
+
m('h3.text-sm.font-semibold.text-gray-900', 'Referrer sources'),
|
|
425
|
+
m('span.text-xs.text-gray-400', 'By hostname · last ' + s.days + ' days'),
|
|
426
|
+
]),
|
|
427
|
+
m('div.p-4', [
|
|
428
|
+
!s.referrerSources || s.referrerSources.length === 0
|
|
429
|
+
? m('p.text-gray-400.text-sm.text-center.py-4', 'No external referrer data (direct visits or missing Referer header)')
|
|
430
|
+
: (function() {
|
|
431
|
+
var maxV = s.referrerSources[0]?.views || 1;
|
|
432
|
+
return m('div.grid.grid-cols-1.sm:grid-cols-2.gap-x-8.gap-y-2', s.referrerSources.map(function(r) {
|
|
433
|
+
return m('div.flex.items-center.gap-3.py-1', [
|
|
434
|
+
m('span.text-sm.text-gray-700.flex-1.min-w-0.truncate', { title: r.source }, r.source),
|
|
435
|
+
m('div.w-32.shrink-0', m(HBar, {
|
|
436
|
+
pct: (r.views / maxV) * 100,
|
|
437
|
+
color: 'bg-sky-500',
|
|
438
|
+
})),
|
|
439
|
+
m('span.text-xs.text-gray-500.w-12.text-right.tabular-nums', formatNumber(r.views)),
|
|
440
|
+
]);
|
|
441
|
+
}));
|
|
442
|
+
})(),
|
|
443
|
+
]),
|
|
444
|
+
]),
|
|
445
|
+
|
|
416
446
|
// Country Stats
|
|
417
447
|
m('div.bg-white.rounded-lg.shadow.mb-6', [
|
|
418
448
|
m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
|
|
@@ -29,6 +29,24 @@ function createAnalyticsApiHandlers(options) {
|
|
|
29
29
|
return d.toISOString();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Extract registrable hostname from Referer URL (strip www.)
|
|
34
|
+
* @param {string|null|undefined} referrer
|
|
35
|
+
* @returns {string|null}
|
|
36
|
+
*/
|
|
37
|
+
function hostnameFromReferrer(referrer) {
|
|
38
|
+
if (!referrer || typeof referrer !== 'string' || !referrer.trim()) return null;
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(referrer);
|
|
41
|
+
const h = u.hostname;
|
|
42
|
+
if (!h) return null;
|
|
43
|
+
const norm = h.replace(/^www\./i, '').toLowerCase();
|
|
44
|
+
return norm || null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
/**
|
|
33
51
|
* GET /stats - Summary statistics
|
|
34
52
|
*/
|
|
@@ -42,6 +60,18 @@ function createAnalyticsApiHandlers(options) {
|
|
|
42
60
|
.where('is_bot', false)
|
|
43
61
|
.count('id as count');
|
|
44
62
|
|
|
63
|
+
const totalViews = parseInt(views.count) || 0;
|
|
64
|
+
|
|
65
|
+
const [directRow] = await knex(tableName)
|
|
66
|
+
.where('created_at', '>=', since)
|
|
67
|
+
.where('is_bot', false)
|
|
68
|
+
.where((qb) => {
|
|
69
|
+
qb.whereNull('referrer').orWhere('referrer', '');
|
|
70
|
+
})
|
|
71
|
+
.count('id as count');
|
|
72
|
+
|
|
73
|
+
const directViews = parseInt(directRow.count) || 0;
|
|
74
|
+
|
|
45
75
|
const [visitors] = await knex(tableName)
|
|
46
76
|
.where('created_at', '>=', since)
|
|
47
77
|
.where('is_bot', false)
|
|
@@ -58,7 +88,9 @@ function createAnalyticsApiHandlers(options) {
|
|
|
58
88
|
.countDistinct('session_id as count');
|
|
59
89
|
|
|
60
90
|
res.json({
|
|
61
|
-
views:
|
|
91
|
+
views: totalViews,
|
|
92
|
+
directViews,
|
|
93
|
+
referredViews: Math.max(0, totalViews - directViews),
|
|
62
94
|
visitors: parseInt(visitors.count) || 0,
|
|
63
95
|
uniquePages: parseInt(uniquePages.count) || 0,
|
|
64
96
|
sessions: parseInt(sessions.count) || 0,
|
|
@@ -183,6 +215,46 @@ function createAnalyticsApiHandlers(options) {
|
|
|
183
215
|
}
|
|
184
216
|
}
|
|
185
217
|
|
|
218
|
+
/**
|
|
219
|
+
* GET /referrer-sources - Top referrer hostnames (aggregated from full Referer URLs)
|
|
220
|
+
*/
|
|
221
|
+
async function getReferrerSources(req, res) {
|
|
222
|
+
try {
|
|
223
|
+
const days = parseDays(req);
|
|
224
|
+
const since = sinceDate(days);
|
|
225
|
+
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
|
226
|
+
/** Max distinct full Referer URLs read before hostname merge (long tail capped). */
|
|
227
|
+
const referrerGroupLimit = 400;
|
|
228
|
+
|
|
229
|
+
const rows = await knex(tableName)
|
|
230
|
+
.select('referrer')
|
|
231
|
+
.where('created_at', '>=', since)
|
|
232
|
+
.where('is_bot', false)
|
|
233
|
+
.whereNotNull('referrer')
|
|
234
|
+
.where('referrer', '!=', '')
|
|
235
|
+
.count('id as views')
|
|
236
|
+
.groupBy('referrer')
|
|
237
|
+
.orderBy('views', 'desc')
|
|
238
|
+
.limit(referrerGroupLimit);
|
|
239
|
+
|
|
240
|
+
const byHost = new Map();
|
|
241
|
+
for (const r of rows) {
|
|
242
|
+
const host = hostnameFromReferrer(r.referrer) || 'Other';
|
|
243
|
+
const n = parseInt(r.views) || 0;
|
|
244
|
+
byHost.set(host, (byHost.get(host) || 0) + n);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const list = [...byHost.entries()]
|
|
248
|
+
.map(([source, views]) => ({ source, views }))
|
|
249
|
+
.sort((a, b) => b.views - a.views)
|
|
250
|
+
.slice(0, limit);
|
|
251
|
+
|
|
252
|
+
res.json(list);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
res.status(500).json({ error: e.message });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
186
258
|
/**
|
|
187
259
|
* GET /countries - Country breakdown
|
|
188
260
|
*/
|
|
@@ -262,6 +334,7 @@ function createAnalyticsApiHandlers(options) {
|
|
|
262
334
|
getViewsOverTime,
|
|
263
335
|
getTopPages,
|
|
264
336
|
getBotActivity,
|
|
337
|
+
getReferrerSources,
|
|
265
338
|
getCountries,
|
|
266
339
|
getClientErrors,
|
|
267
340
|
getRecent,
|
|
@@ -115,6 +115,7 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
115
115
|
{ method: 'get', path: '/views-over-time', handler: handlers.getViewsOverTime },
|
|
116
116
|
{ method: 'get', path: '/top-pages', handler: handlers.getTopPages },
|
|
117
117
|
{ method: 'get', path: '/bot-activity', handler: handlers.getBotActivity },
|
|
118
|
+
{ method: 'get', path: '/referrer-sources', handler: handlers.getReferrerSources },
|
|
118
119
|
{ method: 'get', path: '/countries', handler: handlers.getCountries },
|
|
119
120
|
{ method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
|
|
120
121
|
{ method: 'get', path: '/recent', handler: handlers.getRecent },
|
package/src/file-router.js
CHANGED
|
@@ -276,9 +276,58 @@ function detectLocale(req) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
|
|
279
|
+
* True when the registry entry is (options) => (req, res, next) => …
|
|
280
|
+
* Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
|
|
281
|
+
*/
|
|
282
|
+
function isMiddlewareFactory(fn) {
|
|
283
|
+
return typeof fn === 'function' && fn.length <= 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolve a named middleware from createApp({ middlewares }).
|
|
288
|
+
* @param {string} name
|
|
289
|
+
* @param {Function} entry
|
|
290
|
+
* @param {boolean} fromTuple - true when route used ['name', options]
|
|
291
|
+
* @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
|
|
292
|
+
* @param {Object} middlewareRegistry - for error messages
|
|
293
|
+
* @returns {Function} Express middleware
|
|
294
|
+
*/
|
|
295
|
+
function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
|
|
296
|
+
if (!entry) {
|
|
297
|
+
throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
|
|
298
|
+
}
|
|
299
|
+
if (typeof entry !== 'function') {
|
|
300
|
+
throw new Error(`Middleware "${name}" must be a function`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (fromTuple) {
|
|
304
|
+
if (!isMiddlewareFactory(entry)) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const produced = entry(tupleOptions);
|
|
310
|
+
if (typeof produced !== 'function') {
|
|
311
|
+
throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
|
|
312
|
+
}
|
|
313
|
+
return produced;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (isMiddlewareFactory(entry)) {
|
|
317
|
+
const produced = entry({});
|
|
318
|
+
if (typeof produced !== 'function') {
|
|
319
|
+
throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
|
|
320
|
+
}
|
|
321
|
+
return produced;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return entry;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolve middleware from config — functions, string names, or [name, options] tuples
|
|
329
|
+
* @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
|
|
330
|
+
* @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
|
|
282
331
|
* @returns {Array} Array of resolved middleware functions
|
|
283
332
|
*/
|
|
284
333
|
function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
@@ -292,17 +341,17 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
|
292
341
|
}
|
|
293
342
|
|
|
294
343
|
if (typeof mw === 'string') {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
return resolved;
|
|
344
|
+
return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
|
|
348
|
+
const name = mw[0];
|
|
349
|
+
return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
|
|
303
350
|
}
|
|
304
351
|
|
|
305
|
-
throw new Error(
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
|
|
354
|
+
);
|
|
306
355
|
});
|
|
307
356
|
}
|
|
308
357
|
|
|
@@ -164,6 +164,8 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
|
|
|
164
164
|
|
|
165
165
|
**Transactions:** `db.transaction(async (trx) => { trx.getRepository('User') })`.
|
|
166
166
|
|
|
167
|
+
**Query cache (optional):** `createDatabase({ ..., cache: true })` or `cache: { defaultStrategy: 'auto'|'smart', memory: { maxEntries, defaultTtlMs }, provider?: custom }`. Opt-in per model: `defineModel({ ..., cache: 'auto'|'smart'|true })`. API: `db.cache` → `getMetrics()`, `purge()`, `invalidateModel(name)`, `invalidateTags(tags[])`, `resetMetrics()`. Reads bypass cache when using a transaction knex. Admin UI: `ormCacheAdminPlugin({ db })` (needs `admin-panel` and `cache` enabled).
|
|
168
|
+
|
|
167
169
|
Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and plugins. **`pages/api/`** handlers receive **`req.db`** (and route **`middleware`** runs after it). Outside requests, use **`getDb()`** / **`hasDb()`**; for **`setupRoutes`**-only routes, use **`attachDbMiddleware`**.
|
|
168
170
|
|
|
169
171
|
---
|
|
@@ -180,6 +182,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
180
182
|
| `auditLogPlugin` | Admin mutation audit trail |
|
|
181
183
|
| `recaptchaPlugin` | v2/v3 + middleware |
|
|
182
184
|
| `seoCheckerPlugin` | Dev SEO panel |
|
|
185
|
+
| `ormCacheAdminPlugin` | Admin page for ORM cache metrics / purge / invalidate (`db.cache` required) |
|
|
183
186
|
|
|
184
187
|
**Custom plugin:** `name`, `version`, `register(ctx)`, `onRoutesReady(ctx)` — use `ctx.app`, `ctx.db`, `ctx.addHelper`, `ctx.addRoute`, `ctx.usePlugin('other')`. Plugin failures **warn**; app keeps running.
|
|
185
188
|
|