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
@@ -1,3 +1,10 @@
1
+ ---
2
+ name: frontend-forms-api-skill
3
+ description: AJAX form and API request patterns in odac.js for interactive UX and predictable frontend data flows.
4
+ metadata:
5
+ tags: frontend, forms, ajax, api-requests, odac-get, odac-post
6
+ ---
7
+
1
8
  # Frontend Forms & API Skill
2
9
 
3
10
  Handling AJAX form submissions and API requests.
@@ -1,3 +1,10 @@
1
+ ---
2
+ name: frontend-navigation-spa-skill
3
+ description: Single-page navigation patterns in odac.js for smooth transitions, route control, and lifecycle-safe execution.
4
+ metadata:
5
+ tags: frontend, navigation, spa, ajax-navigation, page-lifecycle, transitions
6
+ ---
7
+
1
8
  # Frontend Navigation & SPA Skill
2
9
 
3
10
  Smooth transitions and single-page application behavior using `odac.js`.
@@ -1,3 +1,10 @@
1
+ ---
2
+ name: frontend-realtime-websocket-skill
3
+ description: Realtime frontend communication patterns in odac.js using shared WebSockets, SSE, and resilient reconnect behavior.
4
+ metadata:
5
+ tags: frontend, realtime, websocket, sse, sharedworker, auto-reconnect
6
+ ---
7
+
1
8
  # Frontend Realtime & WebSocket Skill
2
9
 
3
10
  Real-time bidirectional communication and server-sent events with high efficiency.
@@ -1,48 +1,269 @@
1
- # Code-First Migrations
1
+ # Schema-First Migrations
2
2
 
3
- Migration files are great, but sometimes (especially in rapid development or zero-config apps) you want dependencies to define their own table structures automatically.
3
+ ODAC uses a **declarative, schema-first** approach to database migrations. Instead of writing sequential migration files, you define the **desired final state** of each table in a schema file. The engine automatically diffs the schema against your database and applies the necessary changes.
4
4
 
5
- ODAC uses the `.schema()` helper for this logic.
5
+ > **AI Agent Friendly:** A single schema file per table = instant understanding of the final database state. No need to scan hundreds of migration files.
6
6
 
7
- ## Ensuring Tables Exist
7
+ ---
8
8
 
9
- The `.schema()` method checks if a table exists. If it **does not exist**, it runs the provided callback to create it. If it **already exists**, it does nothing.
9
+ ## Quick Start
10
+
11
+ ### 1. Define Your Schema
12
+
13
+ Create a file in the `schema/` directory for each table:
14
+
15
+ ```javascript
16
+ // schema/users.js
17
+ 'use strict'
18
+
19
+ module.exports = {
20
+ columns: {
21
+ id: {type: 'increments'},
22
+ name: {type: 'string', length: 255, nullable: false},
23
+ email: {type: 'string', length: 255, nullable: false},
24
+ role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
25
+ is_active: {type: 'boolean', default: true},
26
+ timestamps: {type: 'timestamps'}
27
+ },
28
+
29
+ indexes: [
30
+ {columns: ['email'], unique: true},
31
+ {columns: ['role', 'is_active']}
32
+ ]
33
+ }
34
+ ```
35
+
36
+ ### 2. Start Your App
37
+
38
+ Migrations run **automatically** when the application starts. No manual commands needed:
39
+
40
+ ```bash
41
+ npx odac dev # Development
42
+ npx odac start # Production
43
+ ```
44
+
45
+ On startup, the engine detects `schema/users.js`, creates the table, and applies indexes — all before the server accepts traffic.
46
+
47
+ > **Zero-Config:** Just define the schema file and deploy. The framework handles the rest.
48
+
49
+ You can also run migrations manually via CLI for inspection or rollback:
50
+
51
+ ### 3. Modify Your Schema
52
+
53
+ Simply edit the schema file. Add a column, remove a column, add an index — the engine handles the rest:
54
+
55
+ ```javascript
56
+ // schema/users.js — added 'bio' column, removed 'is_active'
57
+ module.exports = {
58
+ columns: {
59
+ id: {type: 'increments'},
60
+ name: {type: 'string', length: 255, nullable: false},
61
+ email: {type: 'string', length: 255, nullable: false},
62
+ role: {type: 'enum', values: ['admin', 'user'], default: 'user'},
63
+ bio: {type: 'text', nullable: true},
64
+ timestamps: {type: 'timestamps'}
65
+ },
66
+
67
+ indexes: [
68
+ {columns: ['email'], unique: true}
69
+ ]
70
+ }
71
+ ```
72
+
73
+ ```bash
74
+ npx odac migrate
75
+ ```
76
+
77
+ ```
78
+ [default]
79
+ + ADD COLUMN users.bio
80
+ - DROP COLUMN users.is_active
81
+ - DROP INDEX users (role, is_active)
82
+
83
+ ✅ 3 operation(s) completed.
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Column Types
89
+
90
+ | Type | Usage | Options |
91
+ |------|-------|---------|
92
+ | `increments` | Auto-increment primary key | — |
93
+ | `bigIncrements` | Big auto-increment | — |
94
+ | `integer` | Integer | `unsigned` |
95
+ | `bigInteger` | Big integer | `unsigned` |
96
+ | `float` | Floating point | `precision`, `scale` |
97
+ | `decimal` | Exact decimal | `precision`, `scale` |
98
+ | `string` | Varchar | `length` (default: 255) |
99
+ | `text` | Text blob | `textType` ('text', 'mediumtext', 'longtext') |
100
+ | `boolean` | Boolean | — |
101
+ | `date` | Date only | — |
102
+ | `datetime` | Date and time | — |
103
+ | `timestamp` | Timestamp | — |
104
+ | `timestamps` | Virtual: creates `created_at` + `updated_at` | — |
105
+ | `time` | Time only | — |
106
+ | `binary` | Binary data | `length` |
107
+ | `json` | JSON | — |
108
+ | `jsonb` | Binary JSON (PostgreSQL) | — |
109
+ | `uuid` | UUID | — |
110
+ | `enum` | Enumeration | `values` (array) |
111
+
112
+ ## Column Modifiers
113
+
114
+ ```javascript
115
+ {
116
+ type: 'string',
117
+ length: 100,
118
+ nullable: false, // NOT NULL constraint
119
+ default: 'untitled', // Default value
120
+ unsigned: true, // Unsigned integer
121
+ unique: true, // Unique constraint (inline)
122
+ primary: true, // Primary key
123
+ comment: 'The title', // Column comment
124
+ references: { // Foreign key
125
+ table: 'categories',
126
+ column: 'id'
127
+ },
128
+ onDelete: 'CASCADE',
129
+ onUpdate: 'CASCADE'
130
+ }
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Seed Data
136
+
137
+ Schema files can include declarative seed data that is applied idempotently on every migration:
10
138
 
11
139
  ```javascript
12
- // Ensure 'products' table exists on the fly
13
- await Odac.DB.products.schema(t => {
14
- t.increments('id');
15
- t.string('name').notNullable();
16
- t.decimal('price', 10, 2);
17
- t.boolean('is_active').defaultTo(true);
18
-
19
- // Automatic timestamps (created_at, updated_at)
20
- t.timestamps(true, true);
21
- });
22
- ```
23
-
24
- The `t` argument is a Schema Builder. You can define columns using standard types like:
25
- - `t.string()`
26
- - `t.integer()`
27
- - `t.boolean()`
28
- - `t.text()`
29
- - `t.date()`
30
- - `t.json()`
31
-
32
- ## Usage Example
33
-
34
- A typical pattern is to define schemas in your module's initialization or before the first insert.
140
+ // schema/roles.js
141
+ module.exports = {
142
+ columns: {
143
+ id: {type: 'increments'},
144
+ name: {type: 'string', length: 50},
145
+ level: {type: 'integer', default: 0}
146
+ },
147
+
148
+ indexes: [],
149
+
150
+ seed: [
151
+ {name: 'admin', level: 100},
152
+ {name: 'editor', level: 50},
153
+ {name: 'user', level: 1}
154
+ ],
155
+ seedKey: 'name'
156
+ }
157
+ ```
158
+
159
+ - **`seed`** — Array of rows to ensure exist
160
+ - **`seedKey`** — Column used for uniqueness check (required when `seed` is present)
161
+
162
+ **Behavior:** If the row exists (matched by `seedKey`), it updates if values differ. If not, it inserts. Safe to run repeatedly.
163
+
164
+ ---
165
+
166
+ ## Data Migrations
167
+
168
+ For **one-time data transformations** (splitting columns, backfilling, etc.), use imperative migration files:
169
+
170
+ ```
171
+ migration/
172
+ 20260225_001_split_names.js
173
+ ```
35
174
 
36
175
  ```javascript
37
- // In your controller or module
38
- async function init() {
39
- await Odac.DB.logs.schema(t => {
40
- t.string('level');
41
- t.text('message');
42
- t.timestamps();
43
- });
176
+ // migration/20260225_001_split_names.js
177
+ module.exports = {
178
+ async up(db) {
179
+ const users = await db('users').select('id', 'full_name')
180
+ for (const user of users) {
181
+ const [first, ...rest] = user.full_name.split(' ')
182
+ await db('users').where('id', user.id).update({
183
+ first_name: first,
184
+ last_name: rest.join(' ')
185
+ })
186
+ }
187
+ },
188
+
189
+ async down(db) {
190
+ const users = await db('users').select('id', 'first_name', 'last_name')
191
+ for (const user of users) {
192
+ await db('users').where('id', user.id).update({
193
+ full_name: `${user.first_name} ${user.last_name}`
194
+ })
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ Migration files run **once** and are tracked in the `_odac_migrations` table.
201
+
202
+ ---
203
+
204
+ ## Multiple Databases
205
+
206
+ If your project has multiple database connections, organize schemas by connection:
207
+
208
+ ```
209
+ schema/
210
+ users.js ← default connection
211
+ posts.js ← default connection
212
+ analytics/ ← 'analytics' connection
213
+ events.js
214
+ pageviews.js
215
+ ```
216
+
217
+ The folder name matches the connection key in your `odac.json`:
218
+
219
+ ```json
220
+ {
221
+ "database": {
222
+ "default": {"type": "mysql", "database": "main_db"},
223
+ "analytics": {"type": "postgres", "database": "analytics_db"}
224
+ }
44
225
  }
226
+ ```
227
+
228
+ Migration files follow the same convention:
229
+
230
+ ```
231
+ migration/
232
+ 20260225_001_auto.js ← default connection
233
+ analytics/
234
+ 20260225_001_backfill.js ← analytics connection
235
+ ```
236
+
237
+ ---
238
+
239
+ ## CLI Commands
45
240
 
46
- // Later...
47
- await Odac.DB.logs.insert({ level: 'info', message: 'App started' });
241
+ ```bash
242
+ # Run all pending migrations (schema diff + files + seeds)
243
+ npx odac migrate
244
+
245
+ # Target a specific database connection
246
+ npx odac migrate --db=analytics
247
+
248
+ # Show pending changes without applying (dry-run)
249
+ npx odac migrate:status
250
+
251
+ # Rollback the last batch of migration files
252
+ npx odac migrate:rollback
253
+
254
+ # Reverse-engineer current database into schema/ files
255
+ npx odac migrate:snapshot
256
+ npx odac migrate:snapshot --db=analytics
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Snapshot — Importing Existing Databases
262
+
263
+ For existing projects, use `migrate:snapshot` to generate schema files from your current database:
264
+
265
+ ```bash
266
+ npx odac migrate:snapshot
48
267
  ```
268
+
269
+ This creates a schema file for each table. Review and adjust the generated files, then use them as your source of truth going forward.
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.0",
10
+ "version": "1.4.1",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
package/src/Auth.js CHANGED
@@ -140,30 +140,54 @@ class Auth {
140
140
  this.#user = await Odac.DB[this.#table].where(primaryKey, sql_token[0].user).first()
141
141
  if (!this.#user) return false
142
142
 
143
+ let triggerRotation = false
144
+ let isRecoveryRotation = false
145
+
143
146
  if (!isRotated) {
144
147
  if (shouldRotate && tokenAge > rotationAge) {
145
- // --- Token Rotation ---
146
- const newTokenX = nodeCrypto.randomBytes(32).toString('hex')
147
- const newTokenY = nodeCrypto.randomBytes(32).toString('hex')
148
- const newToken = {
149
- id: Odac.DB.nanoid(),
150
- user: sql_token[0].user,
151
- token_x: newTokenX,
152
- token_y: Odac.Var(newTokenY).hash(),
153
- browser: sql_token[0].browser,
154
- ip: this.#request.ip,
155
- date: new Date(),
156
- active: new Date()
157
- }
148
+ triggerRotation = true
149
+ } else if (inactiveAge > updateAge) {
150
+ // Fallback simple active update if rotation is not triggered
151
+ Odac.DB[tokenTable]
152
+ .where('id', sql_token[0].id)
153
+ .update({active: new Date()})
154
+ .catch(() => {})
155
+ }
156
+ } else {
157
+ // Client still presenting a rotated (grace period) token.
158
+ // This means the previous rotation response was lost (network hiccup, page navigation, etc.)
159
+ // Give the client one more chance by re-issuing new credentials.
160
+ const timeSinceRotation = inactiveAge - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS
161
+ if (timeSinceRotation > 5000) {
162
+ triggerRotation = true
163
+ isRecoveryRotation = true
164
+ }
165
+ }
158
166
 
159
- // 1. Persist new token (await to ensure it exists before client uses new cookies)
160
- const insertOk = await Odac.DB[tokenTable].insert(newToken).catch(e => {
161
- console.error('Odac Auth Error: Token rotation failed', e.message)
162
- return false
163
- })
167
+ if (triggerRotation) {
168
+ // --- Token Rotation ---
169
+ const newTokenX = nodeCrypto.randomBytes(32).toString('hex')
170
+ const newTokenY = nodeCrypto.randomBytes(32).toString('hex')
171
+ const newToken = {
172
+ id: Odac.DB.nanoid(),
173
+ user: sql_token[0].user,
174
+ token_x: newTokenX,
175
+ token_y: Odac.Var(newTokenY).hash(),
176
+ browser: sql_token[0].browser,
177
+ ip: this.#request.ip,
178
+ date: new Date(),
179
+ active: new Date()
180
+ }
181
+
182
+ // 1. Persist new token (await to ensure it exists before client uses new cookies)
183
+ const insertOk = await Odac.DB[tokenTable].insert(newToken).catch(e => {
184
+ console.error('Odac Auth Error: Token rotation failed', e.message)
185
+ return false
186
+ })
164
187
 
165
- if (insertOk !== false) {
166
- // 2. Mark old token as rotated and set exactly 60 seconds grace period
188
+ if (insertOk !== false) {
189
+ if (!isRecoveryRotation) {
190
+ // 2a. Normal rotation: Mark old token as rotated with 60s grace period
167
191
  // Non-blocking I/O (Fire & Forget) -> High Throughput
168
192
  const rotatedActiveDate = new Date(now - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS)
169
193
  const epochDate = new Date(0)
@@ -175,27 +199,29 @@ class Auth {
175
199
  date: epochDate
176
200
  })
177
201
  .catch(() => {})
178
-
179
- // 3. Issue new cookies immediately
180
- this.#request.cookie('odac_x', newTokenX, {
181
- httpOnly: true,
182
- secure: true,
183
- sameSite: 'Lax',
184
- maxAge: maxAge
185
- })
186
- this.#request.cookie('odac_y', newTokenY, {
187
- httpOnly: true,
188
- secure: true,
189
- sameSite: 'Lax',
190
- maxAge: maxAge
191
- })
202
+ } else {
203
+ // 2b. Recovery rotation: Delete old rotated token immediately.
204
+ // Why: Prevents unbounded token multiplication. The old token already
205
+ // had its grace period; one recovery attempt is the maximum.
206
+ Odac.DB[tokenTable]
207
+ .where('id', sql_token[0].id)
208
+ .delete()
209
+ .catch(() => {})
192
210
  }
193
- } else if (inactiveAge > updateAge) {
194
- // Fallback simple active update if rotation is not triggered
195
- Odac.DB[tokenTable]
196
- .where('id', sql_token[0].id)
197
- .update({active: new Date()})
198
- .catch(() => {})
211
+
212
+ // 3. Issue new cookies immediately
213
+ this.#request.cookie('odac_x', newTokenX, {
214
+ httpOnly: true,
215
+ secure: true,
216
+ sameSite: 'Lax',
217
+ 'max-age': Math.floor(maxAge / 1000)
218
+ })
219
+ this.#request.cookie('odac_y', newTokenY, {
220
+ httpOnly: true,
221
+ secure: true,
222
+ sameSite: 'Lax',
223
+ 'max-age': Math.floor(maxAge / 1000)
224
+ })
199
225
  }
200
226
  }
201
227
 
@@ -238,13 +264,13 @@ class Auth {
238
264
  httpOnly: true,
239
265
  secure: true,
240
266
  sameSite: 'Lax',
241
- maxAge: maxAge
267
+ 'max-age': Math.floor(maxAge / 1000)
242
268
  })
243
269
  this.#request.cookie('odac_y', token_y, {
244
270
  httpOnly: true,
245
271
  secure: true,
246
272
  sameSite: 'Lax',
247
- maxAge: maxAge
273
+ 'max-age': Math.floor(maxAge / 1000)
248
274
  })
249
275
 
250
276
  // Knex insert returns ids on some dbs, promise resolves to result
@@ -392,8 +418,8 @@ class Auth {
392
418
  await Odac.DB[tokenTable].where('user', userId).where('browser', browser).delete()
393
419
  }
394
420
 
395
- this.#request.cookie('odac_x', '', {maxAge: -1})
396
- this.#request.cookie('odac_y', '', {maxAge: -1})
421
+ this.#request.cookie('odac_x', '', {'max-age': -1})
422
+ this.#request.cookie('odac_y', '', {'max-age': -1})
397
423
 
398
424
  this.#user = null
399
425
  return true
package/src/Config.js CHANGED
@@ -46,7 +46,7 @@ module.exports = {
46
46
 
47
47
  _interpolate: function (obj) {
48
48
  if (typeof obj === 'string') {
49
- return obj.replace(/\$\{(\w+)\}/g, (_, key) => {
49
+ return obj.replace(/\$\{([^{}]+)\}/g, (_, key) => {
50
50
  // Special variables
51
51
  if (key === 'odac') {
52
52
  return __dirname.replace(/\/src$/, '/client')
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const knex = require('knex')
4
+
5
+ /**
6
+ * Resolves knex client driver from ODAC database type.
7
+ * Why: Keeps connection driver mapping consistent across runtime and CLI migration paths.
8
+ * @param {string} type Database type from config.
9
+ * @returns {string} Knex client name.
10
+ */
11
+ function resolveClient(type) {
12
+ if (type === 'postgres' || type === 'pg' || type === 'postgresql') return 'pg'
13
+ if (type === 'sqlite' || type === 'sqlite3') return 'sqlite3'
14
+ return 'mysql2'
15
+ }
16
+
17
+ /**
18
+ * Builds knex connection config from ODAC database node.
19
+ * Why: Normalizes connection options for all call sites and avoids drift.
20
+ * @param {object} db Single database config node.
21
+ * @param {string} client Knex client name.
22
+ * @returns {object} Knex connection object.
23
+ */
24
+ function buildConnectionConfig(db, client) {
25
+ if (client === 'sqlite3') {
26
+ return {filename: db.filename || db.database || './dev.sqlite3'}
27
+ }
28
+
29
+ return {
30
+ host: db.host || '127.0.0.1',
31
+ user: db.user,
32
+ password: db.password,
33
+ database: db.database,
34
+ port: db.port
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Creates knex connections map from ODAC database config.
40
+ * Why: Centralizes zero-config connection bootstrap used by runtime and migration CLI.
41
+ * @param {object} databaseConfig ODAC database config (single or multiple).
42
+ * @returns {Record<string, any>} Knex connections by key.
43
+ */
44
+ function buildConnections(databaseConfig) {
45
+ const isMultiple = typeof databaseConfig[Object.keys(databaseConfig)[0]] === 'object'
46
+ const dbs = isMultiple ? databaseConfig : {default: databaseConfig}
47
+ const connections = {}
48
+
49
+ for (const key of Object.keys(dbs)) {
50
+ const db = dbs[key]
51
+ const client = resolveClient(db.type)
52
+ const connection = buildConnectionConfig(db, client)
53
+
54
+ connections[key] = knex({
55
+ client,
56
+ connection,
57
+ pool: {min: 0, max: db.connectionLimit || 10},
58
+ useNullAsDefault: true
59
+ })
60
+ }
61
+
62
+ return connections
63
+ }
64
+
65
+ module.exports = {
66
+ buildConnections,
67
+ buildConnectionConfig,
68
+ resolveClient
69
+ }