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.
- package/CHANGELOG.md +45 -0
- package/client/odac.js +1 -1
- package/docs/ai/README.md +3 -2
- package/docs/ai/skills/SKILL.md +3 -2
- package/docs/ai/skills/backend/authentication.md +12 -6
- package/docs/ai/skills/backend/database.md +183 -12
- package/docs/ai/skills/backend/ipc.md +71 -12
- 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/05-write-behind-cache.md +230 -0
- 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/backend/13-utilities/02-ipc.md +117 -0
- 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/WriteBuffer.js +605 -0
- package/src/Database.js +95 -1
- package/src/Ipc.js +343 -81
- package/src/Odac.js +2 -1
- package/src/Storage.js +4 -2
- 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/WriteBuffer/_recoverFromCheckpoint.test.js +207 -0
- package/test/Database/WriteBuffer/buffer.test.js +143 -0
- package/test/Database/WriteBuffer/flush.test.js +192 -0
- package/test/Database/WriteBuffer/get.test.js +72 -0
- package/test/Database/WriteBuffer/increment.test.js +118 -0
- package/test/Database/WriteBuffer/update.test.js +178 -0
- package/test/Database/insert.test.js +98 -0
- package/test/Ipc/hset.test.js +59 -0
- package/test/Ipc/incrBy.test.js +65 -0
- package/test/Ipc/lock.test.js +62 -0
- package/test/Ipc/rpush.test.js +68 -0
- package/test/Ipc/sadd.test.js +68 -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/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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
const action = type.replace('ipc:', '')
|
|
313
|
-
|
|
314
|
-
let response = null
|
|
622
|
+
const action = msg.type.replace('ipc:', '')
|
|
315
623
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !== '' &&
|
|
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
|
}
|