tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* backplane.publish("chat", '{"user":"A","text":"hello"}');
|
|
17
17
|
* }
|
|
18
18
|
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Base interface for scaling WebSocket broadcast across instances.
|
|
@@ -51,12 +52,16 @@ export class RedisBackplane implements WebSocketBackplane {
|
|
|
51
52
|
private ready: Promise<void>;
|
|
52
53
|
|
|
53
54
|
constructor(url?: string) {
|
|
54
|
-
|
|
55
|
+
const resolvedUrl = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "redis://localhost:6379";
|
|
56
|
+
this.url = resolvedUrl;
|
|
55
57
|
|
|
56
58
|
this.ready = (async () => {
|
|
57
59
|
let redis: any;
|
|
58
60
|
try {
|
|
59
|
-
|
|
61
|
+
// Optional peer dependency — resolved via a string specifier so the
|
|
62
|
+
// module isn't required at type-check time when it isn't installed.
|
|
63
|
+
const redisModule: string = "redis";
|
|
64
|
+
redis = await import(redisModule);
|
|
60
65
|
} catch {
|
|
61
66
|
throw new Error(
|
|
62
67
|
"The 'redis' package is required for RedisBackplane. " +
|
|
@@ -64,14 +69,14 @@ export class RedisBackplane implements WebSocketBackplane {
|
|
|
64
69
|
);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
this.publisher = redis.createClient({ url:
|
|
72
|
+
this.publisher = redis.createClient({ url: resolvedUrl });
|
|
68
73
|
this.subscriber = this.publisher.duplicate();
|
|
69
74
|
|
|
70
75
|
await Promise.all([
|
|
71
76
|
this.publisher.connect(),
|
|
72
77
|
this.subscriber.connect(),
|
|
73
78
|
]);
|
|
74
|
-
console.log(`[Tina4] RedisBackplane connected to ${
|
|
79
|
+
console.log(`[Tina4] RedisBackplane connected to ${resolvedUrl}`);
|
|
75
80
|
})();
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -115,12 +120,16 @@ export class NATSBackplane implements WebSocketBackplane {
|
|
|
115
120
|
private ready: Promise<void>;
|
|
116
121
|
|
|
117
122
|
constructor(url?: string) {
|
|
118
|
-
|
|
123
|
+
const resolvedUrl = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "nats://localhost:4222";
|
|
124
|
+
this.url = resolvedUrl;
|
|
119
125
|
|
|
120
126
|
this.ready = (async () => {
|
|
121
127
|
let nats: any;
|
|
122
128
|
try {
|
|
123
|
-
|
|
129
|
+
// Optional peer dependency — resolved via a string specifier so the
|
|
130
|
+
// module isn't required at type-check time when it isn't installed.
|
|
131
|
+
const natsModule: string = "nats";
|
|
132
|
+
nats = await import(natsModule);
|
|
124
133
|
} catch {
|
|
125
134
|
throw new Error(
|
|
126
135
|
"The 'nats' package is required for NATSBackplane. " +
|
|
@@ -128,21 +137,23 @@ export class NATSBackplane implements WebSocketBackplane {
|
|
|
128
137
|
);
|
|
129
138
|
}
|
|
130
139
|
|
|
131
|
-
this.nc = await nats.connect({ servers:
|
|
132
|
-
console.log(`[Tina4] NATSBackplane connected to ${
|
|
140
|
+
this.nc = await nats.connect({ servers: resolvedUrl });
|
|
141
|
+
console.log(`[Tina4] NATSBackplane connected to ${resolvedUrl}`);
|
|
133
142
|
})();
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
async publish(channel: string, message: string): Promise<void> {
|
|
137
146
|
await this.ready;
|
|
138
|
-
const
|
|
147
|
+
const natsModule: string = "nats";
|
|
148
|
+
const { StringCodec } = await import(natsModule);
|
|
139
149
|
const sc = StringCodec();
|
|
140
150
|
this.nc.publish(channel, sc.encode(message));
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
|
144
154
|
await this.ready;
|
|
145
|
-
const
|
|
155
|
+
const natsModule: string = "nats";
|
|
156
|
+
const { StringCodec } = await import(natsModule);
|
|
146
157
|
const sc = StringCodec();
|
|
147
158
|
const sub = this.nc.subscribe(channel);
|
|
148
159
|
this.subs.set(channel, sub);
|
|
@@ -197,3 +208,192 @@ export function createBackplane(url?: string): WebSocketBackplane | null {
|
|
|
197
208
|
throw new Error(`Unknown TINA4_WS_BACKPLANE value: '${backend}'`);
|
|
198
209
|
}
|
|
199
210
|
}
|
|
211
|
+
|
|
212
|
+
// ── Multi-instance scaling (backplane manager) ───────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The shared pub/sub channel name. Identical across all four Tina4 frameworks
|
|
216
|
+
* (cross-framework constant parity) so a Python, PHP, Ruby and Node instance
|
|
217
|
+
* can all relay each other's broadcasts over the same bus.
|
|
218
|
+
*/
|
|
219
|
+
export const WS_BACKPLANE_CHANNEL = "tina4:ws";
|
|
220
|
+
|
|
221
|
+
/** What `kind` a broadcast envelope carries — mirrors the master design. */
|
|
222
|
+
export type WsEnvelopeKind = "all" | "path" | "room";
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The JSON envelope published to the backplane channel. The wire shape is
|
|
226
|
+
* identical across all four frameworks. JSON can't carry bytes, so a string
|
|
227
|
+
* message rides under `text` and a binary message rides under `b64`
|
|
228
|
+
* (base64 of the bytes).
|
|
229
|
+
*/
|
|
230
|
+
export interface WsEnvelope {
|
|
231
|
+
/** Stable per-process instance id of the publisher (for the origin guard). */
|
|
232
|
+
src: string;
|
|
233
|
+
/** Delivery kind: every local conn / a path / a room. */
|
|
234
|
+
kind: WsEnvelopeKind;
|
|
235
|
+
/** Optional connection id to skip on delivery. */
|
|
236
|
+
exclude?: string | null;
|
|
237
|
+
/** Room name (only when kind === "room"). */
|
|
238
|
+
room?: string | null;
|
|
239
|
+
/** Path (only when kind === "path"). */
|
|
240
|
+
path?: string | null;
|
|
241
|
+
/** Text payload (str messages). */
|
|
242
|
+
text?: string;
|
|
243
|
+
/** Base64 payload (binary messages). */
|
|
244
|
+
b64?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Wires a {@link WebSocketBackplane} into a local connection manager so a
|
|
249
|
+
* broadcast on one server instance reaches the local connections of every
|
|
250
|
+
* sibling instance.
|
|
251
|
+
*
|
|
252
|
+
* Node is single-threaded async, so — unlike Python's bg-thread →
|
|
253
|
+
* `run_coroutine_threadsafe` bridge — the subscribe callback can relay
|
|
254
|
+
* directly on the event loop. We still apply the two invariants that keep a
|
|
255
|
+
* cluster correct:
|
|
256
|
+
*
|
|
257
|
+
* 1. **Origin guard** — drop any envelope whose `src` is *this* instance's
|
|
258
|
+
* id. We already delivered it locally on broadcast; relaying it again
|
|
259
|
+
* would double-send.
|
|
260
|
+
* 2. **No re-publish** — the relay path only delivers to LOCAL connections;
|
|
261
|
+
* it never publishes, so a message can't loop around the cluster.
|
|
262
|
+
*
|
|
263
|
+
* The manager is generic over the local-delivery callback (`relay`) so it can
|
|
264
|
+
* sit beside the WebSocketServer without importing it (no module cycle).
|
|
265
|
+
*/
|
|
266
|
+
export class WsBackplaneManager {
|
|
267
|
+
/** Stable per-process id so we can ignore our own echoes. */
|
|
268
|
+
readonly instanceId: string;
|
|
269
|
+
readonly channel: string;
|
|
270
|
+
private backplane: WebSocketBackplane | null = null;
|
|
271
|
+
private started = false;
|
|
272
|
+
/** Local-delivery callback, installed by the owner (WebSocketServer). */
|
|
273
|
+
private relay: ((env: WsEnvelope) => void) | null = null;
|
|
274
|
+
|
|
275
|
+
constructor(channel: string = WS_BACKPLANE_CHANNEL) {
|
|
276
|
+
this.instanceId = randomInstanceId();
|
|
277
|
+
this.channel = channel;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** True once a backplane is actually attached (a network bus is configured). */
|
|
281
|
+
get active(): boolean {
|
|
282
|
+
return this.backplane !== null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Lazily wire the configured backplane and subscribe. Idempotent and
|
|
287
|
+
* best-effort — a failure here logs and leaves the manager in local-only
|
|
288
|
+
* mode; it must NEVER crash a broadcast. The `relay` callback is invoked
|
|
289
|
+
* (on this same event loop) for every *remote* envelope that survives the
|
|
290
|
+
* origin guard.
|
|
291
|
+
*/
|
|
292
|
+
async ensure(relay: (env: WsEnvelope) => void, log?: WsBackplaneLogger): Promise<void> {
|
|
293
|
+
if (this.started) return;
|
|
294
|
+
// Set immediately so we only ever attempt the wiring once, even if it
|
|
295
|
+
// fails (no retry storm on every broadcast).
|
|
296
|
+
this.started = true;
|
|
297
|
+
this.relay = relay;
|
|
298
|
+
try {
|
|
299
|
+
const backplane = createBackplane();
|
|
300
|
+
if (backplane === null) return; // No backplane configured — stay local-only.
|
|
301
|
+
this.backplane = backplane;
|
|
302
|
+
await backplane.subscribe(this.channel, (raw) => this.onMessage(raw));
|
|
303
|
+
log?.info(
|
|
304
|
+
`WebSocket backplane active (instance ${this.instanceId}, channel '${this.channel}')`,
|
|
305
|
+
);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
this.backplane = null;
|
|
308
|
+
log?.error(
|
|
309
|
+
`WebSocket backplane wiring failed, continuing local-only: ${(err as Error).message}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Handle a raw envelope arriving on the channel. Applies the origin guard
|
|
316
|
+
* then hands a *remote* envelope to the local relay. Never throws (a
|
|
317
|
+
* malformed envelope is dropped silently).
|
|
318
|
+
*/
|
|
319
|
+
onMessage(raw: string): void {
|
|
320
|
+
let env: unknown;
|
|
321
|
+
try {
|
|
322
|
+
env = JSON.parse(raw);
|
|
323
|
+
} catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (typeof env !== "object" || env === null) return;
|
|
327
|
+
const envelope = env as WsEnvelope;
|
|
328
|
+
// Origin guard: ignore our own broadcasts echoed back over the channel.
|
|
329
|
+
// We already delivered them locally; relaying again would double-send.
|
|
330
|
+
if (envelope.src === this.instanceId) return;
|
|
331
|
+
this.relay?.(envelope);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Publish a broadcast to the shared channel for sibling instances. No-op
|
|
336
|
+
* when no backplane is configured. Best-effort — a publish failure logs and
|
|
337
|
+
* is swallowed so the local broadcast that already happened is never undone
|
|
338
|
+
* by a flaky message bus.
|
|
339
|
+
*/
|
|
340
|
+
publish(
|
|
341
|
+
kind: WsEnvelopeKind,
|
|
342
|
+
message: string | Buffer,
|
|
343
|
+
opts: { room?: string | null; path?: string | null; exclude?: string | null } = {},
|
|
344
|
+
log?: WsBackplaneLogger,
|
|
345
|
+
): void {
|
|
346
|
+
if (!this.backplane) return;
|
|
347
|
+
const envelope = buildEnvelope(this.instanceId, kind, message, opts);
|
|
348
|
+
// publish() is async; we fire-and-forget but still catch a rejection so a
|
|
349
|
+
// dead bus can't produce an unhandled rejection that crashes the worker.
|
|
350
|
+
Promise.resolve(this.backplane.publish(this.channel, JSON.stringify(envelope))).catch(
|
|
351
|
+
(err) => log?.warn(`WebSocket backplane publish failed: ${(err as Error).message}`),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reconstruct the original str/Buffer message from an envelope. JSON can't
|
|
357
|
+
* carry bytes, so `text` → string and `b64` → Buffer.
|
|
358
|
+
*/
|
|
359
|
+
static decodeMessage(env: WsEnvelope): string | Buffer | null {
|
|
360
|
+
if (typeof env.text === "string") return env.text;
|
|
361
|
+
if (typeof env.b64 === "string") return Buffer.from(env.b64, "base64");
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Minimal logger shape so the manager doesn't import the logger module. */
|
|
367
|
+
export interface WsBackplaneLogger {
|
|
368
|
+
info(message: string): void;
|
|
369
|
+
warn(message: string): void;
|
|
370
|
+
error(message: string): void;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Build the cross-framework envelope. Exported for tests. */
|
|
374
|
+
export function buildEnvelope(
|
|
375
|
+
src: string,
|
|
376
|
+
kind: WsEnvelopeKind,
|
|
377
|
+
message: string | Buffer,
|
|
378
|
+
opts: { room?: string | null; path?: string | null; exclude?: string | null } = {},
|
|
379
|
+
): WsEnvelope {
|
|
380
|
+
const envelope: WsEnvelope = {
|
|
381
|
+
src,
|
|
382
|
+
kind,
|
|
383
|
+
exclude: opts.exclude ?? null,
|
|
384
|
+
room: opts.room ?? null,
|
|
385
|
+
path: opts.path ?? null,
|
|
386
|
+
};
|
|
387
|
+
// JSON can't carry bytes — encode a string as text, bytes as base64.
|
|
388
|
+
if (Buffer.isBuffer(message)) {
|
|
389
|
+
envelope.b64 = message.toString("base64");
|
|
390
|
+
} else {
|
|
391
|
+
envelope.text = message;
|
|
392
|
+
}
|
|
393
|
+
return envelope;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** A stable, short per-process id (16 hex chars), matching the master. */
|
|
397
|
+
function randomInstanceId(): string {
|
|
398
|
+
return randomUUID().replace(/-/g, "").slice(0, 16);
|
|
399
|
+
}
|
|
@@ -13,6 +13,12 @@ export interface WebSocketConnection {
|
|
|
13
13
|
headers: Record<string, string>;
|
|
14
14
|
/** Route parameters extracted from `{param}` segments in the path */
|
|
15
15
|
params: Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Verified JWT payload on a `@secured` / `.secure()` WebSocket route, or
|
|
18
|
+
* `null` on a public route (the default). Set on the upgrade after the token
|
|
19
|
+
* is validated — mirrors Python's `connection.auth`.
|
|
20
|
+
*/
|
|
21
|
+
auth: Record<string, unknown> | null;
|
|
16
22
|
/** Send a message to this connection only */
|
|
17
23
|
send(message: string): void;
|
|
18
24
|
/** Serialize an object to JSON and send it to this connection. Parity with Python/PHP. */
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* }
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { Log } from "./logger.js";
|
|
23
|
+
import { isDebugMode } from "./errorOverlay.js";
|
|
24
|
+
|
|
22
25
|
// ── Types ────────────────────────────────────────────────────
|
|
23
26
|
|
|
24
27
|
export interface WSDLOperationMeta {
|
|
@@ -261,13 +264,27 @@ export abstract class WSDLService {
|
|
|
261
264
|
private convertValue(value: string, typeName: string): unknown {
|
|
262
265
|
switch (typeName) {
|
|
263
266
|
case "int":
|
|
264
|
-
case "integer":
|
|
265
|
-
|
|
267
|
+
case "integer": {
|
|
268
|
+
// Match Python's int(value) — a non-numeric value RAISES rather than
|
|
269
|
+
// silently yielding NaN. The thrown Error is caught in handle() and
|
|
270
|
+
// becomes a Server fault.
|
|
271
|
+
const n = parseInt(value, 10);
|
|
272
|
+
if (Number.isNaN(n)) {
|
|
273
|
+
throw new Error(`invalid integer value: ${JSON.stringify(value)}`);
|
|
274
|
+
}
|
|
275
|
+
return n;
|
|
276
|
+
}
|
|
266
277
|
case "float":
|
|
267
278
|
case "double":
|
|
268
279
|
case "number":
|
|
269
|
-
case "numeric":
|
|
270
|
-
|
|
280
|
+
case "numeric": {
|
|
281
|
+
// Match Python's float(value) — non-numeric raises (→ Server fault).
|
|
282
|
+
const f = parseFloat(value);
|
|
283
|
+
if (Number.isNaN(f)) {
|
|
284
|
+
throw new Error(`invalid numeric value: ${JSON.stringify(value)}`);
|
|
285
|
+
}
|
|
286
|
+
return f;
|
|
287
|
+
}
|
|
271
288
|
case "bool":
|
|
272
289
|
case "boolean":
|
|
273
290
|
return ["true", "1", "yes"].includes(value.toLowerCase());
|
|
@@ -389,6 +406,16 @@ export abstract class WSDLService {
|
|
|
389
406
|
async handle(soapXml: string = ""): Promise<string> {
|
|
390
407
|
const ops = this.discoverOperations();
|
|
391
408
|
|
|
409
|
+
// SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message.
|
|
410
|
+
// Rejecting any DOCTYPE/DTD up front — BEFORE the body is parsed — also
|
|
411
|
+
// closes the XML entity-expansion (billion-laughs) and external-entity
|
|
412
|
+
// (XXE) attack surface regardless of parser internals. The operation
|
|
413
|
+
// never runs. (Node's parser is hand-rolled and already immune; this is
|
|
414
|
+
// defence in depth + consistent fault behaviour across all 4 frameworks.)
|
|
415
|
+
if (/<!DOCTYPE/i.test(soapXml)) {
|
|
416
|
+
return this.soapFault("Client", "DOCTYPE declarations are not allowed in SOAP messages");
|
|
417
|
+
}
|
|
418
|
+
|
|
392
419
|
// Parse SOAP body
|
|
393
420
|
const body = extractSoapBody(soapXml);
|
|
394
421
|
if (!body) {
|
|
@@ -413,33 +440,40 @@ export abstract class WSDLService {
|
|
|
413
440
|
return this.soapFault("Client", `Operation not implemented: ${opName}`);
|
|
414
441
|
}
|
|
415
442
|
|
|
416
|
-
// Extract parameters from the operation element
|
|
417
|
-
const children = extractChildren(operation.content);
|
|
418
|
-
const params: unknown[] = [];
|
|
419
|
-
|
|
420
|
-
if (opMeta.input) {
|
|
421
|
-
for (const [paramName, paramType] of Object.entries(opMeta.input)) {
|
|
422
|
-
const child = children.find((c) => c.name === paramName);
|
|
423
|
-
if (child) {
|
|
424
|
-
params.push(this.convertValue(child.value, paramType));
|
|
425
|
-
} else {
|
|
426
|
-
params.push(null);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
443
|
// Lifecycle hook: before invocation
|
|
432
444
|
this.onRequest(soapXml);
|
|
433
445
|
|
|
434
|
-
// Invoke the method
|
|
446
|
+
// Invoke the method. Parameter conversion runs INSIDE the try so a
|
|
447
|
+
// non-numeric value for an int/float param (convertValue throws, matching
|
|
448
|
+
// Python's int()/float() raise) becomes a Server fault — not a silent NaN.
|
|
435
449
|
try {
|
|
450
|
+
// Extract parameters from the operation element
|
|
451
|
+
const children = extractChildren(operation.content);
|
|
452
|
+
const params: unknown[] = [];
|
|
453
|
+
|
|
454
|
+
if (opMeta.input) {
|
|
455
|
+
for (const [paramName, paramType] of Object.entries(opMeta.input)) {
|
|
456
|
+
const child = children.find((c) => c.name === paramName);
|
|
457
|
+
if (child) {
|
|
458
|
+
params.push(this.convertValue(child.value, paramType));
|
|
459
|
+
} else {
|
|
460
|
+
params.push(null);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
436
465
|
const rawResult = await (method as (...args: unknown[]) => Promise<unknown>).call(this, ...params);
|
|
437
466
|
// Lifecycle hook: after invocation — allow result transformation
|
|
438
467
|
const result = this.onResult(rawResult as Record<string, unknown>);
|
|
439
468
|
return this.soapResponse(opName, result);
|
|
440
469
|
} catch (err) {
|
|
470
|
+
// Log the real cause, but only leak the detail to the client in debug
|
|
471
|
+
// mode — a resolver exception can carry internal state (DB credentials,
|
|
472
|
+
// file paths) that must not reach a SOAP client.
|
|
441
473
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
442
|
-
|
|
474
|
+
Log.error(`WSDL operation '${opName}' failed: ${errMsg}`);
|
|
475
|
+
const detail = isDebugMode() ? errMsg : "Internal server error";
|
|
476
|
+
return this.soapFault("Server", detail);
|
|
443
477
|
}
|
|
444
478
|
}
|
|
445
479
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ambient type surface for the optional `pg` (node-postgres) peer
|
|
3
|
+
* dependency. `@types/pg` is intentionally NOT a dependency (pg is an optional
|
|
4
|
+
* peer loaded lazily via createRequire), so without this declaration the
|
|
5
|
+
* `typeof import("pg")` references in postgres.ts resolve to an *implicit* any
|
|
6
|
+
* (TS7016) and collapse `this.client` to `never`.
|
|
7
|
+
*
|
|
8
|
+
* This declares only the subset of the pg API that the PostgresAdapter uses:
|
|
9
|
+
* the `Client` class (connect/query/end) and the global `types.setTypeParser`
|
|
10
|
+
* registry. It carries no runtime weight — purely a compile-time contract that
|
|
11
|
+
* matches node-postgres' real shape.
|
|
12
|
+
*/
|
|
13
|
+
declare module "pg" {
|
|
14
|
+
export interface QueryResultRow {
|
|
15
|
+
[column: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QueryResult<R extends QueryResultRow = QueryResultRow> {
|
|
19
|
+
rows: R[];
|
|
20
|
+
rowCount: number | null;
|
|
21
|
+
command: string;
|
|
22
|
+
oid: number;
|
|
23
|
+
fields: unknown[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClientConfig {
|
|
27
|
+
host?: string;
|
|
28
|
+
port?: number;
|
|
29
|
+
user?: string;
|
|
30
|
+
password?: string;
|
|
31
|
+
database?: string;
|
|
32
|
+
connectionString?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Client {
|
|
36
|
+
constructor(config?: ClientConfig | string);
|
|
37
|
+
connect(): Promise<void>;
|
|
38
|
+
query<R extends QueryResultRow = QueryResultRow>(
|
|
39
|
+
queryText: string,
|
|
40
|
+
values?: unknown[],
|
|
41
|
+
): Promise<QueryResult<R>>;
|
|
42
|
+
end(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class Pool {
|
|
46
|
+
constructor(config?: ClientConfig | string);
|
|
47
|
+
connect(): Promise<Client>;
|
|
48
|
+
query<R extends QueryResultRow = QueryResultRow>(
|
|
49
|
+
queryText: string,
|
|
50
|
+
values?: unknown[],
|
|
51
|
+
): Promise<QueryResult<R>>;
|
|
52
|
+
end(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TypeParsers {
|
|
56
|
+
setTypeParser(oid: number, parseFn: (value: string) => unknown): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const types: TypeParsers;
|
|
60
|
+
}
|
|
@@ -86,7 +86,13 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
86
86
|
await this.client!.connect();
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// NOTE: a plain runtime guard, not an `asserts this is ...` predicate. A
|
|
90
|
+
// type-narrowing predicate that re-declares the private `client` member
|
|
91
|
+
// intersects the class with a structural literal and collapses `this` to
|
|
92
|
+
// `never` (TS treats the private brand as unsatisfiable). The non-null
|
|
93
|
+
// `this.client!` assertions at the (already-guarded) call sites carry the
|
|
94
|
+
// narrowing instead, so behaviour is unchanged.
|
|
95
|
+
private ensureConnected(): void {
|
|
90
96
|
if (!this.client) {
|
|
91
97
|
throw new Error("PostgreSQL adapter not connected. Call connect() first.");
|
|
92
98
|
}
|
|
@@ -107,6 +113,22 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
107
113
|
});
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Normalise an `id` column value (typed `unknown` because pg row values are
|
|
118
|
+
* `unknown`) into the `number | bigint | null` shape `_lastInsertId` /
|
|
119
|
+
* `DatabaseResult.lastInsertId` expect. At runtime PG returns numeric PKs as
|
|
120
|
+
* number/bigint (the int8/numeric type parsers above coerce them to Number);
|
|
121
|
+
* a numeric string is coerced, anything else (null/undefined/non-numeric)
|
|
122
|
+
* becomes null.
|
|
123
|
+
*/
|
|
124
|
+
private normalizeId(value: unknown): number | bigint | null {
|
|
125
|
+
if (typeof value === "number" || typeof value === "bigint") return value;
|
|
126
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
127
|
+
return Number(value);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
execute(sql: string, params?: unknown[]): unknown {
|
|
111
133
|
this.ensureConnected();
|
|
112
134
|
const convertedSql = this.convertPlaceholders(sql);
|
|
@@ -139,7 +161,7 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
139
161
|
const convertedSql = this.convertPlaceholders(sql);
|
|
140
162
|
const result = await this.client!.query(convertedSql, params);
|
|
141
163
|
if (result.rows?.[0]?.id !== undefined) {
|
|
142
|
-
this._lastInsertId = result.rows[0].id;
|
|
164
|
+
this._lastInsertId = this.normalizeId(result.rows[0].id);
|
|
143
165
|
}
|
|
144
166
|
return result;
|
|
145
167
|
}
|
|
@@ -194,12 +216,12 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
194
216
|
try {
|
|
195
217
|
const result = await this.client!.query(sql, values);
|
|
196
218
|
const insertedRow = result.rows[0];
|
|
197
|
-
const id = insertedRow?.id
|
|
219
|
+
const id = this.normalizeId(insertedRow?.id);
|
|
198
220
|
if (id !== null) this._lastInsertId = id;
|
|
199
221
|
return {
|
|
200
222
|
success: true,
|
|
201
223
|
rowsAffected: result.rowCount ?? 1,
|
|
202
|
-
lastInsertId: id,
|
|
224
|
+
lastInsertId: id ?? undefined,
|
|
203
225
|
};
|
|
204
226
|
} catch (e) {
|
|
205
227
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|