webspresso 0.0.87 → 0.0.89

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.
@@ -40,6 +40,8 @@ function buildContext(payload, meta) {
40
40
  };
41
41
  }
42
42
 
43
+ const EMPTY_ARRAY = Object.freeze([]);
44
+
43
45
  /**
44
46
  * @returns {{ dispatch: Function, publish: Function, on: Function, off: Function, buildContext: typeof buildContext }}
45
47
  */
@@ -74,7 +76,8 @@ function createEventBus() {
74
76
  * @param {KernelEventContext} ctx
75
77
  */
76
78
  async function dispatch(eventName, ctx) {
77
- const list = listeners.get(eventName) || [];
79
+ const list = listeners.get(eventName);
80
+ if (!list || list.length === 0) return undefined;
78
81
  let last;
79
82
  for (const fn of list) {
80
83
  last = await fn(ctx);
@@ -87,8 +90,13 @@ function createEventBus() {
87
90
  * @param {KernelEventContext} ctx
88
91
  */
89
92
  async function publish(eventName, ctx) {
90
- const list = listeners.get(eventName) || [];
91
- await Promise.all(list.map((fn) => Promise.resolve(fn(ctx))));
93
+ const list = listeners.get(eventName);
94
+ if (!list || list.length === 0) return;
95
+ const promises = [];
96
+ for (const fn of list) {
97
+ promises.push(Promise.resolve(fn(ctx)));
98
+ }
99
+ await Promise.all(promises);
92
100
  }
93
101
 
94
102
  return { dispatch, publish, on, off, buildContext };
@@ -10,6 +10,7 @@ const { getModel } = require('../model');
10
10
  /** @type {Set<import('./layer').OrmCacheLayer>} */
11
11
  const layers = new Set();
12
12
  let hooked = false;
13
+ let unsubs = [];
13
14
 
14
15
  /**
15
16
  * @param {import('./layer').OrmCacheLayer} cacheLayer
@@ -37,21 +38,20 @@ function registerOrmCacheListeners(cacheLayer) {
37
38
  }
38
39
  }
39
40
 
40
- ModelEvents.on(`*.${Hooks.AFTER_CREATE}`, (record, ctx) => {
41
- handleMutation(record, ctx, 'create');
42
- });
43
-
44
- ModelEvents.on(`*.${Hooks.AFTER_UPDATE}`, (record, ctx) => {
45
- handleMutation(record, ctx, 'update');
46
- });
47
-
48
- ModelEvents.on(`*.${Hooks.AFTER_DELETE}`, (record, ctx) => {
49
- handleMutation(record, ctx, 'delete');
50
- });
51
-
52
- ModelEvents.on(`*.${Hooks.AFTER_RESTORE}`, (record, ctx) => {
53
- handleMutation(record, ctx, 'restore');
54
- });
41
+ unsubs = [
42
+ ModelEvents.on(`*.${Hooks.AFTER_CREATE}`, (record, ctx) => {
43
+ handleMutation(record, ctx, 'create');
44
+ }),
45
+ ModelEvents.on(`*.${Hooks.AFTER_UPDATE}`, (record, ctx) => {
46
+ handleMutation(record, ctx, 'update');
47
+ }),
48
+ ModelEvents.on(`*.${Hooks.AFTER_DELETE}`, (record, ctx) => {
49
+ handleMutation(record, ctx, 'delete');
50
+ }),
51
+ ModelEvents.on(`*.${Hooks.AFTER_RESTORE}`, (record, ctx) => {
52
+ handleMutation(record, ctx, 'restore');
53
+ }),
54
+ ];
55
55
  }
56
56
 
57
57
  /**
@@ -59,6 +59,13 @@ function registerOrmCacheListeners(cacheLayer) {
59
59
  */
60
60
  function unregisterOrmCacheListeners(cacheLayer) {
61
61
  layers.delete(cacheLayer);
62
+ if (layers.size === 0 && hooked) {
63
+ for (const unsub of unsubs) {
64
+ if (typeof unsub === 'function') unsub();
65
+ }
66
+ unsubs = [];
67
+ hooked = false;
68
+ }
62
69
  }
63
70
 
64
71
  module.exports = {
@@ -63,39 +63,37 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
63
63
  * @param {import('./types').ScopeContext} scopeContext - Scope context
64
64
  */
65
65
  async function loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
66
- // Collect unique foreign key values
67
- const foreignKeyValues = [...new Set(
68
- records
69
- .map(r => r[foreignKey])
70
- .filter(v => v !== null && v !== undefined)
71
- )];
72
-
73
- if (foreignKeyValues.length === 0) {
74
- // No foreign keys, set all relations to null
75
- for (const record of records) {
76
- record[relationName] = null;
66
+ const fkSet = new Set();
67
+ const len = records.length;
68
+ for (let i = 0; i < len; i++) {
69
+ const v = records[i][foreignKey];
70
+ if (v !== null && v !== undefined) fkSet.add(v);
71
+ }
72
+
73
+ if (fkSet.size === 0) {
74
+ for (let i = 0; i < len; i++) {
75
+ records[i][relationName] = null;
77
76
  }
78
77
  return;
79
78
  }
80
79
 
81
- // Build query for related records
80
+ const foreignKeyValues = Array.from(fkSet);
82
81
  let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
83
-
84
- // Apply scopes to related model
85
82
  qb = applyScopes(qb, scopeContext, relatedModel);
86
-
87
83
  const relatedRecords = await qb;
88
84
 
89
- // Index related records by primary key
90
85
  const relatedMap = new Map();
91
- for (const related of relatedRecords) {
92
- relatedMap.set(related[relatedModel.primaryKey], related);
86
+ const rLen = relatedRecords.length;
87
+ const pk = relatedModel.primaryKey;
88
+ for (let i = 0; i < rLen; i++) {
89
+ const item = relatedRecords[i];
90
+ relatedMap.set(item[pk], item);
93
91
  }
94
92
 
95
- // Attach related records to parent records
96
- for (const record of records) {
97
- const fkValue = record[foreignKey];
98
- record[relationName] = fkValue !== null && fkValue !== undefined
93
+ for (let i = 0; i < len; i++) {
94
+ const rec = records[i];
95
+ const fkValue = rec[foreignKey];
96
+ rec[relationName] = fkValue !== null && fkValue !== undefined
99
97
  ? relatedMap.get(fkValue) || null
100
98
  : null;
101
99
  }
@@ -115,43 +113,42 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
115
113
  * @param {import('./types').ScopeContext} scopeContext - Scope context
116
114
  */
117
115
  async function loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
118
- // Collect unique primary key values from parent records
119
- const primaryKeyValues = [...new Set(
120
- records
121
- .map(r => r[localKey])
122
- .filter(v => v !== null && v !== undefined)
123
- )];
124
-
125
- if (primaryKeyValues.length === 0) {
126
- // No primary keys, set all relations to empty arrays
127
- for (const record of records) {
128
- record[relationName] = [];
116
+ const pkSet = new Set();
117
+ const len = records.length;
118
+ for (let i = 0; i < len; i++) {
119
+ const v = records[i][localKey];
120
+ if (v !== null && v !== undefined) pkSet.add(v);
121
+ }
122
+
123
+ if (pkSet.size === 0) {
124
+ for (let i = 0; i < len; i++) {
125
+ records[i][relationName] = [];
129
126
  }
130
127
  return;
131
128
  }
132
129
 
133
- // Build query for related records
130
+ const primaryKeyValues = Array.from(pkSet);
134
131
  let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
135
-
136
- // Apply scopes to related model
137
132
  qb = applyScopes(qb, scopeContext, relatedModel);
138
-
139
133
  const relatedRecords = await qb;
140
134
 
141
- // Group related records by foreign key
142
135
  const relatedGroups = new Map();
143
- for (const related of relatedRecords) {
144
- const fkValue = related[foreignKey];
145
- if (!relatedGroups.has(fkValue)) {
146
- relatedGroups.set(fkValue, []);
136
+ const rLen = relatedRecords.length;
137
+ for (let i = 0; i < rLen; i++) {
138
+ const item = relatedRecords[i];
139
+ const fkValue = item[foreignKey];
140
+ let group = relatedGroups.get(fkValue);
141
+ if (group === undefined) {
142
+ group = [];
143
+ relatedGroups.set(fkValue, group);
147
144
  }
148
- relatedGroups.get(fkValue).push(related);
145
+ group.push(item);
149
146
  }
150
147
 
151
- // Attach related records to parent records
152
- for (const record of records) {
153
- const pkValue = record[localKey];
154
- record[relationName] = relatedGroups.get(pkValue) || [];
148
+ for (let i = 0; i < len; i++) {
149
+ const rec = records[i];
150
+ const pkValue = rec[localKey];
151
+ rec[relationName] = (pkValue !== null && pkValue !== undefined ? relatedGroups.get(pkValue) : undefined) || [];
155
152
  }
156
153
  }
157
154
 
@@ -169,42 +166,39 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
169
166
  * @param {import('./types').ScopeContext} scopeContext - Scope context
170
167
  */
171
168
  async function loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
172
- // Collect unique primary key values from parent records
173
- const primaryKeyValues = [...new Set(
174
- records
175
- .map(r => r[localKey])
176
- .filter(v => v !== null && v !== undefined)
177
- )];
178
-
179
- if (primaryKeyValues.length === 0) {
180
- // No primary keys, set all relations to null
181
- for (const record of records) {
182
- record[relationName] = null;
169
+ const pkSet = new Set();
170
+ const len = records.length;
171
+ for (let i = 0; i < len; i++) {
172
+ const v = records[i][localKey];
173
+ if (v !== null && v !== undefined) pkSet.add(v);
174
+ }
175
+
176
+ if (pkSet.size === 0) {
177
+ for (let i = 0; i < len; i++) {
178
+ records[i][relationName] = null;
183
179
  }
184
180
  return;
185
181
  }
186
182
 
187
- // Build query for related records
183
+ const primaryKeyValues = Array.from(pkSet);
188
184
  let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
189
-
190
- // Apply scopes to related model
191
185
  qb = applyScopes(qb, scopeContext, relatedModel);
192
-
193
186
  const relatedRecords = await qb;
194
187
 
195
- // Index related records by foreign key (first occurrence wins for hasOne)
196
188
  const relatedMap = new Map();
197
- for (const related of relatedRecords) {
198
- const fkValue = related[foreignKey];
189
+ const rLen = relatedRecords.length;
190
+ for (let i = 0; i < rLen; i++) {
191
+ const item = relatedRecords[i];
192
+ const fkValue = item[foreignKey];
199
193
  if (!relatedMap.has(fkValue)) {
200
- relatedMap.set(fkValue, related);
194
+ relatedMap.set(fkValue, item);
201
195
  }
202
196
  }
203
197
 
204
- // Attach related records to parent records
205
- for (const record of records) {
206
- const pkValue = record[localKey];
207
- record[relationName] = relatedMap.get(pkValue) || null;
198
+ for (let i = 0; i < len; i++) {
199
+ const rec = records[i];
200
+ const pkValue = rec[localKey];
201
+ rec[relationName] = (pkValue !== null && pkValue !== undefined ? relatedMap.get(pkValue) : undefined) || null;
208
202
  }
209
203
  }
210
204
 
@@ -15,6 +15,13 @@
15
15
  * @property {import('knex').Knex.Transaction|null} trx - Transaction if in one
16
16
  */
17
17
 
18
+ const EMPTY_ARRAY = Object.freeze([]);
19
+
20
+ function cancelContext(reason = 'Operation cancelled') {
21
+ this.isCancelled = true;
22
+ this.cancelReason = reason;
23
+ }
24
+
18
25
  /**
19
26
  * Create a cancellation context for before hooks
20
27
  * @param {string} model - Model name
@@ -23,18 +30,14 @@
23
30
  * @returns {EventContext}
24
31
  */
25
32
  function createEventContext(model, operation, trx = null) {
26
- const context = {
33
+ return {
27
34
  model,
28
35
  operation,
29
36
  trx,
30
37
  isCancelled: false,
31
38
  cancelReason: null,
32
- cancel(reason = 'Operation cancelled') {
33
- context.isCancelled = true;
34
- context.cancelReason = reason;
35
- },
39
+ cancel: cancelContext,
36
40
  };
37
- return context;
38
41
  }
39
42
 
40
43
  /**
@@ -114,6 +117,10 @@ class ModelEventsClass {
114
117
  * @returns {Function[]} Array of matching listeners
115
118
  */
116
119
  getMatchingListeners(model, hook) {
120
+ if (this.listeners.size === 0) {
121
+ return EMPTY_ARRAY;
122
+ }
123
+
117
124
  const listeners = [];
118
125
  const specificEvent = `${model}.${hook}`;
119
126
  const wildcardModel = `*.${hook}`;
@@ -121,26 +128,47 @@ class ModelEventsClass {
121
128
  const wildcardAll = '*.*';
122
129
 
123
130
  // Check specific event
124
- if (this.listeners.has(specificEvent)) {
125
- listeners.push(...this.listeners.get(specificEvent));
131
+ const sList = this.listeners.get(specificEvent);
132
+ if (sList) {
133
+ listeners.push(...sList);
126
134
  }
127
135
 
128
136
  // Check wildcard model (*.beforeCreate)
129
- if (this.listeners.has(wildcardModel)) {
130
- listeners.push(...this.listeners.get(wildcardModel));
137
+ const wmList = this.listeners.get(wildcardModel);
138
+ if (wmList) {
139
+ listeners.push(...wmList);
131
140
  }
132
141
 
133
142
  // Check wildcard hook (User.*)
134
- if (this.listeners.has(wildcardHook)) {
135
- listeners.push(...this.listeners.get(wildcardHook));
143
+ const whList = this.listeners.get(wildcardHook);
144
+ if (whList) {
145
+ listeners.push(...whList);
136
146
  }
137
147
 
138
148
  // Check full wildcard (*.*)
139
- if (this.listeners.has(wildcardAll)) {
140
- listeners.push(...this.listeners.get(wildcardAll));
149
+ const waList = this.listeners.get(wildcardAll);
150
+ if (waList) {
151
+ listeners.push(...waList);
141
152
  }
142
153
 
143
- return listeners;
154
+ return listeners.length > 0 ? listeners : EMPTY_ARRAY;
155
+ }
156
+
157
+ /**
158
+ * Fast boolean check whether any listener matches
159
+ * @param {string} model - Model name
160
+ * @param {string} hook - Hook name
161
+ * @returns {boolean}
162
+ */
163
+ hasListeners(model, hook) {
164
+ if (this.listeners.size === 0) return false;
165
+ const l = this.listeners;
166
+ return (
167
+ l.has(`${model}.${hook}`) ||
168
+ l.has(`*.${hook}`) ||
169
+ l.has(`${model}.*`) ||
170
+ l.has('*.*')
171
+ );
144
172
  }
145
173
 
146
174
  /**
@@ -152,6 +180,7 @@ class ModelEventsClass {
152
180
  */
153
181
  emit(model, hook, data, context) {
154
182
  const listeners = this.getMatchingListeners(model, hook);
183
+ if (listeners.length === 0) return;
155
184
  const ctx = context || createEventContext(model, hook);
156
185
 
157
186
  for (const listener of listeners) {
@@ -176,6 +205,7 @@ class ModelEventsClass {
176
205
  async emitAsync(model, hook, data, context) {
177
206
  const listeners = this.getMatchingListeners(model, hook);
178
207
  const ctx = context || createEventContext(model, hook);
208
+ if (listeners.length === 0) return ctx;
179
209
 
180
210
  for (const listener of listeners) {
181
211
  if (ctx.isCancelled) break;
@@ -28,6 +28,7 @@ const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('
28
28
  function createRepository(model, knex, initialContext, cacheLayer = null) {
29
29
  const scopeContext = initialContext || createScopeContext();
30
30
  const jsonColumns = getJsonColumns(model);
31
+ const hasJson = jsonColumns.size > 0;
31
32
 
32
33
  /**
33
34
  * Get base query builder
@@ -47,12 +48,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
47
48
  */
48
49
  async function findById(id, options = {}, emitEvents = true) {
49
50
  const { with: withs = [], select } = options;
50
- const ctx = createEventContext(model.name, 'find');
51
- const query = { [model.primaryKey]: id };
51
+ const shouldEmit = emitEvents && (
52
+ ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
53
+ ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
54
+ );
55
+ const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
52
56
 
53
57
  async function loadFromDb() {
54
- if (emitEvents) {
55
- await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, query, ctx);
58
+ if (shouldEmit) {
59
+ await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, { [model.primaryKey]: id }, ctx);
56
60
  if (ctx.isCancelled) {
57
61
  throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
58
62
  }
@@ -70,13 +74,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
70
74
  return null;
71
75
  }
72
76
 
73
- deserializeJsonFields(record, jsonColumns);
77
+ if (hasJson) {
78
+ deserializeJsonFields(record, jsonColumns);
79
+ }
74
80
 
75
81
  if (withs.length > 0) {
76
82
  await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
77
83
  }
78
84
 
79
- if (emitEvents) {
85
+ if (shouldEmit) {
80
86
  ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
81
87
  }
82
88
 
@@ -113,7 +119,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
113
119
  */
114
120
  async function findOne(conditions, options = {}) {
115
121
  const { with: withs = [], select } = options;
116
- const ctx = createEventContext(model.name, 'find');
122
+ const shouldEmit = (
123
+ ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
124
+ ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
125
+ );
126
+ const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
117
127
 
118
128
  const condKeys = Object.keys(conditions);
119
129
  const isPkOnly =
@@ -122,9 +132,11 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
122
132
  condKeys[0] === model.primaryKey;
123
133
 
124
134
  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);
135
+ if (shouldEmit) {
136
+ await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, conditions, ctx);
137
+ if (ctx.isCancelled) {
138
+ throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
139
+ }
128
140
  }
129
141
 
130
142
  let qb = baseQuery();
@@ -143,13 +155,17 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
143
155
  return null;
144
156
  }
145
157
 
146
- deserializeJsonFields(record, jsonColumns);
158
+ if (hasJson) {
159
+ deserializeJsonFields(record, jsonColumns);
160
+ }
147
161
 
148
162
  if (withs.length > 0) {
149
163
  await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
150
164
  }
151
165
 
152
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
166
+ if (shouldEmit) {
167
+ ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
168
+ }
153
169
 
154
170
  return record;
155
171
  }
@@ -183,12 +199,18 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
183
199
  */
184
200
  async function findAll(options = {}) {
185
201
  const { with: withs = [], select } = options;
186
- const ctx = createEventContext(model.name, 'find');
202
+ const shouldEmit = (
203
+ ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
204
+ ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
205
+ );
206
+ const ctx = shouldEmit ? createEventContext(model.name, 'find') : null;
187
207
 
188
208
  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);
209
+ if (shouldEmit) {
210
+ await ModelEvents.emitAsync(model.name, Hooks.BEFORE_FIND, {}, ctx);
211
+ if (ctx.isCancelled) {
212
+ throw new HookCancellationError(ctx.cancelReason, model.name, Hooks.BEFORE_FIND);
213
+ }
192
214
  }
193
215
 
194
216
  let qb = baseQuery();
@@ -199,16 +221,22 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
199
221
 
200
222
  const records = await qb;
201
223
 
202
- for (const record of records) {
203
- deserializeJsonFields(record, jsonColumns);
224
+ if (hasJson) {
225
+ const len = records.length;
226
+ for (let i = 0; i < len; i++) {
227
+ deserializeJsonFields(records[i], jsonColumns);
228
+ }
204
229
  }
205
230
 
206
231
  if (withs.length > 0 && records.length > 0) {
207
232
  await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
208
233
  }
209
234
 
210
- for (const record of records) {
211
- ModelEvents.emit(model.name, Hooks.AFTER_FIND, record, ctx);
235
+ if (shouldEmit) {
236
+ const len = records.length;
237
+ for (let i = 0; i < len; i++) {
238
+ ModelEvents.emit(model.name, Hooks.AFTER_FIND, records[i], ctx);
239
+ }
212
240
  }
213
241
 
214
242
  return records;
@@ -261,7 +289,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
261
289
  let insertData = applyInsertModifiers(validated, scopeContext, model);
262
290
 
263
291
  // Serialize JSON fields
264
- insertData = serializeJsonFields(insertData, jsonColumns);
292
+ if (hasJson) {
293
+ insertData = serializeJsonFields(insertData, jsonColumns);
294
+ }
265
295
 
266
296
  // Insert and return the record
267
297
  const [id] = await knex(model.table).insert(insertData).returning(model.primaryKey);
@@ -341,7 +371,9 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
341
371
  delete updateData[model.primaryKey];
342
372
 
343
373
  // Serialize JSON fields
344
- updateData = serializeJsonFields(updateData, jsonColumns);
374
+ if (hasJson) {
375
+ updateData = serializeJsonFields(updateData, jsonColumns);
376
+ }
345
377
 
346
378
  // Update the record
347
379
  const updated = await baseQuery()
@@ -5,6 +5,9 @@
5
5
  */
6
6
 
7
7
  const METADATA_MARKER = '__wdb__';
8
+ const metaWeakMap = new WeakMap();
9
+ const metaStringCache = new Map();
10
+ const schemaColumnsWeakMap = new WeakMap();
8
11
 
9
12
  /**
10
13
  * Encode column metadata into a JSON string for Zod .describe()
@@ -22,14 +25,25 @@ function encodeColumnMeta(meta) {
22
25
  */
23
26
  function decodeColumnMeta(description) {
24
27
  if (!description) return null;
28
+ const cached = metaStringCache.get(description);
29
+ if (cached !== undefined) return cached;
30
+
25
31
  try {
26
32
  const parsed = JSON.parse(description);
27
33
  if (parsed && parsed[METADATA_MARKER]) {
28
- return parsed.meta;
34
+ const meta = parsed.meta;
35
+ if (metaStringCache.size < 500) {
36
+ metaStringCache.set(description, meta);
37
+ }
38
+ return meta;
29
39
  }
30
40
  } catch {
31
41
  // Not our metadata, ignore
32
42
  }
43
+
44
+ if (metaStringCache.size < 500) {
45
+ metaStringCache.set(description, null);
46
+ }
33
47
  return null;
34
48
  }
35
49
 
@@ -39,7 +53,7 @@ function decodeColumnMeta(description) {
39
53
  * @returns {boolean}
40
54
  */
41
55
  function hasColumnMeta(schema) {
42
- return decodeColumnMeta(schema.description) !== null;
56
+ return getColumnMeta(schema) !== null;
43
57
  }
44
58
 
45
59
  /**
@@ -48,6 +62,10 @@ function hasColumnMeta(schema) {
48
62
  * @returns {import('./types').ColumnMeta|null}
49
63
  */
50
64
  function getColumnMeta(schema) {
65
+ if (!schema) return null;
66
+ const direct = metaWeakMap.get(schema);
67
+ if (direct !== undefined) return direct;
68
+ if (schema._wdbMeta) return schema._wdbMeta;
51
69
  return decodeColumnMeta(schema.description);
52
70
  }
53
71
 
@@ -253,6 +271,7 @@ class SchemaBuilder {
253
271
 
254
272
  // Apply metadata to schema
255
273
  this._schema = this._schema.describe(encodeColumnMeta(finalMeta));
274
+ metaWeakMap.set(this._schema, finalMeta);
256
275
  this._finalized = true;
257
276
  return this._schema;
258
277
  }
@@ -770,6 +789,15 @@ function createSchemaHelpers(z) {
770
789
  * @returns {Map<string, import('./types').ColumnMeta>}
771
790
  */
772
791
  function extractColumnsFromSchema(schema) {
792
+ if (!schema || typeof schema !== 'object' || !schema.shape) {
793
+ return new Map();
794
+ }
795
+
796
+ const cached = schemaColumnsWeakMap.get(schema);
797
+ if (cached !== undefined) {
798
+ return cached;
799
+ }
800
+
773
801
  const columns = new Map();
774
802
  const shape = schema.shape;
775
803
 
@@ -806,6 +834,7 @@ function extractColumnsFromSchema(schema) {
806
834
  }
807
835
  }
808
836
 
837
+ schemaColumnsWeakMap.set(schema, columns);
809
838
  return columns;
810
839
  }
811
840
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.87",
3
+ "version": "0.0.89",
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",
@@ -11,6 +11,10 @@ const nunjucks = require('nunjucks');
11
11
  // Storage to pass the active request to Nunjucks template helpers in a thread-safe manner
12
12
  const csrfStorage = new AsyncLocalStorage();
13
13
 
14
+ const NOOP = () => {};
15
+ const EMPTY_OBJECT = Object.freeze({});
16
+ const ignoreRegexCache = new Map();
17
+
14
18
  /**
15
19
  * Compare two strings in constant time to prevent timing attacks
16
20
  * @param {string} a
@@ -43,11 +47,18 @@ function isIgnored(req, ignorePaths) {
43
47
  if (path.startsWith(pattern)) return true;
44
48
  // Basic glob matching
45
49
  if (pattern.includes('*')) {
46
- const regexPattern = pattern
47
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
48
- .replace(/\*\*/g, '.*')
49
- .replace(/\*/g, '[^/]*');
50
- if (new RegExp(`^${regexPattern}$`).test(path)) return true;
50
+ let regex = ignoreRegexCache.get(pattern);
51
+ if (regex === undefined) {
52
+ const regexPattern = pattern
53
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
54
+ .replace(/\*\*/g, '.*')
55
+ .replace(/\*/g, '[^/]*');
56
+ regex = new RegExp(`^${regexPattern}$`);
57
+ if (ignoreRegexCache.size < 500) {
58
+ ignoreRegexCache.set(pattern, regex);
59
+ }
60
+ }
61
+ if (regex.test(path)) return true;
51
62
  }
52
63
  } else if (pattern instanceof RegExp) {
53
64
  if (pattern.test(path)) return true;
@@ -126,11 +137,14 @@ function csrfPlugin(options = {}) {
126
137
  cookieOptions.maxAge = cookieOpts.maxAge;
127
138
  }
128
139
 
140
+ const cookieSecret = cookieOptions.signed ? (cookieOpts.secret || process.env.SESSION_SECRET) : undefined;
141
+ const parseCookies = cookieParser(cookieSecret);
142
+
129
143
  /**
130
144
  * Internal middleware creator
131
145
  * @param {Object} [overrideOpts]
132
146
  */
133
- function createMiddleware(overrideOpts = {}) {
147
+ function createMiddleware(overrideOpts = EMPTY_OBJECT) {
134
148
  const activeMethods = overrideOpts.methods || methods;
135
149
  const activeIgnorePaths = overrideOpts.ignorePaths || ignorePaths;
136
150
 
@@ -140,8 +154,7 @@ function csrfPlugin(options = {}) {
140
154
 
141
155
  // Initialize cookie parser dynamically if needed
142
156
  if (useCookie && !req.cookies) {
143
- const secret = cookieOptions.signed ? (cookieOpts.secret || process.env.SESSION_SECRET) : undefined;
144
- cookieParser(secret)(req, res, () => {});
157
+ parseCookies(req, res, NOOP);
145
158
  }
146
159
 
147
160
  // 1. Retrieve or generate secret token
@@ -173,8 +186,8 @@ function csrfPlugin(options = {}) {
173
186
  // 2. Perform CSRF verification for mutating HTTP methods
174
187
  const method = req.method ? req.method.toUpperCase() : 'GET';
175
188
  if (activeMethods.includes(method) && !isIgnored(req, activeIgnorePaths)) {
176
- const body = req.body && typeof req.body === 'object' ? req.body : {};
177
- const query = req.query && typeof req.query === 'object' ? req.query : {};
189
+ const body = req.body && typeof req.body === 'object' ? req.body : EMPTY_OBJECT;
190
+ const query = req.query && typeof req.query === 'object' ? req.query : EMPTY_OBJECT;
178
191
  const submitted = (
179
192
  body._csrf ||
180
193
  query._csrf ||
@@ -82,6 +82,8 @@ function createTrackingMiddleware(options) {
82
82
  // Sessions tracked in-memory (visitor_id -> last activity timestamp)
83
83
  const sessionMap = new Map();
84
84
  const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
85
+ const MAX_SESSIONS = 10000;
86
+ const MAX_QUEUE_SIZE = 5000;
85
87
 
86
88
  // Periodic session cleanup
87
89
  setInterval(() => {
@@ -102,6 +104,11 @@ function createTrackingMiddleware(options) {
102
104
  return existing.sessionId;
103
105
  }
104
106
 
107
+ if (sessionMap.size >= MAX_SESSIONS) {
108
+ const firstKey = sessionMap.keys().next().value;
109
+ if (firstKey !== undefined) sessionMap.delete(firstKey);
110
+ }
111
+
105
112
  const sessionId = quickHash(visitorId + '-' + now + '-' + Math.random());
106
113
  sessionMap.set(visitorId, { sessionId, lastSeen: now });
107
114
  return sessionId;
@@ -119,8 +126,15 @@ function createTrackingMiddleware(options) {
119
126
  await knex(tableName).insert(batch);
120
127
  } catch (e) {
121
128
  console.error('[site-analytics] Batch insert failed:', e.message);
122
- // Re-queue failed items (optional - could drop to avoid loops)
123
- queue.unshift(...batch);
129
+ // Re-queue failed items with max size limit to prevent memory leak
130
+ if (queue.length + batch.length <= MAX_QUEUE_SIZE) {
131
+ queue.unshift(...batch);
132
+ } else {
133
+ const available = Math.max(0, MAX_QUEUE_SIZE - queue.length);
134
+ if (available > 0) {
135
+ queue.unshift(...batch.slice(batch.length - available));
136
+ }
137
+ }
124
138
  }
125
139
  }
126
140
 
@@ -16,9 +16,16 @@ const {
16
16
  clearNjkFrontmatterCaches,
17
17
  } = require('./njk-frontmatter');
18
18
 
19
+ const NOOP = () => {};
20
+ const EMPTY_OBJECT = Object.freeze({});
21
+ const EMPTY_ARRAY = Object.freeze([]);
22
+
19
23
  // Cache for i18n files (key: filePath, value: { mtime, data })
20
24
  const i18nCache = new Map();
21
25
 
26
+ // Cache for merged i18n (key: `${pagesDir}::${routeDir}::${locale}`, value: { globalMtime, routeMtime, data })
27
+ const mergedI18nCache = new Map();
28
+
22
29
  // Cache for route configs in production
23
30
  const configCache = new Map();
24
31
 
@@ -28,14 +35,25 @@ const routeConfigDevCache = new Map();
28
35
  // Cache for API filename -> { method, baseName } (basename keys; stable per process)
29
36
  const methodFromFilenameCache = new Map();
30
37
 
38
+ // Cache for translation param replacement regexes
39
+ const paramRegexCache = new Map();
40
+
31
41
  const MAX_LOCALE_LEN = 16;
32
42
 
43
+ let cachedSupportedLocalesRaw = null;
44
+ let cachedSupportedLocaleSet = null;
45
+
33
46
  /** @returns {Set<string>} */
34
47
  function parseSupportedLocaleSet() {
35
48
  const raw = process.env.SUPPORTED_LOCALES || 'en';
36
- return new Set(
49
+ if (raw === cachedSupportedLocalesRaw && cachedSupportedLocaleSet !== null) {
50
+ return cachedSupportedLocaleSet;
51
+ }
52
+ cachedSupportedLocalesRaw = raw;
53
+ cachedSupportedLocaleSet = new Set(
37
54
  raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
38
55
  );
56
+ return cachedSupportedLocaleSet;
39
57
  }
40
58
 
41
59
  /**
@@ -352,7 +370,7 @@ function scanDirectory(dir, baseDir = dir) {
352
370
  */
353
371
  function loadI18nFile(filePath) {
354
372
  if (!fs.existsSync(filePath)) {
355
- return {};
373
+ return EMPTY_OBJECT;
356
374
  }
357
375
 
358
376
  try {
@@ -370,7 +388,7 @@ function loadI18nFile(filePath) {
370
388
  return data;
371
389
  } catch (err) {
372
390
  console.error('Error loading i18n file:', filePath, err.message);
373
- return {};
391
+ return EMPTY_OBJECT;
374
392
  }
375
393
  }
376
394
 
@@ -390,8 +408,28 @@ function loadI18n(pagesDir, routeDir, locale) {
390
408
  const routePath = path.join(routeDir, 'locales', `${locale}.json`);
391
409
  const routeTranslations = loadI18nFile(routePath);
392
410
 
411
+ const gEmpty = globalTranslations === EMPTY_OBJECT || !globalTranslations || Object.keys(globalTranslations).length === 0;
412
+ const rEmpty = routeTranslations === EMPTY_OBJECT || !routeTranslations || Object.keys(routeTranslations).length === 0;
413
+
414
+ if (gEmpty && rEmpty) return EMPTY_OBJECT;
415
+ if (gEmpty) return routeTranslations;
416
+ if (rEmpty) return globalTranslations;
417
+
418
+ const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
419
+ const gCached = i18nCache.get(globalPath);
420
+ const rCached = i18nCache.get(routePath);
421
+ const gm = gCached?.mtime ?? 0;
422
+ const rm = rCached?.mtime ?? 0;
423
+
424
+ const mergedCached = mergedI18nCache.get(cacheKey);
425
+ if (mergedCached && mergedCached.gm === gm && mergedCached.rm === rm) {
426
+ return mergedCached.data;
427
+ }
428
+
393
429
  // Merge: route-specific overrides global
394
- return { ...globalTranslations, ...routeTranslations };
430
+ const data = { ...globalTranslations, ...routeTranslations };
431
+ mergedI18nCache.set(cacheKey, { gm, rm, data });
432
+ return data;
395
433
  }
396
434
 
397
435
  /**
@@ -400,7 +438,7 @@ function loadI18n(pagesDir, routeDir, locale) {
400
438
  * @returns {Function} Translation function t(key)
401
439
  */
402
440
  function createTranslator(translations) {
403
- return function t(key, params = {}) {
441
+ return function t(key, params = EMPTY_OBJECT) {
404
442
  let value = translations[key];
405
443
 
406
444
  if (value === undefined) {
@@ -422,10 +460,17 @@ function createTranslator(translations) {
422
460
  }
423
461
 
424
462
  // Replace params like {{name}} in the translation
425
- if (typeof value === 'string' && Object.keys(params).length > 0) {
463
+ if (typeof value === 'string' && params && params !== EMPTY_OBJECT && Object.keys(params).length > 0) {
426
464
  for (const [paramKey, paramValue] of Object.entries(params)) {
427
- const escaped = escapeRegExp(paramKey);
428
- value = value.replace(new RegExp(`{{\\s*${escaped}\\s*}}`, 'g'), paramValue);
465
+ let regex = paramRegexCache.get(paramKey);
466
+ if (regex === undefined) {
467
+ const escaped = escapeRegExp(paramKey);
468
+ regex = new RegExp(`{{\\s*${escaped}\\s*}}`, 'g');
469
+ if (paramRegexCache.size < 500) {
470
+ paramRegexCache.set(paramKey, regex);
471
+ }
472
+ }
473
+ value = value.replace(regex, paramValue);
429
474
  }
430
475
  }
431
476
 
@@ -585,9 +630,9 @@ function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middleware
585
630
  * @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
586
631
  * @returns {Array} Array of resolved middleware functions
587
632
  */
588
- function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
589
- if (!middlewareConfig || !Array.isArray(middlewareConfig)) {
590
- return [];
633
+ function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
634
+ if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
635
+ return EMPTY_ARRAY;
591
636
  }
592
637
 
593
638
  return middlewareConfig.map((mw, index) => {
@@ -640,7 +685,7 @@ function mountPages(app, options) {
640
685
  ? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
641
686
  : { alpine: false, swup: false };
642
687
  const isDev = process.env.NODE_ENV !== 'production';
643
- const log = silent ? () => {} : console.log.bind(console);
688
+ const log = silent ? NOOP : console.log.bind(console);
644
689
 
645
690
  // Get absolute path to pages directory
646
691
  const absolutePagesDir = path.resolve(pagesDir);
@@ -783,7 +828,7 @@ function mountPages(app, options) {
783
828
 
784
829
  const preResolvedMw = routeMiddleware
785
830
  ? resolveMiddlewares(routeMiddleware, middlewares)
786
- : [];
831
+ : EMPTY_ARRAY;
787
832
 
788
833
  if (typeof handlerFn !== 'function') {
789
834
  console.warn(`API route ${route.file} does not export a function`);
@@ -867,7 +912,7 @@ function mountPages(app, options) {
867
912
  const mountConfig = loadRouteConfig(route.configPath, isDev);
868
913
  const preResolvedPageMw = mountConfig?.middleware
869
914
  ? resolveMiddlewares(mountConfig.middleware, middlewares)
870
- : [];
915
+ : EMPTY_ARRAY;
871
916
 
872
917
  app.get(route.routePath, async (req, res, next) => {
873
918
  try {
@@ -880,11 +925,11 @@ function mountPages(app, options) {
880
925
 
881
926
  // Load route config
882
927
  const config = loadRouteConfig(route.configPath, isDev);
883
- const routeHooks = config?.hooks || {};
928
+ const routeHooks = config?.hooks || EMPTY_OBJECT;
884
929
 
885
930
  // Create context with plugin helpers merged
886
931
  const baseHelpers = createHelpers({ req, res, locale });
887
- const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
932
+ const pluginHelpers = pluginManager ? pluginManager.getHelpers() : EMPTY_OBJECT;
888
933
  if (pluginManager) {
889
934
  const contentApi = pluginManager.getPluginAPI('content');
890
935
  if (contentApi?.createRequestHelpers) {
package/src/helpers.js CHANGED
@@ -18,6 +18,9 @@ dayjs.extend(utc);
18
18
  dayjs.extend(timezone);
19
19
  dayjs.extend(customParseFormat);
20
20
 
21
+ // Cache for isPath regex patterns
22
+ const isPathRegexCache = new Map();
23
+
21
24
  /**
22
25
  * Asset Manager - handles asset paths, versioning, and manifest
23
26
  */
@@ -385,7 +388,13 @@ function createHelpers(ctx) {
385
388
  const currentPath = req.path;
386
389
  if (pattern === currentPath) return true;
387
390
  if (pattern.includes('*')) {
388
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
391
+ let regex = isPathRegexCache.get(pattern);
392
+ if (regex === undefined) {
393
+ regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
394
+ if (isPathRegexCache.size < 500) {
395
+ isPathRegexCache.set(pattern, regex);
396
+ }
397
+ }
389
398
  return regex.test(currentPath);
390
399
  }
391
400
  return false;