odac 1.4.10 โ†’ 1.4.12

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.
@@ -0,0 +1,77 @@
1
+ ## ๐Ÿš€ Proxy Cache
2
+
3
+ `Odac.cache()` lets you tell the ODAC Proxy to cache the current page's HTML response, so repeat visitors get a near-instant response without hitting your application server at all.
4
+
5
+ > **ODAC Ecosystem Only:** This feature works exclusively within the ODAC ecosystem. It relies on the `X-ODAC-Cache` header that only the ODAC Proxy understands and acts upon.
6
+
7
+ ### Basic Usage
8
+
9
+ Call `Odac.cache(seconds)` at the top of your controller with a TTL (time-to-live) in seconds:
10
+
11
+ ```javascript
12
+ module.exports = function (Odac) {
13
+ Odac.cache(3600) // Cache this page for 1 hour
14
+
15
+ Odac.set('title', 'About Us')
16
+ Odac.View.skeleton('main').set('content', 'about')
17
+ }
18
+ ```
19
+
20
+ That's it. The ODAC Proxy handles the rest.
21
+
22
+ ### How It Works
23
+
24
+ When `Odac.cache(seconds)` is called, ODAC sets two response headers:
25
+
26
+ | Header | Value | Purpose |
27
+ |--------|-------|---------|
28
+ | `X-ODAC-Cache` | `3600` | Tells the ODAC Proxy to cache this response for the given TTL |
29
+ | `Cache-Control` | `public, max-age=3600` | Standard browser/CDN cache directive |
30
+
31
+ The ODAC Proxy intercepts the response, stores it, and serves it directly on subsequent requests โ€” bypassing your application entirely until the TTL expires.
32
+
33
+ ### Smart Cache Invalidation
34
+
35
+ The ODAC Proxy is intelligent about cache invalidation. You don't need to manually clear the cache in most cases:
36
+
37
+ - **Content changes:** If the underlying page content changes (e.g. a file is updated or a deployment happens), the Proxy detects this and automatically invalidates the cache on the next request.
38
+ - **Dynamic content detection:** If the Proxy detects that a response contains dynamic or user-specific content, it cancels the cache for that response automatically.
39
+
40
+ ### When to Use It
41
+
42
+ `Odac.cache()` is designed for pages where the HTML output is **identical for all visitors**:
43
+
44
+ โœ… **Good candidates:**
45
+ - Marketing and landing pages
46
+ - Blog posts and articles
47
+ - Documentation pages
48
+ - Product listing pages (without personalization)
49
+ - Static "About", "Contact", "FAQ" pages
50
+
51
+ โŒ **Do not use on:**
52
+ - Pages with user-specific content (dashboards, profiles, account pages)
53
+ - Pages that display session data or authentication state
54
+ - Pages with per-user pricing, recommendations, or notifications
55
+ - Any page where the HTML output differs between users
56
+
57
+ > Even though the ODAC Proxy can detect dynamic content and cancel caching, you should not rely on this as a safety net. If a page is user-specific, simply don't call `Odac.cache()`.
58
+
59
+ ### TTL Reference
60
+
61
+ | Scenario | Recommended TTL |
62
+ |----------|----------------|
63
+ | Frequently updated content (news, blog) | `300` โ€“ `900` (5โ€“15 min) |
64
+ | Semi-static content (docs, product pages) | `3600` โ€“ `86400` (1โ€“24 hrs) |
65
+ | Fully static pages (about, landing) | `86400` โ€“ `604800` (1โ€“7 days) |
66
+
67
+ ### Error Handling
68
+
69
+ `Odac.cache()` throws a `TypeError` if the argument is not a positive integer:
70
+
71
+ ```javascript
72
+ Odac.cache(3600) // โœ… Valid
73
+ Odac.cache(0) // โŒ TypeError
74
+ Odac.cache(-1) // โŒ TypeError
75
+ Odac.cache('3600') // โŒ TypeError
76
+ Odac.cache(3.5) // โŒ TypeError
77
+ ```
@@ -0,0 +1,156 @@
1
+ # ๐Ÿ“ฆ Scripts & TypeScript
2
+
3
+ ODAC comes with built-in, **Zero-Config** support for frontend JavaScript and TypeScript. Write your scripts in `view/js/`, and ODAC handles transpilation, bundling, minification, and tree-shaking automatically โ€” just like the Tailwind CSS pipeline.
4
+
5
+ ## How it Works
6
+
7
+ The framework uses [esbuild](https://esbuild.github.io/) under the hood for blazing-fast builds:
8
+
9
+ 1. **Development (`npm run dev`)**:
10
+ * ODAC watches all `.ts`, `.js`, `.mts`, and `.mjs` files in `view/js/`.
11
+ * Changes trigger instant rebuilds (sub-millisecond).
12
+ * Source maps are enabled for easy debugging.
13
+
14
+ 2. **Production (`npm run build`)**:
15
+ * All entry points are bundled, minified, and tree-shaken.
16
+ * Output goes to `public/assets/js/{name}.js`.
17
+
18
+ 3. **Serving (`npm start`)**:
19
+ * The compiled JS files are served statically. No runtime overhead.
20
+
21
+ ## Quick Start
22
+
23
+ Create a file at **`view/js/app.ts`** (or `app.js` for plain JavaScript):
24
+
25
+ ```typescript
26
+ // view/js/app.ts
27
+ interface User {
28
+ id: number
29
+ name: string
30
+ }
31
+
32
+ const greet = (user: User): string => {
33
+ return `Hello, ${user.name}!`
34
+ }
35
+
36
+ document.addEventListener('DOMContentLoaded', () => {
37
+ console.log(greet({ id: 1, name: 'World' }))
38
+ })
39
+ ```
40
+
41
+ That's it. Run `npm run dev` and ODAC compiles it to `public/assets/js/app.js`.
42
+
43
+ ## Entry Points & Imports
44
+
45
+ Each file in `view/js/` becomes a separate entry point (bundle). You can use standard ES module imports between files:
46
+
47
+ ```
48
+ view/js/
49
+ โ”œโ”€โ”€ app.ts โ†’ public/assets/js/app.js
50
+ โ”œโ”€โ”€ admin.ts โ†’ public/assets/js/admin.js
51
+ โ”œโ”€โ”€ _utils.ts (ignored โ€” partial/import only)
52
+ โ””โ”€โ”€ _api.ts (ignored โ€” partial/import only)
53
+ ```
54
+
55
+ > **Convention:** Files starting with `_` (underscore) are **not** compiled as entry points. Use them as shared modules that get imported by your entry points.
56
+
57
+ ```typescript
58
+ // view/js/_api.ts (shared module โ€” not compiled on its own)
59
+ export const fetchUsers = async (): Promise<unknown> => {
60
+ const res = await fetch('/api/users')
61
+ return res.json()
62
+ }
63
+ ```
64
+
65
+ ```typescript
66
+ // view/js/admin.ts (entry point โ€” compiled to admin.js)
67
+ import { fetchUsers } from './_api'
68
+
69
+ document.addEventListener('DOMContentLoaded', async () => {
70
+ const users = await fetchUsers()
71
+ console.log(users)
72
+ })
73
+ ```
74
+
75
+ esbuild bundles the imported code into the final output โ€” no extra network requests.
76
+
77
+ ## TypeScript or JavaScript โ€” Your Choice
78
+
79
+ ODAC doesn't force TypeScript on you. Both work equally well:
80
+
81
+ | Extension | Behavior |
82
+ |-----------|----------|
83
+ | `.ts` | TypeScript with full type-checking support |
84
+ | `.js` | Plain JavaScript, passed through as-is |
85
+ | `.mts` | TypeScript with ES module syntax |
86
+ | `.mjs` | JavaScript with ES module syntax |
87
+
88
+ ## HTML Integration
89
+
90
+ In your skeleton or layout files, reference the compiled output:
91
+
92
+ ```html
93
+ <script src="/assets/js/app.js"></script>
94
+ ```
95
+
96
+ The default project template already includes this in `skeleton/main.html`.
97
+
98
+ ## Configuration (Optional)
99
+
100
+ ODAC works with zero configuration, but you can customize the JS pipeline in `odac.json`:
101
+
102
+ ```json
103
+ {
104
+ "js": {
105
+ "target": "es2020",
106
+ "minify": true,
107
+ "sourcemap": false,
108
+ "bundle": true,
109
+ "obfuscate": false
110
+ }
111
+ }
112
+ ```
113
+
114
+ | Option | Default | Description |
115
+ |-------------|------------|-------------|
116
+ | `target` | `"es2020"` | JavaScript target version (`es2015`, `es2020`, `esnext`, etc.) |
117
+ | `minify` | `true` | Enable minification in production builds |
118
+ | `sourcemap` | `false` | Generate source maps in production (always enabled in dev) |
119
+ | `bundle` | `true` | Bundle imported modules into a single file |
120
+ | `obfuscate` | `false` | Code obfuscation level (`false`, `true`/`"low"`, `"medium"`, `"high"`) |
121
+
122
+ ## Obfuscation
123
+
124
+ ODAC supports three levels of code obfuscation for production builds. Obfuscation is disabled by default and only applied during `odac build` โ€” development mode is never obfuscated.
125
+
126
+ ### Levels
127
+
128
+ | Level | What it does |
129
+ |----------|-------------|
130
+ | `false` | No obfuscation. Standard minification only. |
131
+ | `true` / `"low"` | Mangles properties starting with `_` (private-by-convention). |
132
+ | `"medium"` | Low + drops `debugger` statements + removes `console.debug` and `console.trace`. |
133
+ | `"high"` | Maximum โ€” mangles `_` and `$` prefixed properties, drops all `console.*` calls and `debugger` statements. |
134
+
135
+ ### Example
136
+
137
+ ```json
138
+ {
139
+ "js": {
140
+ "obfuscate": "medium"
141
+ }
142
+ }
143
+ ```
144
+
145
+ > **Tip:** Start with `"low"` or `"medium"`. The `"high"` level mangles `$`-prefixed properties which may break code that interacts with external libraries using `$` conventions (e.g., jQuery, some frameworks). Test thoroughly before deploying with `"high"`.
146
+
147
+ ## What You Get
148
+
149
+ - **TypeScript Support** โ€” Write type-safe frontend code without any setup
150
+ - **Bundling** โ€” `import`/`export` between files, everything merged into one output
151
+ - **Minification** โ€” Whitespace removal, variable shortening, dead code elimination
152
+ - **Tree-Shaking** โ€” Unused exports are automatically removed
153
+ - **Obfuscation** โ€” Optional property mangling and console stripping (3 levels)
154
+ - **Source Maps** โ€” Enabled in development for easy debugging
155
+ - **Multiple Entry Points** โ€” Separate bundles for different pages/sections
156
+ - **Sub-millisecond Rebuilds** โ€” esbuild's native speed keeps your dev loop instant
@@ -66,6 +66,17 @@ You can configure multiple database connections. The connection named `default`
66
66
  }
67
67
  ```
68
68
 
69
+ To use a named connection in your code, simply access it through `Odac.DB`:
70
+
71
+ ```javascript
72
+ // Primary database (default)
73
+ const users = await Odac.DB.users.where('active', true)
74
+
75
+ // Analytics database
76
+ const logs = await Odac.DB.analytics.events.insert({ type: 'login' })
77
+ ```
78
+
79
+
69
80
  ---
70
81
 
71
82
  ## Environment Variables
@@ -8,7 +8,8 @@ The `<odac:register>` component provides a zero-configuration way to create secu
8
8
 
9
9
  ```json
10
10
  {
11
- "mysql": {
11
+ "database": {
12
+ "type": "mysql",
12
13
  "host": "localhost",
13
14
  "user": "root",
14
15
  "password": "",
@@ -54,7 +55,8 @@ If you want to customize table names or primary key:
54
55
 
55
56
  ```json
56
57
  {
57
- "mysql": {
58
+ "database": {
59
+ "type": "mysql",
58
60
  "host": "localhost",
59
61
  "user": "root",
60
62
  "password": "",
@@ -468,11 +470,12 @@ This provides instant feedback to users before form submission.
468
470
 
469
471
  ### Required Configuration
470
472
 
471
- Only MySQL configuration is required:
473
+ Only database configuration is required:
472
474
 
473
475
  ```json
474
476
  {
475
- "mysql": {
477
+ "database": {
478
+ "type": "mysql",
476
479
  "host": "localhost",
477
480
  "user": "root",
478
481
  "password": "",
@@ -8,7 +8,8 @@ The `<odac:login>` component provides a zero-configuration way to create secure
8
8
 
9
9
  ```json
10
10
  {
11
- "mysql": {
11
+ "database": {
12
+ "type": "mysql",
12
13
  "host": "localhost",
13
14
  "user": "root",
14
15
  "password": "",
@@ -47,7 +48,8 @@ If you want to customize table names or primary key:
47
48
 
48
49
  ```json
49
50
  {
50
- "mysql": {
51
+ "database": {
52
+ "type": "mysql",
51
53
  "host": "localhost",
52
54
  "user": "root",
53
55
  "password": "",
@@ -428,11 +430,12 @@ input._odac_error {
428
430
 
429
431
  ### Required Configuration
430
432
 
431
- Only MySQL configuration is required:
433
+ Only database configuration is required:
432
434
 
433
435
  ```json
434
436
  {
435
- "mysql": {
437
+ "database": {
438
+ "type": "mysql",
436
439
  "host": "localhost",
437
440
  "user": "root",
438
441
  "password": "",
package/docs/index.json CHANGED
@@ -165,6 +165,10 @@
165
165
  {
166
166
  "file": "02-sending-a-response-replying-to-the-user.md",
167
167
  "title": "Response Object"
168
+ },
169
+ {
170
+ "file": "03-proxy-cache.md",
171
+ "title": "Proxy Cache"
168
172
  }
169
173
  ]
170
174
  },
@@ -215,6 +219,10 @@
215
219
  {
216
220
  "file": "10-styling-and-tailwind.md",
217
221
  "title": "Styling & Tailwind CSS"
222
+ },
223
+ {
224
+ "file": "12-scripts-and-typescript.md",
225
+ "title": "Scripts & TypeScript"
218
226
  }
219
227
  ]
220
228
  },
package/eslint.config.mjs CHANGED
@@ -38,7 +38,7 @@ export default defineConfig([
38
38
  },
39
39
  {
40
40
  files: ['template/**/*.js'],
41
- ignores: ['template/public/**/*.js'],
41
+ ignores: ['template/public/**/*.js', 'template/view/js/**/*.js'],
42
42
  languageOptions: {
43
43
  globals: {
44
44
  ...globals.node,
@@ -56,6 +56,25 @@ export default defineConfig([
56
56
  'prettier/prettier': 'error'
57
57
  }
58
58
  },
59
+ {
60
+ files: ['template/view/js/**/*.js'],
61
+ languageOptions: {
62
+ globals: {
63
+ ...globals.browser,
64
+ Odac: 'readonly'
65
+ },
66
+ sourceType: 'script'
67
+ },
68
+ plugins: {
69
+ js,
70
+ prettier: prettierPlugin
71
+ },
72
+ rules: {
73
+ ...js.configs.recommended.rules,
74
+ ...prettierConfig.rules,
75
+ 'prettier/prettier': 'error'
76
+ }
77
+ },
59
78
  {
60
79
  files: ['template/public/**/*.js'],
61
80
  languageOptions: {
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.10",
10
+ "version": "1.4.12",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@tailwindcss/cli": "^4.1.18",
25
+ "esbuild": "^0.25.12",
25
26
  "knex": "^3.1.0",
26
27
  "lmdb": "^3.4.4",
27
28
  "tailwindcss": "^4.1.18"
package/src/Config.js CHANGED
@@ -27,6 +27,13 @@ module.exports = {
27
27
  driver: 'memory',
28
28
  redis: 'default'
29
29
  },
30
+ js: {
31
+ target: 'es2020',
32
+ minify: true,
33
+ sourcemap: false,
34
+ bundle: true,
35
+ obfuscate: false
36
+ },
30
37
  debug: process.env.NODE_ENV !== 'production',
31
38
 
32
39
  init: function () {
@@ -681,7 +681,7 @@ class Migration {
681
681
  */
682
682
  async _createTable(knex, tableName, schema) {
683
683
  await knex.schema.createTable(tableName, table => {
684
- this._buildColumns(table, schema.columns)
684
+ this._buildColumns(knex, table, schema.columns)
685
685
  this._buildIndexes(table, schema.indexes)
686
686
  })
687
687
  }
@@ -716,13 +716,13 @@ class Migration {
716
716
  for (const op of batchOps) {
717
717
  switch (op.type) {
718
718
  case 'add_column':
719
- this._addColumn(table, op.column, op.definition)
719
+ this._addColumn(knex, table, op.column, op.definition)
720
720
  break
721
721
  case 'drop_column':
722
722
  table.dropColumn(op.column)
723
723
  break
724
724
  case 'alter_column':
725
- this._alterColumn(table, op.column, op.definition, op.currentNullable)
725
+ this._alterColumn(knex, table, op.column, op.definition, op.currentNullable)
726
726
  break
727
727
  }
728
728
  }
@@ -739,8 +739,9 @@ class Migration {
739
739
 
740
740
  // Apply default value change if specified
741
741
  if (op.definition.default !== undefined) {
742
- if (op.definition.default === 'now()') {
743
- await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT now()`, [tableName, op.column])
742
+ const lower = String(op.definition.default).toLowerCase().trim()
743
+ if (lower === 'now()' || lower === 'current_timestamp' || lower === 'current_timestamp()') {
744
+ await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT ${lower}`, [tableName, op.column])
744
745
  } else {
745
746
  await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT ?`, [tableName, op.column, op.definition.default])
746
747
  }
@@ -915,7 +916,27 @@ class Migration {
915
916
  * @param {object} table - Knex TableBuilder instance
916
917
  * @param {object} columns - Column definition map
917
918
  */
918
- _buildColumns(table, columns) {
919
+ /**
920
+ * Resolves a column default value, wrapping special SQL keywords in knex.raw().
921
+ * Why: Knex.defaultTo() quotes string values by default. For keywords like
922
+ * CURRENT_TIMESTAMP, this results in 'CURRENT_TIMESTAMP' which MySQL rejects.
923
+ * Wrapping in knex.raw() ensures the keyword is emitted without quotes.
924
+ * @param {object} knex - Knex instance
925
+ * @param {*} value - Raw default value from schema
926
+ * @returns {*} Resolved value (possibly knex.raw)
927
+ */
928
+ _resolveDefault(knex, value) {
929
+ if (typeof value !== 'string') return value
930
+
931
+ const lower = value.toLowerCase().trim()
932
+ if (lower === 'current_timestamp' || lower === 'current_timestamp()' || lower === 'now()') {
933
+ return knex.raw(value)
934
+ }
935
+
936
+ return value
937
+ }
938
+
939
+ _buildColumns(knex, table, columns) {
919
940
  if (!columns) return
920
941
 
921
942
  for (const [colName, def] of Object.entries(columns)) {
@@ -930,7 +951,7 @@ class Migration {
930
951
  if (def.nullable === false) col.notNullable()
931
952
  else if (def.nullable === true) col.nullable()
932
953
 
933
- if (def.default !== undefined) col.defaultTo(def.default)
954
+ if (def.default !== undefined) col.defaultTo(this._resolveDefault(knex, def.default))
934
955
  if (def.unsigned) col.unsigned()
935
956
  // Column-level unique is handled via _normalizeSchema โ†’ _buildIndexes.
936
957
  // Applying it here as well would create duplicate constraints.
@@ -1019,14 +1040,14 @@ class Migration {
1019
1040
  * @param {string} colName - Column name
1020
1041
  * @param {object} def - Column definition
1021
1042
  */
1022
- _addColumn(table, colName, def) {
1043
+ _addColumn(knex, table, colName, def) {
1023
1044
  const col = this._createColumnBuilder(table, colName, def)
1024
1045
  if (!col) return
1025
1046
 
1026
1047
  if (def.nullable === false) col.notNullable()
1027
1048
  else col.nullable()
1028
1049
 
1029
- if (def.default !== undefined) col.defaultTo(def.default)
1050
+ if (def.default !== undefined) col.defaultTo(this._resolveDefault(knex, def.default))
1030
1051
  if (def.unsigned) col.unsigned()
1031
1052
  if (def.references) col.references(def.references.column).inTable(def.references.table)
1032
1053
  if (def.onDelete) col.onDelete(def.onDelete)
@@ -1039,7 +1060,7 @@ class Migration {
1039
1060
  * @param {string} colName - Column name
1040
1061
  * @param {object} def - Column definition
1041
1062
  */
1042
- _alterColumn(table, colName, def, currentNullable) {
1063
+ _alterColumn(knex, table, colName, def, currentNullable) {
1043
1064
  const col = this._createColumnBuilder(table, colName, def)
1044
1065
  if (!col) return
1045
1066
 
@@ -1052,7 +1073,7 @@ class Migration {
1052
1073
  else if (currentNullable === false) col.notNullable()
1053
1074
  else if (currentNullable === true) col.nullable()
1054
1075
 
1055
- if (def.default !== undefined) col.defaultTo(def.default)
1076
+ if (def.default !== undefined) col.defaultTo(this._resolveDefault(knex, def.default))
1056
1077
 
1057
1078
  col.alter()
1058
1079
  }
package/src/Mail.js CHANGED
@@ -317,6 +317,62 @@ class Mail {
317
317
  return '=?UTF-8?B?' + Buffer.from(text).toString('base64') + '?='
318
318
  }
319
319
 
320
+ #wrapLines(content, limit = 76) {
321
+ if (!content) return ''
322
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
323
+ const out = []
324
+ for (const line of normalized.split('\n')) {
325
+ if (Buffer.byteLength(line, 'utf8') <= limit) {
326
+ out.push(line)
327
+ continue
328
+ }
329
+ let buf = ''
330
+ let bufBytes = 0
331
+ const flush = () => {
332
+ if (buf.length) out.push(buf)
333
+ buf = ''
334
+ bufBytes = 0
335
+ }
336
+ for (const word of line.split(/(\s+)/)) {
337
+ if (!word) continue
338
+ const wordBytes = Buffer.byteLength(word, 'utf8')
339
+ if (bufBytes + wordBytes <= limit) {
340
+ buf += word
341
+ bufBytes += wordBytes
342
+ continue
343
+ }
344
+ if (buf.length && /^\s+$/.test(word)) {
345
+ flush()
346
+ continue
347
+ }
348
+ if (buf.length) flush()
349
+ if (wordBytes <= limit) {
350
+ buf = word
351
+ bufBytes = wordBytes
352
+ continue
353
+ }
354
+ let chunk = ''
355
+ let chunkBytes = 0
356
+ for (const ch of word) {
357
+ const chBytes = Buffer.byteLength(ch, 'utf8')
358
+ if (chunkBytes + chBytes > limit) {
359
+ out.push(chunk)
360
+ chunk = ''
361
+ chunkBytes = 0
362
+ }
363
+ chunk += ch
364
+ chunkBytes += chBytes
365
+ }
366
+ if (chunk.length) {
367
+ buf = chunk
368
+ bufBytes = chunkBytes
369
+ }
370
+ }
371
+ flush()
372
+ }
373
+ return out.join('\r\n')
374
+ }
375
+
320
376
  #stripHtml(html) {
321
377
  if (!html) return ''
322
378
 
@@ -376,6 +432,11 @@ class Mail {
376
432
  }
377
433
  }
378
434
 
435
+ // RFC 5321 ยง4.5.3.1.6: SMTP lines must be โ‰ค1000 octets including CRLF.
436
+ // Wrap to 990 chars for HTML and 76 for text to prevent SMTP rejection.
437
+ htmlContent = this.#wrapLines(htmlContent, 990)
438
+ textContent = this.#wrapLines(textContent)
439
+
379
440
  if (!this.#header['From']) this.#header['From'] = `${this.#encode(this.#from.name)} <${this.#from.email}>`
380
441
  if (!this.#header['To']) {
381
442
  const t = this.#to.value[0]
package/src/Odac.js CHANGED
@@ -156,6 +156,9 @@ module.exports = {
156
156
  _odac.write = function (value) {
157
157
  return _odac.Request.write(value)
158
158
  }
159
+ _odac.cache = function (seconds) {
160
+ return _odac.Request.cache(seconds)
161
+ }
159
162
  _odac.stream = function (input) {
160
163
  _odac.Request.clearTimeout()
161
164
  return new (require('./Stream'))(_odac.Request.req, _odac.Request.res, input, _odac)
package/src/Request.js CHANGED
@@ -310,6 +310,27 @@ class OdacRequest {
310
310
  }
311
311
  }
312
312
 
313
+ // - SET PROXY CACHE
314
+ /**
315
+ * Enables ODAC Proxy caching for the current response.
316
+ * Sets the X-ODAC-Cache header with the specified TTL (in seconds)
317
+ * and updates Cache-Control to allow proxy caching.
318
+ *
319
+ * Why: Allows controllers to declaratively opt-in to proxy-level
320
+ * caching for static or semi-static HTML responses, offloading
321
+ * repeated rendering from the application server.
322
+ *
323
+ * @param {number} seconds - Cache TTL in seconds (must be a positive integer)
324
+ * @throws {TypeError} If seconds is not a positive integer
325
+ */
326
+ cache(seconds) {
327
+ if (!Number.isInteger(seconds) || seconds < 1) {
328
+ throw new TypeError('Odac.cache() requires a positive integer (seconds)')
329
+ }
330
+ this.header('X-ODAC-Cache', seconds)
331
+ this.header('Cache-Control', `public, max-age=${seconds}`)
332
+ }
333
+
313
334
  // - HTTP CODE
314
335
  status(code) {
315
336
  this.#status = code
package/src/Route/Cron.js CHANGED
@@ -104,6 +104,15 @@ class Cron {
104
104
  }
105
105
  }
106
106
 
107
+ /**
108
+ * Removes all cron jobs registered from a given route file.
109
+ * Called before hot-reloading a route file to prevent duplicate cron registrations.
110
+ */
111
+ clear(route) {
112
+ if (!route) return
113
+ this.#jobs = this.#jobs.filter(job => job.route !== route)
114
+ }
115
+
107
116
  job(controller) {
108
117
  let path
109
118
  if (typeof controller !== 'function') {
@@ -114,6 +123,7 @@ class Cron {
114
123
  }
115
124
  }
116
125
  this.#jobs.push({
126
+ route: global.Odac?.Route?.buff || null,
117
127
  controller: typeof controller === 'function' ? null : controller,
118
128
  lastRun: null,
119
129
  condition: [],
package/src/Route.js CHANGED
@@ -452,6 +452,7 @@ class Route {
452
452
 
453
453
  if (!routes2[Odac.Route.buff] || routes2[Odac.Route.buff] < mtime - 1000) {
454
454
  delete require.cache[require.resolve(filePath)]
455
+ Cron.clear(Odac.Route.buff)
455
456
  routes2[Odac.Route.buff] = mtime
456
457
  const routeModule = require(filePath)
457
458
  if (typeof routeModule === 'function') {
File without changes