odac 1.4.1 → 1.4.3

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 (77) hide show
  1. package/.agent/rules/memory.md +5 -0
  2. package/.releaserc.js +9 -2
  3. package/CHANGELOG.md +64 -0
  4. package/README.md +1 -1
  5. package/bin/odac.js +3 -2
  6. package/client/odac.js +124 -28
  7. package/docs/ai/skills/backend/database.md +19 -0
  8. package/docs/ai/skills/backend/forms.md +107 -13
  9. package/docs/ai/skills/backend/migrations.md +8 -2
  10. package/docs/ai/skills/backend/validation.md +132 -32
  11. package/docs/ai/skills/frontend/forms.md +43 -15
  12. package/docs/backend/08-database/02-basics.md +49 -9
  13. package/docs/backend/08-database/04-migrations.md +1 -0
  14. package/package.json +1 -1
  15. package/src/Auth.js +15 -2
  16. package/src/Database/ConnectionFactory.js +1 -0
  17. package/src/Database/Migration.js +26 -1
  18. package/src/Database/nanoid.js +30 -0
  19. package/src/Database.js +122 -11
  20. package/src/Ipc.js +37 -0
  21. package/src/Odac.js +1 -1
  22. package/src/Route/Cron.js +11 -0
  23. package/src/Route.js +49 -30
  24. package/src/Server.js +77 -23
  25. package/src/Storage.js +15 -1
  26. package/src/Validator.js +22 -20
  27. package/test/{Auth.test.js → Auth/check.test.js} +91 -5
  28. package/test/Client/data.test.js +91 -0
  29. package/test/Client/get.test.js +90 -0
  30. package/test/Client/storage.test.js +87 -0
  31. package/test/Client/token.test.js +82 -0
  32. package/test/Client/ws.test.js +118 -0
  33. package/test/Config/deepMerge.test.js +14 -0
  34. package/test/Config/init.test.js +66 -0
  35. package/test/Config/interpolate.test.js +35 -0
  36. package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
  37. package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
  38. package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
  39. package/test/Database/Migration/migrate_column.test.js +52 -0
  40. package/test/Database/Migration/migrate_files.test.js +70 -0
  41. package/test/Database/Migration/migrate_index.test.js +89 -0
  42. package/test/Database/Migration/migrate_nanoid.test.js +160 -0
  43. package/test/Database/Migration/migrate_seed.test.js +77 -0
  44. package/test/Database/Migration/migrate_table.test.js +88 -0
  45. package/test/Database/Migration/rollback.test.js +61 -0
  46. package/test/Database/Migration/snapshot.test.js +38 -0
  47. package/test/Database/Migration/status.test.js +41 -0
  48. package/test/Database/autoNanoid.test.js +215 -0
  49. package/test/Database/nanoid.test.js +19 -0
  50. package/test/Lang/constructor.test.js +25 -0
  51. package/test/Lang/get.test.js +65 -0
  52. package/test/Lang/set.test.js +49 -0
  53. package/test/Odac/init.test.js +42 -0
  54. package/test/Odac/instance.test.js +58 -0
  55. package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
  56. package/test/Route/Middleware/use.test.js +35 -0
  57. package/test/{Route.test.js → Route/check.test.js} +100 -50
  58. package/test/Route/set.test.js +52 -0
  59. package/test/Route/ws.test.js +23 -0
  60. package/test/View/EarlyHints/cache.test.js +32 -0
  61. package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
  62. package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
  63. package/test/View/EarlyHints/send.test.js +99 -0
  64. package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
  65. package/test/View/constructor.test.js +22 -0
  66. package/test/View/print.test.js +19 -0
  67. package/test/WebSocket/Client/limits.test.js +55 -0
  68. package/test/WebSocket/Server/broadcast.test.js +33 -0
  69. package/test/WebSocket/Server/route.test.js +37 -0
  70. package/test/Client.test.js +0 -197
  71. package/test/Config.test.js +0 -119
  72. package/test/Database/ConnectionFactory.test.js +0 -80
  73. package/test/Lang.test.js +0 -92
  74. package/test/Migration.test.js +0 -943
  75. package/test/Odac.test.js +0 -88
  76. package/test/View/EarlyHints.test.js +0 -282
  77. package/test/WebSocket.test.js +0 -238
@@ -1,943 +0,0 @@
1
- 'use strict'
2
-
3
- const path = require('node:path')
4
- const fs = require('node:fs')
5
- const os = require('node:os')
6
- const knex = require('knex')
7
-
8
- const Migration = require('../src/Database/Migration')
9
-
10
- /**
11
- * Migration Engine integration tests.
12
- * Uses SQLite in-memory for true isolation — no external DB required.
13
- */
14
-
15
- let db
16
- let tmpDir
17
-
18
- beforeEach(async () => {
19
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-migration-'))
20
-
21
- db = knex({
22
- client: 'sqlite3',
23
- connection: {filename: ':memory:'},
24
- useNullAsDefault: true
25
- })
26
-
27
- Migration.init(tmpDir, {default: db})
28
- })
29
-
30
- afterEach(async () => {
31
- await db.destroy()
32
- fs.rmSync(tmpDir, {recursive: true, force: true})
33
- })
34
-
35
- // ---------------------------------------------------------------------------
36
- // HELPERS
37
- // ---------------------------------------------------------------------------
38
-
39
- function writeSchema(name, content, subDir) {
40
- const dir = subDir ? path.join(tmpDir, 'schema', subDir) : path.join(tmpDir, 'schema')
41
- fs.mkdirSync(dir, {recursive: true})
42
- fs.writeFileSync(path.join(dir, `${name}.js`), `module.exports = ${JSON.stringify(content, null, 2)}`)
43
- }
44
-
45
- function writeMigrationFile(name, upFn, downFn, subDir) {
46
- const dir = subDir ? path.join(tmpDir, 'migration', subDir) : path.join(tmpDir, 'migration')
47
- fs.mkdirSync(dir, {recursive: true})
48
-
49
- const content = `
50
- module.exports = {
51
- up: ${upFn.toString()},
52
- down: ${downFn ? downFn.toString() : 'undefined'}
53
- }
54
- `
55
- fs.writeFileSync(path.join(dir, name), content)
56
- }
57
-
58
- // ---------------------------------------------------------------------------
59
- // TABLE CREATION
60
- // ---------------------------------------------------------------------------
61
-
62
- describe('Migration Engine', () => {
63
- describe('Table Creation', () => {
64
- it('should create a new table from a schema file', async () => {
65
- writeSchema('products', {
66
- columns: {
67
- id: {type: 'increments'},
68
- name: {type: 'string', length: 100, nullable: false},
69
- price: {type: 'decimal', precision: 10, scale: 2},
70
- is_active: {type: 'boolean', default: true}
71
- },
72
- indexes: []
73
- })
74
-
75
- const result = await Migration.migrate()
76
- const ops = result.default.schema
77
-
78
- expect(ops).toEqual(expect.arrayContaining([expect.objectContaining({type: 'create_table', table: 'products'})]))
79
-
80
- const exists = await db.schema.hasTable('products')
81
- expect(exists).toBe(true)
82
-
83
- const info = await db('products').columnInfo()
84
- expect(info).toHaveProperty('id')
85
- expect(info).toHaveProperty('name')
86
- expect(info).toHaveProperty('price')
87
- expect(info).toHaveProperty('is_active')
88
- })
89
-
90
- it('should create a table with timestamps virtual type', async () => {
91
- writeSchema('logs', {
92
- columns: {
93
- id: {type: 'increments'},
94
- message: {type: 'text'},
95
- timestamps: {type: 'timestamps'}
96
- },
97
- indexes: []
98
- })
99
-
100
- await Migration.migrate()
101
-
102
- const info = await db('logs').columnInfo()
103
- expect(info).toHaveProperty('created_at')
104
- expect(info).toHaveProperty('updated_at')
105
- })
106
-
107
- it('should create a table with indexes', async () => {
108
- writeSchema('users', {
109
- columns: {
110
- id: {type: 'increments'},
111
- email: {type: 'string', length: 255, nullable: false},
112
- role: {type: 'string', length: 50}
113
- },
114
- indexes: [{columns: ['email'], unique: true}, {columns: ['role']}]
115
- })
116
-
117
- await Migration.migrate()
118
-
119
- const exists = await db.schema.hasTable('users')
120
- expect(exists).toBe(true)
121
- })
122
-
123
- it('should skip creation if table already exists', async () => {
124
- writeSchema('existing', {
125
- columns: {
126
- id: {type: 'increments'},
127
- name: {type: 'string'}
128
- },
129
- indexes: []
130
- })
131
-
132
- await Migration.migrate()
133
- const result2 = await Migration.migrate()
134
-
135
- // Second run should not try to create again
136
- const createOps = result2.default.schema.filter(op => op.type === 'create_table')
137
- expect(createOps).toHaveLength(0)
138
- })
139
- })
140
-
141
- // ---------------------------------------------------------------------------
142
- // COLUMN DIFF
143
- // ---------------------------------------------------------------------------
144
-
145
- describe('Column Diff', () => {
146
- it('should add a new column to an existing table', async () => {
147
- writeSchema('posts', {
148
- columns: {
149
- id: {type: 'increments'},
150
- title: {type: 'string'}
151
- },
152
- indexes: []
153
- })
154
-
155
- await Migration.migrate()
156
-
157
- // Now add a column
158
- writeSchema('posts', {
159
- columns: {
160
- id: {type: 'increments'},
161
- title: {type: 'string'},
162
- body: {type: 'text', nullable: true}
163
- },
164
- indexes: []
165
- })
166
-
167
- const result = await Migration.migrate()
168
- const addOps = result.default.schema.filter(op => op.type === 'add_column')
169
-
170
- expect(addOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'add_column', column: 'body', table: 'posts'})]))
171
-
172
- const info = await db('posts').columnInfo()
173
- expect(info).toHaveProperty('body')
174
- })
175
-
176
- it('should drop a column removed from schema', async () => {
177
- writeSchema('items', {
178
- columns: {
179
- id: {type: 'increments'},
180
- name: {type: 'string'},
181
- obsolete: {type: 'string'}
182
- },
183
- indexes: []
184
- })
185
-
186
- await Migration.migrate()
187
-
188
- // Remove the obsolete column
189
- writeSchema('items', {
190
- columns: {
191
- id: {type: 'increments'},
192
- name: {type: 'string'}
193
- },
194
- indexes: []
195
- })
196
-
197
- const result = await Migration.migrate()
198
- const dropOps = result.default.schema.filter(op => op.type === 'drop_column')
199
-
200
- expect(dropOps).toEqual(expect.arrayContaining([expect.objectContaining({type: 'drop_column', column: 'obsolete', table: 'items'})]))
201
- })
202
- })
203
-
204
- // ---------------------------------------------------------------------------
205
- // INDEX DIFF
206
- // ---------------------------------------------------------------------------
207
-
208
- describe('Index Diff', () => {
209
- it('should add a new index to an existing table', async () => {
210
- writeSchema('articles', {
211
- columns: {
212
- id: {type: 'increments'},
213
- slug: {type: 'string', length: 255},
214
- status: {type: 'string', length: 50}
215
- },
216
- indexes: []
217
- })
218
-
219
- await Migration.migrate()
220
-
221
- writeSchema('articles', {
222
- columns: {
223
- id: {type: 'increments'},
224
- slug: {type: 'string', length: 255},
225
- status: {type: 'string', length: 50}
226
- },
227
- indexes: [{columns: ['slug'], unique: true}]
228
- })
229
-
230
- const result = await Migration.migrate()
231
- const indexOps = result.default.schema.filter(op => op.type === 'add_index')
232
-
233
- expect(indexOps).toHaveLength(1)
234
- expect(indexOps[0].index.columns).toEqual(['slug'])
235
- expect(indexOps[0].index.unique).toBe(true)
236
- })
237
-
238
- it('should drop an index removed from schema', async () => {
239
- writeSchema('tags', {
240
- columns: {
241
- id: {type: 'increments'},
242
- name: {type: 'string', length: 100}
243
- },
244
- indexes: [{columns: ['name'], unique: false}]
245
- })
246
-
247
- await Migration.migrate()
248
-
249
- writeSchema('tags', {
250
- columns: {
251
- id: {type: 'increments'},
252
- name: {type: 'string', length: 100}
253
- },
254
- indexes: []
255
- })
256
-
257
- const result = await Migration.migrate()
258
- const dropIndexOps = result.default.schema.filter(op => op.type === 'drop_index')
259
-
260
- expect(dropIndexOps.length).toBeGreaterThanOrEqual(1)
261
- })
262
-
263
- it('should normalize column-level unique into indexes and be idempotent', async () => {
264
- writeSchema('apps', {
265
- columns: {
266
- id: {type: 'increments'},
267
- name: {type: 'string', length: 100, unique: true}
268
- },
269
- indexes: []
270
- })
271
-
272
- // First run: creates table with unique constraint via _buildIndexes
273
- const result1 = await Migration.migrate()
274
- const createOps = result1.default.schema.filter(op => op.type === 'create_table')
275
- expect(createOps).toHaveLength(1)
276
-
277
- // Second run: introspection finds the unique index, normalization puts it
278
- // in desiredIndexes — signatures should match, producing zero index diff ops.
279
- const result2 = await Migration.migrate()
280
- const indexOps2 = result2.default.schema.filter(op => op.type === 'add_index' || op.type === 'drop_index')
281
- expect(indexOps2).toHaveLength(0)
282
-
283
- // Third run: still stable (no index churn)
284
- const result3 = await Migration.migrate()
285
- const indexOps3 = result3.default.schema.filter(op => op.type === 'add_index' || op.type === 'drop_index')
286
- expect(indexOps3).toHaveLength(0)
287
- })
288
-
289
- it('should deduplicate column-level unique that also appears in indexes', async () => {
290
- writeSchema('orgs', {
291
- columns: {
292
- id: {type: 'increments'},
293
- slug: {type: 'string', length: 255, unique: true}
294
- },
295
- indexes: [{columns: ['slug'], unique: true}]
296
- })
297
-
298
- // Should not throw from duplicate constraint
299
- await Migration.migrate()
300
-
301
- // Subsequent run must produce zero index diff
302
- const result = await Migration.migrate()
303
- const indexOps = result.default.schema.filter(op => op.type === 'add_index' || op.type === 'drop_index')
304
- expect(indexOps).toHaveLength(0)
305
- })
306
-
307
- it('should not create implicit indexes for increments or timestamps', async () => {
308
- writeSchema('counters', {
309
- columns: {
310
- id: {type: 'increments'},
311
- ts: {type: 'timestamps'}
312
- },
313
- indexes: []
314
- })
315
-
316
- await Migration.migrate()
317
-
318
- // Verify the normalized schema didn't inject unexpected indexes
319
- const result = await Migration.migrate()
320
- expect(result.default.schema).toHaveLength(0)
321
- })
322
-
323
- it('should survive add_index when constraint already exists (idempotent)', async () => {
324
- // Create a table with a unique index directly via Knex
325
- await db.schema.createTable('apps', t => {
326
- t.increments('id')
327
- t.string('name', 100).notNullable()
328
- t.unique(['name'])
329
- })
330
-
331
- // Now define a schema that also declares unique on name
332
- // This forces _computeDiff to see the unique in desiredIndexes.
333
- // Even if introspection misses the existing constraint (PG edge case),
334
- // the idempotent _applyIndexOp must catch "already exists" gracefully.
335
- writeSchema('apps', {
336
- columns: {
337
- id: {type: 'increments'},
338
- name: {type: 'string', length: 100, nullable: false, unique: true}
339
- },
340
- indexes: []
341
- })
342
-
343
- // Should NOT throw — idempotent handling catches duplicate
344
- await expect(Migration.migrate()).resolves.toBeDefined()
345
-
346
- // Running again should also be stable
347
- await expect(Migration.migrate()).resolves.toBeDefined()
348
- })
349
-
350
- it('should survive drop_index when index already removed', async () => {
351
- // Create table WITHOUT an index
352
- await db.schema.createTable('widgets', t => {
353
- t.increments('id')
354
- t.string('code', 50)
355
- })
356
-
357
- // Schema says there should be an index — creates it
358
- writeSchema('widgets', {
359
- columns: {
360
- id: {type: 'increments'},
361
- code: {type: 'string', length: 50}
362
- },
363
- indexes: [{columns: ['code'], unique: false}]
364
- })
365
-
366
- await Migration.migrate()
367
-
368
- // Now remove the index from schema
369
- writeSchema('widgets', {
370
- columns: {
371
- id: {type: 'increments'},
372
- code: {type: 'string', length: 50}
373
- },
374
- indexes: []
375
- })
376
-
377
- // Drop the index manually first to simulate the "already removed" edge case
378
- await db.schema.alterTable('widgets', t => t.dropIndex(['code']))
379
-
380
- // Migration should NOT throw — idempotent handling catches "does not exist"
381
- await expect(Migration.migrate()).resolves.toBeDefined()
382
- })
383
- })
384
-
385
- // ---------------------------------------------------------------------------
386
- // SEED DATA
387
- // ---------------------------------------------------------------------------
388
-
389
- describe('Seed Data', () => {
390
- it('should insert seed data on first migrate', async () => {
391
- writeSchema('roles', {
392
- columns: {
393
- id: {type: 'increments'},
394
- name: {type: 'string', length: 50},
395
- level: {type: 'integer', default: 0}
396
- },
397
- indexes: [],
398
- seed: [
399
- {name: 'admin', level: 100},
400
- {name: 'user', level: 1}
401
- ],
402
- seedKey: 'name'
403
- })
404
-
405
- const result = await Migration.migrate()
406
-
407
- expect(result.default.seeds).toEqual(
408
- expect.arrayContaining([
409
- expect.objectContaining({type: 'seed_insert', table: 'roles', key: 'admin'}),
410
- expect.objectContaining({type: 'seed_insert', table: 'roles', key: 'user'})
411
- ])
412
- )
413
-
414
- const rows = await db('roles').select()
415
- expect(rows).toHaveLength(2)
416
- expect(rows.find(r => r.name === 'admin').level).toBe(100)
417
- })
418
-
419
- it('should update seed data if values changed', async () => {
420
- writeSchema('settings', {
421
- columns: {
422
- id: {type: 'increments'},
423
- key: {type: 'string', length: 100},
424
- value: {type: 'string', length: 255}
425
- },
426
- indexes: [],
427
- seed: [{key: 'site_name', value: 'My App'}],
428
- seedKey: 'key'
429
- })
430
-
431
- await Migration.migrate()
432
-
433
- // Update the seed value
434
- writeSchema('settings', {
435
- columns: {
436
- id: {type: 'increments'},
437
- key: {type: 'string', length: 100},
438
- value: {type: 'string', length: 255}
439
- },
440
- indexes: [],
441
- seed: [{key: 'site_name', value: 'New App Name'}],
442
- seedKey: 'key'
443
- })
444
-
445
- const result = await Migration.migrate()
446
-
447
- expect(result.default.seeds).toEqual(
448
- expect.arrayContaining([expect.objectContaining({type: 'seed_update', table: 'settings', key: 'site_name'})])
449
- )
450
-
451
- const row = await db('settings').where('key', 'site_name').first()
452
- expect(row.value).toBe('New App Name')
453
- })
454
-
455
- it('should not re-insert existing seed data', async () => {
456
- writeSchema('statuses', {
457
- columns: {
458
- id: {type: 'increments'},
459
- name: {type: 'string', length: 50}
460
- },
461
- indexes: [],
462
- seed: [{name: 'active'}, {name: 'inactive'}],
463
- seedKey: 'name'
464
- })
465
-
466
- await Migration.migrate()
467
- const result2 = await Migration.migrate()
468
-
469
- // Seeds should have nothing to do second time
470
- expect(result2.default.seeds).toHaveLength(0)
471
- })
472
-
473
- it('should throw if seed exists but seedKey is missing', async () => {
474
- writeSchema('bad_seed', {
475
- columns: {
476
- id: {type: 'increments'},
477
- name: {type: 'string'}
478
- },
479
- indexes: [],
480
- seed: [{name: 'oops'}]
481
- })
482
-
483
- await expect(Migration.migrate()).rejects.toThrow('seedKey')
484
- })
485
-
486
- it('should correctly compare JSON object seed values without false-positive updates', async () => {
487
- writeSchema('apps', {
488
- columns: {
489
- id: {type: 'increments'},
490
- name: {type: 'string', length: 100},
491
- config: {type: 'json'}
492
- },
493
- indexes: [],
494
- seed: [{name: 'myapp', config: JSON.stringify({host: 'data', container: '/data'})}],
495
- seedKey: 'name'
496
- })
497
-
498
- // First migrate — inserts seed
499
- const result1 = await Migration.migrate()
500
- expect(result1.default.seeds).toEqual(expect.arrayContaining([expect.objectContaining({type: 'seed_insert', table: 'apps'})]))
501
-
502
- // Second migrate — identical JSON seed must NOT trigger update
503
- const result2 = await Migration.migrate()
504
- const updateOps = result2.default.seeds.filter(s => s.type === 'seed_update')
505
- expect(updateOps).toHaveLength(0)
506
- })
507
-
508
- it('should detect actual changes in JSON seed values', async () => {
509
- writeSchema('services', {
510
- columns: {
511
- id: {type: 'increments'},
512
- name: {type: 'string', length: 100},
513
- meta: {type: 'json'}
514
- },
515
- indexes: [],
516
- seed: [{name: 'api', meta: JSON.stringify({version: 1})}],
517
- seedKey: 'name'
518
- })
519
-
520
- await Migration.migrate()
521
-
522
- // Update the JSON value
523
- writeSchema('services', {
524
- columns: {
525
- id: {type: 'increments'},
526
- name: {type: 'string', length: 100},
527
- meta: {type: 'json'}
528
- },
529
- indexes: [],
530
- seed: [{name: 'api', meta: JSON.stringify({version: 2, new_field: true})}],
531
- seedKey: 'name'
532
- })
533
-
534
- const result = await Migration.migrate()
535
- expect(result.default.seeds).toEqual(
536
- expect.arrayContaining([expect.objectContaining({type: 'seed_update', table: 'services', key: 'api'})])
537
- )
538
- })
539
-
540
- it('should automatically stringify raw objects/arrays in JSON seed data to prevent PG array conversion errors', async () => {
541
- writeSchema('raw_json_seed', {
542
- columns: {
543
- id: {type: 'increments'},
544
- name: {type: 'string', length: 100},
545
- ports: {type: 'jsonb'},
546
- volumes: {type: 'jsonb'}
547
- },
548
- indexes: [],
549
- seed: [
550
- {
551
- name: 'app1',
552
- ports: ['80:80', '443:443'],
553
- volumes: [{host: 'data', container: '/data'}]
554
- }
555
- ],
556
- seedKey: 'name'
557
- })
558
-
559
- // First migrate — inserts seed
560
- const result1 = await Migration.migrate()
561
- expect(result1.default.seeds).toEqual(
562
- expect.arrayContaining([expect.objectContaining({type: 'seed_insert', table: 'raw_json_seed'})])
563
- )
564
-
565
- // Verify the data was stringified correctly
566
- const rows = await db('raw_json_seed').orderBy('name')
567
- expect(rows[0].ports).toBe('["80:80","443:443"]')
568
- expect(rows[0].volumes).toBe('[{"host":"data","container":"/data"}]')
569
-
570
- // Second migrate — identical raw JSON seed must NOT trigger update
571
- const result2 = await Migration.migrate()
572
- const updateOps = result2.default.seeds.filter(s => s.type === 'seed_update')
573
- expect(updateOps).toHaveLength(0)
574
- })
575
-
576
- it('should handle null values in seed comparison without crashing', async () => {
577
- writeSchema('flags', {
578
- columns: {
579
- id: {type: 'increments'},
580
- name: {type: 'string', length: 50},
581
- value: {type: 'string', nullable: true}
582
- },
583
- indexes: [],
584
- seed: [{name: 'debug', value: null}],
585
- seedKey: 'name'
586
- })
587
-
588
- await Migration.migrate()
589
-
590
- // Second run — null stays null, no update needed
591
- const result = await Migration.migrate()
592
- expect(result.default.seeds.filter(s => s.type === 'seed_update')).toHaveLength(0)
593
- })
594
- })
595
-
596
- // ---------------------------------------------------------------------------
597
- // IMPERATIVE MIGRATION FILES
598
- // ---------------------------------------------------------------------------
599
-
600
- describe('Migration Files', () => {
601
- it('should run pending migration files in order', async () => {
602
- // Create a table first
603
- await db.schema.createTable('counters', t => {
604
- t.increments('id')
605
- t.string('name')
606
- t.integer('value').defaultTo(0)
607
- })
608
-
609
- writeMigrationFile(
610
- '20260225_001_init_counters.js',
611
- async function up(db) {
612
- await db('counters').insert({name: 'visits', value: 0})
613
- },
614
- async function down(db) {
615
- await db('counters').where('name', 'visits').del()
616
- }
617
- )
618
-
619
- writeMigrationFile(
620
- '20260225_002_add_counter.js',
621
- async function up(db) {
622
- await db('counters').insert({name: 'signups', value: 0})
623
- },
624
- async function down(db) {
625
- await db('counters').where('name', 'signups').del()
626
- }
627
- )
628
-
629
- const result = await Migration.migrate()
630
- const fileOps = result.default.files
631
-
632
- expect(fileOps).toHaveLength(2)
633
- expect(fileOps[0].name).toBe('20260225_001_init_counters.js')
634
- expect(fileOps[1].name).toBe('20260225_002_add_counter.js')
635
-
636
- const rows = await db('counters').select()
637
- expect(rows).toHaveLength(2)
638
- })
639
-
640
- it('should not re-run already applied migration files', async () => {
641
- await db.schema.createTable('data', t => {
642
- t.increments('id')
643
- t.string('value')
644
- })
645
-
646
- writeMigrationFile('20260225_001_insert.js', async function up(db) {
647
- await db('data').insert({value: 'test'})
648
- })
649
-
650
- await Migration.migrate()
651
- const result2 = await Migration.migrate()
652
-
653
- expect(result2.default.files).toHaveLength(0)
654
-
655
- const rows = await db('data').select()
656
- expect(rows).toHaveLength(1) // Only inserted once
657
- })
658
-
659
- it('should throw if migration file has no up function', async () => {
660
- const dir = path.join(tmpDir, 'migration')
661
- fs.mkdirSync(dir, {recursive: true})
662
- fs.writeFileSync(path.join(dir, '20260225_001_bad.js'), 'module.exports = {}')
663
-
664
- await expect(Migration.migrate()).rejects.toThrow("missing an 'up' function")
665
- })
666
- })
667
-
668
- // ---------------------------------------------------------------------------
669
- // ROLLBACK
670
- // ---------------------------------------------------------------------------
671
-
672
- describe('Rollback', () => {
673
- it('should rollback the last batch', async () => {
674
- await db.schema.createTable('entries', t => {
675
- t.increments('id')
676
- t.string('name')
677
- })
678
-
679
- writeMigrationFile(
680
- '20260225_001_add_entry.js',
681
- async function up(db) {
682
- await db('entries').insert({name: 'first'})
683
- },
684
- async function down(db) {
685
- await db('entries').where('name', 'first').del()
686
- }
687
- )
688
-
689
- await Migration.migrate()
690
-
691
- const before = await db('entries').select()
692
- expect(before).toHaveLength(1)
693
-
694
- const result = await Migration.rollback()
695
-
696
- expect(result.default).toEqual(
697
- expect.arrayContaining([expect.objectContaining({type: 'rolled_back', name: '20260225_001_add_entry.js'})])
698
- )
699
-
700
- const after = await db('entries').select()
701
- expect(after).toHaveLength(0)
702
- })
703
- })
704
-
705
- // ---------------------------------------------------------------------------
706
- // DRY RUN (STATUS)
707
- // ---------------------------------------------------------------------------
708
-
709
- describe('Status (Dry Run)', () => {
710
- it('should show pending changes without applying them', async () => {
711
- writeSchema('preview', {
712
- columns: {
713
- id: {type: 'increments'},
714
- name: {type: 'string'}
715
- },
716
- indexes: []
717
- })
718
-
719
- const result = await Migration.status()
720
-
721
- expect(result.default.schema).toEqual(expect.arrayContaining([expect.objectContaining({type: 'create_table', table: 'preview'})]))
722
-
723
- // Table should NOT exist (dry-run)
724
- const exists = await db.schema.hasTable('preview')
725
- expect(exists).toBe(false)
726
- })
727
- })
728
-
729
- // ---------------------------------------------------------------------------
730
- // SNAPSHOT
731
- // ---------------------------------------------------------------------------
732
-
733
- describe('Snapshot', () => {
734
- it('should reverse-engineer existing tables into schema files', async () => {
735
- await db.schema.createTable('customers', t => {
736
- t.increments('id')
737
- t.string('name', 100)
738
- t.string('email', 255)
739
- t.boolean('vip').defaultTo(false)
740
- })
741
-
742
- const result = await Migration.snapshot()
743
- const files = result.default
744
-
745
- expect(files.length).toBeGreaterThanOrEqual(1)
746
-
747
- const customerFile = files.find(f => f.includes('customers'))
748
- expect(customerFile).toBeDefined()
749
- expect(fs.existsSync(customerFile)).toBe(true)
750
-
751
- const content = fs.readFileSync(customerFile, 'utf8')
752
- expect(content).toContain('customers')
753
- expect(content).toContain('columns')
754
- })
755
-
756
- it('should skip the tracking table in snapshot', async () => {
757
- writeSchema('dummy', {
758
- columns: {id: {type: 'increments'}},
759
- indexes: []
760
- })
761
- await Migration.migrate()
762
-
763
- const result = await Migration.snapshot()
764
- const files = result.default
765
-
766
- const trackingFile = files.find(f => f.includes('_odac_migrations'))
767
- expect(trackingFile).toBeUndefined()
768
- })
769
- })
770
-
771
- // ---------------------------------------------------------------------------
772
- // MULTI-DATABASE
773
- // ---------------------------------------------------------------------------
774
-
775
- describe('Multi-Database', () => {
776
- let analyticsDb
777
-
778
- beforeEach(async () => {
779
- analyticsDb = knex({
780
- client: 'sqlite3',
781
- connection: {filename: ':memory:'},
782
- useNullAsDefault: true
783
- })
784
-
785
- Migration.init(tmpDir, {default: db, analytics: analyticsDb})
786
- })
787
-
788
- afterEach(async () => {
789
- await analyticsDb.destroy()
790
- })
791
-
792
- it('should migrate different schemas to different connections', async () => {
793
- writeSchema('users', {
794
- columns: {id: {type: 'increments'}, name: {type: 'string'}},
795
- indexes: []
796
- })
797
-
798
- writeSchema(
799
- 'events',
800
- {
801
- columns: {id: {type: 'increments'}, action: {type: 'string'}},
802
- indexes: []
803
- },
804
- 'analytics'
805
- )
806
-
807
- await Migration.migrate()
808
-
809
- // users only on default
810
- const defaultExists = await db.schema.hasTable('users')
811
- expect(defaultExists).toBe(true)
812
-
813
- // events only on analytics
814
- const analyticsExists = await analyticsDb.schema.hasTable('events')
815
- expect(analyticsExists).toBe(true)
816
-
817
- // Cross-check: events should NOT be on default
818
- const crossCheck = await db.schema.hasTable('events')
819
- expect(crossCheck).toBe(false)
820
- })
821
-
822
- it('should migrate only targeted db with --db flag', async () => {
823
- writeSchema('alpha', {
824
- columns: {id: {type: 'increments'}},
825
- indexes: []
826
- })
827
-
828
- writeSchema(
829
- 'beta',
830
- {
831
- columns: {id: {type: 'increments'}},
832
- indexes: []
833
- },
834
- 'analytics'
835
- )
836
-
837
- const result = await Migration.migrate({db: 'analytics'})
838
-
839
- // Only analytics should be in the result
840
- expect(result).toHaveProperty('analytics')
841
- expect(result).not.toHaveProperty('default')
842
-
843
- // beta should exist on analytics
844
- const betaExists = await analyticsDb.schema.hasTable('beta')
845
- expect(betaExists).toBe(true)
846
-
847
- // alpha should NOT exist on default (wasn't targeted)
848
- const alphaExists = await db.schema.hasTable('alpha')
849
- expect(alphaExists).toBe(false)
850
- })
851
-
852
- it('should throw for unknown connection key', async () => {
853
- await expect(Migration.migrate({db: 'nonexistent'})).rejects.toThrow('Unknown database connection')
854
- })
855
- })
856
-
857
- // ---------------------------------------------------------------------------
858
- // TRACKING TABLE
859
- // ---------------------------------------------------------------------------
860
-
861
- describe('Tracking Table', () => {
862
- it('should auto-create the tracking table on first run', async () => {
863
- writeSchema('first', {
864
- columns: {id: {type: 'increments'}},
865
- indexes: []
866
- })
867
-
868
- await Migration.migrate()
869
-
870
- const exists = await db.schema.hasTable('_odac_migrations')
871
- expect(exists).toBe(true)
872
- })
873
- })
874
-
875
- // ---------------------------------------------------------------------------
876
- // EDGE CASES
877
- // ---------------------------------------------------------------------------
878
-
879
- describe('Edge Cases', () => {
880
- it('should handle empty schema directory gracefully', async () => {
881
- const result = await Migration.migrate()
882
-
883
- expect(result.default.schema).toHaveLength(0)
884
- expect(result.default.files).toHaveLength(0)
885
- expect(result.default.seeds).toHaveLength(0)
886
- })
887
-
888
- it('should handle multiple column types correctly', async () => {
889
- writeSchema('all_types', {
890
- columns: {
891
- id: {type: 'increments'},
892
- big_id: {type: 'bigInteger'},
893
- score: {type: 'float'},
894
- amount: {type: 'decimal', precision: 12, scale: 4},
895
- label: {type: 'string', length: 50},
896
- body: {type: 'text'},
897
- active: {type: 'boolean'},
898
- born_on: {type: 'date'},
899
- login_at: {type: 'datetime'},
900
- meta: {type: 'json'},
901
- uid: {type: 'uuid'}
902
- },
903
- indexes: []
904
- })
905
-
906
- await Migration.migrate()
907
-
908
- const info = await db('all_types').columnInfo()
909
- expect(Object.keys(info)).toHaveLength(11)
910
- })
911
-
912
- it('should handle foreign key references', async () => {
913
- writeSchema('categories', {
914
- columns: {
915
- id: {type: 'increments'},
916
- name: {type: 'string'}
917
- },
918
- indexes: []
919
- })
920
-
921
- writeSchema('products', {
922
- columns: {
923
- id: {type: 'increments'},
924
- name: {type: 'string'},
925
- category_id: {
926
- type: 'integer',
927
- unsigned: true,
928
- references: {table: 'categories', column: 'id'},
929
- onDelete: 'CASCADE'
930
- }
931
- },
932
- indexes: []
933
- })
934
-
935
- await Migration.migrate()
936
-
937
- const catExists = await db.schema.hasTable('categories')
938
- const prodExists = await db.schema.hasTable('products')
939
- expect(catExists).toBe(true)
940
- expect(prodExists).toBe(true)
941
- })
942
- })
943
- })