tina4-nodejs 3.13.63 → 3.13.66

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,325 @@
1
+ /**
2
+ * CLI command: queue — run queue workers and manage jobs.
3
+ *
4
+ * The top-level `queue` command wires straight to the real @tina4/core Queue
5
+ * (file backend by default; RabbitMQ/Kafka/MongoDB via TINA4_QUEUE_BACKEND).
6
+ * `stats`, `retry` and `clear` operate on the queue without booting the app or a
7
+ * database; `work` runs the app's consumer for a topic. Distinct from
8
+ * `generate queue`, which SCAFFOLDS a consumer file.
9
+ *
10
+ * tina4nodejs queue work [topic] [--once] [--poll N] [--services DIR]
11
+ * tina4nodejs queue stats [topic] [--json]
12
+ * tina4nodejs queue retry [topic]
13
+ * tina4nodejs queue clear [status] [topic]
14
+ *
15
+ * Mirrors the Python master's _queue* handlers (tina4_python/cli/__init__.py).
16
+ */
17
+ import { readdirSync, statSync } from "node:fs";
18
+ import { extname, join } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { loadEnv } from "../../../core/src/dotenv.js";
21
+ import { Queue } from "../../../core/src/queue.js";
22
+ import type { QueueJob } from "../../../core/src/job.js";
23
+
24
+ // ── Flag parsing ─────────────────────────────────────────────────────
25
+ //
26
+ // --key value and bare --flag, mirroring the Python master's _parse_flags:
27
+ // `once` and `json` are boolean-only (they never swallow the next token).
28
+
29
+ const BOOLEAN_FLAGS = new Set(["once", "json"]);
30
+
31
+ interface ParsedFlags {
32
+ flags: Record<string, string | boolean>;
33
+ positional: string[];
34
+ }
35
+
36
+ function parseFlags(args: string[]): ParsedFlags {
37
+ const flags: Record<string, string | boolean> = {};
38
+ const positional: string[] = [];
39
+ let i = 0;
40
+ while (i < args.length) {
41
+ const arg = args[i];
42
+ if (arg.startsWith("--")) {
43
+ const key = arg.slice(2);
44
+ if (BOOLEAN_FLAGS.has(key)) {
45
+ flags[key] = true;
46
+ i += 1;
47
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
48
+ flags[key] = args[i + 1];
49
+ i += 2;
50
+ } else {
51
+ flags[key] = true;
52
+ i += 1;
53
+ }
54
+ } else {
55
+ positional.push(arg);
56
+ i += 1;
57
+ }
58
+ }
59
+ return { flags, positional };
60
+ }
61
+
62
+ /** A per-job handler declared by a consumer module (receives the job payload). */
63
+ export type QueueHandler = (payload: unknown) => unknown | Promise<unknown>;
64
+
65
+ /**
66
+ * Return the per-job handler that a consumer module declares for `topic`.
67
+ *
68
+ * A consumer module (e.g. the one `generate queue <topic>` scaffolds) exposes a
69
+ * default-export config; when its `topic` matches, `queue work` drives the
70
+ * consumer through that config's per-job `handle` callable — so the worker owns
71
+ * the poll loop (honouring --poll and the bounded --once drain) instead of the
72
+ * consumer's own endless loop. Returns the callable, or null when no consumer in
73
+ * `servicesDir` targets this topic. Mirrors Python's _resolve_queue_handler.
74
+ */
75
+ export async function resolveQueueHandler(
76
+ servicesDir: string,
77
+ topic: string,
78
+ ): Promise<QueueHandler | null> {
79
+ let entries: string[];
80
+ try {
81
+ if (!statSync(servicesDir).isDirectory()) return null;
82
+ entries = readdirSync(servicesDir);
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ for (const entry of entries.sort()) {
88
+ if (entry.startsWith("_")) continue;
89
+ const ext = extname(entry);
90
+ if (ext !== ".ts" && ext !== ".js") continue;
91
+
92
+ const fullPath = join(servicesDir, entry);
93
+ try {
94
+ if (!statSync(fullPath).isFile()) continue;
95
+ const mod = await import(pathToFileURL(fullPath).href);
96
+ const config = (mod.default ?? mod) as Record<string, unknown> | undefined;
97
+ if (
98
+ config &&
99
+ typeof config === "object" &&
100
+ config.topic === topic &&
101
+ typeof config.handle === "function"
102
+ ) {
103
+ return config.handle as QueueHandler;
104
+ }
105
+ } catch {
106
+ // A broken sibling must not sink the worker — skip and keep scanning.
107
+ continue;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** The single available job out of a possible batch yield. */
114
+ function firstJob(yielded: QueueJob | QueueJob[]): QueueJob {
115
+ return Array.isArray(yielded) ? yielded[0] : yielded;
116
+ }
117
+
118
+ // ── Subcommands ──────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Run a consumer loop that pops and processes jobs on a topic.
122
+ *
123
+ * tina4nodejs queue work [topic] [--once] [--poll N] [--services DIR]
124
+ *
125
+ * Long-running by default (polls every --poll seconds, 1.0 default; Ctrl-C to
126
+ * stop). --once does a single-pass drain — it processes every currently
127
+ * available job then exits (poll interval 0). The per-job handler is resolved
128
+ * from the app's consumer for this topic (see resolveQueueHandler); with no
129
+ * handler it drains and acks with a warning rather than inventing behaviour.
130
+ */
131
+ async function queueWork(args: string[]): Promise<void> {
132
+ loadEnv();
133
+ const { flags, positional } = parseFlags(args);
134
+ const topic = positional[0] ?? "default";
135
+ const once = Boolean(flags.once);
136
+
137
+ // --poll is in SECONDS (parity with the Python master); consume() takes ms.
138
+ let pollSeconds: number;
139
+ if (once) {
140
+ pollSeconds = 0; // single-pass: consume() returns as soon as the topic is empty
141
+ } else {
142
+ const pollRaw = typeof flags.poll === "string" ? flags.poll.trim() : "";
143
+ const parsed = Number(pollRaw);
144
+ pollSeconds = pollRaw && Number.isFinite(parsed) ? parsed : 1.0;
145
+ }
146
+
147
+ let servicesDir =
148
+ typeof flags.services === "string" && flags.services
149
+ ? flags.services
150
+ : process.env.TINA4_SERVICE_DIR || "src/services";
151
+ if (flags.services === true) servicesDir = process.env.TINA4_SERVICE_DIR || "src/services";
152
+
153
+ const handler = await resolveQueueHandler(servicesDir, topic);
154
+ const queue = new Queue({ topic });
155
+
156
+ if (handler === null) {
157
+ console.log(` ⚠ No consumer handler found for topic '${topic}' in ${servicesDir}.`);
158
+ console.log(` Scaffold one with: tina4nodejs generate queue ${topic}`);
159
+ console.log(" Draining (consume + ack) without processing.");
160
+ }
161
+
162
+ const mode = once ? "single-pass drain" : `polling every ${pollSeconds}s (Ctrl-C to stop)`;
163
+ console.log(` Queue worker on '${topic}' — ${mode}...`);
164
+
165
+ let processed = 0;
166
+ let failed = 0;
167
+
168
+ // Long-running mode: Ctrl-C prints the tally and exits cleanly (the poll loop
169
+ // is idle in setTimeout when interrupted). --once is bounded, so no handler.
170
+ let onSigint: (() => void) | undefined;
171
+ if (!once) {
172
+ onSigint = () => {
173
+ console.log("\n Interrupted — stopping worker.");
174
+ console.log(` Processed ${processed} job(s), ${failed} failed on '${topic}'.`);
175
+ process.exit(0);
176
+ };
177
+ process.on("SIGINT", onSigint);
178
+ }
179
+
180
+ try {
181
+ for await (const yielded of queue.consume(topic, undefined, pollSeconds * 1000)) {
182
+ const job = firstJob(yielded);
183
+ try {
184
+ if (handler !== null) await handler(job.payload);
185
+ job.complete();
186
+ processed += 1;
187
+ } catch (err) {
188
+ job.fail(String(err instanceof Error ? err.message : err));
189
+ failed += 1;
190
+ }
191
+ }
192
+ } finally {
193
+ if (onSigint) process.off("SIGINT", onSigint);
194
+ }
195
+
196
+ console.log(` Processed ${processed} job(s), ${failed} failed on '${topic}'.`);
197
+ }
198
+
199
+ /**
200
+ * Print pending / in-flight / failed / dead-letter / completed counts.
201
+ *
202
+ * tina4nodejs queue stats [topic] [--json]
203
+ */
204
+ async function queueStats(args: string[]): Promise<void> {
205
+ loadEnv();
206
+ const { flags, positional } = parseFlags(args);
207
+ const topic = positional[0] ?? "default";
208
+
209
+ const queue = new Queue({ topic });
210
+ const stats = {
211
+ topic,
212
+ pending: queue.size("pending"), // waiting to run
213
+ reserved: queue.size("reserved"), // popped, not yet acked (in-flight)
214
+ failed: queue.failed().length, // failed once, still retrying
215
+ dead: queue.size("dead"), // exhausted retries (dead-letter)
216
+ completed: queue.size("completed"), // terminal-completed (0 on the file backend)
217
+ };
218
+
219
+ if (flags.json) {
220
+ console.log(JSON.stringify(stats, null, 2));
221
+ return;
222
+ }
223
+
224
+ console.log(`\n Queue '${topic}'`);
225
+ console.log(` pending ${stats.pending}`);
226
+ console.log(` reserved ${stats.reserved} (in-flight)`);
227
+ console.log(` failed ${stats.failed} (retrying)`);
228
+ console.log(` dead ${stats.dead} (dead-letter)`);
229
+ console.log(` completed ${stats.completed}`);
230
+ console.log("");
231
+ }
232
+
233
+ /**
234
+ * Re-queue failed and dead-letter jobs so they run again.
235
+ *
236
+ * tina4nodejs queue retry [topic]
237
+ *
238
+ * Revives every dead-letter job (manual override, regardless of attempt count)
239
+ * and re-queues any failed-but-still-eligible jobs.
240
+ */
241
+ async function queueRetry(args: string[]): Promise<void> {
242
+ loadEnv();
243
+ const { positional } = parseFlags(args);
244
+ const topic = positional[0] ?? "default";
245
+
246
+ const queue = new Queue({ topic });
247
+
248
+ // maxRetries=0 => every job in the dead-letter store, whatever its attempt
249
+ // count (matches what stats/size("dead") reports), not only attempts>=N.
250
+ const dead = queue.deadLetters(0);
251
+ let revived = 0;
252
+ for (const job of dead) {
253
+ if (queue.retry(job.id)) revived += 1;
254
+ }
255
+ // Any failed-but-retryable jobs still under the limit (no-op on the file
256
+ // backend once the above moved them out, meaningful for other backends).
257
+ const requeued = queue.retryFailed();
258
+
259
+ const total = revived + requeued;
260
+ console.log(
261
+ ` Re-queued ${total} job(s) on '${topic}' (${revived} dead-letter, ${requeued} failed).`,
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Purge jobs of a given status (default: completed).
267
+ *
268
+ * tina4nodejs queue clear [status] [topic]
269
+ *
270
+ * status is one of pending / reserved / completed / failed / dead. The default
271
+ * 'completed' clears finished jobs; pass e.g. `queue clear pending` or
272
+ * `queue clear dead orders` to purge another status / topic.
273
+ */
274
+ async function queueClear(args: string[]): Promise<void> {
275
+ loadEnv();
276
+ const { positional } = parseFlags(args);
277
+ const status = positional[0] ?? "completed";
278
+ const topic = positional[1] ?? "default";
279
+
280
+ const queue = new Queue({ topic });
281
+ const removed = queue.purge(status);
282
+ console.log(` Cleared ${removed} '${status}' job(s) from '${topic}'.`);
283
+ }
284
+
285
+ // ── Sub-dispatch table ───────────────────────────────────────────────
286
+ //
287
+ // The single source for the queue subcommands — drives dispatch AND the
288
+ // manifest's queue.subcommands (bin.ts COMMANDS). Mirrors Python's
289
+ // _QUEUE_SUBCOMMANDS.
290
+
291
+ const QUEUE_SUBCOMMANDS: Record<string, (args: string[]) => Promise<void>> = {
292
+ work: queueWork,
293
+ stats: queueStats,
294
+ retry: queueRetry,
295
+ clear: queueClear,
296
+ };
297
+
298
+ /** Subcommand names, in order — surfaced in `commands --json` for the tina4 client. */
299
+ export const QUEUE_SUBCOMMAND_NAMES = Object.keys(QUEUE_SUBCOMMANDS);
300
+
301
+ /**
302
+ * Top-level queue command: run workers and manage jobs. Dispatches to the
303
+ * subcommand handlers above; unknown / missing subcommands fail loud (exit 1).
304
+ */
305
+ export async function queueCommand(args: string[] = []): Promise<void> {
306
+ const list = QUEUE_SUBCOMMAND_NAMES.join(", ");
307
+
308
+ if (!args || args.length === 0) {
309
+ console.log("Usage: tina4nodejs queue <work|stats|retry|clear> [options]");
310
+ console.log(` Subcommands: ${list}`);
311
+ process.exit(1);
312
+ return;
313
+ }
314
+
315
+ const sub = args[0].toLowerCase();
316
+ const handler = QUEUE_SUBCOMMANDS[sub];
317
+ if (!handler) {
318
+ console.log(`Unknown queue subcommand: ${sub}`);
319
+ console.log(` Available: ${list}`);
320
+ process.exit(1);
321
+ return;
322
+ }
323
+
324
+ await handler(args.slice(1));
325
+ }
@@ -81,6 +81,12 @@ var _frondModule = (() => {
81
81
  xhr.onerror = function() {
82
82
  if (opts.onError) opts.onError(xhr.status, xhr);
83
83
  };
84
+ if (opts.timeout !== 0) {
85
+ xhr.timeout = opts.timeout || 3e4;
86
+ xhr.ontimeout = function() {
87
+ if (opts.onError) opts.onError(xhr.status, xhr);
88
+ };
89
+ }
84
90
  xhr.send(body);
85
91
  }
86
92
  function inject(html, target) {
@@ -123,7 +129,7 @@ var _frondModule = (() => {
123
129
  });
124
130
  return body.innerHTML;
125
131
  }
126
- function load(url, target, callback) {
132
+ function load(url, target, callback, onError) {
127
133
  const targetId = target || "content";
128
134
  request(url, {
129
135
  method: "GET",
@@ -134,10 +140,11 @@ var _frondModule = (() => {
134
140
  } else {
135
141
  if (callback) callback(data);
136
142
  }
137
- }
143
+ },
144
+ onError
138
145
  });
139
146
  }
140
- function post(url, data, target, callback) {
147
+ function post(url, data, target, callback, onError) {
141
148
  const targetId = target || "content";
142
149
  request(url, {
143
150
  method: "POST",
@@ -153,7 +160,8 @@ var _frondModule = (() => {
153
160
  return;
154
161
  }
155
162
  if (callback) callback(html, responseData);
156
- }
163
+ },
164
+ onError
157
165
  });
158
166
  }
159
167
  var form = {
@@ -209,10 +217,11 @@ var _frondModule = (() => {
209
217
  * @param url - URL to POST to.
210
218
  * @param target - DOM id to inject response into (default: "message").
211
219
  * @param callback - Optional callback.
220
+ * @param onError - Optional error callback — fires on non-2xx, transport error, or timeout.
212
221
  */
213
- submit: function(formId, url, target, callback) {
222
+ submit: function(formId, url, target, callback, onError) {
214
223
  const data = form.collect(formId);
215
- post(url, data, target || "message", callback);
224
+ post(url, data, target || "message", callback, onError);
216
225
  },
217
226
  /**
218
227
  * Load a form via the given action and inject response HTML.
@@ -224,8 +233,9 @@ var _frondModule = (() => {
224
233
  * @param url - URL to fetch.
225
234
  * @param target - DOM id to inject into (default: "form").
226
235
  * @param callback - Optional callback.
236
+ * @param onError - Optional error callback — fires on non-2xx, transport error, or timeout.
227
237
  */
228
- show: function(action, url, target, callback) {
238
+ show: function(action, url, target, callback, onError) {
229
239
  let method = action.toUpperCase();
230
240
  if (action === "create" || action === "edit") method = "GET";
231
241
  if (action === "delete") method = "DELETE";
@@ -243,7 +253,8 @@ var _frondModule = (() => {
243
253
  return;
244
254
  }
245
255
  if (callback) callback(html);
246
- }
256
+ },
257
+ onError
247
258
  });
248
259
  }
249
260
  };
@@ -1,2 +1,2 @@
1
- var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var q=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of A(s))!H.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};q(z,{frond:()=>L});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e){let o=s||"content";v(t,{method:"GET",onSuccess:function(n,c){if(document.getElementById(o)){let u=h(n,o);e&&e(u,n)}else e&&e(n)}})}function R(t,s,e,o){let n=e||"content";v(t,{method:"POST",body:s,onSuccess:function(c){let u="";if(c&&c.message!==void 0)u=h(c.message,n);else if(document.getElementById(n))u=h(c,n);else{o&&o(c);return}o&&o(u,c)}})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o){let n=x.collect(t);R(s,n,e||"message",o)},show:function(t,s,e,o){let n=t.toUpperCase();(t==="create"||t==="edit")&&(n="GET"),t==="delete"&&(n="DELETE");let c=e||"form";v(s,{method:n,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r)}})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function p(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=p(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||m()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function p(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(p(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){m(p(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function B(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,p=(r-o)/2/i+c,m=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+p+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function X(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(X(t,s),t.children.length||s.children.length?C(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function C(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),p=null;l&&n[l]?p=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(p=e[r]),p&&p.tagName===i.tagName?(G(p,i),reused.push(p),c.push(p)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function k(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}C(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var L={request:v,load:_,post:R,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:B,report:P,graphql:F,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=L,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
1
+ var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var q=Object.prototype.hasOwnProperty;var A=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of H(s))!q.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};A(z,{frond:()=>C});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},e.timeout!==0&&(n.timeout=e.timeout||3e4,n.ontimeout=function(){e.onError&&e.onError(n.status,n)}),n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e,o){let n=s||"content";v(t,{method:"GET",onSuccess:function(c,u){if(document.getElementById(n)){let r=h(c,n);e&&e(r,c)}else e&&e(c)},onError:o})}function k(t,s,e,o,n){let c=e||"content";v(t,{method:"POST",body:s,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r,u)},onError:n})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o,n){let c=x.collect(t);k(s,c,e||"message",o,n)},show:function(t,s,e,o,n){let c=t.toUpperCase();(t==="create"||t==="edit")&&(c="GET"),t==="delete"&&(c="DELETE");let u=e||"form";v(s,{method:c,onSuccess:function(r){let i="";if(r&&r.message!==void 0)i=h(r.message,u);else if(document.getElementById(u))i=h(r,u);else{o&&o(r);return}o&&o(i)},onError:n})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function m(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function p(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=m(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||p()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function m(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){p(m(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){p(m(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function X(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,m=(r-o)/2/i+c,p=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+m+",left="+l);return window.focus&&p&&p.focus(),p}function B(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function P(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function F(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(F(t,s),t.children.length||s.children.length?L(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function L(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),m=null;l&&n[l]?m=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(m=e[r]),m&&m.tagName===i.tagName?(G(m,i),reused.push(m),c.push(m)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function R(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}L(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){R(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&R(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var C={request:v,load:_,post:k,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:X,report:B,graphql:P,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=C,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
2
2
  /* Frond v2.2.0 - tina4.com */
@@ -64,8 +64,16 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
64
64
 
65
65
  const meta: RouteMeta | undefined = mod.meta;
66
66
  const template: string | undefined = typeof mod.template === "string" ? mod.template : undefined;
67
-
68
- definitions.push({ method, pattern, handler, filePath, meta, template });
67
+ // Auth opt-outs a route file can export (parity with the imperative
68
+ // `.noAuth()` / AutoCrud `secure: false`). A generated public write file
69
+ // (`generate route … --public`, or the always-public auth login/register)
70
+ // does `export const secure = false;`; without threading it here the
71
+ // router would keep its secure-by-default write gate and the opt-out
72
+ // would be inert. Only booleans are honoured — anything else is ignored.
73
+ const secure: boolean | undefined = typeof mod.secure === "boolean" ? mod.secure : undefined;
74
+ const noAuth: boolean | undefined = typeof mod.noAuth === "boolean" ? mod.noAuth : undefined;
75
+
76
+ definitions.push({ method, pattern, handler, filePath, meta, template, secure, noAuth });
69
77
  _seenFiles.add(filePath);
70
78
  _seenMtimes.set(filePath, currentMtime);
71
79
  registeredFromThisScan++;
@@ -109,7 +109,15 @@ export type Tina4Response =
109
109
  ((data?: unknown, statusCode?: number, contentType?: string) => Tina4Response)
110
110
  & Tina4ResponseMethods;
111
111
 
112
- export type RouteHandler = (req: Tina4Request, res: Tina4Response) => Promise<void> | void;
112
+ // A route handler may resolve `void` (it wrote to `res` directly) OR return the
113
+ // `Tina4Response` from `res.json(...)` / `response(...)` - the established
114
+ // convention across the framework's own routes, realtime handlers, and the
115
+ // `generate` scaffolding (`return res.json(...)`). The dispatcher tolerates a
116
+ // returned response; the type just has to allow it.
117
+ export type RouteHandler = (
118
+ req: Tina4Request,
119
+ res: Tina4Response,
120
+ ) => Tina4Response | void | Promise<Tina4Response | void>;
113
121
 
114
122
  export interface RouteDefinition {
115
123
  method: string;
@@ -586,6 +586,7 @@ export class BaseModel {
586
586
  * @param limit Max records (default 20)
587
587
  * @param offset Skip records (default 0)
588
588
  * @param include Relationship names to eager-load
589
+ * @param orderBy ORDER BY clause (e.g. "name ASC")
589
590
  */
590
591
  static async where<T extends BaseModel>(
591
592
  this: new (data?: Record<string, unknown>) => T,
@@ -594,6 +595,7 @@ export class BaseModel {
594
595
  limit: number = 20,
595
596
  offset: number = 0,
596
597
  include?: string[],
598
+ orderBy?: string,
597
599
  ): Promise<T[]> {
598
600
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
599
601
  const db = ModelClass.getDb();
@@ -607,7 +609,8 @@ export class BaseModel {
607
609
  }
608
610
  parts.push(`(${conditions})`);
609
611
 
610
- const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")} LIMIT ${limit} OFFSET ${offset}`;
612
+ const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
613
+ const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")}${orderClause} LIMIT ${limit} OFFSET ${offset}`;
611
614
 
612
615
  const rows = await adapterQuery(db, sql, params);
613
616
  const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);