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
package/src/Database.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const {buildConnections} = require('./Database/ConnectionFactory')
|
|
3
3
|
const nanoid = require('./Database/nanoid')
|
|
4
|
+
const readCache = require('./Database/ReadCache')
|
|
4
5
|
const writeBuffer = require('./Database/WriteBuffer')
|
|
5
6
|
|
|
6
7
|
class DatabaseManager {
|
|
@@ -33,8 +34,11 @@ class DatabaseManager {
|
|
|
33
34
|
// Runs on ALL processes (primary + workers) since every process may insert data.
|
|
34
35
|
this._loadNanoidMeta()
|
|
35
36
|
|
|
37
|
+
// Initialize Read-Through Cache (all processes — every worker may read cached data)
|
|
38
|
+
readCache.init()
|
|
39
|
+
|
|
36
40
|
// Initialize Write-Behind Cache (Primary holds state, Workers communicate via IPC)
|
|
37
|
-
await writeBuffer.init(this.connections)
|
|
41
|
+
await writeBuffer.init(this.connections, this._nanoidColumns)
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -200,6 +204,54 @@ const tableProxyHandler = {
|
|
|
200
204
|
flush: () => writeBuffer.flush(connectionKey, prop)
|
|
201
205
|
}
|
|
202
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)
|
|
254
|
+
|
|
203
255
|
// Odac DX Improvement: Wrap count() to return a clean number
|
|
204
256
|
const originalCount = qb.count
|
|
205
257
|
qb.count = function (...args) {
|
|
@@ -228,6 +280,24 @@ const tableProxyHandler = {
|
|
|
228
280
|
}
|
|
229
281
|
}
|
|
230
282
|
|
|
283
|
+
// Cache invalidation for insert — applied AFTER nanoid wrap so both paths are covered.
|
|
284
|
+
// IMPORTANT: Unlike update/delete/truncate, insert is NOT terminal — it supports
|
|
285
|
+
// chaining (e.g. .insert().onConflict().merge()). So we cannot use wrapWithInvalidation
|
|
286
|
+
// which returns a plain thenable. Instead, override .then() on the query builder to
|
|
287
|
+
// inject invalidation at execution time, preserving the full Knex chain.
|
|
288
|
+
const insertBeforeInvalidation = qb.insert
|
|
289
|
+
qb.insert = function (...args) {
|
|
290
|
+
const result = insertBeforeInvalidation.apply(this, args)
|
|
291
|
+
const origThen = result.then
|
|
292
|
+
result.then = function (resolve, reject) {
|
|
293
|
+
return origThen
|
|
294
|
+
.call(this)
|
|
295
|
+
.then(res => readCache.invalidate(connectionKey, prop).then(() => res))
|
|
296
|
+
.then(resolve, reject)
|
|
297
|
+
}
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
300
|
+
|
|
231
301
|
const originalThen = qb.then
|
|
232
302
|
qb.then = function (resolve, reject) {
|
|
233
303
|
if (this._odacIsCount) {
|
|
@@ -288,6 +358,13 @@ const rootProxy = new Proxy(manager, {
|
|
|
288
358
|
}
|
|
289
359
|
}
|
|
290
360
|
|
|
361
|
+
// Global ReadCache: Odac.DB.cache.clear(connection, table)
|
|
362
|
+
if (prop === 'cache') {
|
|
363
|
+
return {
|
|
364
|
+
clear: (connection, table) => readCache.clear(connection, table)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
291
368
|
// Access to specific database connection: Odac.DB.analytics
|
|
292
369
|
if (target.connections[prop]) {
|
|
293
370
|
return new Proxy(target.connections[prop], tableProxyHandler)
|
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
|
}
|
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,315 @@ 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
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('Migration.migrate() - Column Type Change', () => {
|
|
223
|
+
it('should alter a column when its type changes (string → text)', async () => {
|
|
224
|
+
writeSchema('articles', {columns: {id: {type: 'increments'}, body: {type: 'string'}}})
|
|
225
|
+
await Migration.migrate()
|
|
226
|
+
|
|
227
|
+
writeSchema('articles', {columns: {id: {type: 'increments'}, body: {type: 'text'}}})
|
|
228
|
+
const result = await Migration.migrate()
|
|
229
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
230
|
+
|
|
231
|
+
expect(alterOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'alter_column', column: 'body', table: 'articles'})]))
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('should alter a column when its type changes (integer → bigInteger)', async () => {
|
|
235
|
+
writeSchema('counters', {columns: {id: {type: 'increments'}, value: {type: 'integer'}}})
|
|
236
|
+
await Migration.migrate()
|
|
237
|
+
|
|
238
|
+
writeSchema('counters', {columns: {id: {type: 'increments'}, value: {type: 'bigInteger'}}})
|
|
239
|
+
const result = await Migration.migrate()
|
|
240
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
241
|
+
|
|
242
|
+
expect(alterOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'alter_column', column: 'value', table: 'counters'})]))
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should not alter a column when its type is unchanged', async () => {
|
|
246
|
+
writeSchema('logs', {columns: {id: {type: 'increments'}, message: {type: 'text'}}})
|
|
247
|
+
await Migration.migrate()
|
|
248
|
+
|
|
249
|
+
writeSchema('logs', {columns: {id: {type: 'increments'}, message: {type: 'text'}}})
|
|
250
|
+
const result = await Migration.migrate()
|
|
251
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
252
|
+
|
|
253
|
+
expect(alterOps).toHaveLength(0)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should not produce alter for nanoid columns stored as string', async () => {
|
|
257
|
+
writeSchema('tokens', {columns: {id: {type: 'nanoid', length: 21}, name: {type: 'string'}}})
|
|
258
|
+
await Migration.migrate()
|
|
259
|
+
|
|
260
|
+
// Re-run with same schema — nanoid maps to varchar in DB, should not trigger false alter
|
|
261
|
+
writeSchema('tokens', {columns: {id: {type: 'nanoid', length: 21}, name: {type: 'string'}}})
|
|
262
|
+
const result = await Migration.migrate()
|
|
263
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
264
|
+
|
|
265
|
+
expect(alterOps).toHaveLength(0)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('Migration.migrate() - Nullable Preservation on Alter', () => {
|
|
270
|
+
it('should preserve NOT NULL when altering a column that has no explicit nullable in schema', async () => {
|
|
271
|
+
// Create table with a NOT NULL column
|
|
272
|
+
writeSchema('domains', {columns: {id: {type: 'increments'}, code: {type: 'string', nullable: false, default: 'A'}}})
|
|
273
|
+
await Migration.migrate()
|
|
274
|
+
|
|
275
|
+
// Change default value but omit nullable — should preserve NOT NULL from DB
|
|
276
|
+
writeSchema('domains', {columns: {id: {type: 'increments'}, code: {type: 'string', default: 'B'}}})
|
|
277
|
+
const result = await Migration.migrate()
|
|
278
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
279
|
+
|
|
280
|
+
expect(alterOps).toHaveLength(1)
|
|
281
|
+
expect(alterOps[0]).toMatchObject({column: 'code', currentNullable: false})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('should preserve NULLABLE when altering a column that has no explicit nullable in schema', async () => {
|
|
285
|
+
// Create table with a NULLABLE column
|
|
286
|
+
writeSchema('logs', {columns: {id: {type: 'increments'}, note: {type: 'string', nullable: true, default: 'x'}}})
|
|
287
|
+
await Migration.migrate()
|
|
288
|
+
|
|
289
|
+
// Change default but omit nullable — should preserve nullable from DB
|
|
290
|
+
writeSchema('logs', {columns: {id: {type: 'increments'}, note: {type: 'string', default: 'y'}}})
|
|
291
|
+
const result = await Migration.migrate()
|
|
292
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
293
|
+
|
|
294
|
+
expect(alterOps).toHaveLength(1)
|
|
295
|
+
expect(alterOps[0]).toMatchObject({column: 'note', currentNullable: true})
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('Migration - PG Primary Key Alter Safety', () => {
|
|
300
|
+
it('should carry primary flag in alter_column diff for PK columns', async () => {
|
|
301
|
+
// Create table with a primary nanoid column
|
|
302
|
+
writeSchema('domains', {columns: {id: {type: 'nanoid', primary: true}, name: {type: 'string'}}})
|
|
303
|
+
await Migration.migrate()
|
|
304
|
+
|
|
305
|
+
// Simulate a type mismatch by changing to a different length — triggers alter
|
|
306
|
+
writeSchema('domains', {columns: {id: {type: 'nanoid', primary: true, length: 30}, name: {type: 'string'}}})
|
|
307
|
+
const result = await Migration.migrate({dryRun: true})
|
|
308
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column' && op.column === 'id')
|
|
309
|
+
|
|
310
|
+
expect(alterOps).toHaveLength(1)
|
|
311
|
+
expect(alterOps[0].definition.primary).toBe(true)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('should map ODAC types to PG types correctly via _pgColumnType', () => {
|
|
315
|
+
const m = Migration
|
|
316
|
+
expect(m._pgColumnType({type: 'nanoid'})).toBe('varchar(21)')
|
|
317
|
+
expect(m._pgColumnType({type: 'nanoid', length: 30})).toBe('varchar(30)')
|
|
318
|
+
expect(m._pgColumnType({type: 'string'})).toBe('varchar(255)')
|
|
319
|
+
expect(m._pgColumnType({type: 'string', length: 100})).toBe('varchar(100)')
|
|
320
|
+
expect(m._pgColumnType({type: 'text'})).toBe('text')
|
|
321
|
+
expect(m._pgColumnType({type: 'integer'})).toBe('integer')
|
|
322
|
+
expect(m._pgColumnType({type: 'bigInteger'})).toBe('bigint')
|
|
323
|
+
expect(m._pgColumnType({type: 'boolean'})).toBe('boolean')
|
|
324
|
+
expect(m._pgColumnType({type: 'uuid'})).toBe('uuid')
|
|
325
|
+
expect(m._pgColumnType({type: 'jsonb'})).toBe('jsonb')
|
|
326
|
+
expect(m._pgColumnType({type: 'timestamp'})).toBe('timestamp')
|
|
327
|
+
expect(m._pgColumnType({type: 'binary'})).toBe('bytea')
|
|
328
|
+
expect(m._pgColumnType({type: 'decimal'})).toBe('numeric(10,2)')
|
|
329
|
+
expect(m._pgColumnType({type: 'decimal', precision: 8, scale: 4})).toBe('numeric(8,4)')
|
|
330
|
+
expect(m._pgColumnType({type: 'specificType', length: 'text[]'})).toBe('text[]')
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('Migration - specificType handling', () => {
|
|
335
|
+
it('should create a specificType column using the length field as the raw DB type', async () => {
|
|
336
|
+
writeSchema('events', {
|
|
337
|
+
columns: {
|
|
338
|
+
id: {type: 'increments'},
|
|
339
|
+
tags: {type: 'specificType', length: 'text'}
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
await Migration.migrate()
|
|
343
|
+
|
|
344
|
+
const info = await db('events').columnInfo()
|
|
345
|
+
expect(info).toHaveProperty('tags')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('should not produce false alter for specificType when DB type matches', async () => {
|
|
349
|
+
writeSchema('events', {
|
|
350
|
+
columns: {
|
|
351
|
+
id: {type: 'increments'},
|
|
352
|
+
tags: {type: 'specificType', length: 'text'}
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
await Migration.migrate()
|
|
356
|
+
|
|
357
|
+
// Re-run with same schema — should not trigger alter
|
|
358
|
+
const result = await Migration.migrate()
|
|
359
|
+
const alterOps = result.default.schema.filter(op => op.type === 'alter_column')
|
|
360
|
+
|
|
361
|
+
expect(alterOps).toHaveLength(0)
|
|
362
|
+
})
|
|
52
363
|
})
|