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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * In-memory CacheProvider (process-local, tag-based invalidation)
3
+ * @module core/orm/cache/memory-provider
4
+ */
5
+
6
+ /**
7
+ * @typedef {Object} MemoryCacheProviderOptions
8
+ * @property {number} [maxEntries=10000] - Max stored keys; FIFO evict on overflow
9
+ * @property {number} [defaultTtlMs=0] - Default TTL (0 = no expiry)
10
+ */
11
+
12
+ /**
13
+ * @param {MemoryCacheProviderOptions} [options]
14
+ * @returns {import('./types').CacheProvider}
15
+ */
16
+ function createMemoryCacheProvider(options = {}) {
17
+ const maxEntries = options.maxEntries ?? 10000;
18
+ const defaultTtlMs = options.defaultTtlMs ?? 0;
19
+
20
+ /** @type {Map<string, { value: *, expiresAt: number, tags: Set<string> }>} */
21
+ const store = new Map();
22
+ /** @type {Map<string, Set<string>>} */
23
+ const tagToKeys = new Map();
24
+ /** @type {Map<string, Set<string>>} */
25
+ const keyToTags = new Map();
26
+
27
+ function removeKey(key) {
28
+ const tags = keyToTags.get(key);
29
+ if (tags) {
30
+ for (const t of tags) {
31
+ const set = tagToKeys.get(t);
32
+ if (set) {
33
+ set.delete(key);
34
+ if (set.size === 0) tagToKeys.delete(t);
35
+ }
36
+ }
37
+ }
38
+ keyToTags.delete(key);
39
+ store.delete(key);
40
+ }
41
+
42
+ function get(key) {
43
+ const entry = store.get(key);
44
+ if (!entry) return undefined;
45
+ if (entry.expiresAt > 0 && Date.now() > entry.expiresAt) {
46
+ removeKey(key);
47
+ return undefined;
48
+ }
49
+ return entry.value;
50
+ }
51
+
52
+ function set(key, value, opts = {}) {
53
+ const tags = Array.isArray(opts.tags) ? opts.tags : [];
54
+ const ttlMs = opts.ttlMs ?? defaultTtlMs;
55
+
56
+ if (store.has(key)) removeKey(key);
57
+
58
+ if (maxEntries > 0 && store.size >= maxEntries) {
59
+ const first = store.keys().next().value;
60
+ if (first !== undefined) removeKey(first);
61
+ }
62
+
63
+ const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
64
+ const tagSet = new Set(tags);
65
+ store.set(key, { value, expiresAt, tags: tagSet });
66
+ keyToTags.set(key, new Set(tags));
67
+
68
+ for (const t of tags) {
69
+ if (!tagToKeys.has(t)) tagToKeys.set(t, new Set());
70
+ tagToKeys.get(t).add(key);
71
+ }
72
+ }
73
+
74
+ function invalidateTags(tags) {
75
+ const toDelete = new Set();
76
+ for (const t of tags) {
77
+ const keys = tagToKeys.get(t);
78
+ if (!keys) continue;
79
+ for (const k of keys) toDelete.add(k);
80
+ }
81
+ for (const k of toDelete) removeKey(k);
82
+ for (const t of tags) tagToKeys.delete(t);
83
+ }
84
+
85
+ function clear() {
86
+ store.clear();
87
+ tagToKeys.clear();
88
+ keyToTags.clear();
89
+ }
90
+
91
+ function getSizeStats() {
92
+ return {
93
+ entries: store.size,
94
+ tags: tagToKeys.size,
95
+ };
96
+ }
97
+
98
+ return {
99
+ get,
100
+ set,
101
+ invalidateTags,
102
+ clear,
103
+ getSizeStats,
104
+ };
105
+ }
106
+
107
+ module.exports = {
108
+ createMemoryCacheProvider,
109
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @typedef {Object} CacheSetOptions
3
+ * @property {string[]} [tags]
4
+ * @property {number} [ttlMs]
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} CacheProvider
9
+ * @property {(key: string) => *|undefined} get
10
+ * @property {(key: string, value: *, opts?: CacheSetOptions) => void} set
11
+ * @property {(tags: string[]) => void} invalidateTags
12
+ * @property {() => void} clear
13
+ * @property {() => { entries: number, tags: number }} getSizeStats
14
+ */
15
+
16
+ /**
17
+ * @typedef {'auto'|'smart'} CacheStrategy
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} OrmCacheConfigResolved
22
+ * @property {boolean} enabled
23
+ * @property {CacheStrategy} defaultStrategy
24
+ * @property {import('./memory-provider').MemoryCacheProviderOptions} [memoryOptions]
25
+ */
26
+
27
+ module.exports = {};
package/core/orm/index.js CHANGED
@@ -15,6 +15,7 @@ const { createScopeContext } = require('./scopes');
15
15
  const { ModelEvents, Hooks, HookCancellationError, createEventContext } = require('./events');
16
16
  const { omitHiddenColumns, sanitizeForOutput } = require('./utils');
17
17
  const { generateNanoid, zodNanoid, extendZ } = require('./utils/nanoid');
18
+ const { createOrmCacheFromConfig, unregisterOrmCacheListeners } = require('./cache');
18
19
 
19
20
  /**
20
21
  * Create a database instance
@@ -93,6 +94,10 @@ function createDatabase(config) {
93
94
  const migrationConfig = config.migrations || {};
94
95
  const migrate = createMigrationManager(knexInstance, migrationConfig);
95
96
 
97
+ const { layer: ormCacheLayer, publicApi: ormCachePublicApi } = createOrmCacheFromConfig(
98
+ config.cache
99
+ );
100
+
96
101
  // Auto-load models from models directory
97
102
  const modelsDir = config.models || './models';
98
103
  const absoluteModelsDir = path.resolve(process.cwd(), modelsDir);
@@ -180,7 +185,7 @@ function createDatabase(config) {
180
185
  const model = getModelInstance(modelName);
181
186
  // Always create fresh scope context if not provided to avoid shared state
182
187
  const ctx = scopeContext || createScopeContext();
183
- return createRepository(model, knexInstance, ctx);
188
+ return createRepository(model, knexInstance, ctx, ormCacheLayer);
184
189
  }
185
190
 
186
191
  /**
@@ -192,7 +197,7 @@ function createDatabase(config) {
192
197
  function query(modelName, scopeContext) {
193
198
  const model = getModelInstance(modelName);
194
199
  const ctx = scopeContext || createScopeContext();
195
- const repo = createRepository(model, knexInstance, ctx);
200
+ const repo = createRepository(model, knexInstance, ctx, ormCacheLayer);
196
201
  return repo.query();
197
202
  }
198
203
 
@@ -212,7 +217,7 @@ function createDatabase(config) {
212
217
  */
213
218
  function createRepositoryFromModel(model, scopeContext) {
214
219
  const ctx = scopeContext || createScopeContext();
215
- return createRepository(model, knexInstance, ctx);
220
+ return createRepository(model, knexInstance, ctx, ormCacheLayer);
216
221
  }
217
222
 
218
223
  /**
@@ -228,11 +233,11 @@ function createDatabase(config) {
228
233
  getRepository(modelName, scopeContext) {
229
234
  const model = getModelInstance(modelName);
230
235
  const ctx = scopeContext || createScopeContext();
231
- return createRepository(model, trx, ctx);
236
+ return createRepository(model, trx, ctx, ormCacheLayer);
232
237
  },
233
238
  createRepository(model, scopeContext) {
234
239
  const ctx = scopeContext || createScopeContext();
235
- return createRepository(model, trx, ctx);
240
+ return createRepository(model, trx, ctx, ormCacheLayer);
236
241
  },
237
242
  };
238
243
  return callback(trxContext);
@@ -251,7 +256,11 @@ function createDatabase(config) {
251
256
  query,
252
257
  transaction,
253
258
  createSeeder: createSeederInstance,
254
- destroy: () => knexInstance.destroy(),
259
+ cache: ormCachePublicApi,
260
+ destroy: () => {
261
+ if (ormCacheLayer) unregisterOrmCacheListeners(ormCacheLayer);
262
+ return knexInstance.destroy();
263
+ },
255
264
  };
256
265
  }
257
266
 
@@ -262,6 +271,10 @@ const zdb = z ? createSchemaHelpers(z) : null;
262
271
  module.exports = {
263
272
  // Main factory
264
273
  createDatabase,
274
+ // ORM cache (provider + layer utilities)
275
+ createMemoryCacheProvider: require('./cache/memory-provider').createMemoryCacheProvider,
276
+ OrmCacheLayer: require('./cache/layer').OrmCacheLayer,
277
+ createOrmCacheFromConfig: require('./cache').createOrmCacheFromConfig,
265
278
  // Schema helpers
266
279
  zdb,
267
280
  createSchemaHelpers,
package/core/orm/model.js CHANGED
@@ -30,6 +30,7 @@ function defineModel(options) {
30
30
  rest = {},
31
31
  hooks = {},
32
32
  hidden = [],
33
+ cache: cacheOption,
33
34
  } = options;
34
35
 
35
36
  // Validate required fields
@@ -97,6 +98,7 @@ function defineModel(options) {
97
98
  },
98
99
  hidden: Array.isArray(hidden) ? hidden : [],
99
100
  hooks: {},
101
+ cache: cacheOption,
100
102
  };
101
103
 
102
104
  // Register model
@@ -21,8 +21,8 @@ const {
21
21
  * @param {import('./types').ScopeContext} [initialContext] - Initial scope context
22
22
  * @returns {QueryBuilder}
23
23
  */
24
- function createQueryBuilder(model, knex, initialContext) {
25
- return new QueryBuilder(model, knex, initialContext);
24
+ function createQueryBuilder(model, knex, initialContext, cacheLayer = null) {
25
+ return new QueryBuilder(model, knex, initialContext, cacheLayer);
26
26
  }
27
27
 
28
28
  /**
@@ -34,11 +34,13 @@ class QueryBuilder {
34
34
  * @param {import('./types').ModelDefinition} model
35
35
  * @param {import('knex').Knex|import('knex').Knex.Transaction} knex
36
36
  * @param {import('./types').ScopeContext} [initialContext]
37
+ * @param {import('./cache/layer').OrmCacheLayer|null} [cacheLayer]
37
38
  */
38
- constructor(model, knex, initialContext) {
39
+ constructor(model, knex, initialContext, cacheLayer = null) {
39
40
  this.model = model;
40
41
  this.knex = knex;
41
42
  this.scopeContext = initialContext || createScopeContext();
43
+ this.cacheLayer = cacheLayer;
42
44
  this.jsonColumns = getJsonColumns(model);
43
45
 
44
46
  /** @type {import('./types').QueryState} */
@@ -376,24 +378,64 @@ class QueryBuilder {
376
378
  */
377
379
  async first() {
378
380
  const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
379
- await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
380
- if (ctx.isCancelled) {
381
- throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
382
- }
381
+ const self = this;
382
+
383
+ async function loadFromDb() {
384
+ await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
385
+ if (ctx.isCancelled) {
386
+ throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
387
+ }
383
388
 
384
- const qb = this.toKnex().first();
385
- const result = await qb;
386
- if (!result) return null;
389
+ const qb = self.toKnex().first();
390
+ const result = await qb;
391
+ if (!result) return null;
387
392
 
388
- deserializeJsonFields(result, this.jsonColumns);
393
+ deserializeJsonFields(result, self.jsonColumns);
394
+
395
+ const withs = self.getWiths();
396
+ if (withs.length > 0) {
397
+ await loadRelations([result], ensureArray(withs), self.model, self.knex, self.scopeContext);
398
+ }
389
399
 
390
- const withs = this.getWiths();
391
- if (withs.length > 0) {
392
- await loadRelations([result], ensureArray(withs), this.model, this.knex, this.scopeContext);
400
+ ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
401
+ return result;
393
402
  }
394
403
 
395
- ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, result, ctx);
396
- return result;
404
+ if (
405
+ self.cacheLayer &&
406
+ self.cacheLayer.strategyFor(self.model)
407
+ ) {
408
+ const strat = self.cacheLayer.strategyFor(self.model);
409
+ const classification = self.cacheLayer.classifyQueryBuilder(self.model, self.state, 'first');
410
+ if (!classification.cacheable) {
411
+ self.cacheLayer.metrics.bypassed += 1;
412
+ return loadFromDb();
413
+ }
414
+ const kind = classification.kind === 'pk' ? 'pk' : 'collection';
415
+ const fingerprint = self.cacheLayer.queryBuilderFingerprint(
416
+ self.model,
417
+ self.scopeContext,
418
+ self.state,
419
+ 'first'
420
+ );
421
+ const tags = self.cacheLayer.buildReadTags(
422
+ self.model,
423
+ strat,
424
+ kind,
425
+ classification.kind === 'pk' ? classification.pkValue : null
426
+ );
427
+ return self.cacheLayer.wrapRead(
428
+ self.model,
429
+ self.knex,
430
+ self.scopeContext,
431
+ fingerprint,
432
+ tags,
433
+ loadFromDb,
434
+ (r) => r != null
435
+ );
436
+ }
437
+
438
+ return loadFromDb();
397
439
  }
398
440
 
399
441
  /**
@@ -402,26 +444,57 @@ class QueryBuilder {
402
444
  */
403
445
  async list() {
404
446
  const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
405
- await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
406
- if (ctx.isCancelled) {
407
- throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
408
- }
447
+ const self = this;
409
448
 
410
- const results = await this.toKnex();
449
+ async function loadFromDb() {
450
+ await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
451
+ if (ctx.isCancelled) {
452
+ throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
453
+ }
411
454
 
412
- for (const record of results) {
413
- deserializeJsonFields(record, this.jsonColumns);
414
- }
455
+ const results = await self.toKnex();
415
456
 
416
- const withs = this.getWiths();
417
- if (withs.length > 0 && results.length > 0) {
418
- await loadRelations(results, ensureArray(withs), this.model, this.knex, this.scopeContext);
457
+ for (const record of results) {
458
+ deserializeJsonFields(record, self.jsonColumns);
459
+ }
460
+
461
+ const withs = self.getWiths();
462
+ if (withs.length > 0 && results.length > 0) {
463
+ await loadRelations(results, ensureArray(withs), self.model, self.knex, self.scopeContext);
464
+ }
465
+
466
+ for (const record of results) {
467
+ ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
468
+ }
469
+ return results;
419
470
  }
420
471
 
421
- for (const record of results) {
422
- ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, record, ctx);
472
+ if (self.cacheLayer && self.cacheLayer.strategyFor(self.model)) {
473
+ const strat = self.cacheLayer.strategyFor(self.model);
474
+ const classification = self.cacheLayer.classifyQueryBuilder(self.model, self.state, 'list');
475
+ if (!classification.cacheable) {
476
+ self.cacheLayer.metrics.bypassed += 1;
477
+ return loadFromDb();
478
+ }
479
+ const fingerprint = self.cacheLayer.queryBuilderFingerprint(
480
+ self.model,
481
+ self.scopeContext,
482
+ self.state,
483
+ 'list'
484
+ );
485
+ const tags = self.cacheLayer.buildReadTags(self.model, strat, 'collection', null);
486
+ return self.cacheLayer.wrapRead(
487
+ self.model,
488
+ self.knex,
489
+ self.scopeContext,
490
+ fingerprint,
491
+ tags,
492
+ loadFromDb,
493
+ () => true
494
+ );
423
495
  }
424
- return results;
496
+
497
+ return loadFromDb();
425
498
  }
426
499
 
427
500
  /**
@@ -437,8 +510,42 @@ class QueryBuilder {
437
510
  * @returns {Promise<number>}
438
511
  */
439
512
  async count() {
440
- const result = await this.toKnex({ includeLimitOffset: false }).count('* as count').first();
441
- return parseInt(result?.count || 0, 10);
513
+ const self = this;
514
+
515
+ async function loadFromDb() {
516
+ const result = await self
517
+ .toKnex({ includeLimitOffset: false })
518
+ .count('* as count')
519
+ .first();
520
+ return parseInt(result?.count || 0, 10);
521
+ }
522
+
523
+ if (self.cacheLayer && self.cacheLayer.strategyFor(self.model)) {
524
+ const strat = self.cacheLayer.strategyFor(self.model);
525
+ const classification = self.cacheLayer.classifyQueryBuilder(self.model, self.state, 'count');
526
+ if (!classification.cacheable) {
527
+ self.cacheLayer.metrics.bypassed += 1;
528
+ return loadFromDb();
529
+ }
530
+ const fingerprint = self.cacheLayer.queryBuilderFingerprint(
531
+ self.model,
532
+ self.scopeContext,
533
+ self.state,
534
+ 'count'
535
+ );
536
+ const tags = self.cacheLayer.buildReadTags(self.model, strat, 'collection', null);
537
+ return self.cacheLayer.wrapRead(
538
+ self.model,
539
+ self.knex,
540
+ self.scopeContext,
541
+ fingerprint,
542
+ tags,
543
+ loadFromDb,
544
+ () => true
545
+ );
546
+ }
547
+
548
+ return loadFromDb();
442
549
  }
443
550
 
444
551
  /**
@@ -458,39 +565,71 @@ class QueryBuilder {
458
565
  */
459
566
  async paginate(page = 1, perPage = 15) {
460
567
  const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
461
- await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
462
- if (ctx.isCancelled) {
463
- throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
464
- }
568
+ const self = this;
569
+
570
+ async function loadFromDb() {
571
+ await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
572
+ if (ctx.isCancelled) {
573
+ throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
574
+ }
465
575
 
466
- const base = this.toKnex({ includeLimitOffset: false });
576
+ const base = self.toKnex({ includeLimitOffset: false });
467
577
 
468
- const countResult = await base.clone().count('* as count').first();
469
- const total = parseInt(countResult?.count || 0, 10);
578
+ const countResult = await base.clone().count('* as count').first();
579
+ const total = parseInt(countResult?.count || 0, 10);
470
580
 
471
- const offset = (page - 1) * perPage;
472
- const data = await base.clone().limit(perPage).offset(offset);
581
+ const offset = (page - 1) * perPage;
582
+ const data = await base.clone().limit(perPage).offset(offset);
473
583
 
474
- for (const record of data) {
475
- deserializeJsonFields(record, this.jsonColumns);
476
- }
584
+ for (const record of data) {
585
+ deserializeJsonFields(record, self.jsonColumns);
586
+ }
587
+
588
+ const withs = self.getWiths();
589
+ if (withs.length > 0 && data.length > 0) {
590
+ await loadRelations(data, ensureArray(withs), self.model, self.knex, self.scopeContext);
591
+ }
592
+
593
+ for (const record of data) {
594
+ ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
595
+ }
477
596
 
478
- const withs = this.getWiths();
479
- if (withs.length > 0 && data.length > 0) {
480
- await loadRelations(data, ensureArray(withs), this.model, this.knex, this.scopeContext);
597
+ return {
598
+ data,
599
+ total,
600
+ page,
601
+ perPage,
602
+ totalPages: Math.ceil(total / perPage),
603
+ };
481
604
  }
482
605
 
483
- for (const record of data) {
484
- ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, record, ctx);
606
+ if (self.cacheLayer && self.cacheLayer.strategyFor(self.model)) {
607
+ const strat = self.cacheLayer.strategyFor(self.model);
608
+ const classification = self.cacheLayer.classifyQueryBuilder(self.model, self.state, 'paginate');
609
+ if (!classification.cacheable) {
610
+ self.cacheLayer.metrics.bypassed += 1;
611
+ return loadFromDb();
612
+ }
613
+ const fingerprint = self.cacheLayer.queryBuilderFingerprint(
614
+ self.model,
615
+ self.scopeContext,
616
+ self.state,
617
+ 'paginate',
618
+ { page, perPage }
619
+ );
620
+ const tags = self.cacheLayer.buildReadTags(self.model, strat, 'collection', null);
621
+ return self.cacheLayer.wrapRead(
622
+ self.model,
623
+ self.knex,
624
+ self.scopeContext,
625
+ fingerprint,
626
+ tags,
627
+ loadFromDb,
628
+ () => true
629
+ );
485
630
  }
486
631
 
487
- return {
488
- data,
489
- total,
490
- page,
491
- perPage,
492
- totalPages: Math.ceil(total / perPage),
493
- };
632
+ return loadFromDb();
494
633
  }
495
634
 
496
635
  /**
@@ -498,7 +637,11 @@ class QueryBuilder {
498
637
  * @returns {Promise<number>} Number of deleted records
499
638
  */
500
639
  async delete() {
501
- return this.toKnex().delete();
640
+ const n = await this.toKnex().delete();
641
+ if (this.cacheLayer && n > 0) {
642
+ this.cacheLayer.invalidateModelAll(this.model);
643
+ }
644
+ return n;
502
645
  }
503
646
 
504
647
  /**
@@ -508,7 +651,11 @@ class QueryBuilder {
508
651
  */
509
652
  async update(data) {
510
653
  const serialized = serializeJsonFields(data, this.jsonColumns);
511
- return this.toKnex().update(serialized);
654
+ const n = await this.toKnex().update(serialized);
655
+ if (this.cacheLayer && n > 0) {
656
+ this.cacheLayer.invalidateModelAll(this.model);
657
+ }
658
+ return n;
512
659
  }
513
660
 
514
661
  /**
@@ -532,7 +679,7 @@ class QueryBuilder {
532
679
  * @returns {QueryBuilder}
533
680
  */
534
681
  clone() {
535
- const cloned = new QueryBuilder(this.model, this.knex, { ...this.scopeContext });
682
+ const cloned = new QueryBuilder(this.model, this.knex, { ...this.scopeContext }, this.cacheLayer);
536
683
  cloned.jsonColumns = this.jsonColumns;
537
684
  cloned.state = {
538
685
  wheres: [...this.state.wheres],