tina4-nodejs 3.10.66 → 3.10.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +92 -2
- package/package.json +1 -1
- package/packages/core/src/auth.ts +38 -12
- package/packages/core/src/events.ts +22 -0
- package/packages/core/src/graphql.ts +123 -1
- package/packages/core/src/i18n.ts +47 -4
- package/packages/core/src/queue.ts +23 -3
- package/packages/core/src/request.ts +24 -5
- package/packages/core/src/response.ts +8 -0
- package/packages/core/src/session.ts +33 -26
- package/packages/core/src/types.ts +4 -1
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketConnection.ts +8 -4
- package/packages/core/src/wsdl.ts +76 -41
- package/packages/orm/src/baseModel.ts +89 -30
- package/packages/orm/src/database.ts +36 -7
package/CLAUDE.md
CHANGED
|
@@ -153,13 +153,103 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
|
|
|
153
153
|
- `fakeData.ts` — ORM-aware fake data extending core (adds `forField()` with column-name heuristics)
|
|
154
154
|
- `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
|
|
155
155
|
- `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
|
|
156
|
-
- `
|
|
157
|
-
- `
|
|
156
|
+
- **Instance methods:** `save(): this|null` (fluent, null on failure), `delete()`, `forceDelete()`, `restore()`, `load(sql, params?, include?): boolean`, `validate(): string[]`, `toDict(include?)`, `toAssoc(include?)`, `toObject()`, `toArray(): unknown[]`, `toList()`, `toJson(include?)`, `hasOne(class, fk)`, `hasMany(class, fk, limit?, offset?)`, `belongsTo(class, fk)`
|
|
157
|
+
- **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findOrFail(id)`, `create(data)`, `all(where?, params?, include?)`, `select(sql, params?)`, `selectOne(sql, params?, include?)`, `where(conditions, params?, limit?, offset?, include?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?, limit?, offset?)`, `scope(name, filterSql, params?)` (registers reusable method), `createTable()`, `query()`
|
|
158
158
|
- QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
|
|
159
159
|
- `getNextId(table: string, pkColumn?: string, generatorName?: string): Promise<number>` — Race-safe ID generation using atomic sequence table (`tina4_sequences`). SQLite/MySQL/MSSQL use `tina4_sequences` with atomic UPDATE+SELECT. PostgreSQL auto-creates sequences if missing. Firebird uses existing generators (unchanged).
|
|
160
160
|
|
|
161
161
|
**`tina4_sequences` table** — Auto-created by `getNextId()` on first use for SQLite, MySQL, and MSSQL. Stores the current sequence value per table. Do not modify this table manually.
|
|
162
162
|
|
|
163
|
+
### File Uploads
|
|
164
|
+
|
|
165
|
+
Multipart file uploads via `req.files` (dict keyed by field name):
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// req.files["avatar"] =>
|
|
169
|
+
{
|
|
170
|
+
fieldName: "avatar",
|
|
171
|
+
filename: "photo.png",
|
|
172
|
+
type: "image/png",
|
|
173
|
+
content: Buffer, // raw bytes — NOT base64
|
|
174
|
+
size: 102400
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
post("/api/upload", (req, res) => {
|
|
180
|
+
const file = req.files["avatar"];
|
|
181
|
+
if (!file) return res.json({ error: "No file" }, 400);
|
|
182
|
+
fs.writeFileSync(`src/public/uploads/${(file as any).filename}`, (file as any).content);
|
|
183
|
+
return res.json({ ok: true });
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Max upload size: `TINA4_MAX_UPLOAD_SIZE` env var (default 10MB).
|
|
188
|
+
|
|
189
|
+
### Auth
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// expires_in is in MINUTES (default 60). Reads SECRET from env if not passed.
|
|
193
|
+
getToken(payload, secret?, expiresIn=60): string
|
|
194
|
+
validToken(token, secret?): Record | null
|
|
195
|
+
getPayload(token): Record | null
|
|
196
|
+
refreshToken(token, secret?, expiresIn=60): string | null
|
|
197
|
+
hashPassword(password, salt?, iterations=260000): string // PBKDF2-SHA256, $ delimiter
|
|
198
|
+
checkPassword(password, hash): boolean // timing-safe
|
|
199
|
+
validateApiKey(provided, expected?): boolean // reads TINA4_API_KEY from env
|
|
200
|
+
authenticateRequest(headers, secret?): Record | null // Bearer JWT, falls back to API key
|
|
201
|
+
// Also available as Auth.getToken(), Auth.validToken(), etc.
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Session
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
session.start(sessionId?): string
|
|
208
|
+
session.get(key, defaultValue?): unknown
|
|
209
|
+
session.set(key, value): void
|
|
210
|
+
session.delete(key): void
|
|
211
|
+
session.has(key): boolean
|
|
212
|
+
session.all(): Record
|
|
213
|
+
session.clear(): void
|
|
214
|
+
session.destroy(): void
|
|
215
|
+
session.regenerate(): string
|
|
216
|
+
session.flash(key, value?): unknown // Dual-mode: set with value, get+remove without
|
|
217
|
+
session.getFlash(key, defaultValue?): unknown
|
|
218
|
+
session.save(): void // Public — persist to backend
|
|
219
|
+
session.cookieHeader(name?): string // Set-Cookie header value
|
|
220
|
+
session.getSessionId(): string | null
|
|
221
|
+
session.gc(): void
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Backends: file, redis, redis-npm, valkey, mongodb, database.
|
|
225
|
+
|
|
226
|
+
### Database extras
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
db.execute(sql, params?): boolean | unknown // bool for writes, result for RETURNING/CALL/EXEC
|
|
230
|
+
db.getLastId(): string | number
|
|
231
|
+
db.getError(): string | null
|
|
232
|
+
db.cacheStats(): { enabled, size, ttl }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Request extras
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
req.files: Record<string, UploadedFile> // dict keyed by field name (not array)
|
|
239
|
+
req.cookies: Record<string, string> // parsed from Cookie header
|
|
240
|
+
req.contentType: string // from content-type header
|
|
241
|
+
req.query: Record<string, string> // query string params
|
|
242
|
+
response.xml(content, status?): Tina4Response
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Queue
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
queue.consume(topic?, id?, pollInterval=1000): AsyncGenerator<QueueJob>
|
|
249
|
+
// Long-running async generator. Sleeps when empty. pollInterval=0 for single-pass.
|
|
250
|
+
// Usage: for await (const job of queue.consume("emails")) { ... }
|
|
251
|
+
```
|
|
252
|
+
|
|
163
253
|
### @tina4/swagger (`packages/swagger/`)
|
|
164
254
|
Auto-generates OpenAPI 3.0 docs.
|
|
165
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.68",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -40,16 +40,23 @@ function base64urlDecode(str: string): Buffer {
|
|
|
40
40
|
*/
|
|
41
41
|
export function getToken(
|
|
42
42
|
payload: Record<string, unknown>,
|
|
43
|
-
secret
|
|
44
|
-
expiresIn: number =
|
|
43
|
+
secret?: string,
|
|
44
|
+
expiresIn: number = 60,
|
|
45
45
|
algorithm: string = "HS256",
|
|
46
46
|
): string {
|
|
47
|
+
if (!secret) {
|
|
48
|
+
secret = process.env.SECRET ?? "";
|
|
49
|
+
if (!secret) {
|
|
50
|
+
console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
const header = { alg: algorithm, typ: "JWT" };
|
|
48
55
|
const now = Math.floor(Date.now() / 1000);
|
|
49
56
|
|
|
50
57
|
const claims: Record<string, unknown> = { ...payload, iat: now };
|
|
51
58
|
if (expiresIn !== 0) {
|
|
52
|
-
claims.exp = now + expiresIn;
|
|
59
|
+
claims.exp = now + Math.floor(expiresIn * 60);
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
|
|
@@ -65,9 +72,15 @@ export function getToken(
|
|
|
65
72
|
*/
|
|
66
73
|
export function validToken(
|
|
67
74
|
token: string,
|
|
68
|
-
secret
|
|
75
|
+
secret?: string,
|
|
69
76
|
algorithm: string = "HS256",
|
|
70
77
|
): Record<string, unknown> | null {
|
|
78
|
+
if (!secret) {
|
|
79
|
+
secret = process.env.SECRET ?? "";
|
|
80
|
+
if (!secret) {
|
|
81
|
+
console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
71
84
|
try {
|
|
72
85
|
const parts = token.split(".");
|
|
73
86
|
if (parts.length !== 3) return null;
|
|
@@ -145,24 +158,27 @@ function verifySignature(input: string, sig: string, secret: string, algorithm:
|
|
|
145
158
|
* @param password - Plaintext password
|
|
146
159
|
* @param salt - Hex-encoded salt (auto-generated if omitted)
|
|
147
160
|
* @param iterations - PBKDF2 iterations (default 100000)
|
|
148
|
-
* @returns Format: `pbkdf2_sha256
|
|
161
|
+
* @returns Format: `pbkdf2_sha256$iterations$salt$hash` (all hex-encoded)
|
|
149
162
|
*/
|
|
150
163
|
export function hashPassword(
|
|
151
164
|
password: string,
|
|
152
165
|
salt?: string,
|
|
153
|
-
iterations: number =
|
|
166
|
+
iterations: number = 260000,
|
|
154
167
|
): string {
|
|
155
168
|
const actualSalt = salt ?? randomBytes(16).toString("hex");
|
|
156
169
|
const dk = pbkdf2Sync(password, actualSalt, iterations, 32, "sha256");
|
|
157
|
-
return `pbkdf2_sha256
|
|
170
|
+
return `pbkdf2_sha256$${iterations}$${actualSalt}$${dk.toString("hex")}`;
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
/**
|
|
161
174
|
* Check a password against a PBKDF2 hash string.
|
|
175
|
+
* Supports both $ and : delimiters for backward compatibility.
|
|
162
176
|
*/
|
|
163
177
|
export function checkPassword(password: string, hash: string): boolean {
|
|
164
178
|
try {
|
|
165
|
-
|
|
179
|
+
// Support both $ (standard) and : (legacy) delimiters
|
|
180
|
+
const delimiter = hash.includes("$") ? "$" : ":";
|
|
181
|
+
const parts = hash.split(delimiter);
|
|
166
182
|
if (parts.length !== 4 || parts[0] !== "pbkdf2_sha256") return false;
|
|
167
183
|
|
|
168
184
|
const iterations = parseInt(parts[1], 10);
|
|
@@ -225,8 +241,8 @@ export function authMiddleware(secret: string, algorithm: string = "HS256"): Mid
|
|
|
225
241
|
*/
|
|
226
242
|
export function refreshToken(
|
|
227
243
|
token: string,
|
|
228
|
-
secret
|
|
229
|
-
expiresIn: number =
|
|
244
|
+
secret?: string,
|
|
245
|
+
expiresIn: number = 60,
|
|
230
246
|
algorithm: string = "HS256",
|
|
231
247
|
): string | null {
|
|
232
248
|
const payload = validToken(token, secret, algorithm);
|
|
@@ -249,7 +265,7 @@ export function refreshToken(
|
|
|
249
265
|
*/
|
|
250
266
|
export function authenticateRequest(
|
|
251
267
|
headers: Record<string, string | string[] | undefined>,
|
|
252
|
-
secret
|
|
268
|
+
secret?: string,
|
|
253
269
|
algorithm: string = "HS256",
|
|
254
270
|
): Record<string, unknown> | null {
|
|
255
271
|
const authHeader =
|
|
@@ -258,7 +274,17 @@ export function authenticateRequest(
|
|
|
258
274
|
if (!authHeader.startsWith("Bearer ")) return null;
|
|
259
275
|
|
|
260
276
|
const token = authHeader.slice(7);
|
|
261
|
-
|
|
277
|
+
|
|
278
|
+
// Try JWT first
|
|
279
|
+
const payload = validToken(token, secret, algorithm);
|
|
280
|
+
if (payload !== null) return payload;
|
|
281
|
+
|
|
282
|
+
// Fallback: treat Bearer value as API key
|
|
283
|
+
if (validateApiKey(token)) {
|
|
284
|
+
return { _auth: "api_key" };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return null;
|
|
262
288
|
}
|
|
263
289
|
|
|
264
290
|
// ── Backward-Compatible Aliases ──────────────────────────────────
|
|
@@ -87,6 +87,28 @@ export class Events {
|
|
|
87
87
|
return results;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Emit an event and await all async listeners.
|
|
92
|
+
* Returns array of resolved results from each listener.
|
|
93
|
+
*/
|
|
94
|
+
static async emitAsync(event: string, ...args: unknown[]): Promise<unknown[]> {
|
|
95
|
+
const entries = _listeners.get(event);
|
|
96
|
+
if (!entries) return [];
|
|
97
|
+
|
|
98
|
+
const snapshot = [...entries];
|
|
99
|
+
const results: unknown[] = [];
|
|
100
|
+
|
|
101
|
+
for (const entry of snapshot) {
|
|
102
|
+
if (entry.once) {
|
|
103
|
+
const idx = entries.indexOf(entry);
|
|
104
|
+
if (idx !== -1) entries.splice(idx, 1);
|
|
105
|
+
}
|
|
106
|
+
results.push(await entry.callback(...args));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
/**
|
|
91
113
|
* Get all listener callbacks for an event (in priority order).
|
|
92
114
|
*/
|
|
@@ -383,6 +383,21 @@ export class GraphQL {
|
|
|
383
383
|
|
|
384
384
|
constructor() {}
|
|
385
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Return schema metadata for debugging.
|
|
388
|
+
*/
|
|
389
|
+
introspect(): Record<string, unknown> {
|
|
390
|
+
const queries: Record<string, unknown> = {};
|
|
391
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
392
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
393
|
+
}
|
|
394
|
+
const mutations: Record<string, unknown> = {};
|
|
395
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
396
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
397
|
+
}
|
|
398
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
399
|
+
}
|
|
400
|
+
|
|
386
401
|
/**
|
|
387
402
|
* Register a named type with its fields.
|
|
388
403
|
*/
|
|
@@ -391,6 +406,21 @@ export class GraphQL {
|
|
|
391
406
|
return this;
|
|
392
407
|
}
|
|
393
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Return schema metadata for debugging.
|
|
411
|
+
*/
|
|
412
|
+
introspect(): Record<string, unknown> {
|
|
413
|
+
const queries: Record<string, unknown> = {};
|
|
414
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
415
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
416
|
+
}
|
|
417
|
+
const mutations: Record<string, unknown> = {};
|
|
418
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
419
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
420
|
+
}
|
|
421
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
422
|
+
}
|
|
423
|
+
|
|
394
424
|
/**
|
|
395
425
|
* Register a query resolver.
|
|
396
426
|
*/
|
|
@@ -404,6 +434,21 @@ export class GraphQL {
|
|
|
404
434
|
return this;
|
|
405
435
|
}
|
|
406
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Return schema metadata for debugging.
|
|
439
|
+
*/
|
|
440
|
+
introspect(): Record<string, unknown> {
|
|
441
|
+
const queries: Record<string, unknown> = {};
|
|
442
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
443
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
444
|
+
}
|
|
445
|
+
const mutations: Record<string, unknown> = {};
|
|
446
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
447
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
448
|
+
}
|
|
449
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
450
|
+
}
|
|
451
|
+
|
|
407
452
|
/**
|
|
408
453
|
* Register a mutation resolver.
|
|
409
454
|
*/
|
|
@@ -417,6 +462,21 @@ export class GraphQL {
|
|
|
417
462
|
return this;
|
|
418
463
|
}
|
|
419
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Return schema metadata for debugging.
|
|
467
|
+
*/
|
|
468
|
+
introspect(): Record<string, unknown> {
|
|
469
|
+
const queries: Record<string, unknown> = {};
|
|
470
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
471
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
472
|
+
}
|
|
473
|
+
const mutations: Record<string, unknown> = {};
|
|
474
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
475
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
476
|
+
}
|
|
477
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
478
|
+
}
|
|
479
|
+
|
|
420
480
|
/**
|
|
421
481
|
* Execute a GraphQL query string.
|
|
422
482
|
*/
|
|
@@ -464,10 +524,57 @@ export class GraphQL {
|
|
|
464
524
|
return result;
|
|
465
525
|
}
|
|
466
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Return schema metadata for debugging.
|
|
529
|
+
*/
|
|
530
|
+
introspect(): Record<string, unknown> {
|
|
531
|
+
const queries: Record<string, unknown> = {};
|
|
532
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
533
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
534
|
+
}
|
|
535
|
+
const mutations: Record<string, unknown> = {};
|
|
536
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
537
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
538
|
+
}
|
|
539
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Return schema metadata for debugging.
|
|
545
|
+
*/
|
|
546
|
+
introspect(): Record<string, unknown> {
|
|
547
|
+
const queries: Record<string, unknown> = {};
|
|
548
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
549
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
550
|
+
}
|
|
551
|
+
const mutations: Record<string, unknown> = {};
|
|
552
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
553
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
554
|
+
}
|
|
555
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
556
|
+
}
|
|
557
|
+
|
|
467
558
|
/**
|
|
468
559
|
* Generate SDL schema string.
|
|
469
560
|
*/
|
|
470
|
-
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Return schema metadata for debugging.
|
|
564
|
+
*/
|
|
565
|
+
introspect(): Record<string, unknown> {
|
|
566
|
+
const queries: Record<string, unknown> = {};
|
|
567
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
568
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
569
|
+
}
|
|
570
|
+
const mutations: Record<string, unknown> = {};
|
|
571
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
572
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
573
|
+
}
|
|
574
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
schemaSdl(): string {
|
|
471
578
|
const lines: string[] = [];
|
|
472
579
|
|
|
473
580
|
// Types
|
|
@@ -505,6 +612,21 @@ export class GraphQL {
|
|
|
505
612
|
return lines.join("\n");
|
|
506
613
|
}
|
|
507
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Return schema metadata for debugging.
|
|
617
|
+
*/
|
|
618
|
+
introspect(): Record<string, unknown> {
|
|
619
|
+
const queries: Record<string, unknown> = {};
|
|
620
|
+
for (const [name, config] of Object.entries(this.queries)) {
|
|
621
|
+
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
622
|
+
}
|
|
623
|
+
const mutations: Record<string, unknown> = {};
|
|
624
|
+
for (const [name, config] of Object.entries(this.mutations)) {
|
|
625
|
+
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
626
|
+
}
|
|
627
|
+
return { types: Object.keys(this.types), queries, mutations };
|
|
628
|
+
}
|
|
629
|
+
|
|
508
630
|
/**
|
|
509
631
|
* Auto-generate type, queries, and CRUD mutations from an ORM model class.
|
|
510
632
|
*
|
|
@@ -92,8 +92,8 @@ export class I18n {
|
|
|
92
92
|
try {
|
|
93
93
|
const files = readdirSync(this._localeDir);
|
|
94
94
|
const locales = files
|
|
95
|
-
.filter((f) => f.endsWith(".json"))
|
|
96
|
-
.map((f) => f.replace(/\.json$/, ""))
|
|
95
|
+
.filter((f) => f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml"))
|
|
96
|
+
.map((f) => f.replace(/\.(json|ya?ml)$/, ""))
|
|
97
97
|
.sort();
|
|
98
98
|
return locales.length > 0 ? locales : [this._defaultLocale];
|
|
99
99
|
} catch {
|
|
@@ -112,15 +112,58 @@ export class I18n {
|
|
|
112
112
|
const raw = readFileSync(filePath, "utf-8");
|
|
113
113
|
const data = JSON.parse(raw);
|
|
114
114
|
this._translations.set(locale, I18n._flatten(data));
|
|
115
|
+
return;
|
|
115
116
|
} catch {
|
|
116
117
|
this._translations.set(locale, {});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Try YAML (.yml or .yaml) — zero-dep parser
|
|
122
|
+
for (const ext of [".yml", ".yaml"]) {
|
|
123
|
+
const yamlPath = join(this._localeDir, `${locale}${ext}`);
|
|
124
|
+
if (existsSync(yamlPath)) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = readFileSync(yamlPath, "utf-8");
|
|
127
|
+
const data = I18n._parseSimpleYaml(raw);
|
|
128
|
+
this._translations.set(locale, I18n._flatten(data));
|
|
129
|
+
return;
|
|
130
|
+
} catch { /* skip */ }
|
|
117
131
|
}
|
|
118
|
-
} else {
|
|
119
|
-
this._translations.set(locale, {});
|
|
120
132
|
}
|
|
133
|
+
this._translations.set(locale, {});
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
/** Flatten nested objects: {"a": {"b": "c"}} → {"a.b": "c"} */
|
|
137
|
+
|
|
138
|
+
/** Zero-dep YAML parser for simple key: value locale files. */
|
|
139
|
+
private static _parseSimpleYaml(text: string): Record<string, unknown> {
|
|
140
|
+
const result: Record<string, unknown> = {};
|
|
141
|
+
let currentParent: string | null = null;
|
|
142
|
+
for (const line of text.split("\n")) {
|
|
143
|
+
const stripped = line.trim();
|
|
144
|
+
if (!stripped || stripped.startsWith("#")) continue;
|
|
145
|
+
const indent = line.length - line.trimStart().length;
|
|
146
|
+
const colonIdx = stripped.indexOf(":");
|
|
147
|
+
if (colonIdx === -1) continue;
|
|
148
|
+
const key = stripped.substring(0, colonIdx).trim();
|
|
149
|
+
let value = stripped.substring(colonIdx + 1).trim();
|
|
150
|
+
// Strip quotes
|
|
151
|
+
if (value && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
|
|
152
|
+
value = value.substring(1, value.length - 1);
|
|
153
|
+
}
|
|
154
|
+
if (!value) {
|
|
155
|
+
currentParent = key;
|
|
156
|
+
result[key] = {};
|
|
157
|
+
} else if (indent > 0 && currentParent && typeof result[currentParent] === "object") {
|
|
158
|
+
(result[currentParent] as Record<string, string>)[key] = value;
|
|
159
|
+
} else {
|
|
160
|
+
currentParent = null;
|
|
161
|
+
result[key] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
124
167
|
private static _flatten(data: Record<string, unknown>, prefix = ""): Record<string, string> {
|
|
125
168
|
const result: Record<string, string> = {};
|
|
126
169
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -623,7 +623,20 @@ export class Queue {
|
|
|
623
623
|
* processEmail(job);
|
|
624
624
|
* }
|
|
625
625
|
*/
|
|
626
|
-
|
|
626
|
+
/**
|
|
627
|
+
* Long-running async generator that polls the queue continuously.
|
|
628
|
+
* When empty, sleeps for pollInterval ms before polling again.
|
|
629
|
+
* No external while-loop or sleep needed.
|
|
630
|
+
*
|
|
631
|
+
* @param topic Queue topic (defaults to constructor topic)
|
|
632
|
+
* @param id Optional job ID — single yield, no polling
|
|
633
|
+
* @param pollInterval Milliseconds to sleep when queue is empty (default 1000)
|
|
634
|
+
*
|
|
635
|
+
* Usage:
|
|
636
|
+
* for await (const job of queue.consume("emails")) { ... }
|
|
637
|
+
* for await (const job of queue.consume("emails", undefined, 5000)) { ... }
|
|
638
|
+
*/
|
|
639
|
+
async *consume(topic?: string, id?: string, pollInterval: number = 1000): AsyncGenerator<QueueJob> {
|
|
627
640
|
const q = topic ?? this.topic;
|
|
628
641
|
|
|
629
642
|
if (id !== undefined) {
|
|
@@ -632,8 +645,15 @@ export class Queue {
|
|
|
632
645
|
return;
|
|
633
646
|
}
|
|
634
647
|
|
|
635
|
-
|
|
636
|
-
|
|
648
|
+
// pollInterval=0 → single-pass drain (returns when empty)
|
|
649
|
+
// pollInterval>0 → long-running poll (sleeps when empty, never returns)
|
|
650
|
+
while (true) {
|
|
651
|
+
const raw = this.pop(q) as any;
|
|
652
|
+
if (raw === null) {
|
|
653
|
+
if (pollInterval <= 0) break;
|
|
654
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
637
657
|
yield createJob(raw, this, q);
|
|
638
658
|
}
|
|
639
659
|
}
|
|
@@ -12,7 +12,19 @@ export function createRequest(req: IncomingMessage): Tina4Request {
|
|
|
12
12
|
tReq.params = {};
|
|
13
13
|
tReq.query = query;
|
|
14
14
|
tReq.body = undefined;
|
|
15
|
-
tReq.files =
|
|
15
|
+
tReq.files = {};
|
|
16
|
+
tReq.contentType = (req.headers["content-type"] ?? "") as string;
|
|
17
|
+
|
|
18
|
+
// Parse cookies from Cookie header
|
|
19
|
+
const cookieHeader = (req.headers.cookie ?? "") as string;
|
|
20
|
+
const cookies: Record<string, string> = {};
|
|
21
|
+
if (cookieHeader) {
|
|
22
|
+
for (const pair of cookieHeader.split(";")) {
|
|
23
|
+
const [k, ...v] = pair.trim().split("=");
|
|
24
|
+
if (k) cookies[k.trim()] = v.join("=").trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
tReq.cookies = cookies;
|
|
16
28
|
|
|
17
29
|
// Determine client IP with X-Forwarded-For support
|
|
18
30
|
const forwarded = req.headers["x-forwarded-for"];
|
|
@@ -109,9 +121,9 @@ function extractBoundary(contentType: string): string | null {
|
|
|
109
121
|
export function parseMultipart(
|
|
110
122
|
body: Buffer,
|
|
111
123
|
boundary: string,
|
|
112
|
-
): { fields: Record<string, string>; files: UploadedFile[] } {
|
|
124
|
+
): { fields: Record<string, string>; files: Record<string, UploadedFile | UploadedFile[]> } {
|
|
113
125
|
const fields: Record<string, string> = {};
|
|
114
|
-
const files: UploadedFile[] =
|
|
126
|
+
const files: Record<string, UploadedFile | UploadedFile[]> = {};
|
|
115
127
|
|
|
116
128
|
const delimiter = Buffer.from(`--${boundary}`);
|
|
117
129
|
const closeDelimiter = Buffer.from(`--${boundary}--`);
|
|
@@ -152,13 +164,20 @@ export function parseMultipart(
|
|
|
152
164
|
|
|
153
165
|
if (disposition.filename) {
|
|
154
166
|
// File upload — standardised format: filename, type, content (raw bytes), size
|
|
155
|
-
|
|
167
|
+
const file: UploadedFile = {
|
|
156
168
|
fieldName: disposition.name,
|
|
157
169
|
filename: disposition.filename,
|
|
158
170
|
type: partContentType ?? "application/octet-stream",
|
|
159
171
|
content: Buffer.from(content),
|
|
160
172
|
size: content.length,
|
|
161
|
-
}
|
|
173
|
+
};
|
|
174
|
+
// Dict keyed by field name — multiple files under same name become array
|
|
175
|
+
if (files[disposition.name]) {
|
|
176
|
+
const existing = files[disposition.name];
|
|
177
|
+
files[disposition.name] = Array.isArray(existing) ? [...existing, file] : [existing, file];
|
|
178
|
+
} else {
|
|
179
|
+
files[disposition.name] = file;
|
|
180
|
+
}
|
|
162
181
|
} else if (disposition.name) {
|
|
163
182
|
// Regular field
|
|
164
183
|
fields[disposition.name] = content.toString("utf-8");
|
|
@@ -110,6 +110,14 @@ export function createResponse(res: ServerResponse): Tina4Response {
|
|
|
110
110
|
return response;
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
response.xml = function (content: string, status?: number): Tina4Response {
|
|
114
|
+
if (res.headersSent) return response;
|
|
115
|
+
if (status !== undefined) res.statusCode = status;
|
|
116
|
+
safeSetHeader("Content-Type", "application/xml; charset=utf-8");
|
|
117
|
+
safeEnd(content);
|
|
118
|
+
return response;
|
|
119
|
+
};
|
|
120
|
+
|
|
113
121
|
response.send = function (data: unknown, statusCode?: number, contentType?: string): Tina4Response {
|
|
114
122
|
return response(data, statusCode, contentType);
|
|
115
123
|
};
|
|
@@ -430,18 +430,6 @@ export class Session {
|
|
|
430
430
|
this.save();
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
-
/**
|
|
434
|
-
* Clear all session data without destroying the session.
|
|
435
|
-
* The session ID and cookie remain — only the data is wiped.
|
|
436
|
-
*/
|
|
437
|
-
clear(): void {
|
|
438
|
-
if (!this.data) return;
|
|
439
|
-
for (const key of Object.keys(this.data)) {
|
|
440
|
-
delete this.data[key];
|
|
441
|
-
}
|
|
442
|
-
this.save();
|
|
443
|
-
}
|
|
444
|
-
|
|
445
433
|
/**
|
|
446
434
|
* Destroy the entire session.
|
|
447
435
|
*/
|
|
@@ -506,31 +494,49 @@ export class Session {
|
|
|
506
494
|
}
|
|
507
495
|
|
|
508
496
|
/**
|
|
509
|
-
*
|
|
497
|
+
* Dual-mode flash: set with value, get+remove without.
|
|
498
|
+
*
|
|
499
|
+
* session.flash("message", "Saved!") // set
|
|
500
|
+
* session.flash("message") // get + auto-remove → "Saved!"
|
|
510
501
|
*/
|
|
511
|
-
flash(key: string, value
|
|
512
|
-
|
|
502
|
+
flash(key: string, value?: unknown): unknown {
|
|
503
|
+
const flashKey = `${FLASH_PREFIX}${key}`;
|
|
504
|
+
if (value !== undefined) {
|
|
505
|
+
// Set mode
|
|
506
|
+
this.set(flashKey, value);
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
// Get mode — read and remove
|
|
510
|
+
if (!this.data || !(flashKey in this.data)) return undefined;
|
|
511
|
+
const stored = this.data[flashKey];
|
|
512
|
+
delete this.data[flashKey];
|
|
513
|
+
this.save();
|
|
514
|
+
return stored;
|
|
513
515
|
}
|
|
514
516
|
|
|
515
517
|
/**
|
|
516
|
-
* Get flash data (
|
|
518
|
+
* Get flash data by key (alias for flash(key) without value).
|
|
517
519
|
*/
|
|
518
520
|
getFlash(key: string, defaultValue?: unknown): unknown {
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
const value = this.data[flashKey];
|
|
522
|
-
delete this.data[flashKey];
|
|
523
|
-
this.save();
|
|
524
|
-
return value ?? defaultValue;
|
|
521
|
+
const result = this.flash(key);
|
|
522
|
+
return result !== undefined ? result : defaultValue;
|
|
525
523
|
}
|
|
526
524
|
|
|
527
525
|
/**
|
|
528
526
|
* Get the current session ID.
|
|
529
527
|
*/
|
|
530
|
-
|
|
528
|
+
getSessionId(): string | null {
|
|
531
529
|
return this.sessionId;
|
|
532
530
|
}
|
|
533
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Return a Set-Cookie header value for this session.
|
|
534
|
+
*/
|
|
535
|
+
cookieHeader(cookieName: string = "tina4_session"): string {
|
|
536
|
+
const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
|
|
537
|
+
return `${cookieName}=${this.sessionId}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${this.ttl}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
534
540
|
/**
|
|
535
541
|
* Run garbage collection on the session backend.
|
|
536
542
|
* Removes expired file/database sessions. Redis/Valkey/Mongo handle TTL natively.
|
|
@@ -541,9 +547,10 @@ export class Session {
|
|
|
541
547
|
}
|
|
542
548
|
}
|
|
543
549
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Persist session data to the backend.
|
|
552
|
+
*/
|
|
553
|
+
save(): void {
|
|
547
554
|
if (!this.sessionId || !this.data) return;
|
|
548
555
|
this.handler.write(this.sessionId, this.data, this.ttl);
|
|
549
556
|
}
|
|
@@ -22,7 +22,9 @@ export interface Tina4Request extends IncomingMessage {
|
|
|
22
22
|
query: Record<string, string>;
|
|
23
23
|
body: unknown;
|
|
24
24
|
ip: string;
|
|
25
|
-
files: UploadedFile[]
|
|
25
|
+
files: Record<string, UploadedFile | UploadedFile[]>;
|
|
26
|
+
cookies: Record<string, string>;
|
|
27
|
+
contentType: string;
|
|
26
28
|
session: Tina4Session;
|
|
27
29
|
user?: Record<string, unknown>;
|
|
28
30
|
}
|
|
@@ -41,6 +43,7 @@ export interface Tina4ResponseMethods {
|
|
|
41
43
|
json(data: unknown, status?: number): Tina4Response;
|
|
42
44
|
html(content: string, status?: number): Tina4Response;
|
|
43
45
|
text(content: string, status?: number): Tina4Response;
|
|
46
|
+
xml(content: string, status?: number): Tina4Response;
|
|
44
47
|
status(code: number): Tina4Response;
|
|
45
48
|
header(name: string, value: string | number | readonly string[]): Tina4Response;
|
|
46
49
|
send(data: unknown, statusCode?: number, contentType?: string): Tina4Response;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* import { WebSocketServer } from "@tina4/core";
|
|
7
7
|
*
|
|
8
8
|
* const wss = new WebSocketServer({ port: 8080 });
|
|
9
|
-
* wss.on("
|
|
9
|
+
* wss.on("open", (client) => {
|
|
10
10
|
* console.log("Connected:", client.id);
|
|
11
11
|
* });
|
|
12
12
|
* wss.on("message", (client, message) => {
|
|
@@ -330,7 +330,7 @@ export class WebSocketServer {
|
|
|
330
330
|
};
|
|
331
331
|
|
|
332
332
|
this.clients.set(clientId, client);
|
|
333
|
-
this.emit("
|
|
333
|
+
this.emit("open", client);
|
|
334
334
|
|
|
335
335
|
// Handle incoming data
|
|
336
336
|
let buffer = Buffer.alloc(0);
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
export interface WebSocketConnection {
|
|
6
6
|
/** Unique connection identifier */
|
|
7
7
|
id: string;
|
|
8
|
+
/** The WebSocket route path this connection is on */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Client IP address */
|
|
11
|
+
ip: string;
|
|
12
|
+
/** HTTP headers from the upgrade request */
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
/** Route parameters extracted from `{param}` segments in the path */
|
|
15
|
+
params: Record<string, string>;
|
|
8
16
|
/** Send a message to this connection only */
|
|
9
17
|
send(message: string): void;
|
|
10
18
|
/** Broadcast a message to all connections on the same path (path-scoped) */
|
|
11
19
|
broadcast(message: string): void;
|
|
12
20
|
/** Close this connection */
|
|
13
21
|
close(): void;
|
|
14
|
-
/** The WebSocket route path this connection is on */
|
|
15
|
-
path: string;
|
|
16
|
-
/** Route parameters extracted from `{param}` segments in the path */
|
|
17
|
-
params: Record<string, string>;
|
|
18
22
|
}
|
|
@@ -68,60 +68,95 @@ function escapeXml(value: string): string {
|
|
|
68
68
|
.replace(/'/g, "'");
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
71
|
+
// ── Minimal zero-dep XML DOM parser ────────────────────────────
|
|
72
|
+
// Stack-based parser that builds a tree. Handles namespaces by
|
|
73
|
+
// stripping prefixes. No external dependencies.
|
|
74
|
+
|
|
75
|
+
interface XmlNode {
|
|
76
|
+
tag: string;
|
|
77
|
+
children: XmlNode[];
|
|
78
|
+
text: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseXml(xml: string): XmlNode {
|
|
82
|
+
const root: XmlNode = { tag: "", children: [], text: "" };
|
|
83
|
+
const stack: XmlNode[] = [root];
|
|
84
|
+
let i = 0;
|
|
85
|
+
|
|
86
|
+
while (i < xml.length) {
|
|
87
|
+
if (xml[i] === "<") {
|
|
88
|
+
const closeIdx = xml.indexOf(">", i);
|
|
89
|
+
if (closeIdx === -1) break;
|
|
90
|
+
const tagContent = xml.substring(i + 1, closeIdx).trim();
|
|
91
|
+
|
|
92
|
+
if (tagContent.startsWith("/")) {
|
|
93
|
+
stack.pop();
|
|
94
|
+
} else if (tagContent.startsWith("?") || tagContent.startsWith("!")) {
|
|
95
|
+
// PI or comment — skip
|
|
96
|
+
} else {
|
|
97
|
+
const selfClosing = tagContent.endsWith("/");
|
|
98
|
+
const raw = selfClosing ? tagContent.slice(0, -1).trim() : tagContent;
|
|
99
|
+
const spaceIdx = raw.indexOf(" ");
|
|
100
|
+
const fullTag = spaceIdx === -1 ? raw : raw.substring(0, spaceIdx);
|
|
101
|
+
const colonIdx = fullTag.indexOf(":");
|
|
102
|
+
const localTag = colonIdx === -1 ? fullTag : fullTag.substring(colonIdx + 1);
|
|
103
|
+
|
|
104
|
+
const node: XmlNode = { tag: localTag, children: [], text: "" };
|
|
105
|
+
stack[stack.length - 1].children.push(node);
|
|
106
|
+
if (!selfClosing) stack.push(node);
|
|
107
|
+
}
|
|
108
|
+
i = closeIdx + 1;
|
|
109
|
+
} else {
|
|
110
|
+
const nextTag = xml.indexOf("<", i);
|
|
111
|
+
const text = (nextTag === -1 ? xml.substring(i) : xml.substring(i, nextTag)).trim();
|
|
112
|
+
if (text && stack.length > 1) {
|
|
113
|
+
stack[stack.length - 1].text += text;
|
|
114
|
+
}
|
|
115
|
+
i = nextTag === -1 ? xml.length : nextTag;
|
|
116
|
+
}
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
return
|
|
119
|
+
return root;
|
|
88
120
|
}
|
|
89
121
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const results: Array<{ name: string; value: string }> = [];
|
|
96
|
-
// Match opening tags, capturing name (strip namespace prefix) and content
|
|
97
|
-
const pattern = /<(?:[a-zA-Z0-9]+:)?([a-zA-Z0-9_]+)[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?(\1)[^>]*>/g;
|
|
98
|
-
|
|
99
|
-
let match: RegExpExecArray | null;
|
|
100
|
-
while ((match = pattern.exec(xml)) !== null) {
|
|
101
|
-
results.push({ name: match[1], value: match[2].trim() });
|
|
122
|
+
function findNode(node: XmlNode, tagName: string): XmlNode | null {
|
|
123
|
+
if (node.tag === tagName) return node;
|
|
124
|
+
for (const child of node.children) {
|
|
125
|
+
const found = findNode(child, tagName);
|
|
126
|
+
if (found) return found;
|
|
102
127
|
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
103
130
|
|
|
104
|
-
|
|
131
|
+
function extractElement(xml: string, tagName: string): string | null {
|
|
132
|
+
const tree = parseXml(xml);
|
|
133
|
+
const node = findNode(tree, tagName);
|
|
134
|
+
if (!node) return null;
|
|
135
|
+
// Rebuild inner content from children
|
|
136
|
+
if (node.children.length === 0) return node.text || null;
|
|
137
|
+
// For complex content, fall back to regex on the original XML
|
|
138
|
+
const pattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${tagName}>`, "i");
|
|
139
|
+
const match = xml.match(pattern);
|
|
140
|
+
return match ? match[1] : node.text || null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function extractChildren(xml: string): Array<{ name: string; value: string }> {
|
|
144
|
+
const tree = parseXml(xml);
|
|
145
|
+
const target = tree.children.length === 1 ? tree.children[0] : tree;
|
|
146
|
+
return target.children.map(c => ({ name: c.tag, value: c.text }));
|
|
105
147
|
}
|
|
106
148
|
|
|
107
|
-
/**
|
|
108
|
-
* Extract the SOAP Body content from a SOAP envelope.
|
|
109
|
-
*/
|
|
110
149
|
function extractSoapBody(xml: string): string | null {
|
|
111
150
|
return extractElement(xml, "Body");
|
|
112
151
|
}
|
|
113
152
|
|
|
114
|
-
/**
|
|
115
|
-
* Extract the operation element from the SOAP body.
|
|
116
|
-
* Returns { name, content } or null.
|
|
117
|
-
*/
|
|
118
153
|
function extractOperation(bodyXml: string): { name: string; content: string } | null {
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return
|
|
154
|
+
const tree = parseXml(bodyXml);
|
|
155
|
+
const firstChild = tree.children[0];
|
|
156
|
+
if (!firstChild) return null;
|
|
157
|
+
const pattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${firstChild.tag}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${firstChild.tag}>`, "i");
|
|
158
|
+
const match = bodyXml.match(pattern);
|
|
159
|
+
return { name: firstChild.tag, content: match ? match[1] : "" };
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
// ── Metadata storage ─────────────────────────────────────────
|
|
@@ -240,18 +240,30 @@ export class BaseModel {
|
|
|
240
240
|
return (this as unknown as typeof BaseModel).findById.call(this, id, include) as T | null;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Load a record into this instance via selectOne.
|
|
245
|
+
* Returns true if found and loaded, false otherwise.
|
|
246
|
+
*/
|
|
247
|
+
load(sql: string, params?: unknown[], include?: string[]): boolean {
|
|
248
|
+
const ModelClass = this.constructor as typeof BaseModel & (new (data?: Record<string, unknown>) => BaseModel);
|
|
249
|
+
const result = ModelClass.selectOne(sql, params, include);
|
|
250
|
+
if (!result) return false;
|
|
251
|
+
const data = (result as any).toJSON ? (result as any).toJSON() : result;
|
|
252
|
+
for (const [key, value] of Object.entries(data)) {
|
|
253
|
+
(this as any)[key] = value;
|
|
254
|
+
}
|
|
255
|
+
(this as any)._exists = true;
|
|
256
|
+
return true;
|
|
246
257
|
}
|
|
247
258
|
|
|
248
259
|
/**
|
|
249
260
|
* Find all records, optionally with a where clause.
|
|
261
|
+
* Alias: all()
|
|
250
262
|
* @param where Optional WHERE clause.
|
|
251
263
|
* @param params Optional query parameters.
|
|
252
264
|
* @param include Optional array of relationship names to eager-load.
|
|
253
265
|
*/
|
|
254
|
-
static
|
|
266
|
+
static all<T extends BaseModel>(
|
|
255
267
|
this: new (data?: Record<string, unknown>) => T,
|
|
256
268
|
where?: string,
|
|
257
269
|
params?: unknown[],
|
|
@@ -282,10 +294,51 @@ export class BaseModel {
|
|
|
282
294
|
return instances;
|
|
283
295
|
}
|
|
284
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Query records with a WHERE clause.
|
|
299
|
+
* Matches Python/PHP/Ruby where() API.
|
|
300
|
+
*
|
|
301
|
+
* @param conditions WHERE clause (e.g. "age > ? AND active = ?")
|
|
302
|
+
* @param params Bind parameters
|
|
303
|
+
* @param limit Max records (default 20)
|
|
304
|
+
* @param offset Skip records (default 0)
|
|
305
|
+
* @param include Relationship names to eager-load
|
|
306
|
+
*/
|
|
307
|
+
static where<T extends BaseModel>(
|
|
308
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
309
|
+
conditions: string,
|
|
310
|
+
params?: unknown[],
|
|
311
|
+
limit: number = 20,
|
|
312
|
+
offset: number = 0,
|
|
313
|
+
include?: string[],
|
|
314
|
+
): T[] {
|
|
315
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
316
|
+
const db = ModelClass.getDb();
|
|
317
|
+
|
|
318
|
+
const parts: string[] = [];
|
|
319
|
+
if (ModelClass.softDelete) {
|
|
320
|
+
parts.push("is_deleted = 0");
|
|
321
|
+
}
|
|
322
|
+
if (ModelClass.tableFilter) {
|
|
323
|
+
parts.push(ModelClass.tableFilter);
|
|
324
|
+
}
|
|
325
|
+
parts.push(`(${conditions})`);
|
|
326
|
+
|
|
327
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")} LIMIT ${limit} OFFSET ${offset}`;
|
|
328
|
+
|
|
329
|
+
const rows = db.query(sql, params);
|
|
330
|
+
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
331
|
+
if (include) {
|
|
332
|
+
ModelClass._eagerLoad(instances, include);
|
|
333
|
+
}
|
|
334
|
+
return instances;
|
|
335
|
+
}
|
|
336
|
+
|
|
285
337
|
/**
|
|
286
338
|
* Save this instance (insert or update).
|
|
339
|
+
* Returns this on success (fluent), null on failure.
|
|
287
340
|
*/
|
|
288
|
-
save():
|
|
341
|
+
save(): this | null {
|
|
289
342
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
290
343
|
const db = ModelClass.getDb();
|
|
291
344
|
const pk = ModelClass.getPkField();
|
|
@@ -328,8 +381,10 @@ export class BaseModel {
|
|
|
328
381
|
db.commit();
|
|
329
382
|
} catch (e) {
|
|
330
383
|
db.rollback();
|
|
331
|
-
|
|
384
|
+
return null;
|
|
332
385
|
}
|
|
386
|
+
(this as any)._exists = true;
|
|
387
|
+
return this;
|
|
333
388
|
}
|
|
334
389
|
|
|
335
390
|
/**
|
|
@@ -443,6 +498,13 @@ export class BaseModel {
|
|
|
443
498
|
return result;
|
|
444
499
|
}
|
|
445
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Convert to an associative object (alias for toDict).
|
|
503
|
+
*/
|
|
504
|
+
toAssoc(include?: string[]): Record<string, unknown> {
|
|
505
|
+
return this.toDict(include);
|
|
506
|
+
}
|
|
507
|
+
|
|
446
508
|
/**
|
|
447
509
|
* Convert to a plain object (alias for toDict).
|
|
448
510
|
*/
|
|
@@ -466,9 +528,10 @@ export class BaseModel {
|
|
|
466
528
|
|
|
467
529
|
/**
|
|
468
530
|
* Convert to JSON string.
|
|
531
|
+
* @param include Optional relationship names to include.
|
|
469
532
|
*/
|
|
470
|
-
toJson(): string {
|
|
471
|
-
return JSON.stringify(this.toDict());
|
|
533
|
+
toJson(include?: string[]): string {
|
|
534
|
+
return JSON.stringify(this.toDict(include));
|
|
472
535
|
}
|
|
473
536
|
|
|
474
537
|
/**
|
|
@@ -648,7 +711,7 @@ export class BaseModel {
|
|
|
648
711
|
conditions?: string,
|
|
649
712
|
params?: unknown[],
|
|
650
713
|
limit?: number,
|
|
651
|
-
|
|
714
|
+
offset?: number,
|
|
652
715
|
): T[] {
|
|
653
716
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
654
717
|
const db = ModelClass.getDb();
|
|
@@ -668,8 +731,8 @@ export class BaseModel {
|
|
|
668
731
|
if (limit !== undefined) {
|
|
669
732
|
sql += ` LIMIT ${limit}`;
|
|
670
733
|
}
|
|
671
|
-
if (
|
|
672
|
-
sql += ` OFFSET ${
|
|
734
|
+
if (offset !== undefined) {
|
|
735
|
+
sql += ` OFFSET ${offset}`;
|
|
673
736
|
}
|
|
674
737
|
|
|
675
738
|
const rows = db.query(sql, params);
|
|
@@ -698,29 +761,22 @@ export class BaseModel {
|
|
|
698
761
|
}
|
|
699
762
|
|
|
700
763
|
/**
|
|
701
|
-
*
|
|
764
|
+
* Register a reusable query scope on the class.
|
|
765
|
+
*
|
|
766
|
+
* Usage:
|
|
767
|
+
* User.scope("active", "active = ?", [1]);
|
|
768
|
+
* const users = (User as any).active(); // calls where("active = ?", [1])
|
|
769
|
+
* const users = (User as any).active(10, 5); // with limit/offset
|
|
702
770
|
*/
|
|
703
|
-
static scope
|
|
704
|
-
this: new (data?: Record<string, unknown>) => T,
|
|
771
|
+
static scope(
|
|
705
772
|
name: string,
|
|
706
773
|
filterSql: string,
|
|
707
774
|
params?: unknown[],
|
|
708
|
-
):
|
|
709
|
-
const ModelClass = this as unknown as typeof BaseModel
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (ModelClass.softDelete) {
|
|
714
|
-
conditions.push("is_deleted = 0");
|
|
715
|
-
}
|
|
716
|
-
if (ModelClass.tableFilter) {
|
|
717
|
-
conditions.push(ModelClass.tableFilter);
|
|
718
|
-
}
|
|
719
|
-
conditions.push(filterSql);
|
|
720
|
-
|
|
721
|
-
const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${conditions.join(" AND ")}`;
|
|
722
|
-
const rows = db.query(sql, params);
|
|
723
|
-
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
775
|
+
): void {
|
|
776
|
+
const ModelClass = this as unknown as typeof BaseModel;
|
|
777
|
+
(ModelClass as any)[name] = (limit: number = 20, offset: number = 0) => {
|
|
778
|
+
return ModelClass.where.call(ModelClass as any, filterSql, params, limit, offset);
|
|
779
|
+
};
|
|
724
780
|
}
|
|
725
781
|
|
|
726
782
|
/**
|
|
@@ -762,6 +818,8 @@ export class BaseModel {
|
|
|
762
818
|
this: T,
|
|
763
819
|
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
764
820
|
foreignKey: string,
|
|
821
|
+
limit: number = 100,
|
|
822
|
+
offset: number = 0,
|
|
765
823
|
): R[] {
|
|
766
824
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
767
825
|
const pk = ModelClass.getPkField();
|
|
@@ -776,6 +834,7 @@ export class BaseModel {
|
|
|
776
834
|
if (relatedClass.softDelete) {
|
|
777
835
|
sql += ` AND is_deleted = 0`;
|
|
778
836
|
}
|
|
837
|
+
sql += ` LIMIT ${limit} OFFSET ${offset}`;
|
|
779
838
|
|
|
780
839
|
const rows = db.query(sql, [pkValue]);
|
|
781
840
|
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
|
|
@@ -233,6 +233,7 @@ export class Database {
|
|
|
233
233
|
|
|
234
234
|
/** Whether to automatically commit after each write operation */
|
|
235
235
|
private autoCommit: boolean = process.env.TINA4_AUTOCOMMIT === "true";
|
|
236
|
+
private lastError: string | null = null;
|
|
236
237
|
|
|
237
238
|
/** Database engine type (sqlite, postgres, mysql, mssql, firebird) */
|
|
238
239
|
private dbType: string = "sqlite";
|
|
@@ -340,14 +341,28 @@ export class Database {
|
|
|
340
341
|
return this.getNextAdapter().fetchOne<T>(sql, params);
|
|
341
342
|
}
|
|
342
343
|
|
|
343
|
-
/**
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Execute a write statement. Returns true/false for simple writes.
|
|
346
|
+
* If SQL contains RETURNING, CALL, EXEC, or SELECT, returns the result set.
|
|
347
|
+
*/
|
|
348
|
+
execute(sql: string, params?: unknown[]): boolean | unknown {
|
|
349
|
+
try {
|
|
350
|
+
const adapter = this.getNextAdapter();
|
|
351
|
+
const result = adapter.execute(sql, params);
|
|
352
|
+
if (this.autoCommit) {
|
|
353
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
354
|
+
}
|
|
355
|
+
this.lastError = null;
|
|
356
|
+
const upper = sql.trim().toUpperCase();
|
|
357
|
+
if (upper.includes("RETURNING") || upper.startsWith("CALL ") ||
|
|
358
|
+
upper.startsWith("EXEC ") || upper.startsWith("SELECT ")) {
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
} catch (e: any) {
|
|
363
|
+
this.lastError = e?.message ?? String(e);
|
|
364
|
+
return false;
|
|
349
365
|
}
|
|
350
|
-
return result;
|
|
351
366
|
}
|
|
352
367
|
|
|
353
368
|
/** Insert a row into a table. */
|
|
@@ -457,6 +472,20 @@ export class Database {
|
|
|
457
472
|
return results;
|
|
458
473
|
}
|
|
459
474
|
|
|
475
|
+
/** Return the last execute() error message, or null. */
|
|
476
|
+
getError(): string | null {
|
|
477
|
+
return this.lastError ?? null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Return query cache statistics. */
|
|
481
|
+
cacheStats(): { enabled: boolean; size: number; ttl: number } {
|
|
482
|
+
return {
|
|
483
|
+
enabled: process.env.TINA4_DB_CACHE === "true",
|
|
484
|
+
size: 0,
|
|
485
|
+
ttl: parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
460
489
|
/** Get the last auto-increment id. */
|
|
461
490
|
getLastId(): string | number {
|
|
462
491
|
const id = this.getNextAdapter().lastInsertId();
|