odac 1.0.0 → 1.1.0

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 (61) hide show
  1. package/.github/workflows/auto-pr-description.yml +3 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +39 -36
  4. package/bin/odac.js +1 -31
  5. package/client/odac.js +871 -994
  6. package/docs/backend/01-overview/03-development-server.md +7 -7
  7. package/docs/backend/02-structure/01-typical-project-layout.md +1 -0
  8. package/docs/backend/03-config/00-configuration-overview.md +9 -0
  9. package/docs/backend/03-config/01-database-connection.md +1 -1
  10. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  11. package/docs/backend/04-routing/09-websocket.md +29 -0
  12. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  13. package/docs/backend/05-controllers/03-controller-classes.md +27 -41
  14. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  15. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  16. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  17. package/docs/backend/07-views/03-variables.md +5 -5
  18. package/docs/backend/07-views/04-request-data.md +1 -1
  19. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  20. package/docs/backend/08-database/01-getting-started.md +100 -0
  21. package/docs/backend/08-database/02-basics.md +136 -0
  22. package/docs/backend/08-database/03-advanced.md +84 -0
  23. package/docs/backend/08-database/04-migrations.md +48 -0
  24. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  25. package/docs/backend/10-authentication/03-register.md +8 -1
  26. package/docs/backend/10-authentication/04-odac-register-forms.md +46 -46
  27. package/docs/backend/10-authentication/05-session-management.md +1 -1
  28. package/docs/backend/10-authentication/06-odac-login-forms.md +48 -48
  29. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  30. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  31. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  32. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  33. package/docs/backend/13-utilities/02-ipc.md +73 -0
  34. package/docs/frontend/01-overview/01-introduction.md +5 -1
  35. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  36. package/docs/index.json +16 -124
  37. package/eslint.config.mjs +5 -47
  38. package/package.json +9 -4
  39. package/src/Auth.js +362 -104
  40. package/src/Config.js +7 -2
  41. package/src/Database.js +188 -0
  42. package/src/Ipc.js +330 -0
  43. package/src/Mail.js +408 -37
  44. package/src/Odac.js +65 -9
  45. package/src/Request.js +70 -48
  46. package/src/Route/Cron.js +4 -1
  47. package/src/Route/Internal.js +214 -11
  48. package/src/Route/Middleware.js +7 -2
  49. package/src/Route.js +106 -26
  50. package/src/Server.js +80 -11
  51. package/src/Storage.js +165 -0
  52. package/src/Validator.js +94 -2
  53. package/src/View/Form.js +193 -17
  54. package/src/View.js +46 -1
  55. package/src/WebSocket.js +18 -3
  56. package/template/config.json +1 -1
  57. package/template/route/www.js +12 -10
  58. package/test/core/{Candy.test.js → Odac.test.js} +2 -2
  59. package/docs/backend/08-database/01-database-connection.md +0 -99
  60. package/docs/backend/08-database/02-using-mysql.md +0 -322
  61. package/src/Mysql.js +0 -575
@@ -0,0 +1,188 @@
1
+ 'use strict'
2
+ const knex = require('knex')
3
+
4
+ class DatabaseManager {
5
+ constructor() {
6
+ this.connections = {}
7
+ }
8
+
9
+ async init() {
10
+ if (!Odac.Config.database) return
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
+ })
46
+
47
+ // Test connection
48
+ try {
49
+ await this.connections[key].raw('SELECT 1')
50
+ } catch (e) {
51
+ console.error(`Odac Database Error: Failed to connect to '${key}' database.`)
52
+ console.error(e.message)
53
+ }
54
+ }
55
+ }
56
+
57
+ nanoid(size = 21) {
58
+ const nodeCrypto = require('crypto')
59
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
60
+ let id = ''
61
+ while (id.length < size) {
62
+ const bytes = nodeCrypto.randomBytes(size + 5)
63
+ for (let i = 0; i < bytes.length; i++) {
64
+ const byte = bytes[i] & 63
65
+ if (byte < 62) {
66
+ id += alphabet[byte]
67
+ if (id.length === size) break
68
+ }
69
+ }
70
+ }
71
+ return id
72
+ }
73
+ }
74
+
75
+ const manager = new DatabaseManager()
76
+
77
+ const tableProxyHandler = {
78
+ get(knexInstance, prop) {
79
+ // 1. Check for legacy/alias methods
80
+ if (prop === 'run') return knexInstance.raw.bind(knexInstance)
81
+ if (prop === 'table')
82
+ return function (tableName) {
83
+ return knexInstance(tableName)
84
+ }
85
+
86
+ // 2. Pass through Knex instance methods (raw, schema, fn, destroy, etc.)
87
+ if (typeof knexInstance[prop] === 'function') {
88
+ return knexInstance[prop].bind(knexInstance)
89
+ }
90
+ if (prop in knexInstance) {
91
+ return knexInstance[prop]
92
+ }
93
+
94
+ // 3. Assume it's a table name and return a Query Builder
95
+ // But we need to be careful not to intercept Promise methods if they are accessed on the instance (though knex instance isn't a promise)
96
+
97
+ // Create the Query Builder
98
+ const qb = knexInstance(prop)
99
+
100
+ // Odac DX Improvement: Wrap count() to return a clean number
101
+ const originalCount = qb.count
102
+ qb.count = function (...args) {
103
+ this._odacIsCount = true
104
+ return originalCount.apply(this, args)
105
+ }
106
+
107
+ const originalThen = qb.then
108
+ qb.then = function (resolve, reject) {
109
+ if (this._odacIsCount) {
110
+ return originalThen.call(
111
+ this,
112
+ result => {
113
+ // If the result is a single row with a single key, treat it as a scalar count usually
114
+ const isScalar = Array.isArray(result) && result.length === 1 && Object.keys(result[0]).length === 1
115
+
116
+ if (isScalar) {
117
+ const keys = Object.keys(result[0])
118
+ if (keys.length === 1) {
119
+ const val = result[0][keys[0]]
120
+ // Parse string numbers (common in Postgres for count)
121
+ if (val != null && String(val).trim() !== '' && !isNaN(val)) {
122
+ resolve(Number(val))
123
+ return
124
+ }
125
+ }
126
+ }
127
+ resolve(result)
128
+ },
129
+ reject
130
+ )
131
+ }
132
+ return originalThen.call(this, resolve, reject)
133
+ }
134
+
135
+ // 4. Extend the Query Builder with ODAC specific methods
136
+
137
+ // .schema(callback) for "Code-First" migrations
138
+ // Usage: await Odac.DB.users.schema(t => { t.string('name') })
139
+ qb.schema = async function (callback) {
140
+ const exists = await knexInstance.schema.hasTable(prop)
141
+ if (!exists) {
142
+ await knexInstance.schema.createTable(prop, callback)
143
+ }
144
+ return this
145
+ }
146
+
147
+ return qb
148
+ }
149
+ }
150
+
151
+ const rootProxy = new Proxy(manager, {
152
+ get(target, prop) {
153
+ // Access to internal manager methods
154
+ if (prop === 'init') return target.init.bind(target)
155
+ if (prop === 'connections') return target.connections
156
+
157
+ // Access to specific database connection: Odac.DB.analytics
158
+ if (target.connections[prop]) {
159
+ return new Proxy(target.connections[prop], tableProxyHandler)
160
+ }
161
+
162
+ // Direct access to raw/fn/schema/table on default connection
163
+ if (target.connections['default'] && (prop === 'raw' || prop === 'fn' || prop === 'schema' || prop === 'table')) {
164
+ if (prop === 'table')
165
+ return function (tableName) {
166
+ return target.connections['default'](tableName)
167
+ }
168
+
169
+ const val = target.connections['default'][prop]
170
+ if (typeof val === 'function') {
171
+ return val.bind(target.connections['default'])
172
+ }
173
+ return val
174
+ }
175
+
176
+ // Expose nanoid helper directly on Odac.DB.nanoid()
177
+ if (prop === 'nanoid') return target.nanoid.bind(target)
178
+
179
+ // Default connection fallback: Odac.DB.users -> default.users
180
+ if (target.connections['default']) {
181
+ return tableProxyHandler.get(target.connections['default'], prop)
182
+ }
183
+
184
+ return undefined
185
+ }
186
+ })
187
+
188
+ module.exports = rootProxy
package/src/Ipc.js ADDED
@@ -0,0 +1,330 @@
1
+ const cluster = require('node:cluster')
2
+ const {EventEmitter} = require('node:events')
3
+
4
+ class Ipc extends EventEmitter {
5
+ constructor() {
6
+ super()
7
+ this.driver = null
8
+ this.config = {}
9
+ this._requests = new Map() // For memory driver response tracking
10
+ this._subs = new Map() // For memory driver subscriptions
11
+ }
12
+
13
+ async init() {
14
+ if (this.initialized) return
15
+ this.initialized = true
16
+
17
+ this.config = Odac.Config.ipc || {driver: 'memory'}
18
+
19
+ // default MaxListeners is 10. If we have thousands of different channels, it's fine.
20
+ // But if we attach many listeners to the "same" channel or event emitter, we might need more.
21
+ // For Ipc (which extends EventEmitter), let's bump it up just in case.
22
+ this.setMaxListeners(0) // Unlimited
23
+
24
+ if (this.config.driver === 'redis') {
25
+ await this._initRedis()
26
+ } else {
27
+ await this._initMemory()
28
+ }
29
+ }
30
+
31
+ // --- Public API ---
32
+
33
+ async set(key, value, ttl = 0) {
34
+ if (this.config.driver === 'redis') {
35
+ const args = [key, JSON.stringify(value)]
36
+ if (ttl > 0) args.push('EX', ttl)
37
+ return this.redis.set(...args)
38
+ } else {
39
+ return this._sendMemory('set', {key, value, ttl})
40
+ }
41
+ }
42
+
43
+ async get(key) {
44
+ if (this.config.driver === 'redis') {
45
+ const val = await this.redis.get(key)
46
+ return val ? JSON.parse(val) : null
47
+ } else {
48
+ return this._sendMemory('get', {key})
49
+ }
50
+ }
51
+
52
+ async del(key) {
53
+ if (this.config.driver === 'redis') {
54
+ return this.redis.del(key)
55
+ } else {
56
+ return this._sendMemory('del', {key})
57
+ }
58
+ }
59
+
60
+ async publish(channel, message) {
61
+ if (this.config.driver === 'redis') {
62
+ return this.redis.publish(channel, JSON.stringify(message))
63
+ } else {
64
+ return this._sendMemory('publish', {channel, message})
65
+ }
66
+ }
67
+
68
+ async subscribe(channel, callback) {
69
+ if (this.config.driver === 'redis') {
70
+ if (!this.subRedis) {
71
+ this.subRedis = this.redis.duplicate()
72
+ await this.subRedis.connect()
73
+ this.subRedis.on('message', (chan, msg) => {
74
+ this.emit(chan, JSON.parse(msg))
75
+ })
76
+ }
77
+ // Redis handles duplicate subscriptions gracefully (ignores them)
78
+ await this.subRedis.subscribe(channel)
79
+ this.on(channel, callback)
80
+ } else {
81
+ // Memory driver subscription
82
+ if (!this._subs.has(channel)) {
83
+ this._subs.set(channel, new Set())
84
+ // Inform main process that this worker is subscribed
85
+ this._sendMemory('subscribe', {channel})
86
+ }
87
+ this._subs.get(channel).add(callback)
88
+ }
89
+ }
90
+
91
+ async unsubscribe(channel, callback) {
92
+ if (this.config.driver === 'redis') {
93
+ this.removeListener(channel, callback)
94
+ // If no more listeners for this channel, unsubscribe from redis to save resources
95
+ if (this.listenerCount(channel) === 0 && this.subRedis) {
96
+ await this.subRedis.unsubscribe(channel)
97
+ }
98
+ } else {
99
+ if (this._subs.has(channel)) {
100
+ const callbacks = this._subs.get(channel)
101
+ callbacks.delete(callback)
102
+ if (callbacks.size === 0) {
103
+ this._subs.delete(channel)
104
+ this._sendMemory('unsubscribe', {channel})
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // --- Drivers ---
111
+
112
+ async _initRedis() {
113
+ try {
114
+ const Redis = require('redis')
115
+ this.redis = Redis.createClient(Odac.Config.database?.redis?.[this.config.redis || 'default'] || {})
116
+ await this.redis.connect()
117
+ } catch (e) {
118
+ console.error('IPC Redis Driver Error:', e)
119
+ // Re-throw to ensure application doesn't start in a broken state.
120
+ throw e
121
+ }
122
+ }
123
+
124
+ async _initMemory() {
125
+ if (cluster.isPrimary) {
126
+ if (!this._memoryStore) this._memoryStore = new Map()
127
+ if (!this._memorySubs) this._memorySubs = new Map()
128
+
129
+ // PREVENT DUPLICATE LISTENERS via Global References
130
+ // If Ipc is reloaded (hot-reload), the old listener remains on 'cluster' (global).
131
+ // We must remove it before adding a new one.
132
+
133
+ if (global.__odac_ipc_message_handler) {
134
+ cluster.removeListener('message', global.__odac_ipc_message_handler)
135
+ }
136
+ if (global.__odac_ipc_exit_handler) {
137
+ cluster.removeListener('exit', global.__odac_ipc_exit_handler)
138
+ }
139
+
140
+ const messageHandler = (worker, msg) => {
141
+ if (msg && msg.type && msg.type.startsWith('ipc:')) {
142
+ this._handlePrimaryMessage(worker, msg)
143
+ }
144
+ }
145
+
146
+ const exitHandler = worker => {
147
+ // Cleanup worker subscriptions on exit
148
+ for (const [channel, workers] of this._memorySubs) {
149
+ workers.delete(worker.id)
150
+ if (workers.size === 0) {
151
+ this._memorySubs.delete(channel)
152
+ }
153
+ }
154
+ }
155
+
156
+ // Save references globally
157
+ global.__odac_ipc_message_handler = messageHandler
158
+ global.__odac_ipc_exit_handler = exitHandler
159
+
160
+ cluster.on('message', messageHandler)
161
+ cluster.on('exit', exitHandler)
162
+
163
+ this._startGarbageCollector()
164
+ } else {
165
+ process.on('message', msg => {
166
+ if (msg && msg.type === 'ipc:response') {
167
+ const req = this._requests.get(msg.id)
168
+ // If request exists (hasn't timed out yet)
169
+ if (req) {
170
+ clearTimeout(req.timeout) // Stop the timeout timer
171
+ req.resolve(msg.data)
172
+ this._requests.delete(msg.id)
173
+ }
174
+ } else if (msg && msg.type === 'ipc:message') {
175
+ // Pub/Sub message received from Primary
176
+ const subs = this._subs.get(msg.channel)
177
+ if (subs) {
178
+ subs.forEach(cb => cb(msg.message))
179
+ }
180
+ }
181
+ })
182
+ }
183
+ }
184
+
185
+ _sendMemory(action, payload) {
186
+ if (cluster.isPrimary) {
187
+ // If used from primary directly (rare but possible)
188
+ // Logic would be direct call to _handlePrimaryMessage logic essentially,
189
+ // but simpler. For now, assuming IPC is mostly used by workers.
190
+ // If primary uses it, we should implement direct store access.
191
+ return this._handleDirectPrimaryCall(action, payload)
192
+ }
193
+
194
+ return new Promise((resolve, reject) => {
195
+ const id = require('node:crypto').randomUUID()
196
+ if (action !== 'subscribe' && action !== 'publish' && action !== 'unsubscribe') {
197
+ // Only wait for response for data ops
198
+ const timeout = setTimeout(() => {
199
+ if (this._requests.has(id)) {
200
+ this._requests.delete(id)
201
+ reject(new Error(`IPC request timed out: ${action}`))
202
+ }
203
+ }, 5000)
204
+
205
+ this._requests.set(id, {resolve, reject, timeout})
206
+ } else {
207
+ resolve() // Pub/Sub/Unsub doesn't wait for ack
208
+ }
209
+ process.send({type: `ipc:${action}`, id, ...payload})
210
+ })
211
+ }
212
+
213
+ _handleDirectPrimaryCall(action, payload) {
214
+ // Basic implementation for Primary process using itself
215
+ if (action === 'set') {
216
+ const expireAt = payload.ttl > 0 ? Date.now() + payload.ttl * 1000 : Infinity
217
+ this._memoryStore.set(payload.key, {value: payload.value, expireAt})
218
+ return true
219
+ }
220
+ if (action === 'get') {
221
+ const data = this._memoryStore.get(payload.key)
222
+ if (!data) return null
223
+ if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
224
+ this._memoryStore.delete(payload.key)
225
+ return null
226
+ }
227
+ return data.value
228
+ }
229
+ if (action === 'del') return this._memoryStore.delete(payload.key)
230
+ if (action === 'publish') {
231
+ const workers = this._memorySubs.get(payload.channel)
232
+ if (workers) {
233
+ workers.forEach(wId => {
234
+ const w = cluster.workers[wId]
235
+ if (w) w.send({type: 'ipc:message', channel: payload.channel, message: payload.message})
236
+ })
237
+ }
238
+ }
239
+ // subscribe on primary not deeply implemented to avoid complexity, usually workers listen.
240
+ }
241
+
242
+ _startGarbageCollector() {
243
+ // Run every 5 minutes.
244
+ // This is "lazy enough" not to impact CPU, but frequent enough to free memory.
245
+ const interval = setInterval(
246
+ () => {
247
+ try {
248
+ const now = Date.now()
249
+ for (const [key, data] of this._memoryStore) {
250
+ if (data.expireAt !== Infinity && now > data.expireAt) {
251
+ this._memoryStore.delete(key)
252
+ }
253
+ }
254
+ } catch (e) {
255
+ console.error('[Odac IPC GC Error]', e)
256
+ }
257
+ },
258
+ 5 * 60 * 1000
259
+ )
260
+
261
+ // Allow process to exit even if this interval is running
262
+ interval.unref()
263
+ }
264
+
265
+ _handlePrimaryMessage(worker, msg) {
266
+ const {type, id, key, value, ttl, channel, message} = msg
267
+ const action = type.replace('ipc:', '')
268
+
269
+ let response = null
270
+
271
+ switch (action) {
272
+ case 'set': {
273
+ const expireAt = ttl > 0 ? Date.now() + ttl * 1000 : Infinity
274
+ this._memoryStore.set(key, {value, expireAt})
275
+ response = true
276
+ break
277
+ }
278
+ case 'get': {
279
+ const data = this._memoryStore.get(key)
280
+ if (data) {
281
+ if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
282
+ this._memoryStore.delete(key)
283
+ response = null
284
+ } else {
285
+ response = data.value
286
+ }
287
+ } else {
288
+ response = null
289
+ }
290
+ break
291
+ }
292
+ case 'del':
293
+ response = this._memoryStore.delete(key)
294
+ break
295
+ case 'subscribe':
296
+ if (!this._memorySubs.has(channel)) {
297
+ this._memorySubs.set(channel, new Set())
298
+ }
299
+ this._memorySubs.get(channel).add(worker.id)
300
+ break
301
+ case 'unsubscribe':
302
+ if (this._memorySubs.has(channel)) {
303
+ this._memorySubs.get(channel).delete(worker.id)
304
+ if (this._memorySubs.get(channel).size === 0) {
305
+ this._memorySubs.delete(channel)
306
+ }
307
+ }
308
+ break
309
+ case 'publish': {
310
+ // Relay to all subscribed workers
311
+ const workers = this._memorySubs.get(channel)
312
+ if (workers) {
313
+ workers.forEach(wId => {
314
+ // Don't echo back to sender if desired? Usually pub/sub receives own too if subbed.
315
+ // Redis publishes to all subscribers.
316
+ const w = cluster.workers[wId]
317
+ if (w) w.send({type: 'ipc:message', channel, message})
318
+ })
319
+ }
320
+ break
321
+ }
322
+ }
323
+
324
+ if (id) {
325
+ worker.send({type: 'ipc:response', id, data: response})
326
+ }
327
+ }
328
+ }
329
+
330
+ module.exports = new Ipc()