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/core/orm/repository.js
CHANGED
|
@@ -22,9 +22,10 @@ const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('
|
|
|
22
22
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
23
23
|
* @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance
|
|
24
24
|
* @param {import('./types').ScopeContext} [initialContext] - Initial scope context
|
|
25
|
+
* @param {import('./cache/layer').OrmCacheLayer|null} [cacheLayer] - Optional ORM cache layer
|
|
25
26
|
* @returns {import('./types').Repository}
|
|
26
27
|
*/
|
|
27
|
-
function createRepository(model, knex, initialContext) {
|
|
28
|
+
function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
28
29
|
const scopeContext = initialContext || createScopeContext();
|
|
29
30
|
const jsonColumns = getJsonColumns(model);
|
|
30
31
|
|
|
@@ -49,40 +50,59 @@ function createRepository(model, knex, initialContext) {
|
|
|
49
50
|
const ctx = createEventContext(model.name, 'find');
|
|
50
51
|
const query = { [model.primaryKey]: id };
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
async function loadFromDb() {
|
|
54
|
+
if (emitEvents) {
|
|
55
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, query, ctx);
|
|
56
|
+
if (ctx.isCancelled) {
|
|
57
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
58
|
+
}
|
|
57
59
|
}
|
|
58
|
-
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (select && select.length > 0) {
|
|
63
|
-
qb = qb.select(select);
|
|
64
|
-
}
|
|
61
|
+
let qb = baseQuery().where(model.primaryKey, id);
|
|
65
62
|
|
|
66
|
-
|
|
63
|
+
if (select && select.length > 0) {
|
|
64
|
+
qb = qb.select(select);
|
|
65
|
+
}
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
67
|
+
const record = await qb.first();
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
if (!record) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
74
72
|
|
|
75
|
-
|
|
76
|
-
if (withs.length > 0) {
|
|
77
|
-
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
78
|
-
}
|
|
73
|
+
deserializeJsonFields(record, jsonColumns);
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
75
|
+
if (withs.length > 0) {
|
|
76
|
+
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
77
|
+
}
|
|
84
78
|
|
|
85
|
-
|
|
79
|
+
if (emitEvents) {
|
|
80
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cacheLayer && emitEvents && cacheLayer.strategyFor(model)) {
|
|
87
|
+
const strat = cacheLayer.strategyFor(model);
|
|
88
|
+
const kind = withs.length > 0 ? 'collection' : 'pk';
|
|
89
|
+
const fingerprint = cacheLayer.findByIdFingerprint(
|
|
90
|
+
model,
|
|
91
|
+
scopeContext,
|
|
92
|
+
id,
|
|
93
|
+
select || [],
|
|
94
|
+
withs
|
|
95
|
+
);
|
|
96
|
+
const tags = cacheLayer.buildReadTags(
|
|
97
|
+
model,
|
|
98
|
+
strat,
|
|
99
|
+
kind,
|
|
100
|
+
kind === 'pk' ? id : null
|
|
101
|
+
);
|
|
102
|
+
return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return loadFromDb();
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
/**
|
|
@@ -95,40 +115,65 @@ function createRepository(model, knex, initialContext) {
|
|
|
95
115
|
const { with: withs = [], select } = options;
|
|
96
116
|
const ctx = createEventContext(model.name, 'find');
|
|
97
117
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
const condKeys = Object.keys(conditions);
|
|
119
|
+
const isPkOnly =
|
|
120
|
+
withs.length === 0 &&
|
|
121
|
+
condKeys.length === 1 &&
|
|
122
|
+
condKeys[0] === model.primaryKey;
|
|
103
123
|
|
|
104
|
-
|
|
124
|
+
async function loadFromDb() {
|
|
125
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
|
|
126
|
+
if (ctx.isCancelled) {
|
|
127
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
128
|
+
}
|
|
105
129
|
|
|
106
|
-
|
|
107
|
-
qb = qb.where(key, value);
|
|
108
|
-
}
|
|
130
|
+
let qb = baseQuery();
|
|
109
131
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
133
|
+
qb = qb.where(key, value);
|
|
134
|
+
}
|
|
113
135
|
|
|
114
|
-
|
|
136
|
+
if (select && select.length > 0) {
|
|
137
|
+
qb = qb.select(select);
|
|
138
|
+
}
|
|
115
139
|
|
|
116
|
-
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
140
|
+
const record = await qb.first();
|
|
119
141
|
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
if (!record) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
122
145
|
|
|
123
|
-
|
|
124
|
-
if (withs.length > 0) {
|
|
125
|
-
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
126
|
-
}
|
|
146
|
+
deserializeJsonFields(record, jsonColumns);
|
|
127
147
|
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
if (withs.length > 0) {
|
|
149
|
+
await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
|
|
150
|
+
}
|
|
130
151
|
|
|
131
|
-
|
|
152
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
153
|
+
|
|
154
|
+
return record;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (cacheLayer && cacheLayer.strategyFor(model)) {
|
|
158
|
+
const strat = cacheLayer.strategyFor(model);
|
|
159
|
+
const kind = isPkOnly ? 'pk' : 'collection';
|
|
160
|
+
const fingerprint = cacheLayer.findOneFingerprint(
|
|
161
|
+
model,
|
|
162
|
+
scopeContext,
|
|
163
|
+
conditions,
|
|
164
|
+
select || [],
|
|
165
|
+
withs
|
|
166
|
+
);
|
|
167
|
+
const tags = cacheLayer.buildReadTags(
|
|
168
|
+
model,
|
|
169
|
+
strat,
|
|
170
|
+
kind,
|
|
171
|
+
kind === 'pk' ? conditions[model.primaryKey] : null
|
|
172
|
+
);
|
|
173
|
+
return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return loadFromDb();
|
|
132
177
|
}
|
|
133
178
|
|
|
134
179
|
/**
|
|
@@ -140,36 +185,43 @@ function createRepository(model, knex, initialContext) {
|
|
|
140
185
|
const { with: withs = [], select } = options;
|
|
141
186
|
const ctx = createEventContext(model.name, 'find');
|
|
142
187
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
188
|
+
async function loadFromDb() {
|
|
189
|
+
await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
190
|
+
if (ctx.isCancelled) {
|
|
191
|
+
throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
|
|
192
|
+
}
|
|
148
193
|
|
|
149
|
-
|
|
194
|
+
let qb = baseQuery();
|
|
150
195
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
196
|
+
if (select && select.length > 0) {
|
|
197
|
+
qb = qb.select(select);
|
|
198
|
+
}
|
|
154
199
|
|
|
155
|
-
|
|
200
|
+
const records = await qb;
|
|
156
201
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
202
|
+
for (const record of records) {
|
|
203
|
+
deserializeJsonFields(record, jsonColumns);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (withs.length > 0 && records.length > 0) {
|
|
207
|
+
await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const record of records) {
|
|
211
|
+
ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
|
|
212
|
+
}
|
|
161
213
|
|
|
162
|
-
|
|
163
|
-
if (withs.length > 0 && records.length > 0) {
|
|
164
|
-
await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
|
|
214
|
+
return records;
|
|
165
215
|
}
|
|
166
216
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
217
|
+
if (cacheLayer && cacheLayer.strategyFor(model)) {
|
|
218
|
+
const strat = cacheLayer.strategyFor(model);
|
|
219
|
+
const fingerprint = cacheLayer.findAllFingerprint(model, scopeContext, select || []);
|
|
220
|
+
const tags = cacheLayer.buildReadTags(model, strat, 'collection', null);
|
|
221
|
+
return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, () => true);
|
|
170
222
|
}
|
|
171
223
|
|
|
172
|
-
return
|
|
224
|
+
return loadFromDb();
|
|
173
225
|
}
|
|
174
226
|
|
|
175
227
|
/**
|
|
@@ -334,7 +386,11 @@ function createRepository(model, knex, initialContext) {
|
|
|
334
386
|
qb = qb.where(key, value);
|
|
335
387
|
}
|
|
336
388
|
|
|
337
|
-
|
|
389
|
+
const updated = await qb.update(updateData);
|
|
390
|
+
if (cacheLayer && updated > 0) {
|
|
391
|
+
cacheLayer.invalidateModelAll(model);
|
|
392
|
+
}
|
|
393
|
+
return updated;
|
|
338
394
|
}
|
|
339
395
|
|
|
340
396
|
/**
|
|
@@ -391,6 +447,9 @@ function createRepository(model, knex, initialContext) {
|
|
|
391
447
|
const deleted = await knex(model.table)
|
|
392
448
|
.where(model.primaryKey, id)
|
|
393
449
|
.delete();
|
|
450
|
+
if (cacheLayer && deleted > 0) {
|
|
451
|
+
cacheLayer.invalidateModelAll(model);
|
|
452
|
+
}
|
|
394
453
|
return deleted > 0;
|
|
395
454
|
}
|
|
396
455
|
|
|
@@ -445,7 +504,7 @@ function createRepository(model, knex, initialContext) {
|
|
|
445
504
|
* @returns {import('./query-builder').QueryBuilder}
|
|
446
505
|
*/
|
|
447
506
|
function query() {
|
|
448
|
-
return createQueryBuilder(model, knex, scopeContext);
|
|
507
|
+
return createQueryBuilder(model, knex, scopeContext, cacheLayer);
|
|
449
508
|
}
|
|
450
509
|
|
|
451
510
|
/**
|
package/core/orm/types.js
CHANGED
|
@@ -156,6 +156,7 @@
|
|
|
156
156
|
* @property {AdminMetadata} [admin] - Admin panel metadata
|
|
157
157
|
* @property {RestMetadata} [rest] - REST resource plugin metadata
|
|
158
158
|
* @property {string[]} [hidden=[]] - Column names to never expose in API/templates (e.g. password_hash, api_token)
|
|
159
|
+
* @property {boolean|'auto'|'smart'|{strategy:'auto'|'smart'}} [cache] - Per-model query cache when DB cache is enabled (`true` uses DB defaultStrategy)
|
|
159
160
|
*/
|
|
160
161
|
|
|
161
162
|
/**
|
|
@@ -170,6 +171,7 @@
|
|
|
170
171
|
* @property {AdminMetadata} [admin] - Admin panel metadata
|
|
171
172
|
* @property {RestMetadata} rest - REST resource plugin metadata
|
|
172
173
|
* @property {string[]} hidden - Column names never exposed in API/templates
|
|
174
|
+
* @property {boolean|'auto'|'smart'|{strategy:'auto'|'smart'}|undefined} [cache] - Query cache strategy for this model
|
|
173
175
|
*/
|
|
174
176
|
|
|
175
177
|
// ============================================================================
|
|
@@ -265,6 +267,15 @@
|
|
|
265
267
|
* @property {string|Object} connection - Connection string or config object
|
|
266
268
|
* @property {MigrationConfig} [migrations] - Migration configuration
|
|
267
269
|
* @property {Object} [pool] - Connection pool settings
|
|
270
|
+
* @property {boolean|OrmDatabaseCacheConfig} [cache] - Enable ORM read cache (memory provider by default)
|
|
271
|
+
*/
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @typedef {Object} OrmDatabaseCacheConfig
|
|
275
|
+
* @property {boolean} [enabled=true]
|
|
276
|
+
* @property {'auto'|'smart'} [defaultStrategy='auto'] - Default when model.cache is unspecified
|
|
277
|
+
* @property {import('./cache/types').CacheProvider} [provider] - Custom provider (e.g. Redis)
|
|
278
|
+
* @property {Object} [memory] - Options for built-in memory provider (maxEntries, defaultTtlMs)
|
|
268
279
|
*/
|
|
269
280
|
|
|
270
281
|
/**
|
|
@@ -274,6 +285,16 @@
|
|
|
274
285
|
* @property {function(function(TransactionContext): Promise): Promise} transaction - Run in transaction
|
|
275
286
|
* @property {MigrationManager} migrate - Migration manager
|
|
276
287
|
* @property {function(): Promise<void>} destroy - Close all connections
|
|
288
|
+
* @property {OrmCachePublicApi|null} [cache] - Cache controls when enabled
|
|
289
|
+
*/
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @typedef {Object} OrmCachePublicApi
|
|
293
|
+
* @property {() => void} purge
|
|
294
|
+
* @property {(tags: string[]) => void} invalidateTags
|
|
295
|
+
* @property {(modelName: string) => void} invalidateModel
|
|
296
|
+
* @property {() => object} getMetrics
|
|
297
|
+
* @property {() => void} resetMetrics
|
|
277
298
|
*/
|
|
278
299
|
|
|
279
300
|
/**
|
package/index.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ import type { Application, NextFunction, Request, RequestHandler, Response } fro
|
|
|
6
6
|
import type { Knex } from 'knex';
|
|
7
7
|
import type { ZodObject, ZodTypeAny } from 'zod';
|
|
8
8
|
|
|
9
|
+
/** Registered as createApp middlewares[name]: plain handler or (options) => handler (for middleware: ['name', options]). */
|
|
10
|
+
export type WebspressoRegisteredMiddleware =
|
|
11
|
+
| RequestHandler
|
|
12
|
+
| ((options: unknown) => RequestHandler);
|
|
13
|
+
|
|
9
14
|
// --- Express / app ---
|
|
10
15
|
|
|
11
16
|
export interface ErrorPageContext {
|
|
@@ -23,7 +28,7 @@ export interface CreateAppOptions {
|
|
|
23
28
|
publicDir?: string;
|
|
24
29
|
logging?: boolean;
|
|
25
30
|
helmet?: boolean | Record<string, unknown>;
|
|
26
|
-
middlewares?: Record<string,
|
|
31
|
+
middlewares?: Record<string, WebspressoRegisteredMiddleware>;
|
|
27
32
|
plugins?: WebspressoPlugin[];
|
|
28
33
|
assets?: {
|
|
29
34
|
version?: string;
|
|
@@ -220,6 +225,7 @@ export interface ModelOptions {
|
|
|
220
225
|
rest?: RestMetadata;
|
|
221
226
|
hooks?: Record<string, (...args: unknown[]) => unknown>;
|
|
222
227
|
hidden?: string[];
|
|
228
|
+
cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
|
|
223
229
|
}
|
|
224
230
|
|
|
225
231
|
export interface ModelDefinition {
|
|
@@ -248,6 +254,7 @@ export interface ModelDefinition {
|
|
|
248
254
|
};
|
|
249
255
|
hidden: string[];
|
|
250
256
|
hooks: Record<string, unknown>;
|
|
257
|
+
cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
export function defineModel(options: ModelOptions): ModelDefinition;
|
|
@@ -343,6 +350,24 @@ export interface MigrationConfig {
|
|
|
343
350
|
tableName?: string;
|
|
344
351
|
}
|
|
345
352
|
|
|
353
|
+
export interface OrmMemoryCacheOptions {
|
|
354
|
+
maxEntries?: number;
|
|
355
|
+
defaultTtlMs?: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface OrmDatabaseCacheConfig {
|
|
359
|
+
enabled?: boolean;
|
|
360
|
+
defaultStrategy?: 'auto' | 'smart';
|
|
361
|
+
provider?: {
|
|
362
|
+
get(key: string): unknown;
|
|
363
|
+
set(key: string, value: unknown, opts?: { tags?: string[]; ttlMs?: number }): void;
|
|
364
|
+
invalidateTags(tags: string[]): void;
|
|
365
|
+
clear(): void;
|
|
366
|
+
getSizeStats(): { entries: number; tags: number };
|
|
367
|
+
};
|
|
368
|
+
memory?: OrmMemoryCacheOptions;
|
|
369
|
+
}
|
|
370
|
+
|
|
346
371
|
export interface DatabaseConfig {
|
|
347
372
|
client?: string;
|
|
348
373
|
connection?: string | Record<string, unknown>;
|
|
@@ -350,9 +375,18 @@ export interface DatabaseConfig {
|
|
|
350
375
|
migrations?: MigrationConfig;
|
|
351
376
|
pool?: Record<string, unknown>;
|
|
352
377
|
useNullAsDefault?: boolean;
|
|
378
|
+
cache?: boolean | OrmDatabaseCacheConfig;
|
|
353
379
|
[key: string]: unknown;
|
|
354
380
|
}
|
|
355
381
|
|
|
382
|
+
export interface OrmCachePublicApi {
|
|
383
|
+
purge(): void;
|
|
384
|
+
invalidateTags(tags: string[]): void;
|
|
385
|
+
invalidateModel(modelName: string): void;
|
|
386
|
+
getMetrics(): Record<string, unknown>;
|
|
387
|
+
resetMetrics(): void;
|
|
388
|
+
}
|
|
389
|
+
|
|
356
390
|
export interface MigrationStatus {
|
|
357
391
|
name: string;
|
|
358
392
|
completed: boolean;
|
|
@@ -402,11 +436,20 @@ export interface DatabaseInstance {
|
|
|
402
436
|
query(modelName: string, scopeContext?: ScopeContext): QueryBuilder;
|
|
403
437
|
transaction<T>(callback: (ctx: TransactionContext) => Promise<T>): Promise<T>;
|
|
404
438
|
createSeeder(): unknown;
|
|
439
|
+
cache: OrmCachePublicApi | null;
|
|
405
440
|
destroy(): Promise<void>;
|
|
406
441
|
}
|
|
407
442
|
|
|
408
443
|
export function createDatabase(config: DatabaseConfig): DatabaseInstance;
|
|
409
444
|
|
|
445
|
+
export function createMemoryCacheProvider(options?: OrmMemoryCacheOptions): {
|
|
446
|
+
get(key: string): unknown;
|
|
447
|
+
set(key: string, value: unknown, opts?: { tags?: string[]; ttlMs?: number }): void;
|
|
448
|
+
invalidateTags(tags: string[]): void;
|
|
449
|
+
clear(): void;
|
|
450
|
+
getSizeStats(): { entries: number; tags: number };
|
|
451
|
+
};
|
|
452
|
+
|
|
410
453
|
// --- ORM: nanoid / zod ---
|
|
411
454
|
|
|
412
455
|
export function generateNanoid(options?: { maxLength?: number } | number): string;
|
|
@@ -480,3 +523,5 @@ export interface RestResourcePluginOptions {
|
|
|
480
523
|
}
|
|
481
524
|
|
|
482
525
|
export function restResourcePlugin(options?: RestResourcePluginOptions): WebspressoPlugin;
|
|
526
|
+
|
|
527
|
+
export function ormCacheAdminPlugin(options: { db: DatabaseInstance }): WebspressoPlugin;
|
package/index.js
CHANGED
|
@@ -38,7 +38,7 @@ const {
|
|
|
38
38
|
const orm = require('./core/orm');
|
|
39
39
|
|
|
40
40
|
// Built-in plugins
|
|
41
|
-
const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin, restResourcePlugin } = require('./plugins');
|
|
41
|
+
const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin, restResourcePlugin, ormCacheAdminPlugin } = require('./plugins');
|
|
42
42
|
|
|
43
43
|
module.exports = {
|
|
44
44
|
// Main API
|
|
@@ -90,4 +90,5 @@ module.exports = {
|
|
|
90
90
|
swaggerPlugin,
|
|
91
91
|
healthCheckPlugin,
|
|
92
92
|
restResourcePlugin,
|
|
93
|
+
ormCacheAdminPlugin,
|
|
93
94
|
};
|
package/package.json
CHANGED
package/plugins/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const recaptchaPlugin = require('./recaptcha');
|
|
|
15
15
|
const swaggerPlugin = require('./swagger');
|
|
16
16
|
const healthCheckPlugin = require('./health-check');
|
|
17
17
|
const restResourcePlugin = require('./rest-resources');
|
|
18
|
+
const ormCacheAdminPlugin = require('./orm-cache-admin');
|
|
18
19
|
|
|
19
20
|
module.exports = {
|
|
20
21
|
sitemapPlugin,
|
|
@@ -29,5 +30,6 @@ module.exports = {
|
|
|
29
30
|
swaggerPlugin,
|
|
30
31
|
healthCheckPlugin,
|
|
31
32
|
restResourcePlugin,
|
|
33
|
+
ormCacheAdminPlugin,
|
|
32
34
|
};
|
|
33
35
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin SPA page for ORM cache (Mithril string component)
|
|
3
|
+
* @module plugins/orm-cache-admin/admin-component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function generateOrmCacheAdminComponent() {
|
|
7
|
+
return `
|
|
8
|
+
(function() {
|
|
9
|
+
|
|
10
|
+
function ormCacheApi(method, path, body) {
|
|
11
|
+
var opts = { method: method, credentials: 'same-origin' };
|
|
12
|
+
if (body !== undefined) {
|
|
13
|
+
opts.headers = { 'Content-Type': 'application/json' };
|
|
14
|
+
opts.body = JSON.stringify(body);
|
|
15
|
+
}
|
|
16
|
+
return fetch('/_admin/api/orm-cache' + path, opts).then(function(r) {
|
|
17
|
+
return r.json().then(function(j) {
|
|
18
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
19
|
+
return j;
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var OrmCachePage = {
|
|
25
|
+
oninit: function(vnode) {
|
|
26
|
+
vnode.state.loading = true;
|
|
27
|
+
vnode.state.error = null;
|
|
28
|
+
vnode.state.stats = null;
|
|
29
|
+
vnode.state.modelName = '';
|
|
30
|
+
this.refresh(vnode);
|
|
31
|
+
},
|
|
32
|
+
refresh: function(vnode) {
|
|
33
|
+
vnode.state.loading = true;
|
|
34
|
+
vnode.state.error = null;
|
|
35
|
+
ormCacheApi('get', '/stats').then(function(s) {
|
|
36
|
+
vnode.state.stats = s;
|
|
37
|
+
vnode.state.loading = false;
|
|
38
|
+
m.redraw();
|
|
39
|
+
}).catch(function(e) {
|
|
40
|
+
vnode.state.error = e.message || String(e);
|
|
41
|
+
vnode.state.loading = false;
|
|
42
|
+
m.redraw();
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
purge: function(vnode) {
|
|
46
|
+
if (!confirm('Purge entire ORM cache?')) return;
|
|
47
|
+
ormCacheApi('post', '/purge').then(function() {
|
|
48
|
+
return this.refresh(vnode);
|
|
49
|
+
}.bind(this)).catch(function(e) {
|
|
50
|
+
vnode.state.error = e.message;
|
|
51
|
+
m.redraw();
|
|
52
|
+
}.bind(this));
|
|
53
|
+
},
|
|
54
|
+
invalidateModel: function(vnode) {
|
|
55
|
+
var name = (vnode.state.modelName || '').trim();
|
|
56
|
+
if (!name) return;
|
|
57
|
+
ormCacheApi('post', '/invalidate', { model: name }).then(function() {
|
|
58
|
+
vnode.state.modelName = '';
|
|
59
|
+
return this.refresh(vnode);
|
|
60
|
+
}.bind(this)).catch(function(e) {
|
|
61
|
+
vnode.state.error = e.message;
|
|
62
|
+
m.redraw();
|
|
63
|
+
}.bind(this));
|
|
64
|
+
},
|
|
65
|
+
resetMetrics: function(vnode) {
|
|
66
|
+
ormCacheApi('post', '/metrics/reset').then(function() {
|
|
67
|
+
return this.refresh(vnode);
|
|
68
|
+
}.bind(this)).catch(function(e) {
|
|
69
|
+
vnode.state.error = e.message;
|
|
70
|
+
m.redraw();
|
|
71
|
+
}.bind(this));
|
|
72
|
+
},
|
|
73
|
+
view: function(vnode) {
|
|
74
|
+
var s = vnode.state;
|
|
75
|
+
var self = this;
|
|
76
|
+
return m(Layout, [
|
|
77
|
+
m(Breadcrumb, { items: [{ label: 'ORM Cache', href: '/orm-cache' }] }),
|
|
78
|
+
m('div.mb-6', [
|
|
79
|
+
m('h1.text-2xl.font-bold.text-gray-900', 'ORM query cache'),
|
|
80
|
+
m('p.text-gray-500.text-sm.mt-1', 'Hit/miss metrics and manual invalidation (memory provider)'),
|
|
81
|
+
]),
|
|
82
|
+
s.loading
|
|
83
|
+
? m('div.flex.justify-center.py-16', m(Spinner))
|
|
84
|
+
: s.error
|
|
85
|
+
? m('div.bg-red-50.text-red-700.p-4.rounded-lg', s.error)
|
|
86
|
+
: m('div.space-y-6', [
|
|
87
|
+
m('div.grid.grid-cols-2.md:grid-cols-4.gap-4', [
|
|
88
|
+
statBox('Hits', s.stats && s.stats.hits),
|
|
89
|
+
statBox('Misses', s.stats && s.stats.misses),
|
|
90
|
+
statBox('Hit rate', formatRate(s.stats && s.stats.hitRate)),
|
|
91
|
+
statBox('Bypassed', s.stats && s.stats.bypassed),
|
|
92
|
+
]),
|
|
93
|
+
m('div.grid.grid-cols-2.md:grid-cols-4.gap-4', [
|
|
94
|
+
statBox('Sets', s.stats && s.stats.sets),
|
|
95
|
+
statBox('Invalidations', s.stats && s.stats.invalidations),
|
|
96
|
+
statBox('Approx keys', s.stats && s.stats.approxKeys),
|
|
97
|
+
statBox('Approx tags', s.stats && s.stats.approxTags),
|
|
98
|
+
]),
|
|
99
|
+
m('div.flex.flex-wrap.gap-2', [
|
|
100
|
+
m('button.px-4.py-2.bg-red-600.text-white.rounded-md.text-sm', {
|
|
101
|
+
onclick: function() { self.purge(vnode); },
|
|
102
|
+
}, 'Purge all'),
|
|
103
|
+
m('button.px-4.py-2.bg-gray-200.text-gray-800.rounded-md.text-sm', {
|
|
104
|
+
onclick: function() { self.resetMetrics(vnode); },
|
|
105
|
+
}, 'Reset metrics'),
|
|
106
|
+
m('button.px-4.py-2.bg-blue-600.text-white.rounded-md.text-sm', {
|
|
107
|
+
onclick: function() { self.refresh(vnode); },
|
|
108
|
+
}, 'Refresh'),
|
|
109
|
+
]),
|
|
110
|
+
m('div.bg-white.rounded-lg.shadow.p-4', [
|
|
111
|
+
m('h3.text-sm.font-semibold.mb-2', 'Invalidate by model'),
|
|
112
|
+
m('div.flex.gap-2', [
|
|
113
|
+
m('input.flex-1.border.rounded.px-3.py-2.text-sm', {
|
|
114
|
+
placeholder: 'Model name (e.g. User)',
|
|
115
|
+
value: s.modelName,
|
|
116
|
+
oninput: function(e) { s.modelName = e.target.value; },
|
|
117
|
+
}),
|
|
118
|
+
m('button.px-4.py-2.bg-amber-600.text-white.rounded-md.text-sm', {
|
|
119
|
+
onclick: function() { self.invalidateModel(vnode); },
|
|
120
|
+
}, 'Invalidate'),
|
|
121
|
+
]),
|
|
122
|
+
]),
|
|
123
|
+
]),
|
|
124
|
+
]);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function statBox(label, value) {
|
|
129
|
+
return m('div.bg-white.rounded-lg.shadow.p-4', [
|
|
130
|
+
m('p.text-xs.text-gray-500.uppercase', label),
|
|
131
|
+
m('p.text-xl.font-semibold.text-gray-900', value === undefined || value === null ? '—' : String(value)),
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatRate(r) {
|
|
136
|
+
if (r === null || r === undefined || isNaN(r)) return '—';
|
|
137
|
+
return (r * 100).toFixed(1) + '%';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
window.__customPages = window.__customPages || {};
|
|
141
|
+
window.__customPages['orm-cache'] = OrmCachePage;
|
|
142
|
+
})();
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { generateOrmCacheAdminComponent };
|