odac 1.4.0 → 1.4.1

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 (40) hide show
  1. package/.agent/rules/memory.md +3 -0
  2. package/.github/workflows/release.yml +1 -1
  3. package/CHANGELOG.md +26 -0
  4. package/README.md +10 -0
  5. package/bin/odac.js +190 -0
  6. package/docs/ai/skills/SKILL.md +4 -3
  7. package/docs/ai/skills/backend/authentication.md +7 -0
  8. package/docs/ai/skills/backend/config.md +7 -0
  9. package/docs/ai/skills/backend/controllers.md +7 -0
  10. package/docs/ai/skills/backend/cron.md +9 -2
  11. package/docs/ai/skills/backend/database.md +18 -2
  12. package/docs/ai/skills/backend/forms.md +8 -1
  13. package/docs/ai/skills/backend/ipc.md +7 -0
  14. package/docs/ai/skills/backend/mail.md +7 -0
  15. package/docs/ai/skills/backend/migrations.md +80 -0
  16. package/docs/ai/skills/backend/request_response.md +7 -0
  17. package/docs/ai/skills/backend/routing.md +7 -0
  18. package/docs/ai/skills/backend/storage.md +7 -0
  19. package/docs/ai/skills/backend/streaming.md +7 -0
  20. package/docs/ai/skills/backend/structure.md +8 -1
  21. package/docs/ai/skills/backend/translations.md +7 -0
  22. package/docs/ai/skills/backend/utilities.md +7 -0
  23. package/docs/ai/skills/backend/validation.md +7 -0
  24. package/docs/ai/skills/backend/views.md +7 -0
  25. package/docs/ai/skills/frontend/core.md +7 -0
  26. package/docs/ai/skills/frontend/forms.md +7 -0
  27. package/docs/ai/skills/frontend/navigation.md +7 -0
  28. package/docs/ai/skills/frontend/realtime.md +7 -0
  29. package/docs/backend/08-database/04-migrations.md +258 -37
  30. package/package.json +1 -1
  31. package/src/Auth.js +70 -44
  32. package/src/Config.js +1 -1
  33. package/src/Database/ConnectionFactory.js +69 -0
  34. package/src/Database/Migration.js +1203 -0
  35. package/src/Database.js +35 -35
  36. package/template/schema/users.js +23 -0
  37. package/test/Auth.test.js +64 -3
  38. package/test/Config.test.js +7 -0
  39. package/test/Database/ConnectionFactory.test.js +80 -0
  40. package/test/Migration.test.js +943 -0
package/src/Database.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict'
2
- const knex = require('knex')
2
+ const {buildConnections} = require('./Database/ConnectionFactory')
3
3
 
4
4
  class DatabaseManager {
5
5
  constructor() {
@@ -9,41 +9,9 @@ class DatabaseManager {
9
9
  async init() {
10
10
  if (!Odac.Config.database) return
11
11
 
12
- let multiple = typeof Odac.Config.database[Object.keys(Odac.Config.database)[0]] === 'object'
13
- let dbs = multiple ? Odac.Config.database : {default: Odac.Config.database}
14
-
15
- for (let key of Object.keys(dbs)) {
16
- let db = dbs[key]
17
- let client = 'mysql2'
18
- if (db.type === 'postgres' || db.type === 'pg' || db.type === 'postgresql') client = 'pg'
19
- if (db.type === 'sqlite' || db.type === 'sqlite3') client = 'sqlite3'
20
-
21
- let connectionConfig
22
-
23
- if (client === 'sqlite3') {
24
- connectionConfig = {
25
- filename: db.filename || db.database || './dev.sqlite3'
26
- }
27
- } else {
28
- connectionConfig = {
29
- host: db.host || '127.0.0.1',
30
- user: db.user,
31
- password: db.password,
32
- database: db.database,
33
- port: db.port
34
- }
35
- }
36
-
37
- this.connections[key] = knex({
38
- client: client,
39
- connection: connectionConfig,
40
- pool: {
41
- min: 0,
42
- max: db.connectionLimit || 10
43
- },
44
- useNullAsDefault: true // For sqlite
45
- })
12
+ this.connections = buildConnections(Odac.Config.database)
46
13
 
14
+ for (const key of Object.keys(this.connections)) {
47
15
  // Test connection
48
16
  try {
49
17
  await this.connections[key].raw('SELECT 1')
@@ -52,6 +20,38 @@ class DatabaseManager {
52
20
  console.error(e.message)
53
21
  }
54
22
  }
23
+
24
+ // Auto-migrate: sync schema/ files with the database on every startup.
25
+ // Why: Zero-config philosophy — deploy and forget. The app always starts with the correct DB state.
26
+ await this._autoMigrate()
27
+ }
28
+
29
+ /**
30
+ * Runs the schema-first migration engine against all active connections.
31
+ * CLUSTER SAFETY: Only runs on the primary process to prevent race conditions.
32
+ * Workers are forked AFTER Server.init(), which happens after Database.init(),
33
+ * so migrations are guaranteed to complete before any worker touches the DB.
34
+ * Silently skips if no schema/ directory exists (no-op for projects without migrations).
35
+ */
36
+ async _autoMigrate() {
37
+ const cluster = require('node:cluster')
38
+ if (!cluster.isPrimary) return
39
+
40
+ const fs = require('node:fs')
41
+ const path = require('node:path')
42
+ const schemaDir = path.join(global.__dir, 'schema')
43
+
44
+ if (!fs.existsSync(schemaDir)) return
45
+ if (Object.keys(this.connections).length === 0) return
46
+
47
+ const Migration = require('./Database/Migration')
48
+ Migration.init(global.__dir, this.connections)
49
+
50
+ try {
51
+ await Migration.migrate()
52
+ } catch (e) {
53
+ throw new Error(`Odac Migration Error: ${e.message}`, {cause: e})
54
+ }
55
55
  }
56
56
 
57
57
  nanoid(size = 21) {
@@ -0,0 +1,23 @@
1
+ // Schema definition for 'users' — example schema file
2
+ // This is the single source of truth for the 'users' table.
3
+ // AI agents read this file to understand the final database state.
4
+ 'use strict'
5
+
6
+ module.exports = {
7
+ columns: {
8
+ id: {type: 'increments'},
9
+ name: {type: 'string', length: 255, nullable: false},
10
+ email: {type: 'string', length: 255, nullable: false},
11
+ password: {type: 'string', length: 255, nullable: false},
12
+ role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
13
+ is_active: {type: 'boolean', default: true},
14
+ timestamps: {type: 'timestamps'}
15
+ },
16
+
17
+ indexes: [{columns: ['email'], unique: true}, {columns: ['role', 'is_active']}],
18
+
19
+ // Seed data — idempotent, runs on every migrate.
20
+ // seedKey determines the uniqueness check for upsert.
21
+ seed: [{name: 'Admin', email: 'admin@example.com', password: 'changeme', role: 'admin'}],
22
+ seedKey: 'email'
23
+ }
package/test/Auth.test.js CHANGED
@@ -137,11 +137,19 @@ describe('Auth - Refresh Token Rotation', () => {
137
137
  // New cookie values must differ from old ones
138
138
  expect(xSet[1]).not.toBe('old_x')
139
139
  expect(ySet[1]).not.toBe('old_y')
140
+ // Cookie max-age must use proper HTTP attribute name (hyphenated, not camelCase)
141
+ expect(xSet[2]['max-age']).toBeDefined()
142
+ expect(xSet[2].maxAge).toBeUndefined()
140
143
  })
141
144
 
142
- it('should NOT rotate a token already marked as rotated (Epoch Date marker)', async () => {
145
+ it('should NOT rotate a recently-rotated token still within 5s threshold', async () => {
146
+ // Simulate a rotated token where active was set to give ~60s grace period
147
+ // and the rotation JUST happened (< 5 seconds ago)
148
+ const maxAge = 30 * 24 * 60 * 60 * 1000
149
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 60000) // Grace period: ~60s left
150
+
143
151
  const mockRecord = {
144
- active: new Date(), // Still within maxAge
152
+ active: rotatedActiveDate,
145
153
  browser: 'TestBrowser',
146
154
  date: new Date(0), // Epoch marker = already rotated
147
155
  id: 'token_2',
@@ -158,11 +166,64 @@ describe('Auth - Refresh Token Rotation', () => {
158
166
  const result = await authInstance.check()
159
167
 
160
168
  expect(result).toBe(true)
161
- // No rotation should occur
169
+ // No rotation should occur (timeSinceRotation < 5000)
162
170
  expect(dbMock.insert).not.toHaveBeenCalled()
163
171
  expect(dbMock.tracker.updateCalls.length).toBe(0)
164
172
  })
165
173
 
174
+ it('should recovery-rotate and DELETE old token when client lost cookies (rotated token > 5s)', async () => {
175
+ // Simulate a rotated token where 10 seconds have passed since original rotation
176
+ // Client still has old cookies → recovery rotation should trigger
177
+ const maxAge = 30 * 24 * 60 * 60 * 1000
178
+ const timeSinceRotation = 10000 // 10 seconds since original rotation
179
+ // active was set to: rotationTime - maxAge + 60000
180
+ // So: inactiveAge = now - active = now - (rotationTime - maxAge + 60000) = timeSinceRotation + maxAge - 60000
181
+ // timeSinceRotation formula: inactiveAge - maxAge + 60000 = timeSinceRotation = 10000
182
+ const rotatedActiveDate = new Date(Date.now() - maxAge + 60000 - timeSinceRotation)
183
+
184
+ const mockRecord = {
185
+ active: rotatedActiveDate,
186
+ browser: 'TestBrowser',
187
+ date: new Date(0), // Epoch marker = rotated
188
+ id: 'token_recovery',
189
+ ip: '127.0.0.1',
190
+ token_x: 'old_x',
191
+ token_y: 'hashed_old_y',
192
+ user: 'user_10'
193
+ }
194
+
195
+ const dbMock = createDbMock([mockRecord])
196
+ global.Odac.DB.user_tokens = dbMock
197
+ global.Odac.DB.users = dbMock
198
+
199
+ const result = await authInstance.check()
200
+
201
+ expect(result).toBe(true)
202
+
203
+ // Should insert a new token (recovery rotation)
204
+ expect(dbMock.insert).toHaveBeenCalledTimes(1)
205
+ const inserted = dbMock.tracker.insertCalls[0]
206
+ expect(inserted.user).toBe('user_10')
207
+ expect(inserted.token_x).toBeDefined()
208
+
209
+ // Old token should be DELETED, not updated (prevents token multiplication)
210
+ expect(dbMock.tracker.deleteCalls.length).toBe(1)
211
+ expect(dbMock.tracker.updateCalls.length).toBe(0)
212
+
213
+ // New cookies should be issued
214
+ const setCalls = reqMock.cookie.mock.calls.filter(c => c.length >= 2)
215
+ const xSet = setCalls.find(c => c[0] === 'odac_x' && c[2]?.httpOnly === true)
216
+ const ySet = setCalls.find(c => c[0] === 'odac_y' && c[2]?.httpOnly === true)
217
+ expect(xSet).toBeDefined()
218
+ expect(ySet).toBeDefined()
219
+ expect(xSet[1]).not.toBe('old_x')
220
+ expect(ySet[1]).not.toBe('old_y')
221
+
222
+ // Cookie max-age attribute should use proper HTTP naming (hyphenated)
223
+ expect(xSet[2]['max-age']).toBeDefined()
224
+ expect(xSet[2].maxAge).toBeUndefined()
225
+ })
226
+
166
227
  it('should NOT rotate when tokenAge is within rotationAge threshold', async () => {
167
228
  const recentDate = Date.now() - 5 * 60 * 1000 // 5 mins ago -> within 15 min rotationAge
168
229
 
@@ -77,6 +77,13 @@ describe('Config', () => {
77
77
  expect(result).toBe('hello-bar')
78
78
  })
79
79
 
80
+ it('should replace ${VAR} when variable name includes hyphen', () => {
81
+ process.env['MY-VAR'] = 'hyphen-value'
82
+ const result = Config._interpolate('hello-${MY-VAR}')
83
+ expect(result).toBe('hello-hyphen-value')
84
+ delete process.env['MY-VAR']
85
+ })
86
+
80
87
  it('should replace ${odac} with client path', () => {
81
88
  // __dirname in Config.js is /.../src, so it replaces /src with /client
82
89
  const result = Config._interpolate('path-${odac}')
@@ -0,0 +1,80 @@
1
+ const mockKnex = jest.fn()
2
+
3
+ jest.mock(
4
+ 'knex',
5
+ () =>
6
+ (...args) =>
7
+ mockKnex(...args)
8
+ )
9
+
10
+ const {buildConnections, buildConnectionConfig, resolveClient} = require('../../src/Database/ConnectionFactory')
11
+
12
+ describe('Database ConnectionFactory', () => {
13
+ beforeEach(() => {
14
+ mockKnex.mockReset()
15
+ mockKnex.mockImplementation(options => ({
16
+ options,
17
+ raw: jest.fn()
18
+ }))
19
+ })
20
+
21
+ it('resolveClient should map known database aliases', () => {
22
+ expect(resolveClient('postgres')).toBe('pg')
23
+ expect(resolveClient('postgresql')).toBe('pg')
24
+ expect(resolveClient('pg')).toBe('pg')
25
+ expect(resolveClient('sqlite')).toBe('sqlite3')
26
+ expect(resolveClient('sqlite3')).toBe('sqlite3')
27
+ expect(resolveClient('mysql')).toBe('mysql2')
28
+ })
29
+
30
+ it('buildConnectionConfig should create sqlite filename config', () => {
31
+ const config = buildConnectionConfig({database: 'db.sqlite3'}, 'sqlite3')
32
+ expect(config).toEqual({filename: 'db.sqlite3'})
33
+ })
34
+
35
+ it('buildConnectionConfig should create host based config for non-sqlite', () => {
36
+ const config = buildConnectionConfig(
37
+ {
38
+ user: 'root',
39
+ password: 'secret',
40
+ database: 'app',
41
+ port: 3306
42
+ },
43
+ 'mysql2'
44
+ )
45
+
46
+ expect(config).toEqual({
47
+ host: '127.0.0.1',
48
+ user: 'root',
49
+ password: 'secret',
50
+ database: 'app',
51
+ port: 3306
52
+ })
53
+ })
54
+
55
+ it('buildConnections should support single database config', () => {
56
+ const connections = buildConnections({
57
+ type: 'mysql',
58
+ user: 'root',
59
+ database: 'app'
60
+ })
61
+
62
+ expect(Object.keys(connections)).toEqual(['default'])
63
+ expect(mockKnex).toHaveBeenCalledTimes(1)
64
+ expect(mockKnex.mock.calls[0][0]).toMatchObject({
65
+ client: 'mysql2',
66
+ pool: {min: 0, max: 10},
67
+ useNullAsDefault: true
68
+ })
69
+ })
70
+
71
+ it('buildConnections should support multi database config', () => {
72
+ const connections = buildConnections({
73
+ analytics: {type: 'postgres', user: 'u', database: 'a'},
74
+ default: {type: 'sqlite', filename: './dev.sqlite3'}
75
+ })
76
+
77
+ expect(Object.keys(connections).sort()).toEqual(['analytics', 'default'])
78
+ expect(mockKnex).toHaveBeenCalledTimes(2)
79
+ })
80
+ })