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.
@@ -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
- // Emit beforeFind
53
- if (emitEvents) {
54
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, query, ctx);
55
- if (ctx.isCancelled) {
56
- throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
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
- let qb = baseQuery().where(model.primaryKey, id);
61
-
62
- if (select && select.length > 0) {
63
- qb = qb.select(select);
64
- }
61
+ let qb = baseQuery().where(model.primaryKey, id);
65
62
 
66
- const record = await qb.first();
63
+ if (select && select.length > 0) {
64
+ qb = qb.select(select);
65
+ }
67
66
 
68
- if (!record) {
69
- return null;
70
- }
67
+ const record = await qb.first();
71
68
 
72
- // Deserialize JSON fields
73
- deserializeJsonFields(record, jsonColumns);
69
+ if (!record) {
70
+ return null;
71
+ }
74
72
 
75
- // Load relations if requested
76
- if (withs.length > 0) {
77
- await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
78
- }
73
+ deserializeJsonFields(record, jsonColumns);
79
74
 
80
- // Emit afterFind
81
- if (emitEvents) {
82
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
83
- }
75
+ if (withs.length > 0) {
76
+ await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
77
+ }
84
78
 
85
- return record;
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
- // Emit beforeFind
99
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
100
- if (ctx.isCancelled) {
101
- throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
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
- let qb = baseQuery();
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
- for (const [key, value] of Object.entries(conditions)) {
107
- qb = qb.where(key, value);
108
- }
130
+ let qb = baseQuery();
109
131
 
110
- if (select && select.length > 0) {
111
- qb = qb.select(select);
112
- }
132
+ for (const [key, value] of Object.entries(conditions)) {
133
+ qb = qb.where(key, value);
134
+ }
113
135
 
114
- const record = await qb.first();
136
+ if (select && select.length > 0) {
137
+ qb = qb.select(select);
138
+ }
115
139
 
116
- if (!record) {
117
- return null;
118
- }
140
+ const record = await qb.first();
119
141
 
120
- // Deserialize JSON fields
121
- deserializeJsonFields(record, jsonColumns);
142
+ if (!record) {
143
+ return null;
144
+ }
122
145
 
123
- // Load relations if requested
124
- if (withs.length > 0) {
125
- await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
126
- }
146
+ deserializeJsonFields(record, jsonColumns);
127
147
 
128
- // Emit afterFind
129
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
148
+ if (withs.length > 0) {
149
+ await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
150
+ }
130
151
 
131
- return record;
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
- // Emit beforeFind
144
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
145
- if (ctx.isCancelled) {
146
- throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
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
- let qb = baseQuery();
194
+ let qb = baseQuery();
150
195
 
151
- if (select && select.length > 0) {
152
- qb = qb.select(select);
153
- }
196
+ if (select && select.length > 0) {
197
+ qb = qb.select(select);
198
+ }
154
199
 
155
- const records = await qb;
200
+ const records = await qb;
156
201
 
157
- // Deserialize JSON fields
158
- for (const record of records) {
159
- deserializeJsonFields(record, jsonColumns);
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
- // Load relations if requested
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
- // Emit afterFind for each record
168
- for (const record of records) {
169
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
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 records;
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
- return qb.update(updateData);
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, RequestHandler>;
31
+ middlewares?: Record<string, WebspressoRegisteredMiddleware>;
27
32
  plugins?: WebspressoPlugin[];
28
33
  assets?: {
29
34
  version?: string;
@@ -40,6 +45,11 @@ export interface CreateAppOptions {
40
45
  timeout?: string | false;
41
46
  auth?: unknown;
42
47
  db?: DatabaseInstance | null;
48
+ /** Opt-in Alpine / swup assets under `/__webspresso/client-runtime/*`. Env: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP. */
49
+ clientRuntime?: {
50
+ alpine?: boolean | Record<string, unknown>;
51
+ swup?: boolean | Record<string, unknown>;
52
+ };
43
53
  setupRoutes?: (app: Application, ctx: SetupRoutesContext) => void;
44
54
  [key: string]: unknown;
45
55
  }
@@ -49,6 +59,7 @@ export interface SetupRoutesContext {
49
59
  authMiddleware?: RequestHandler;
50
60
  pluginManager: PluginManager;
51
61
  options: CreateAppOptions;
62
+ clientRuntime: { alpine: boolean; swup: boolean };
52
63
  }
53
64
 
54
65
  export interface CreateAppResult {
@@ -220,6 +231,7 @@ export interface ModelOptions {
220
231
  rest?: RestMetadata;
221
232
  hooks?: Record<string, (...args: unknown[]) => unknown>;
222
233
  hidden?: string[];
234
+ cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
223
235
  }
224
236
 
225
237
  export interface ModelDefinition {
@@ -248,6 +260,7 @@ export interface ModelDefinition {
248
260
  };
249
261
  hidden: string[];
250
262
  hooks: Record<string, unknown>;
263
+ cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
251
264
  }
252
265
 
253
266
  export function defineModel(options: ModelOptions): ModelDefinition;
@@ -343,6 +356,24 @@ export interface MigrationConfig {
343
356
  tableName?: string;
344
357
  }
345
358
 
359
+ export interface OrmMemoryCacheOptions {
360
+ maxEntries?: number;
361
+ defaultTtlMs?: number;
362
+ }
363
+
364
+ export interface OrmDatabaseCacheConfig {
365
+ enabled?: boolean;
366
+ defaultStrategy?: 'auto' | 'smart';
367
+ provider?: {
368
+ get(key: string): unknown;
369
+ set(key: string, value: unknown, opts?: { tags?: string[]; ttlMs?: number }): void;
370
+ invalidateTags(tags: string[]): void;
371
+ clear(): void;
372
+ getSizeStats(): { entries: number; tags: number };
373
+ };
374
+ memory?: OrmMemoryCacheOptions;
375
+ }
376
+
346
377
  export interface DatabaseConfig {
347
378
  client?: string;
348
379
  connection?: string | Record<string, unknown>;
@@ -350,9 +381,18 @@ export interface DatabaseConfig {
350
381
  migrations?: MigrationConfig;
351
382
  pool?: Record<string, unknown>;
352
383
  useNullAsDefault?: boolean;
384
+ cache?: boolean | OrmDatabaseCacheConfig;
353
385
  [key: string]: unknown;
354
386
  }
355
387
 
388
+ export interface OrmCachePublicApi {
389
+ purge(): void;
390
+ invalidateTags(tags: string[]): void;
391
+ invalidateModel(modelName: string): void;
392
+ getMetrics(): Record<string, unknown>;
393
+ resetMetrics(): void;
394
+ }
395
+
356
396
  export interface MigrationStatus {
357
397
  name: string;
358
398
  completed: boolean;
@@ -402,11 +442,20 @@ export interface DatabaseInstance {
402
442
  query(modelName: string, scopeContext?: ScopeContext): QueryBuilder;
403
443
  transaction<T>(callback: (ctx: TransactionContext) => Promise<T>): Promise<T>;
404
444
  createSeeder(): unknown;
445
+ cache: OrmCachePublicApi | null;
405
446
  destroy(): Promise<void>;
406
447
  }
407
448
 
408
449
  export function createDatabase(config: DatabaseConfig): DatabaseInstance;
409
450
 
451
+ export function createMemoryCacheProvider(options?: OrmMemoryCacheOptions): {
452
+ get(key: string): unknown;
453
+ set(key: string, value: unknown, opts?: { tags?: string[]; ttlMs?: number }): void;
454
+ invalidateTags(tags: string[]): void;
455
+ clear(): void;
456
+ getSizeStats(): { entries: number; tags: number };
457
+ };
458
+
410
459
  // --- ORM: nanoid / zod ---
411
460
 
412
461
  export function generateNanoid(options?: { maxLength?: number } | number): string;
@@ -480,3 +529,5 @@ export interface RestResourcePluginOptions {
480
529
  }
481
530
 
482
531
  export function restResourcePlugin(options?: RestResourcePluginOptions): WebspressoPlugin;
532
+
533
+ export function ormCacheAdminPlugin(options: { db: DatabaseInstance }): WebspressoPlugin;
package/index.js CHANGED
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  const { createApp } = require('./src/server');
6
+ const { resolveClientRuntime } = require('./src/client-runtime/resolve');
7
+ const { CLIENT_RUNTIME_BASE } = require('./src/client-runtime/mount');
6
8
  const {
7
9
  attachDbMiddleware,
8
10
  getAppContext,
@@ -38,11 +40,13 @@ const {
38
40
  const orm = require('./core/orm');
39
41
 
40
42
  // Built-in plugins
41
- const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin, restResourcePlugin } = require('./plugins');
43
+ const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin, restResourcePlugin, ormCacheAdminPlugin } = require('./plugins');
42
44
 
43
45
  module.exports = {
44
46
  // Main API
45
47
  createApp,
48
+ resolveClientRuntime,
49
+ CLIENT_RUNTIME_BASE,
46
50
 
47
51
  attachDbMiddleware,
48
52
  getAppContext,
@@ -90,4 +94,5 @@ module.exports = {
90
94
  swaggerPlugin,
91
95
  healthCheckPlugin,
92
96
  restResourcePlugin,
97
+ ormCacheAdminPlugin,
93
98
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.65",
3
+ "version": "0.0.67",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -42,9 +42,13 @@
42
42
  "utils/",
43
43
  "core/",
44
44
  "plugins/",
45
- "templates/"
45
+ "templates/",
46
+ "views/partials/webspresso-client-runtime.njk"
46
47
  ],
47
48
  "dependencies": {
49
+ "@swup/head-plugin": "^2.3.1",
50
+ "@swup/scripts-plugin": "^2.1.0",
51
+ "alpinejs": "^3.15.11",
48
52
  "bcrypt": "^5.1.1",
49
53
  "commander": "^11.1.0",
50
54
  "connect-timeout": "^1.9.1",
@@ -57,6 +61,7 @@
57
61
  "knex": "^3.1.0",
58
62
  "nunjucks": "^3.2.4",
59
63
  "sharp": "^0.33.5",
64
+ "swup": "^4.8.3",
60
65
  "zod": "^3.23.0",
61
66
  "zod-to-json-schema": "^3.25.2"
62
67
  },
@@ -85,10 +90,10 @@
85
90
  }
86
91
  },
87
92
  "devDependencies": {
88
- "@types/express": "^4.17.21",
89
- "@types/node": "^20.14.0",
90
93
  "@faker-js/faker": "^9.9.0",
91
94
  "@playwright/test": "^1.48.0",
95
+ "@types/express": "^4.17.21",
96
+ "@types/node": "^20.14.0",
92
97
  "@vitest/coverage-v8": "^3.0.0",
93
98
  "better-sqlite3": "^11.10.0",
94
99
  "chokidar": "^3.5.3",
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