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.
- package/CHANGELOG.md +28 -0
- package/docs/ai/README.md +2 -1
- package/docs/ai/skills/SKILL.md +2 -1
- package/docs/ai/skills/backend/authentication.md +12 -6
- package/docs/ai/skills/backend/database.md +85 -5
- package/docs/ai/skills/backend/migrations.md +23 -0
- package/docs/ai/skills/backend/odac-var.md +155 -0
- package/docs/ai/skills/backend/utilities.md +1 -1
- package/docs/ai/skills/frontend/forms.md +23 -1
- package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
- package/docs/backend/04-routing/09-websocket.md +22 -1
- package/docs/backend/08-database/06-read-through-cache.md +206 -0
- package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
- package/docs/backend/10-authentication/05-session-management.md +12 -3
- package/docs/backend/13-utilities/01-odac-var.md +13 -19
- package/docs/frontend/03-forms/01-form-handling.md +15 -2
- package/docs/index.json +1 -1
- package/package.json +1 -1
- package/src/Auth.js +17 -0
- package/src/Database/Migration.js +219 -3
- package/src/Database/ReadCache.js +174 -0
- package/src/Database.js +63 -0
- package/src/Validator.js +1 -1
- package/src/Var.js +1 -0
- package/src/WebSocket.js +80 -23
- package/test/Database/Migration/migrate_column.test.js +168 -0
- package/test/Database/ReadCache/crossTable.test.js +179 -0
- package/test/Database/ReadCache/get.test.js +128 -0
- package/test/Database/ReadCache/invalidate.test.js +103 -0
- package/test/Database/ReadCache/proxy.test.js +184 -0
- package/test/Database/insert.test.js +98 -0
- package/test/WebSocket/Client/fragmentation.test.js +130 -0
- package/test/WebSocket/Client/limits.test.js +10 -4
- package/test/WebSocket/Client/readyState.test.js +154 -0
- 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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
269
|
-
this.#
|
|
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.#
|
|
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
|
+
})
|