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.
- package/.agent/rules/memory.md +3 -0
- package/.github/workflows/release.yml +1 -1
- package/CHANGELOG.md +26 -0
- package/README.md +10 -0
- package/bin/odac.js +190 -0
- package/docs/ai/skills/SKILL.md +4 -3
- package/docs/ai/skills/backend/authentication.md +7 -0
- package/docs/ai/skills/backend/config.md +7 -0
- package/docs/ai/skills/backend/controllers.md +7 -0
- package/docs/ai/skills/backend/cron.md +9 -2
- package/docs/ai/skills/backend/database.md +18 -2
- package/docs/ai/skills/backend/forms.md +8 -1
- package/docs/ai/skills/backend/ipc.md +7 -0
- package/docs/ai/skills/backend/mail.md +7 -0
- package/docs/ai/skills/backend/migrations.md +80 -0
- package/docs/ai/skills/backend/request_response.md +7 -0
- package/docs/ai/skills/backend/routing.md +7 -0
- package/docs/ai/skills/backend/storage.md +7 -0
- package/docs/ai/skills/backend/streaming.md +7 -0
- package/docs/ai/skills/backend/structure.md +8 -1
- package/docs/ai/skills/backend/translations.md +7 -0
- package/docs/ai/skills/backend/utilities.md +7 -0
- package/docs/ai/skills/backend/validation.md +7 -0
- package/docs/ai/skills/backend/views.md +7 -0
- package/docs/ai/skills/frontend/core.md +7 -0
- package/docs/ai/skills/frontend/forms.md +7 -0
- package/docs/ai/skills/frontend/navigation.md +7 -0
- package/docs/ai/skills/frontend/realtime.md +7 -0
- package/docs/backend/08-database/04-migrations.md +258 -37
- package/package.json +1 -1
- package/src/Auth.js +70 -44
- package/src/Config.js +1 -1
- package/src/Database/ConnectionFactory.js +69 -0
- package/src/Database/Migration.js +1203 -0
- package/src/Database.js +35 -35
- package/template/schema/users.js +23 -0
- package/test/Auth.test.js +64 -3
- package/test/Config.test.js +7 -0
- package/test/Database/ConnectionFactory.test.js +80 -0
- package/test/Migration.test.js +943 -0
package/src/Database.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
const
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
package/test/Config.test.js
CHANGED
|
@@ -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
|
+
})
|