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.
Files changed (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. 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
- this.url = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "redis://localhost:6379";
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
- redis = await import("redis");
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: this.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 ${this.url}`);
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
- this.url = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "nats://localhost:4222";
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
- nats = await import("nats");
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: this.url });
132
- console.log(`[Tina4] NATSBackplane connected to ${this.url}`);
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 { StringCodec } = await import("nats");
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 { StringCodec } = await import("nats");
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
- return parseInt(value, 10);
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
- return parseFloat(value);
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
- return this.soapFault("Server", errMsg);
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
- private ensureConnected(): asserts this is { client: NonNullable<PostgresAdapter["client"]> } {
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 ?? null;
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 };