odac 1.4.8 → 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 (35) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/docs/ai/README.md +2 -1
  3. package/docs/ai/skills/SKILL.md +2 -1
  4. package/docs/ai/skills/backend/authentication.md +12 -6
  5. package/docs/ai/skills/backend/database.md +85 -5
  6. package/docs/ai/skills/backend/migrations.md +23 -0
  7. package/docs/ai/skills/backend/odac-var.md +155 -0
  8. package/docs/ai/skills/backend/utilities.md +1 -1
  9. package/docs/ai/skills/frontend/forms.md +23 -1
  10. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  11. package/docs/backend/04-routing/09-websocket.md +22 -1
  12. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  13. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  14. package/docs/backend/10-authentication/05-session-management.md +12 -3
  15. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  16. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  17. package/docs/index.json +1 -1
  18. package/package.json +1 -1
  19. package/src/Auth.js +17 -0
  20. package/src/Database/Migration.js +219 -3
  21. package/src/Database/ReadCache.js +174 -0
  22. package/src/Database.js +63 -0
  23. package/src/Validator.js +1 -1
  24. package/src/Var.js +1 -0
  25. package/src/WebSocket.js +80 -23
  26. package/test/Database/Migration/migrate_column.test.js +168 -0
  27. package/test/Database/ReadCache/crossTable.test.js +179 -0
  28. package/test/Database/ReadCache/get.test.js +128 -0
  29. package/test/Database/ReadCache/invalidate.test.js +103 -0
  30. package/test/Database/ReadCache/proxy.test.js +184 -0
  31. package/test/Database/insert.test.js +98 -0
  32. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  33. package/test/WebSocket/Client/limits.test.js +10 -4
  34. package/test/WebSocket/Client/readyState.test.js +154 -0
  35. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
package/src/WebSocket.js CHANGED
@@ -14,10 +14,26 @@ const OPCODE = {
14
14
  PONG: 0xa
15
15
  }
16
16
 
17
+ /**
18
+ * RFC 6455 connection lifecycle states.
19
+ * Exposed as static constants on WebSocketClient for consumer-side checks.
20
+ */
21
+ const READY_STATE = {
22
+ CONNECTING: 0,
23
+ OPEN: 1,
24
+ CLOSING: 2,
25
+ CLOSED: 3
26
+ }
27
+
17
28
  class WebSocketClient {
29
+ static CONNECTING = READY_STATE.CONNECTING
30
+ static OPEN = READY_STATE.OPEN
31
+ static CLOSING = READY_STATE.CLOSING
32
+ static CLOSED = READY_STATE.CLOSED
33
+
18
34
  #socket
19
35
  #handlers = {}
20
- #closed = false
36
+ #readyState = READY_STATE.CONNECTING
21
37
  #server
22
38
  #id
23
39
  #rooms = new Set()
@@ -26,6 +42,7 @@ class WebSocketClient {
26
42
  #rateLimitWindow
27
43
  #messageCount = 0
28
44
  #rateLimitTimer
45
+ #fragments = null
29
46
  data = {}
30
47
 
31
48
  constructor(socket, server, id, options = {}) {
@@ -47,10 +64,20 @@ class WebSocketClient {
47
64
  this.#setupListeners()
48
65
  }
49
66
 
67
+ /**
68
+ * Transitions the client from CONNECTING to OPEN and resumes the underlying socket.
69
+ * Must be called after the handler is attached to begin receiving frames.
70
+ */
50
71
  resume() {
72
+ this.#readyState = READY_STATE.OPEN
51
73
  this.#socket.resume()
52
74
  }
53
75
 
76
+ /** @returns {number} Current RFC 6455 ready state (0–3). */
77
+ get readyState() {
78
+ return this.#readyState
79
+ }
80
+
54
81
  get id() {
55
82
  return this.#id
56
83
  }
@@ -154,10 +181,36 @@ class WebSocketClient {
154
181
 
155
182
  switch (frame.opcode) {
156
183
  case OPCODE.TEXT:
157
- this.#handleMessage(frame.payload.toString('utf8'))
158
- break
159
184
  case OPCODE.BINARY:
160
- this.#handleMessage(frame.payload)
185
+ if (frame.fin) {
186
+ if (this.#fragments) {
187
+ // Final fragment of a fragmented sequence
188
+ this.#fragments.buffers.push(frame.payload)
189
+ const merged = Buffer.concat(this.#fragments.buffers)
190
+ const opcode = this.#fragments.opcode
191
+ this.#fragments = null
192
+ this.#handleMessage(opcode === OPCODE.TEXT ? merged.toString('utf8') : merged)
193
+ } else {
194
+ // Single, unfragmented message
195
+ this.#handleMessage(frame.opcode === OPCODE.TEXT ? frame.payload.toString('utf8') : frame.payload)
196
+ }
197
+ } else {
198
+ // First fragment — start accumulating
199
+ this.#fragments = {opcode: frame.opcode, buffers: [frame.payload]}
200
+ }
201
+ break
202
+ case OPCODE.CONTINUATION:
203
+ if (!this.#fragments) {
204
+ this.close(1002, 'Protocol error: unexpected continuation frame')
205
+ return
206
+ }
207
+ this.#fragments.buffers.push(frame.payload)
208
+ if (frame.fin) {
209
+ const merged = Buffer.concat(this.#fragments.buffers)
210
+ const opcode = this.#fragments.opcode
211
+ this.#fragments = null
212
+ this.#handleMessage(opcode === OPCODE.TEXT ? merged.toString('utf8') : merged)
213
+ }
161
214
  break
162
215
  case OPCODE.PING:
163
216
  this.#sendFrame(OPCODE.PONG, frame.payload)
@@ -181,11 +234,16 @@ class WebSocketClient {
181
234
  }
182
235
  }
183
236
 
184
- #handleClose() {
185
- if (this.#closed) return
186
- this.#closed = true
237
+ /**
238
+ * Centralised resource teardown — called by both close() and the socket 'close' event.
239
+ * Idempotent: subsequent calls are no-ops once state reaches CLOSED.
240
+ */
241
+ #cleanup() {
242
+ if (this.#readyState === READY_STATE.CLOSED) return
243
+ this.#readyState = READY_STATE.CLOSED
187
244
 
188
245
  if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
246
+ this.#fragments = null
189
247
 
190
248
  this.#socket.removeAllListeners()
191
249
 
@@ -197,8 +255,15 @@ class WebSocketClient {
197
255
  this.#server.removeClient(this.#id)
198
256
  }
199
257
 
258
+ #handleClose() {
259
+ this.#cleanup()
260
+ }
261
+
200
262
  #sendFrame(opcode, data) {
201
- if (this.#closed && opcode !== OPCODE.CLOSE) return
263
+ if (this.#readyState === READY_STATE.CLOSED) return
264
+ if (this.#readyState === READY_STATE.CLOSING && opcode !== OPCODE.CLOSE) return
265
+ if (!this.#socket.writable) return
266
+
202
267
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
203
268
  const length = payload.length
204
269
 
@@ -247,28 +312,27 @@ class WebSocketClient {
247
312
  }
248
313
 
249
314
  send(data) {
250
- if (this.#closed) return this
315
+ if (this.#readyState !== READY_STATE.OPEN) return this
251
316
  const payload = typeof data === 'object' ? JSON.stringify(data) : String(data)
252
317
  this.#sendFrame(OPCODE.TEXT, payload)
253
318
  return this
254
319
  }
255
320
 
256
321
  sendBinary(data) {
257
- if (this.#closed) return this
322
+ if (this.#readyState !== READY_STATE.OPEN) return this
258
323
  this.#sendFrame(OPCODE.BINARY, data)
259
324
  return this
260
325
  }
261
326
 
262
327
  ping() {
328
+ if (this.#readyState !== READY_STATE.OPEN) return this
263
329
  this.#sendFrame(OPCODE.PING, Buffer.alloc(0))
264
330
  return this
265
331
  }
266
332
 
267
333
  close(code = 1000, reason = '') {
268
- if (this.#closed) return
269
- this.#closed = true
270
-
271
- if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
334
+ if (this.#readyState === READY_STATE.CLOSED || this.#readyState === READY_STATE.CLOSING) return
335
+ this.#readyState = READY_STATE.CLOSING
272
336
 
273
337
  const reasonBuffer = Buffer.from(reason)
274
338
  const payload = Buffer.alloc(2 + reasonBuffer.length)
@@ -277,14 +341,7 @@ class WebSocketClient {
277
341
 
278
342
  this.#sendFrame(OPCODE.CLOSE, payload)
279
343
  this.#socket.end()
280
- this.#socket.removeAllListeners()
281
-
282
- for (const room of this.#rooms) {
283
- this.#server.leaveRoom(this.#id, room)
284
- }
285
-
286
- this.#emit('close')
287
- this.#server.removeClient(this.#id)
344
+ this.#cleanup()
288
345
  }
289
346
 
290
347
  join(room) {
@@ -448,4 +505,4 @@ class WebSocketServer {
448
505
  }
449
506
  }
450
507
 
451
- module.exports = {WebSocketServer, WebSocketClient}
508
+ module.exports = {WebSocketServer, WebSocketClient, READY_STATE}
@@ -49,4 +49,172 @@ describe('Migration.migrate() - Column Diff', () => {
49
49
 
50
50
  expect(dropOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'drop_column', column: 'obsolete', table: 'items'})]))
51
51
  })
52
+
53
+ it('should alter a column when its default value changes', async () => {
54
+ writeSchema('settings', {columns: {id: {type: 'increments'}, status: {type: 'string', default: 'active'}}})
55
+ await Migration.migrate()
56
+
57
+ writeSchema('settings', {columns: {id: {type: 'increments'}, status: {type: 'string', default: 'inactive'}}})
58
+ const result = await Migration.migrate()
59
+ const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
60
+
61
+ expect(alterOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'alter_column', column: 'status', table: 'settings'})]))
62
+ })
63
+
64
+ it('should alter a column when its default value is removed', async () => {
65
+ writeSchema('settings', {columns: {id: {type: 'increments'}, status: {type: 'string', default: 'active'}}})
66
+ await Migration.migrate()
67
+
68
+ writeSchema('settings', {columns: {id: {type: 'increments'}, status: {type: 'string'}}})
69
+ const result = await Migration.migrate()
70
+ const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
71
+
72
+ expect(alterOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'alter_column', column: 'status', table: 'settings'})]))
73
+ })
74
+
75
+ it('should not alter a column when its default value is unchanged', async () => {
76
+ writeSchema('settings', {columns: {id: {type: 'increments'}, status: {type: 'string', default: 'active'}}})
77
+ await Migration.migrate()
78
+
79
+ writeSchema('settings', {columns: {id: {type: 'increments'}, status: {type: 'string', default: 'active'}}})
80
+ const result = await Migration.migrate()
81
+ const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
82
+
83
+ expect(alterOps).toHaveLength(0)
84
+ })
85
+ })
86
+
87
+ describe('Migration.migrate() - Foreign Key Diff', () => {
88
+ it('should add a foreign key to an existing column', async () => {
89
+ writeSchema('users', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
90
+ writeSchema('posts', {columns: {id: {type: 'increments'}, user_id: {type: 'integer', unsigned: true}}})
91
+ await Migration.migrate()
92
+
93
+ writeSchema('posts', {
94
+ columns: {
95
+ id: {type: 'increments'},
96
+ user_id: {type: 'integer', unsigned: true, references: {table: 'users', column: 'id'}, onDelete: 'CASCADE'}
97
+ }
98
+ })
99
+ const result = await Migration.migrate()
100
+ const fkOps = result.default.schema.filter(op => op.type === 'add_foreign_key')
101
+
102
+ expect(fkOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'add_foreign_key', column: 'user_id', table: 'posts'})]))
103
+ })
104
+
105
+ it('should drop a foreign key removed from schema', async () => {
106
+ writeSchema('users', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
107
+ writeSchema('posts', {
108
+ columns: {
109
+ id: {type: 'increments'},
110
+ user_id: {type: 'integer', unsigned: true, references: {table: 'users', column: 'id'}, onDelete: 'CASCADE'}
111
+ }
112
+ })
113
+ await Migration.migrate()
114
+
115
+ writeSchema('posts', {columns: {id: {type: 'increments'}, user_id: {type: 'integer', unsigned: true}}})
116
+ const result = await Migration.migrate()
117
+ const fkOps = result.default.schema.filter(op => op.type === 'drop_foreign_key')
118
+
119
+ expect(fkOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'drop_foreign_key', column: 'user_id', table: 'posts'})]))
120
+ })
121
+
122
+ it('should replace a foreign key when onDelete action changes', async () => {
123
+ writeSchema('users', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
124
+ writeSchema('posts', {
125
+ columns: {
126
+ id: {type: 'increments'},
127
+ user_id: {type: 'integer', unsigned: true, references: {table: 'users', column: 'id'}, onDelete: 'SET NULL'}
128
+ }
129
+ })
130
+ await Migration.migrate()
131
+
132
+ writeSchema('posts', {
133
+ columns: {
134
+ id: {type: 'increments'},
135
+ user_id: {type: 'integer', unsigned: true, references: {table: 'users', column: 'id'}, onDelete: 'CASCADE'}
136
+ }
137
+ })
138
+ const result = await Migration.migrate()
139
+ const dropOps = result.default.schema.filter(op => op.type === 'drop_foreign_key')
140
+ const addOps = result.default.schema.filter(op => op.type === 'add_foreign_key')
141
+
142
+ expect(dropOps).toHaveLength(1)
143
+ expect(addOps).toHaveLength(1)
144
+ expect(addOps[0]).toMatchObject({column: 'user_id', table: 'posts'})
145
+ })
146
+
147
+ it('should not produce FK ops when references are unchanged', async () => {
148
+ writeSchema('users', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
149
+ writeSchema('posts', {
150
+ columns: {
151
+ id: {type: 'increments'},
152
+ user_id: {type: 'integer', unsigned: true, references: {table: 'users', column: 'id'}, onDelete: 'CASCADE'}
153
+ }
154
+ })
155
+ await Migration.migrate()
156
+
157
+ const result = await Migration.migrate()
158
+ const fkOps = result.default.schema.filter(op => op.type === 'add_foreign_key' || op.type === 'drop_foreign_key')
159
+
160
+ expect(fkOps).toHaveLength(0)
161
+ })
162
+
163
+ it('should clean orphan rows before adding a foreign key constraint', async () => {
164
+ writeSchema('users', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
165
+ writeSchema('posts', {columns: {id: {type: 'increments'}, user_id: {type: 'integer', nullable: true}}})
166
+ await Migration.migrate()
167
+
168
+ // Insert a valid parent and an orphan child
169
+ await db('users').insert({name: 'Alice'})
170
+ await db('posts').insert({user_id: 1})
171
+ await db('posts').insert({user_id: 999}) // orphan — user 999 does not exist
172
+
173
+ // Now add FK constraint
174
+ writeSchema('posts', {
175
+ columns: {
176
+ id: {type: 'increments'},
177
+ user_id: {type: 'integer', nullable: true, references: {table: 'users', column: 'id'}, onDelete: 'CASCADE'}
178
+ }
179
+ })
180
+ await Migration.migrate()
181
+
182
+ // Orphan row should have user_id set to NULL (nullable column)
183
+ const orphan = await db('posts').where('id', 2).first()
184
+ expect(orphan.user_id).toBeNull()
185
+
186
+ // Valid row should be untouched
187
+ const valid = await db('posts').where('id', 1).first()
188
+ expect(valid.user_id).toBe(1)
189
+ })
190
+
191
+ it('should skip FK and warn when non-nullable column has orphan rows', async () => {
192
+ writeSchema('users', {columns: {id: {type: 'increments'}, name: {type: 'string'}}})
193
+ writeSchema('posts', {columns: {id: {type: 'increments'}, user_id: {type: 'integer', nullable: false}}})
194
+ await Migration.migrate()
195
+
196
+ await db('users').insert({name: 'Alice'})
197
+ await db('posts').insert({user_id: 1})
198
+ await db('posts').insert({user_id: 999}) // orphan
199
+
200
+ const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
201
+
202
+ writeSchema('posts', {
203
+ columns: {
204
+ id: {type: 'increments'},
205
+ user_id: {type: 'integer', nullable: false, references: {table: 'users', column: 'id'}, onDelete: 'CASCADE'}
206
+ }
207
+ })
208
+ await Migration.migrate()
209
+
210
+ // Orphan row must NOT be deleted — all data preserved
211
+ const rows = await db('posts').select()
212
+ expect(rows).toHaveLength(2)
213
+
214
+ // Warning must have been emitted
215
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping foreign key'))
216
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1 orphan row(s)'))
217
+
218
+ warnSpy.mockRestore()
219
+ })
52
220
  })
@@ -0,0 +1,179 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests cross-table cache invalidation for JOIN queries.
7
+ * Why: A cached query like posts.join('users').cache().select() must be invalidated
8
+ * when EITHER posts OR users is written to. Validates that cache keys are registered
9
+ * in all joined tables' indexes.
10
+ */
11
+
12
+ let knexLib, db
13
+
14
+ beforeEach(async () => {
15
+ jest.resetModules()
16
+
17
+ knexLib = require('knex')
18
+ db = knexLib({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true})
19
+
20
+ await db.schema.createTable('posts', table => {
21
+ table.integer('id').primary()
22
+ table.string('title', 255)
23
+ table.integer('user_id')
24
+ })
25
+
26
+ await db.schema.createTable('users', table => {
27
+ table.integer('id').primary()
28
+ table.string('name', 255)
29
+ })
30
+
31
+ await db.schema.createTable('categories', table => {
32
+ table.integer('id').primary()
33
+ table.string('label', 255)
34
+ })
35
+
36
+ await db('users').insert([
37
+ {id: 1, name: 'Alice'},
38
+ {id: 2, name: 'Bob'}
39
+ ])
40
+
41
+ await db('categories').insert([{id: 1, label: 'Tech'}])
42
+
43
+ await db('posts').insert([
44
+ {id: 1, title: 'Post A', user_id: 1},
45
+ {id: 2, title: 'Post B', user_id: 2}
46
+ ])
47
+
48
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
49
+
50
+ const Ipc = require('../../../src/Ipc')
51
+ global.Odac = {
52
+ Config: {
53
+ cache: {ttl: 60, maxKeys: 10000},
54
+ buffer: {flushInterval: 999999, checkpointInterval: 999999}
55
+ },
56
+ Storage: {
57
+ isReady: () => false,
58
+ put: jest.fn(),
59
+ remove: jest.fn(),
60
+ getRange: () => []
61
+ },
62
+ Ipc
63
+ }
64
+ await Ipc.init()
65
+
66
+ const writeBuffer = require('../../../src/Database/WriteBuffer')
67
+ await writeBuffer.init({default: db})
68
+
69
+ const readCache = require('../../../src/Database/ReadCache')
70
+ readCache.init()
71
+
72
+ const DB = require('../../../src/Database')
73
+ DB.connections = {default: db}
74
+ })
75
+
76
+ afterEach(async () => {
77
+ const writeBuffer = require('../../../src/Database/WriteBuffer')
78
+ await writeBuffer.close()
79
+ await Odac.Ipc.close()
80
+ await db.destroy()
81
+ delete global.Odac
82
+ })
83
+
84
+ describe('Cross-table cache invalidation (JOIN queries)', () => {
85
+ it('should register cache key in joined table index', async () => {
86
+ const readCache = require('../../../src/Database/ReadCache')
87
+
88
+ const qb = db('posts').join('users', 'posts.user_id', '=', 'users.id').select('posts.title', 'users.name')
89
+ const executeFn = () => qb.then(r => r)
90
+ await readCache.get('default', 'posts', qb, executeFn, 60)
91
+
92
+ // Cache key should be in BOTH posts and users indexes
93
+ const postKeys = await Odac.Ipc.smembers('rc:idx:default:posts')
94
+ const userKeys = await Odac.Ipc.smembers('rc:idx:default:users')
95
+
96
+ expect(postKeys).toHaveLength(1)
97
+ expect(userKeys).toHaveLength(1)
98
+ expect(postKeys[0]).toBe(userKeys[0])
99
+ })
100
+
101
+ it('should invalidate joined query when joined table is written to', async () => {
102
+ const DB = require('../../../src/Database')
103
+
104
+ // Cache a JOIN query via proxy
105
+ const result1 = await DB.posts.cache(60).join('users', 'posts.user_id', '=', 'users.id').select('posts.title', 'users.name')
106
+
107
+ expect(result1).toHaveLength(2)
108
+
109
+ // Verify cache exists in both indexes
110
+ let postKeys = await Odac.Ipc.smembers('rc:idx:default:posts')
111
+ let userKeys = await Odac.Ipc.smembers('rc:idx:default:users')
112
+ expect(postKeys).toHaveLength(1)
113
+ expect(userKeys).toHaveLength(1)
114
+
115
+ // Write to the JOINED table (users) — should invalidate the cached join query
116
+ await DB.users.where({id: 1}).update({name: 'Alice Updated'})
117
+
118
+ userKeys = await Odac.Ipc.smembers('rc:idx:default:users')
119
+ expect(userKeys).toHaveLength(0)
120
+
121
+ // The cache entry itself should be deleted — next read should hit DB
122
+ const result2 = await DB.posts.cache(60).join('users', 'posts.user_id', '=', 'users.id').select('posts.title', 'users.name')
123
+
124
+ expect(result2[0].name).toBe('Alice Updated')
125
+ })
126
+
127
+ it('should invalidate joined query when primary table is written to', async () => {
128
+ const DB = require('../../../src/Database')
129
+
130
+ await DB.posts.cache(60).join('users', 'posts.user_id', '=', 'users.id').select('posts.title', 'users.name')
131
+
132
+ // Write to the PRIMARY table (posts)
133
+ await DB.posts.where({id: 1}).update({title: 'Updated Post'})
134
+
135
+ const postKeys = await Odac.Ipc.smembers('rc:idx:default:posts')
136
+ expect(postKeys).toHaveLength(0)
137
+
138
+ // Fresh read should reflect the update
139
+ const result = await DB.posts.cache(60).join('users', 'posts.user_id', '=', 'users.id').select('posts.title', 'users.name')
140
+
141
+ expect(result[0].title).toBe('Updated Post')
142
+ })
143
+
144
+ it('should handle multiple joins', async () => {
145
+ const readCache = require('../../../src/Database/ReadCache')
146
+
147
+ const qb = db('posts')
148
+ .join('users', 'posts.user_id', '=', 'users.id')
149
+ .leftJoin('categories', 'posts.id', '=', 'categories.id')
150
+ .select('posts.title', 'users.name', 'categories.label')
151
+
152
+ const executeFn = () => qb.then(r => r)
153
+ await readCache.get('default', 'posts', qb, executeFn, 60)
154
+
155
+ // Cache key should be in ALL three table indexes
156
+ const postKeys = await Odac.Ipc.smembers('rc:idx:default:posts')
157
+ const userKeys = await Odac.Ipc.smembers('rc:idx:default:users')
158
+ const catKeys = await Odac.Ipc.smembers('rc:idx:default:categories')
159
+
160
+ expect(postKeys).toHaveLength(1)
161
+ expect(userKeys).toHaveLength(1)
162
+ expect(catKeys).toHaveLength(1)
163
+ expect(postKeys[0]).toBe(userKeys[0])
164
+ expect(postKeys[0]).toBe(catKeys[0])
165
+ })
166
+
167
+ it('should handle aliased table names in joins', async () => {
168
+ const readCache = require('../../../src/Database/ReadCache')
169
+
170
+ const qb = db('posts').join('users as u', 'posts.user_id', '=', 'u.id').select('posts.title', 'u.name')
171
+
172
+ const executeFn = () => qb.then(r => r)
173
+ await readCache.get('default', 'posts', qb, executeFn, 60)
174
+
175
+ // Should register under 'users', not 'users as u'
176
+ const userKeys = await Odac.Ipc.smembers('rc:idx:default:users')
177
+ expect(userKeys).toHaveLength(1)
178
+ })
179
+ })
@@ -0,0 +1,128 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests ReadCache.get() — the core read-through logic.
7
+ * Why: Validates cache HIT/MISS behavior, TTL propagation, and maxKeys guard.
8
+ */
9
+
10
+ let knexLib, db
11
+
12
+ beforeEach(async () => {
13
+ jest.resetModules()
14
+
15
+ knexLib = require('knex')
16
+ db = knexLib({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true})
17
+
18
+ await db.schema.createTable('posts', table => {
19
+ table.integer('id').primary()
20
+ table.string('title', 255)
21
+ table.boolean('active').defaultTo(true)
22
+ })
23
+
24
+ await db('posts').insert([
25
+ {id: 1, title: 'First Post', active: true},
26
+ {id: 2, title: 'Second Post', active: true},
27
+ {id: 3, title: 'Draft', active: false}
28
+ ])
29
+
30
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
31
+
32
+ const Ipc = require('../../../src/Ipc')
33
+ global.Odac = {
34
+ Config: {cache: {ttl: 60, maxKeys: 10000}},
35
+ Ipc
36
+ }
37
+ await Ipc.init()
38
+ })
39
+
40
+ afterEach(async () => {
41
+ await Odac.Ipc.close()
42
+ await db.destroy()
43
+ delete global.Odac
44
+ })
45
+
46
+ describe('ReadCache.get()', () => {
47
+ let readCache
48
+
49
+ beforeEach(() => {
50
+ readCache = require('../../../src/Database/ReadCache')
51
+ readCache.init()
52
+ })
53
+
54
+ it('should return DB result on cache MISS and cache it', async () => {
55
+ const qb = db('posts').where({active: true}).select('id', 'title')
56
+ const executeFn = () => qb.then(r => r)
57
+ const result = await readCache.get('default', 'posts', qb, executeFn, 60)
58
+
59
+ expect(result).toHaveLength(2)
60
+ expect(result[0].title).toBe('First Post')
61
+
62
+ // Verify it was cached — check index
63
+ const keys = await Odac.Ipc.smembers('rc:idx:default:posts')
64
+ expect(keys).toHaveLength(1)
65
+ })
66
+
67
+ it('should return cached result on cache HIT without querying DB', async () => {
68
+ const qb1 = db('posts').where({active: true}).select('id', 'title')
69
+ const executeFn1 = () => qb1.then(r => r)
70
+ const result1 = await readCache.get('default', 'posts', qb1, executeFn1, 60)
71
+
72
+ // Modify DB directly — cache should NOT reflect this
73
+ await db('posts').where({id: 1}).update({title: 'Modified'})
74
+
75
+ const qb2 = db('posts').where({active: true}).select('id', 'title')
76
+ const executeFn2 = () => qb2.then(r => r)
77
+ const result2 = await readCache.get('default', 'posts', qb2, executeFn2, 60)
78
+
79
+ // Should still return the old cached value
80
+ expect(result2[0].title).toBe('First Post')
81
+ expect(result1).toEqual(result2)
82
+ })
83
+
84
+ it('should use config default TTL when ttl parameter is 0', async () => {
85
+ const qb = db('posts').where({id: 1}).first()
86
+ const executeFn = () => qb.then(r => r)
87
+ const result = await readCache.get('default', 'posts', qb, executeFn, 0)
88
+
89
+ expect(result.title).toBe('First Post')
90
+
91
+ // Should still be cached
92
+ const keys = await Odac.Ipc.smembers('rc:idx:default:posts')
93
+ expect(keys).toHaveLength(1)
94
+ })
95
+
96
+ it('should respect maxKeys limit', async () => {
97
+ // Re-init with maxKeys = 1
98
+ global.Odac.Config.cache = {ttl: 60, maxKeys: 1}
99
+
100
+ readCache = require('../../../src/Database/ReadCache')
101
+ readCache.init()
102
+
103
+ const qb1 = db('posts').where({id: 1}).first()
104
+ const executeFn1 = () => qb1.then(r => r)
105
+ await readCache.get('default', 'posts', qb1, executeFn1, 60)
106
+
107
+ const qb2 = db('posts').where({id: 2}).first()
108
+ const executeFn2 = () => qb2.then(r => r)
109
+ await readCache.get('default', 'posts', qb2, executeFn2, 60)
110
+
111
+ // Only 1 key should be in the index (first one cached, second skipped)
112
+ const keys = await Odac.Ipc.smembers('rc:idx:default:posts')
113
+ expect(keys).toHaveLength(1)
114
+ })
115
+
116
+ it('should generate different keys for different queries', async () => {
117
+ const qb1 = db('posts').where({id: 1}).first()
118
+ const qb2 = db('posts').where({id: 2}).first()
119
+ const executeFn1 = () => qb1.then(r => r)
120
+ const executeFn2 = () => qb2.then(r => r)
121
+
122
+ await readCache.get('default', 'posts', qb1, executeFn1, 60)
123
+ await readCache.get('default', 'posts', qb2, executeFn2, 60)
124
+
125
+ const keys = await Odac.Ipc.smembers('rc:idx:default:posts')
126
+ expect(keys).toHaveLength(2)
127
+ })
128
+ })