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.
Files changed (37) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/docs/ai/README.md +2 -1
  3. package/docs/ai/skills/SKILL.md +2 -1
  4. package/docs/ai/skills/backend/authentication.md +12 -6
  5. package/docs/ai/skills/backend/database.md +85 -5
  6. package/docs/ai/skills/backend/migrations.md +23 -0
  7. package/docs/ai/skills/backend/odac-var.md +155 -0
  8. package/docs/ai/skills/backend/utilities.md +1 -1
  9. package/docs/ai/skills/frontend/forms.md +23 -1
  10. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  11. package/docs/backend/04-routing/09-websocket.md +22 -1
  12. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  13. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  14. package/docs/backend/10-authentication/05-session-management.md +12 -3
  15. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  16. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  17. package/docs/index.json +1 -1
  18. package/package.json +1 -1
  19. package/src/Auth.js +17 -0
  20. package/src/Database/Migration.js +321 -10
  21. package/src/Database/ReadCache.js +174 -0
  22. package/src/Database/WriteBuffer.js +15 -1
  23. package/src/Database.js +78 -1
  24. package/src/Validator.js +1 -1
  25. package/src/Var.js +1 -0
  26. package/src/WebSocket.js +80 -23
  27. package/test/Database/Migration/migrate_column.test.js +311 -0
  28. package/test/Database/ReadCache/crossTable.test.js +179 -0
  29. package/test/Database/ReadCache/get.test.js +128 -0
  30. package/test/Database/ReadCache/invalidate.test.js +103 -0
  31. package/test/Database/ReadCache/proxy.test.js +184 -0
  32. package/test/Database/WriteBuffer/insert.test.js +118 -0
  33. package/test/Database/insert.test.js +98 -0
  34. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  35. package/test/WebSocket/Client/limits.test.js +10 -4
  36. package/test/WebSocket/Client/readyState.test.js +154 -0
  37. 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 !== '' && !/^[a-zA-Z0-9]+$/.test(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
- #closed = false
36
+ #readyState = READY_STATE.CONNECTING
21
37
  #server
22
38
  #id
23
39
  #rooms = new Set()
@@ -26,6 +42,7 @@ class WebSocketClient {
26
42
  #rateLimitWindow
27
43
  #messageCount = 0
28
44
  #rateLimitTimer
45
+ #fragments = null
29
46
  data = {}
30
47
 
31
48
  constructor(socket, server, id, options = {}) {
@@ -47,10 +64,20 @@ class WebSocketClient {
47
64
  this.#setupListeners()
48
65
  }
49
66
 
67
+ /**
68
+ * Transitions the client from CONNECTING to OPEN and resumes the underlying socket.
69
+ * Must be called after the handler is attached to begin receiving frames.
70
+ */
50
71
  resume() {
72
+ this.#readyState = READY_STATE.OPEN
51
73
  this.#socket.resume()
52
74
  }
53
75
 
76
+ /** @returns {number} Current RFC 6455 ready state (0–3). */
77
+ get readyState() {
78
+ return this.#readyState
79
+ }
80
+
54
81
  get id() {
55
82
  return this.#id
56
83
  }
@@ -154,10 +181,36 @@ class WebSocketClient {
154
181
 
155
182
  switch (frame.opcode) {
156
183
  case OPCODE.TEXT:
157
- this.#handleMessage(frame.payload.toString('utf8'))
158
- break
159
184
  case OPCODE.BINARY:
160
- this.#handleMessage(frame.payload)
185
+ if (frame.fin) {
186
+ if (this.#fragments) {
187
+ // Final fragment of a fragmented sequence
188
+ this.#fragments.buffers.push(frame.payload)
189
+ const merged = Buffer.concat(this.#fragments.buffers)
190
+ const opcode = this.#fragments.opcode
191
+ this.#fragments = null
192
+ this.#handleMessage(opcode === OPCODE.TEXT ? merged.toString('utf8') : merged)
193
+ } else {
194
+ // Single, unfragmented message
195
+ this.#handleMessage(frame.opcode === OPCODE.TEXT ? frame.payload.toString('utf8') : frame.payload)
196
+ }
197
+ } else {
198
+ // First fragment — start accumulating
199
+ this.#fragments = {opcode: frame.opcode, buffers: [frame.payload]}
200
+ }
201
+ break
202
+ case OPCODE.CONTINUATION:
203
+ if (!this.#fragments) {
204
+ this.close(1002, 'Protocol error: unexpected continuation frame')
205
+ return
206
+ }
207
+ this.#fragments.buffers.push(frame.payload)
208
+ if (frame.fin) {
209
+ const merged = Buffer.concat(this.#fragments.buffers)
210
+ const opcode = this.#fragments.opcode
211
+ this.#fragments = null
212
+ this.#handleMessage(opcode === OPCODE.TEXT ? merged.toString('utf8') : merged)
213
+ }
161
214
  break
162
215
  case OPCODE.PING:
163
216
  this.#sendFrame(OPCODE.PONG, frame.payload)
@@ -181,11 +234,16 @@ class WebSocketClient {
181
234
  }
182
235
  }
183
236
 
184
- #handleClose() {
185
- if (this.#closed) return
186
- this.#closed = true
237
+ /**
238
+ * Centralised resource teardown — called by both close() and the socket 'close' event.
239
+ * Idempotent: subsequent calls are no-ops once state reaches CLOSED.
240
+ */
241
+ #cleanup() {
242
+ if (this.#readyState === READY_STATE.CLOSED) return
243
+ this.#readyState = READY_STATE.CLOSED
187
244
 
188
245
  if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
246
+ this.#fragments = null
189
247
 
190
248
  this.#socket.removeAllListeners()
191
249
 
@@ -197,8 +255,15 @@ class WebSocketClient {
197
255
  this.#server.removeClient(this.#id)
198
256
  }
199
257
 
258
+ #handleClose() {
259
+ this.#cleanup()
260
+ }
261
+
200
262
  #sendFrame(opcode, data) {
201
- if (this.#closed && opcode !== OPCODE.CLOSE) return
263
+ if (this.#readyState === READY_STATE.CLOSED) return
264
+ if (this.#readyState === READY_STATE.CLOSING && opcode !== OPCODE.CLOSE) return
265
+ if (!this.#socket.writable) return
266
+
202
267
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
203
268
  const length = payload.length
204
269
 
@@ -247,28 +312,27 @@ class WebSocketClient {
247
312
  }
248
313
 
249
314
  send(data) {
250
- if (this.#closed) return this
315
+ if (this.#readyState !== READY_STATE.OPEN) return this
251
316
  const payload = typeof data === 'object' ? JSON.stringify(data) : String(data)
252
317
  this.#sendFrame(OPCODE.TEXT, payload)
253
318
  return this
254
319
  }
255
320
 
256
321
  sendBinary(data) {
257
- if (this.#closed) return this
322
+ if (this.#readyState !== READY_STATE.OPEN) return this
258
323
  this.#sendFrame(OPCODE.BINARY, data)
259
324
  return this
260
325
  }
261
326
 
262
327
  ping() {
328
+ if (this.#readyState !== READY_STATE.OPEN) return this
263
329
  this.#sendFrame(OPCODE.PING, Buffer.alloc(0))
264
330
  return this
265
331
  }
266
332
 
267
333
  close(code = 1000, reason = '') {
268
- if (this.#closed) return
269
- this.#closed = true
270
-
271
- if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
334
+ if (this.#readyState === READY_STATE.CLOSED || this.#readyState === READY_STATE.CLOSING) return
335
+ this.#readyState = READY_STATE.CLOSING
272
336
 
273
337
  const reasonBuffer = Buffer.from(reason)
274
338
  const payload = Buffer.alloc(2 + reasonBuffer.length)
@@ -277,14 +341,7 @@ class WebSocketClient {
277
341
 
278
342
  this.#sendFrame(OPCODE.CLOSE, payload)
279
343
  this.#socket.end()
280
- this.#socket.removeAllListeners()
281
-
282
- for (const room of this.#rooms) {
283
- this.#server.leaveRoom(this.#id, room)
284
- }
285
-
286
- this.#emit('close')
287
- this.#server.removeClient(this.#id)
344
+ this.#cleanup()
288
345
  }
289
346
 
290
347
  join(room) {
@@ -448,4 +505,4 @@ class WebSocketServer {
448
505
  }
449
506
  }
450
507
 
451
- module.exports = {WebSocketServer, WebSocketClient}
508
+ module.exports = {WebSocketServer, WebSocketClient, READY_STATE}
@@ -49,4 +49,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
  })