odac 1.4.8 → 1.4.10
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 +43 -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 +321 -10
- package/src/Database/ReadCache.js +174 -0
- package/src/Database/WriteBuffer.js +15 -1
- package/src/Database.js +78 -1
- 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 +311 -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/WriteBuffer/insert.test.js +118 -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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests ReadCache.invalidate() — table-level cache purge.
|
|
7
|
+
* Why: Validates that all cached queries for a table are removed on invalidation,
|
|
8
|
+
* and that unrelated tables remain unaffected.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let knexLib, db
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
|
|
16
|
+
knexLib = require('knex')
|
|
17
|
+
db = knexLib({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true})
|
|
18
|
+
|
|
19
|
+
await db.schema.createTable('posts', table => {
|
|
20
|
+
table.integer('id').primary()
|
|
21
|
+
table.string('title', 255)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
await db.schema.createTable('users', table => {
|
|
25
|
+
table.integer('id').primary()
|
|
26
|
+
table.string('name', 255)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
await db('posts').insert([
|
|
30
|
+
{id: 1, title: 'Post A'},
|
|
31
|
+
{id: 2, title: 'Post B'}
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
await db('users').insert([{id: 1, name: 'Alice'}])
|
|
35
|
+
|
|
36
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
37
|
+
|
|
38
|
+
const Ipc = require('../../../src/Ipc')
|
|
39
|
+
global.Odac = {
|
|
40
|
+
Config: {cache: {ttl: 60, maxKeys: 10000}},
|
|
41
|
+
Ipc
|
|
42
|
+
}
|
|
43
|
+
await Ipc.init()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await Odac.Ipc.close()
|
|
48
|
+
await db.destroy()
|
|
49
|
+
delete global.Odac
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('ReadCache.invalidate()', () => {
|
|
53
|
+
let readCache
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
readCache = require('../../../src/Database/ReadCache')
|
|
57
|
+
readCache.init()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should purge all cached queries for the specified table', async () => {
|
|
61
|
+
// Cache two different queries on posts
|
|
62
|
+
const qb1 = db('posts').where({id: 1}).first()
|
|
63
|
+
const qb2 = db('posts').where({id: 2}).first()
|
|
64
|
+
await readCache.get('default', 'posts', qb1, () => qb1.then(r => r), 60)
|
|
65
|
+
await readCache.get('default', 'posts', qb2, () => qb2.then(r => r), 60)
|
|
66
|
+
|
|
67
|
+
const cachedKeys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
68
|
+
expect(cachedKeys).toHaveLength(2)
|
|
69
|
+
|
|
70
|
+
// Invalidate
|
|
71
|
+
await readCache.invalidate('default', 'posts')
|
|
72
|
+
|
|
73
|
+
const keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
74
|
+
expect(keys).toHaveLength(0)
|
|
75
|
+
|
|
76
|
+
// Verify cache entries are actually deleted
|
|
77
|
+
for (const key of cachedKeys) {
|
|
78
|
+
const val = await Odac.Ipc.get(key)
|
|
79
|
+
expect(val).toBeNull()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should not affect cache of other tables', async () => {
|
|
84
|
+
const qbPosts = db('posts').where({id: 1}).first()
|
|
85
|
+
const qbUsers = db('users').where({id: 1}).first()
|
|
86
|
+
await readCache.get('default', 'posts', qbPosts, () => qbPosts.then(r => r), 60)
|
|
87
|
+
await readCache.get('default', 'users', qbUsers, () => qbUsers.then(r => r), 60)
|
|
88
|
+
|
|
89
|
+
// Invalidate only posts
|
|
90
|
+
await readCache.invalidate('default', 'posts')
|
|
91
|
+
|
|
92
|
+
const postKeys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
93
|
+
const userKeys = await Odac.Ipc.smembers('rc:idx:default:users')
|
|
94
|
+
|
|
95
|
+
expect(postKeys).toHaveLength(0)
|
|
96
|
+
expect(userKeys).toHaveLength(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should be a no-op when no cache exists for the table', async () => {
|
|
100
|
+
// Should not throw
|
|
101
|
+
await expect(readCache.invalidate('default', 'nonexistent')).resolves.toBeUndefined()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests the cache chain API exposed via Database.js proxy.
|
|
7
|
+
* Why: Validates that Odac.DB.posts.cache(60).where(...).select(...) pattern
|
|
8
|
+
* correctly delegates to ReadCache, and that write operations auto-invalidate.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let knexLib, db
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
|
|
16
|
+
knexLib = require('knex')
|
|
17
|
+
db = knexLib({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true})
|
|
18
|
+
|
|
19
|
+
await db.schema.createTable('posts', table => {
|
|
20
|
+
table.integer('id').primary()
|
|
21
|
+
table.string('title', 255)
|
|
22
|
+
table.integer('views').defaultTo(0)
|
|
23
|
+
table.boolean('active').defaultTo(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await db('posts').insert([
|
|
27
|
+
{id: 1, title: 'First Post', views: 100, active: true},
|
|
28
|
+
{id: 2, title: 'Second Post', views: 200, active: true},
|
|
29
|
+
{id: 3, title: 'Draft', views: 0, active: false}
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
|
|
33
|
+
|
|
34
|
+
const Ipc = require('../../../src/Ipc')
|
|
35
|
+
global.Odac = {
|
|
36
|
+
Config: {
|
|
37
|
+
cache: {ttl: 60, maxKeys: 10000},
|
|
38
|
+
buffer: {flushInterval: 999999, checkpointInterval: 999999}
|
|
39
|
+
},
|
|
40
|
+
Storage: {
|
|
41
|
+
isReady: () => false,
|
|
42
|
+
put: jest.fn(),
|
|
43
|
+
remove: jest.fn(),
|
|
44
|
+
getRange: () => []
|
|
45
|
+
},
|
|
46
|
+
Ipc
|
|
47
|
+
}
|
|
48
|
+
await Ipc.init()
|
|
49
|
+
|
|
50
|
+
const writeBuffer = require('../../../src/Database/WriteBuffer')
|
|
51
|
+
await writeBuffer.init({default: db})
|
|
52
|
+
|
|
53
|
+
const readCache = require('../../../src/Database/ReadCache')
|
|
54
|
+
readCache.init()
|
|
55
|
+
|
|
56
|
+
const DB = require('../../../src/Database')
|
|
57
|
+
DB.connections = {default: db}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
const writeBuffer = require('../../../src/Database/WriteBuffer')
|
|
62
|
+
await writeBuffer.close()
|
|
63
|
+
await Odac.Ipc.close()
|
|
64
|
+
await db.destroy()
|
|
65
|
+
delete global.Odac
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('Database.js Proxy - cache(ttl).where().select()', () => {
|
|
69
|
+
it('should cache SELECT results with specified TTL', async () => {
|
|
70
|
+
const DB = require('../../../src/Database')
|
|
71
|
+
|
|
72
|
+
const result1 = await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
73
|
+
expect(result1).toHaveLength(2)
|
|
74
|
+
|
|
75
|
+
// Modify DB directly
|
|
76
|
+
await db('posts').where({id: 1}).update({title: 'Modified'})
|
|
77
|
+
|
|
78
|
+
// Should return cached (stale) data
|
|
79
|
+
const result2 = await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
80
|
+
expect(result2[0].title).toBe('First Post')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should cache with default TTL when called without argument', async () => {
|
|
84
|
+
const DB = require('../../../src/Database')
|
|
85
|
+
|
|
86
|
+
const result = await DB.posts.cache().where({id: 1}).first()
|
|
87
|
+
expect(result.title).toBe('First Post')
|
|
88
|
+
|
|
89
|
+
// Verify cached
|
|
90
|
+
const keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
91
|
+
expect(keys).toHaveLength(1)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('Database.js Proxy - cache.clear()', () => {
|
|
96
|
+
it('should manually clear table cache via Odac.DB.posts.cache.clear()', async () => {
|
|
97
|
+
const DB = require('../../../src/Database')
|
|
98
|
+
|
|
99
|
+
await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
100
|
+
|
|
101
|
+
let keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
102
|
+
expect(keys).toHaveLength(1)
|
|
103
|
+
|
|
104
|
+
await DB.posts.cache.clear()
|
|
105
|
+
|
|
106
|
+
keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
107
|
+
expect(keys).toHaveLength(0)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('Database.js Proxy - automatic invalidation on write', () => {
|
|
112
|
+
it('should invalidate cache after update()', async () => {
|
|
113
|
+
const DB = require('../../../src/Database')
|
|
114
|
+
|
|
115
|
+
// Cache a query
|
|
116
|
+
await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
117
|
+
let keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
118
|
+
expect(keys).toHaveLength(1)
|
|
119
|
+
|
|
120
|
+
// Update via proxy — should auto-invalidate
|
|
121
|
+
await DB.posts.where({id: 1}).update({title: 'Updated'})
|
|
122
|
+
|
|
123
|
+
keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
124
|
+
expect(keys).toHaveLength(0)
|
|
125
|
+
|
|
126
|
+
// Next cache() call should fetch fresh data
|
|
127
|
+
const result = await DB.posts.cache(60).where({id: 1}).first()
|
|
128
|
+
expect(result.title).toBe('Updated')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should invalidate cache after insert()', async () => {
|
|
132
|
+
const DB = require('../../../src/Database')
|
|
133
|
+
|
|
134
|
+
await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
135
|
+
let keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
136
|
+
expect(keys).toHaveLength(1)
|
|
137
|
+
|
|
138
|
+
await DB.posts.insert({id: 4, title: 'New Post', views: 0, active: true})
|
|
139
|
+
|
|
140
|
+
keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
141
|
+
expect(keys).toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should invalidate cache after delete()', async () => {
|
|
145
|
+
const DB = require('../../../src/Database')
|
|
146
|
+
|
|
147
|
+
await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
148
|
+
let keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
149
|
+
expect(keys).toHaveLength(1)
|
|
150
|
+
|
|
151
|
+
await DB.posts.where({id: 3}).delete()
|
|
152
|
+
|
|
153
|
+
keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
154
|
+
expect(keys).toHaveLength(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should invalidate cache after del() (alias)', async () => {
|
|
158
|
+
const DB = require('../../../src/Database')
|
|
159
|
+
|
|
160
|
+
await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
161
|
+
let keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
162
|
+
expect(keys).toHaveLength(1)
|
|
163
|
+
|
|
164
|
+
await DB.posts.where({id: 3}).del()
|
|
165
|
+
|
|
166
|
+
keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
167
|
+
expect(keys).toHaveLength(0)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('Database.js Proxy - global cache.clear()', () => {
|
|
172
|
+
it('should clear cache via Odac.DB.cache.clear(connection, table)', async () => {
|
|
173
|
+
const DB = require('../../../src/Database')
|
|
174
|
+
|
|
175
|
+
await DB.posts.cache(60).where({active: true}).select('id', 'title')
|
|
176
|
+
let keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
177
|
+
expect(keys).toHaveLength(1)
|
|
178
|
+
|
|
179
|
+
await DB.cache.clear('default', 'posts')
|
|
180
|
+
|
|
181
|
+
keys = await Odac.Ipc.smembers('rc:idx:default:posts')
|
|
182
|
+
expect(keys).toHaveLength(0)
|
|
183
|
+
})
|
|
184
|
+
})
|