odac 1.4.7 → 1.4.9

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/client/odac.js +1 -1
  3. package/docs/ai/README.md +3 -2
  4. package/docs/ai/skills/SKILL.md +3 -2
  5. package/docs/ai/skills/backend/authentication.md +12 -6
  6. package/docs/ai/skills/backend/database.md +183 -12
  7. package/docs/ai/skills/backend/ipc.md +71 -12
  8. package/docs/ai/skills/backend/migrations.md +23 -0
  9. package/docs/ai/skills/backend/odac-var.md +155 -0
  10. package/docs/ai/skills/backend/utilities.md +1 -1
  11. package/docs/ai/skills/frontend/forms.md +23 -1
  12. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  13. package/docs/backend/04-routing/09-websocket.md +22 -1
  14. package/docs/backend/08-database/05-write-behind-cache.md +230 -0
  15. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  16. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  17. package/docs/backend/10-authentication/05-session-management.md +12 -3
  18. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  19. package/docs/backend/13-utilities/02-ipc.md +117 -0
  20. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  21. package/docs/index.json +1 -1
  22. package/package.json +1 -1
  23. package/src/Auth.js +17 -0
  24. package/src/Database/Migration.js +219 -3
  25. package/src/Database/ReadCache.js +174 -0
  26. package/src/Database/WriteBuffer.js +605 -0
  27. package/src/Database.js +95 -1
  28. package/src/Ipc.js +343 -81
  29. package/src/Odac.js +2 -1
  30. package/src/Storage.js +4 -2
  31. package/src/Validator.js +1 -1
  32. package/src/Var.js +1 -0
  33. package/src/WebSocket.js +80 -23
  34. package/test/Database/Migration/migrate_column.test.js +168 -0
  35. package/test/Database/ReadCache/crossTable.test.js +179 -0
  36. package/test/Database/ReadCache/get.test.js +128 -0
  37. package/test/Database/ReadCache/invalidate.test.js +103 -0
  38. package/test/Database/ReadCache/proxy.test.js +184 -0
  39. package/test/Database/WriteBuffer/_recoverFromCheckpoint.test.js +207 -0
  40. package/test/Database/WriteBuffer/buffer.test.js +143 -0
  41. package/test/Database/WriteBuffer/flush.test.js +192 -0
  42. package/test/Database/WriteBuffer/get.test.js +72 -0
  43. package/test/Database/WriteBuffer/increment.test.js +118 -0
  44. package/test/Database/WriteBuffer/update.test.js +178 -0
  45. package/test/Database/insert.test.js +98 -0
  46. package/test/Ipc/hset.test.js +59 -0
  47. package/test/Ipc/incrBy.test.js +65 -0
  48. package/test/Ipc/lock.test.js +62 -0
  49. package/test/Ipc/rpush.test.js +68 -0
  50. package/test/Ipc/sadd.test.js +68 -0
  51. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  52. package/test/WebSocket/Client/limits.test.js +10 -4
  53. package/test/WebSocket/Client/readyState.test.js +154 -0
  54. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
package/src/Database.js CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict'
2
2
  const {buildConnections} = require('./Database/ConnectionFactory')
3
3
  const nanoid = require('./Database/nanoid')
4
+ const readCache = require('./Database/ReadCache')
5
+ const writeBuffer = require('./Database/WriteBuffer')
4
6
 
5
7
  class DatabaseManager {
6
8
  constructor() {
@@ -31,6 +33,12 @@ class DatabaseManager {
31
33
  // Cache nanoid column metadata from schema files for insert-time auto-generation.
32
34
  // Runs on ALL processes (primary + workers) since every process may insert data.
33
35
  this._loadNanoidMeta()
36
+
37
+ // Initialize Read-Through Cache (all processes — every worker may read cached data)
38
+ readCache.init()
39
+
40
+ // Initialize Write-Behind Cache (Primary holds state, Workers communicate via IPC)
41
+ await writeBuffer.init(this.connections)
34
42
  }
35
43
 
36
44
  /**
@@ -63,9 +71,17 @@ class DatabaseManager {
63
71
 
64
72
  /**
65
73
  * Gracefully destroys all active database connections.
74
+ * Flushes WriteBuffer before closing to prevent data loss.
66
75
  * Called during shutdown to release connection pools and prevent resource leaks.
67
76
  */
68
77
  async close() {
78
+ // Flush buffered writes before destroying connections
79
+ try {
80
+ await writeBuffer.close()
81
+ } catch (err) {
82
+ console.error('\x1b[31m[Database]\x1b[0m WriteBuffer close error:', err.message)
83
+ }
84
+
69
85
  const entries = Object.entries(this.connections)
70
86
  if (entries.length === 0) return
71
87
 
@@ -174,6 +190,67 @@ const tableProxyHandler = {
174
190
 
175
191
  // Create the Query Builder
176
192
  const qb = knexInstance(prop)
193
+ const connectionKey = knexInstance._odacConnectionKey || 'default'
194
+
195
+ // Write-Behind Cache: Odac.DB.posts.buffer.where(id).update({...}) / .increment('col') / .get('col')
196
+ // Odac.DB.posts.buffer.insert(row) / .flush()
197
+ qb.buffer = {
198
+ where: where => ({
199
+ update: data => writeBuffer.update(connectionKey, prop, where, data),
200
+ increment: (column, delta = 1) => writeBuffer.increment(connectionKey, prop, where, column, delta),
201
+ get: column => writeBuffer.get(connectionKey, prop, where, column)
202
+ }),
203
+ insert: row => writeBuffer.insert(connectionKey, prop, row),
204
+ flush: () => writeBuffer.flush(connectionKey, prop)
205
+ }
206
+
207
+ // Read-Through Cache: Odac.DB.posts.cache(60).where({active: true}).select('id', 'title')
208
+ // Odac.DB.posts.cache().where({id: 5}).first()
209
+ // Odac.DB.posts.cache.clear()
210
+ // Why: Returns a Proxy that intercepts .then() to inject cache lookup before DB execution.
211
+ // The ttl parameter on cache() sets per-query TTL; omit for config default.
212
+ const cacheFactory = ttl => {
213
+ const cachedQb = knexInstance(prop)
214
+ cachedQb._odacCacheTtl = ttl || 0
215
+
216
+ // Capture the ORIGINAL .then() before overriding — prevents infinite recursion
217
+ // when ReadCache.get() needs to execute the actual DB query on cache MISS.
218
+ const originalCacheThen = cachedQb.then.bind(cachedQb)
219
+ cachedQb.then = function (resolve, reject) {
220
+ const executeFn = () => new Promise((res, rej) => originalCacheThen(res, rej))
221
+ return readCache.get(connectionKey, prop, this, executeFn, this._odacCacheTtl).then(resolve, reject)
222
+ }
223
+
224
+ return cachedQb
225
+ }
226
+
227
+ qb.cache = Object.assign(cacheFactory, {
228
+ clear: () => readCache.clear(connectionKey, prop)
229
+ })
230
+
231
+ // Automatic cache invalidation on write operations (insert/update/delete/truncate).
232
+ // Why: Prevents stale reads — any mutation on a table purges all cached SELECT results.
233
+ // Write methods are terminal (no further chaining after await), so wrapping the return
234
+ // as a simple thenable is safe and avoids .then() override conflicts with count/other wraps.
235
+ const wrapWithInvalidation = original =>
236
+ function (...args) {
237
+ const qbResult = original.apply(this, args)
238
+ const thenable = {
239
+ then: (resolve, reject) =>
240
+ qbResult.then(res => readCache.invalidate(connectionKey, prop).then(() => res), reject).then(resolve, reject),
241
+ catch: fn => thenable.then(undefined, fn)
242
+ }
243
+ return thenable
244
+ }
245
+
246
+ const originalUpdate = qb.update
247
+ const originalDelete = qb.delete
248
+ const originalDel = qb.del
249
+ const originalTruncate = qb.truncate
250
+ qb.update = wrapWithInvalidation(originalUpdate)
251
+ qb.delete = wrapWithInvalidation(originalDelete)
252
+ qb.del = wrapWithInvalidation(originalDel)
253
+ qb.truncate = wrapWithInvalidation(originalTruncate)
177
254
 
178
255
  // Odac DX Improvement: Wrap count() to return a clean number
179
256
  const originalCount = qb.count
@@ -184,7 +261,6 @@ const tableProxyHandler = {
184
261
 
185
262
  // Odac DX Improvement: Auto-generate NanoID for columns defined as type 'nanoid' in schema.
186
263
  // Why: Zero-config ID generation — no manual Odac.DB.nanoid() calls needed.
187
- const connectionKey = knexInstance._odacConnectionKey || 'default'
188
264
  const nanoidCols = manager._nanoidColumns[connectionKey]?.[prop]
189
265
  if (nanoidCols) {
190
266
  const originalInsert = qb.insert
@@ -204,6 +280,10 @@ const tableProxyHandler = {
204
280
  }
205
281
  }
206
282
 
283
+ // Cache invalidation for insert — applied AFTER nanoid wrap so both paths are covered.
284
+ const currentInsert = qb.insert
285
+ qb.insert = wrapWithInvalidation(currentInsert)
286
+
207
287
  const originalThen = qb.then
208
288
  qb.then = function (resolve, reject) {
209
289
  if (this._odacIsCount) {
@@ -257,6 +337,20 @@ const rootProxy = new Proxy(manager, {
257
337
  if (prop === '_nanoidColumns') return target._nanoidColumns
258
338
  if (prop === '_loadNanoidMeta') return target._loadNanoidMeta.bind(target)
259
339
 
340
+ // Global WriteBuffer: Odac.DB.buffer.flush()
341
+ if (prop === 'buffer') {
342
+ return {
343
+ flush: (connection, table) => writeBuffer.flush(connection, table)
344
+ }
345
+ }
346
+
347
+ // Global ReadCache: Odac.DB.cache.clear(connection, table)
348
+ if (prop === 'cache') {
349
+ return {
350
+ clear: (connection, table) => readCache.clear(connection, table)
351
+ }
352
+ }
353
+
260
354
  // Access to specific database connection: Odac.DB.analytics
261
355
  if (target.connections[prop]) {
262
356
  return new Proxy(target.connections[prop], tableProxyHandler)
package/src/Ipc.js CHANGED
@@ -64,6 +64,197 @@ class Ipc extends EventEmitter {
64
64
  }
65
65
  }
66
66
 
67
+ // --- Atomic Counter Operations ---
68
+
69
+ /**
70
+ * Why: Atomically increment a numeric value. Essential for Write-Behind Cache counters
71
+ * where concurrent workers must not cause lost updates (get→modify→set race).
72
+ * Redis: INCRBYFLOAT. Memory: single-threaded Primary guarantees atomicity.
73
+ *
74
+ * @param {string} key
75
+ * @param {number} delta - Amount to add (can be negative)
76
+ * @returns {Promise<number>} New value after increment
77
+ */
78
+ async incrBy(key, delta) {
79
+ if (this.config.driver === 'redis') {
80
+ return Number(await this.redis.incrByFloat(key, delta))
81
+ }
82
+ return this._sendMemory('incrBy', {key, delta})
83
+ }
84
+
85
+ /**
86
+ * Why: Convenience wrapper. Flush logic needs to subtract flushed deltas atomically.
87
+ */
88
+ async decrBy(key, delta) {
89
+ return this.incrBy(key, -delta)
90
+ }
91
+
92
+ // --- Hash Operations ---
93
+
94
+ /**
95
+ * Why: Write-Behind Cache update coalescing stores pending column updates as hash fields.
96
+ * Merge semantics: existing fields are overwritten, new fields are added (last-write-wins).
97
+ * Redis: HSET key f1 v1 f2 v2. Memory: Object.assign into stored object.
98
+ *
99
+ * @param {string} key
100
+ * @param {object} obj - Field-value pairs to merge
101
+ * @returns {Promise<boolean>}
102
+ */
103
+ async hset(key, obj) {
104
+ if (this.config.driver === 'redis') {
105
+ const args = {}
106
+ for (const [field, value] of Object.entries(obj)) {
107
+ args[field] = JSON.stringify(value)
108
+ }
109
+ await this.redis.hSet(key, args)
110
+ return true
111
+ }
112
+ return this._sendMemory('hset', {key, obj})
113
+ }
114
+
115
+ /**
116
+ * Why: Flush reads all pending update fields for a row in one call.
117
+ *
118
+ * @param {string} key
119
+ * @returns {Promise<object|null>} All field-value pairs, or null if key doesn't exist
120
+ */
121
+ async hgetall(key) {
122
+ if (this.config.driver === 'redis') {
123
+ const raw = await this.redis.hGetAll(key)
124
+ if (!raw || Object.keys(raw).length === 0) return null
125
+ const result = {}
126
+ for (const [field, value] of Object.entries(raw)) {
127
+ result[field] = JSON.parse(value)
128
+ }
129
+ return result
130
+ }
131
+ return this._sendMemory('hgetall', {key})
132
+ }
133
+
134
+ // --- List Operations ---
135
+
136
+ /**
137
+ * Why: Write-Behind Cache batch insert queue. Workers push rows to a shared list;
138
+ * flush drains it to the database in a single INSERT.
139
+ * Redis: RPUSH. Memory: Array.push on Primary.
140
+ *
141
+ * @param {string} key
142
+ * @param {...*} items - Items to append
143
+ * @returns {Promise<number>} New list length
144
+ */
145
+ async rpush(key, ...items) {
146
+ if (this.config.driver === 'redis') {
147
+ const serialized = items.map(i => JSON.stringify(i))
148
+ return this.redis.rPush(key, serialized)
149
+ }
150
+ return this._sendMemory('rpush', {key, items})
151
+ }
152
+
153
+ /**
154
+ * Why: Flush reads queued rows before writing them to the database.
155
+ *
156
+ * @param {string} key
157
+ * @param {number} start - Start index (0-based, inclusive)
158
+ * @param {number} stop - End index (inclusive, -1 for last element)
159
+ * @returns {Promise<Array>} Elements in range
160
+ */
161
+ async lrange(key, start, stop) {
162
+ if (this.config.driver === 'redis') {
163
+ const raw = await this.redis.lRange(key, start, stop)
164
+ return raw.map(i => JSON.parse(i))
165
+ }
166
+ return this._sendMemory('lrange', {key, start, stop})
167
+ }
168
+
169
+ /**
170
+ * Why: Atomic read-and-clear for queue flush. Prevents data loss caused by
171
+ * non-atomic lrange() + del() where new rpush() arrivals between the two
172
+ * calls would be silently deleted. Redis: MULTI/EXEC pipeline.
173
+ * Memory: single-threaded Primary guarantees atomicity.
174
+ *
175
+ * @param {string} key
176
+ * @returns {Promise<Array>} All elements that were in the list
177
+ */
178
+ async lrangeAndDel(key) {
179
+ if (this.config.driver === 'redis') {
180
+ const results = await this.redis.multi().lRange(key, 0, -1).del(key).exec()
181
+ const raw = results[0]
182
+ if (!raw || !Array.isArray(raw)) return []
183
+ return raw.map(i => JSON.parse(i))
184
+ }
185
+ return this._sendMemory('lrangeAndDel', {key})
186
+ }
187
+
188
+ // --- Set Operations ---
189
+
190
+ /**
191
+ * Why: WriteBuffer maintains index sets (e.g., 'wb:idx:counters') to track which keys
192
+ * have pending data. Avoids expensive SCAN/KEYS pattern matching on flush.
193
+ *
194
+ * @param {string} key
195
+ * @param {...string} members
196
+ * @returns {Promise<number>} Number of members added
197
+ */
198
+ async sadd(key, ...members) {
199
+ if (this.config.driver === 'redis') {
200
+ return this.redis.sAdd(key, members)
201
+ }
202
+ return this._sendMemory('sadd', {key, members})
203
+ }
204
+
205
+ /**
206
+ * Why: Flush iterates all tracked keys in an index set to drain pending data.
207
+ *
208
+ * @param {string} key
209
+ * @returns {Promise<Array<string>>} All members
210
+ */
211
+ async smembers(key) {
212
+ if (this.config.driver === 'redis') {
213
+ return this.redis.sMembers(key)
214
+ }
215
+ return this._sendMemory('smembers', {key})
216
+ }
217
+
218
+ /**
219
+ * Why: After flushing a counter/update/queue key, remove it from the tracking index.
220
+ *
221
+ * @param {string} key
222
+ * @param {...string} members
223
+ * @returns {Promise<number>} Number of members removed
224
+ */
225
+ async srem(key, ...members) {
226
+ if (this.config.driver === 'redis') {
227
+ return this.redis.sRem(key, members)
228
+ }
229
+ return this._sendMemory('srem', {key, members})
230
+ }
231
+
232
+ // --- Distributed Lock ---
233
+
234
+ /**
235
+ * Why: Horizontal scaling requires exactly ONE server to run flush at a time.
236
+ * Redis: SET NX EX (atomic test-and-set with TTL). Memory: Primary-local boolean.
237
+ * TTL prevents deadlocks if the lock holder crashes mid-flush.
238
+ *
239
+ * @param {string} key
240
+ * @param {number} [ttl=10] - Lock time-to-live in seconds
241
+ * @returns {Promise<boolean>} true if lock acquired
242
+ */
243
+ async lock(key, ttl = 10) {
244
+ if (this.config.driver === 'redis') {
245
+ const result = await this.redis.set(key, '1', {NX: true, EX: ttl})
246
+ return result === 'OK'
247
+ }
248
+ return this._sendMemory('lock', {key, ttl})
249
+ }
250
+
251
+ /**
252
+ * Why: Release flush lock after completion so the next cycle can proceed.
253
+ */
254
+ async unlock(key) {
255
+ return this.del(key)
256
+ }
257
+
67
258
  async publish(channel, message) {
68
259
  if (this.config.driver === 'redis') {
69
260
  return this.redis.publish(channel, JSON.stringify(message))
@@ -219,32 +410,152 @@ class Ipc extends EventEmitter {
219
410
  }
220
411
 
221
412
  _handleDirectPrimaryCall(action, payload) {
222
- // Basic implementation for Primary process using itself
223
- if (action === 'set') {
224
- const expireAt = payload.ttl > 0 ? Date.now() + payload.ttl * 1000 : Infinity
225
- this._memoryStore.set(payload.key, {value: payload.value, expireAt})
226
- return true
227
- }
228
- if (action === 'get') {
229
- const data = this._memoryStore.get(payload.key)
230
- if (!data) return null
231
- if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
232
- this._memoryStore.delete(payload.key)
233
- return null
234
- }
235
- return data.value
236
- }
237
- if (action === 'del') return this._memoryStore.delete(payload.key)
238
- if (action === 'publish') {
239
- const workers = this._memorySubs.get(payload.channel)
240
- if (workers) {
241
- workers.forEach(wId => {
242
- const w = cluster.workers[wId]
243
- if (w) w.send({type: 'ipc:message', channel: payload.channel, message: payload.message})
244
- })
413
+ return this._executePrimaryAction(action, payload)
414
+ }
415
+
416
+ /**
417
+ * Why: Single source of truth for all memory-driver operations.
418
+ * Both _handlePrimaryMessage (worker→primary) and _handleDirectPrimaryCall (primary self-call)
419
+ * funnel through this method, eliminating logic duplication.
420
+ */
421
+ _executePrimaryAction(action, msg) {
422
+ switch (action) {
423
+ case 'set': {
424
+ const expireAt = msg.ttl > 0 ? Date.now() + msg.ttl * 1000 : Infinity
425
+ this._memoryStore.set(msg.key, {value: msg.value, expireAt})
426
+ return true
427
+ }
428
+ case 'get': {
429
+ const data = this._memoryStore.get(msg.key)
430
+ if (!data) return null
431
+ if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
432
+ this._memoryStore.delete(msg.key)
433
+ return null
434
+ }
435
+ return data.value
436
+ }
437
+ case 'del':
438
+ return this._memoryStore.delete(msg.key)
439
+
440
+ // --- Atomic Counter ---
441
+ case 'incrBy': {
442
+ const data = this._memoryStore.get(msg.key) || {value: 0, expireAt: Infinity}
443
+ data.value = (typeof data.value === 'number' ? data.value : 0) + msg.delta
444
+ this._memoryStore.set(msg.key, data)
445
+ return data.value
446
+ }
447
+
448
+ // --- Hash ---
449
+ case 'hset': {
450
+ const data = this._memoryStore.get(msg.key) || {value: {}, expireAt: Infinity}
451
+ if (typeof data.value !== 'object' || data.value === null || Array.isArray(data.value)) {
452
+ data.value = {}
453
+ }
454
+ Object.assign(data.value, msg.obj)
455
+ this._memoryStore.set(msg.key, data)
456
+ return true
457
+ }
458
+ case 'hgetall': {
459
+ const data = this._memoryStore.get(msg.key)
460
+ if (!data || typeof data.value !== 'object' || Array.isArray(data.value)) return null
461
+ return {...data.value}
462
+ }
463
+
464
+ // --- List ---
465
+ case 'rpush': {
466
+ const data = this._memoryStore.get(msg.key) || {value: [], expireAt: Infinity}
467
+ if (!Array.isArray(data.value)) data.value = []
468
+ data.value.push(...msg.items)
469
+ this._memoryStore.set(msg.key, data)
470
+ return data.value.length
471
+ }
472
+ case 'lrange': {
473
+ const data = this._memoryStore.get(msg.key)
474
+ if (!data || !Array.isArray(data.value)) return []
475
+ return msg.stop === -1 ? data.value.slice(msg.start) : data.value.slice(msg.start, msg.stop + 1)
476
+ }
477
+ case 'lrangeAndDel': {
478
+ const data = this._memoryStore.get(msg.key)
479
+ if (!data || !Array.isArray(data.value)) return []
480
+ const items = data.value
481
+ this._memoryStore.delete(msg.key)
482
+ return items
483
+ }
484
+
485
+ // --- Set ---
486
+ case 'sadd': {
487
+ let data = this._memoryStore.get(msg.key)
488
+ if (!data) {
489
+ data = {value: [], expireAt: Infinity}
490
+ this._memoryStore.set(msg.key, data)
491
+ }
492
+ if (!Array.isArray(data.value)) data.value = []
493
+ let added = 0
494
+ for (const m of msg.members) {
495
+ if (!data.value.includes(m)) {
496
+ data.value.push(m)
497
+ added++
498
+ }
499
+ }
500
+ return added
501
+ }
502
+ case 'smembers': {
503
+ const data = this._memoryStore.get(msg.key)
504
+ return data && Array.isArray(data.value) ? data.value.slice() : []
505
+ }
506
+ case 'srem': {
507
+ const data = this._memoryStore.get(msg.key)
508
+ if (!data || !Array.isArray(data.value)) return 0
509
+ let removed = 0
510
+ for (const m of msg.members) {
511
+ const idx = data.value.indexOf(m)
512
+ if (idx !== -1) {
513
+ data.value.splice(idx, 1)
514
+ removed++
515
+ }
516
+ }
517
+ return removed
518
+ }
519
+
520
+ // --- Lock ---
521
+ case 'lock': {
522
+ const existing = this._memoryStore.get(msg.key)
523
+ if (existing && existing.expireAt > Date.now()) return false
524
+ const expireAt = Date.now() + (msg.ttl || 10) * 1000
525
+ this._memoryStore.set(msg.key, {value: '1', expireAt})
526
+ return true
527
+ }
528
+
529
+ // --- Pub/Sub ---
530
+ case 'publish': {
531
+ const workers = this._memorySubs.get(msg.channel)
532
+ if (workers) {
533
+ workers.forEach(wId => {
534
+ const w = require('node:cluster').workers[wId]
535
+ if (w) w.send({type: 'ipc:message', channel: msg.channel, message: msg.message})
536
+ })
537
+ }
538
+ return undefined
539
+ }
540
+ case 'subscribe': {
541
+ if (!this._memorySubs.has(msg.channel)) {
542
+ this._memorySubs.set(msg.channel, new Set())
543
+ }
544
+ // msg.workerId is set by _handlePrimaryMessage for worker context
545
+ if (msg.workerId) this._memorySubs.get(msg.channel).add(msg.workerId)
546
+ return undefined
547
+ }
548
+ case 'unsubscribe': {
549
+ if (this._memorySubs.has(msg.channel)) {
550
+ this._memorySubs.get(msg.channel).delete(msg.workerId)
551
+ if (this._memorySubs.get(msg.channel).size === 0) {
552
+ this._memorySubs.delete(msg.channel)
553
+ }
554
+ }
555
+ return undefined
245
556
  }
246
557
  }
247
- // subscribe on primary not deeply implemented to avoid complexity, usually workers listen.
558
+ return null
248
559
  }
249
560
 
250
561
  _startGarbageCollector() {
@@ -308,66 +619,17 @@ class Ipc extends EventEmitter {
308
619
  }
309
620
 
310
621
  _handlePrimaryMessage(worker, msg) {
311
- const {type, id, key, value, ttl, channel, message} = msg
312
- const action = type.replace('ipc:', '')
313
-
314
- let response = null
622
+ const action = msg.type.replace('ipc:', '')
315
623
 
316
- switch (action) {
317
- case 'set': {
318
- const expireAt = ttl > 0 ? Date.now() + ttl * 1000 : Infinity
319
- this._memoryStore.set(key, {value, expireAt})
320
- response = true
321
- break
322
- }
323
- case 'get': {
324
- const data = this._memoryStore.get(key)
325
- if (data) {
326
- if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
327
- this._memoryStore.delete(key)
328
- response = null
329
- } else {
330
- response = data.value
331
- }
332
- } else {
333
- response = null
334
- }
335
- break
336
- }
337
- case 'del':
338
- response = this._memoryStore.delete(key)
339
- break
340
- case 'subscribe':
341
- if (!this._memorySubs.has(channel)) {
342
- this._memorySubs.set(channel, new Set())
343
- }
344
- this._memorySubs.get(channel).add(worker.id)
345
- break
346
- case 'unsubscribe':
347
- if (this._memorySubs.has(channel)) {
348
- this._memorySubs.get(channel).delete(worker.id)
349
- if (this._memorySubs.get(channel).size === 0) {
350
- this._memorySubs.delete(channel)
351
- }
352
- }
353
- break
354
- case 'publish': {
355
- // Relay to all subscribed workers
356
- const workers = this._memorySubs.get(channel)
357
- if (workers) {
358
- workers.forEach(wId => {
359
- // Don't echo back to sender if desired? Usually pub/sub receives own too if subbed.
360
- // Redis publishes to all subscribers.
361
- const w = cluster.workers[wId]
362
- if (w) w.send({type: 'ipc:message', channel, message})
363
- })
364
- }
365
- break
366
- }
624
+ // Inject worker context for subscribe/unsubscribe
625
+ if (action === 'subscribe' || action === 'unsubscribe') {
626
+ msg.workerId = worker.id
367
627
  }
368
628
 
369
- if (id) {
370
- worker.send({type: 'ipc:response', id, data: response})
629
+ const response = this._executePrimaryAction(action, msg)
630
+
631
+ if (msg.id) {
632
+ worker.send({type: 'ipc:response', id: msg.id, data: response})
371
633
  }
372
634
  }
373
635
  }
package/src/Odac.js CHANGED
@@ -6,11 +6,12 @@ module.exports = {
6
6
 
7
7
  await global.Odac.Env.init()
8
8
  await global.Odac.Config.init()
9
- await global.Odac.Database.init()
10
9
 
11
10
  global.Odac.Ipc = require('./Ipc.js')
12
11
  await global.Odac.Ipc.init()
13
12
 
13
+ await global.Odac.Database.init()
14
+
14
15
  await global.Odac.Route.init()
15
16
  await global.Odac.Server.init()
16
17
  global.Odac.instance = this.instance
package/src/Storage.js CHANGED
@@ -45,12 +45,14 @@ class OdacStorage {
45
45
 
46
46
  put(key, value) {
47
47
  if (!this.ready) return false
48
- return this.db.put(key, value)
48
+ this.db.putSync(key, value)
49
+ return true
49
50
  }
50
51
 
51
52
  remove(key) {
52
53
  if (!this.ready) return false
53
- return this.db.remove(key)
54
+ this.db.removeSync(key)
55
+ return true
54
56
  }
55
57
 
56
58
  // --- Range Operations ---
package/src/Validator.js CHANGED
@@ -224,7 +224,7 @@ class Validator {
224
224
  error = value && value !== '' && !this.#odac.Var(value).is('url')
225
225
  break
226
226
  case 'username':
227
- error = value && value !== '' && !/^[a-zA-Z0-9]+$/.test(value)
227
+ error = value && value !== '' && !this.#odac.Var(value).is('username')
228
228
  break
229
229
  case 'xss':
230
230
  error = value && value !== '' && /<[^>]*>/g.test(value)
package/src/Var.js CHANGED
@@ -139,6 +139,7 @@ class Var {
139
139
  /([0-9#][\u20E3])|[\u00ae\u00a9\u203C\u2047\u2048\u2049\u3030\u303D\u2139\u2122\u3297\u3299][\uFE00-\uFEFF]?|[\u2190-\u21FF][\uFE00-\uFEFF]?|[\u2300-\u23FF][\uFE00-\uFEFF]?|[\u2460-\u24FF][\uFE00-\uFEFF]?|[\u25A0-\u25FF][\uFE00-\uFEFF]?|[\u2600-\u27BF][\uFE00-\uFEFF]?|[\u2900-\u297F][\uFE00-\uFEFF]?|[\u2B00-\u2BF0][\uFE00-\uFEFF]?|[\u1F000-\u1F6FF][\uFE00-\uFEFF]?/u.test(
140
140
  this.#value
141
141
  ))
142
+ if (args.includes('username')) result = (result || any) && ((any && result) || /^[a-zA-Z0-9]+$/.test(this.#value))
142
143
  if (args.includes('xss')) result = (result || any) && ((any && result) || this.#value == this.#value.replace(/<[^>]*>/g, ''))
143
144
  return result
144
145
  }