@rvoh/dream 2.11.1 → 2.11.2

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 (189) hide show
  1. package/dist/cjs/src/db/DreamDbConnection.js +45 -1
  2. package/dist/cjs/src/db/dbConnectionLeakDiagnostics.js +117 -0
  3. package/dist/cjs/src/dream/QueryDriver/Kysely.js +17 -4
  4. package/dist/esm/src/db/DreamDbConnection.js +45 -1
  5. package/dist/esm/src/db/dbConnectionLeakDiagnostics.js +117 -0
  6. package/dist/esm/src/dream/QueryDriver/Kysely.js +17 -4
  7. package/dist/types/src/db/dbConnectionLeakDiagnostics.d.ts +10 -0
  8. package/dist/types/src/dream-app/index.d.ts +21 -0
  9. package/docs/classes/db.DreamMigrationHelpers.html +9 -9
  10. package/docs/classes/db.KyselyQueryDriver.html +32 -32
  11. package/docs/classes/db.PostgresQueryDriver.html +33 -33
  12. package/docs/classes/db.QueryDriverBase.html +31 -31
  13. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  14. package/docs/classes/errors.ColumnOverflow.html +3 -3
  15. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  16. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  17. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  18. package/docs/classes/errors.DecryptionError.html +2 -2
  19. package/docs/classes/errors.DecryptionParseError.html +2 -2
  20. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  21. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  22. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  23. package/docs/classes/errors.InvalidClockTime.html +2 -2
  24. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  25. package/docs/classes/errors.InvalidDateTime.html +2 -2
  26. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  27. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  28. package/docs/classes/errors.NotNullViolation.html +3 -3
  29. package/docs/classes/errors.RecordNotFound.html +3 -3
  30. package/docs/classes/errors.ValidationError.html +3 -3
  31. package/docs/classes/index.CalendarDate.html +33 -33
  32. package/docs/classes/index.ClockTime.html +32 -32
  33. package/docs/classes/index.ClockTimeTz.html +35 -35
  34. package/docs/classes/index.DateTime.html +86 -86
  35. package/docs/classes/index.Decorators.html +19 -19
  36. package/docs/classes/index.Dream.html +118 -118
  37. package/docs/classes/index.DreamApp.html +5 -5
  38. package/docs/classes/index.DreamTransaction.html +2 -2
  39. package/docs/classes/index.Env.html +2 -2
  40. package/docs/classes/index.Query.html +56 -56
  41. package/docs/classes/system.CliFileWriter.html +4 -4
  42. package/docs/classes/system.DreamBin.html +2 -2
  43. package/docs/classes/system.DreamCLI.html +7 -7
  44. package/docs/classes/system.DreamImporter.html +2 -2
  45. package/docs/classes/system.DreamLogos.html +2 -2
  46. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  47. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  48. package/docs/classes/system.PathHelpers.html +3 -3
  49. package/docs/classes/utils.Encrypt.html +3 -3
  50. package/docs/classes/utils.Range.html +2 -2
  51. package/docs/functions/db.closeAllDbConnections.html +1 -1
  52. package/docs/functions/db.dreamDbConnections.html +1 -1
  53. package/docs/functions/db.untypedDb.html +1 -1
  54. package/docs/functions/db.validateColumn.html +1 -1
  55. package/docs/functions/db.validateTable.html +1 -1
  56. package/docs/functions/errors.pgErrorType.html +1 -1
  57. package/docs/functions/index.DreamSerializer.html +1 -1
  58. package/docs/functions/index.ObjectSerializer.html +1 -1
  59. package/docs/functions/index.ReplicaSafe.html +1 -1
  60. package/docs/functions/index.STI.html +1 -1
  61. package/docs/functions/index.SoftDelete.html +1 -1
  62. package/docs/functions/utils.camelize.html +1 -1
  63. package/docs/functions/utils.capitalize.html +1 -1
  64. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  65. package/docs/functions/utils.compact.html +1 -1
  66. package/docs/functions/utils.groupBy.html +1 -1
  67. package/docs/functions/utils.hyphenize.html +1 -1
  68. package/docs/functions/utils.intersection.html +1 -1
  69. package/docs/functions/utils.isEmpty.html +1 -1
  70. package/docs/functions/utils.normalizeUnicode.html +1 -1
  71. package/docs/functions/utils.pascalize.html +1 -1
  72. package/docs/functions/utils.percent.html +1 -1
  73. package/docs/functions/utils.range.html +1 -1
  74. package/docs/functions/utils.round.html +1 -1
  75. package/docs/functions/utils.sanitizeString.html +1 -1
  76. package/docs/functions/utils.snakeify.html +1 -1
  77. package/docs/functions/utils.sort.html +1 -1
  78. package/docs/functions/utils.sortBy.html +1 -1
  79. package/docs/functions/utils.sortObjectByKey.html +1 -1
  80. package/docs/functions/utils.sortObjectByValue.html +1 -1
  81. package/docs/functions/utils.uncapitalize.html +1 -1
  82. package/docs/functions/utils.uniq.html +1 -1
  83. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  84. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  85. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  86. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  87. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  88. package/docs/interfaces/types.DecoratorContext.html +2 -2
  89. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  90. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  91. package/docs/interfaces/types.DurationObject.html +2 -2
  92. package/docs/interfaces/types.EncryptOptions.html +2 -2
  93. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  94. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  95. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  96. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  97. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  98. package/docs/types/openapi.OpenapiFormats.html +1 -1
  99. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  100. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  101. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  102. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  103. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  104. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  105. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  106. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  107. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  108. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  109. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  110. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  111. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  112. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  113. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  114. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  115. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  116. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  117. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  118. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  119. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  120. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  121. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  122. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  123. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  124. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  125. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  126. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  127. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  128. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  129. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  130. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  131. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  132. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  133. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  134. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  135. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  136. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  137. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  138. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  139. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  140. package/docs/types/types.CalendarDateObject.html +1 -1
  141. package/docs/types/types.Camelized.html +1 -1
  142. package/docs/types/types.ClockTimeObject.html +1 -1
  143. package/docs/types/types.DbConnectionType.html +1 -1
  144. package/docs/types/types.DbTypes.html +1 -1
  145. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  146. package/docs/types/types.DreamAttributes.html +1 -1
  147. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  148. package/docs/types/types.DreamClassColumn.html +1 -1
  149. package/docs/types/types.DreamColumn.html +1 -1
  150. package/docs/types/types.DreamColumnNames.html +1 -1
  151. package/docs/types/types.DreamLogLevel.html +1 -1
  152. package/docs/types/types.DreamLogger.html +2 -2
  153. package/docs/types/types.DreamModelSerializerType.html +1 -1
  154. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  155. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  156. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  157. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  158. package/docs/types/types.DreamSerializable.html +1 -1
  159. package/docs/types/types.DreamSerializableArray.html +1 -1
  160. package/docs/types/types.DreamSerializerKey.html +1 -1
  161. package/docs/types/types.DreamSerializers.html +1 -1
  162. package/docs/types/types.DreamVirtualColumns.html +1 -1
  163. package/docs/types/types.DurationUnit.html +1 -1
  164. package/docs/types/types.EncryptAlgorithm.html +1 -1
  165. package/docs/types/types.HasManyStatement.html +1 -1
  166. package/docs/types/types.HasOneStatement.html +1 -1
  167. package/docs/types/types.Hyphenized.html +1 -1
  168. package/docs/types/types.Pascalized.html +1 -1
  169. package/docs/types/types.PrimaryKeyType.html +1 -1
  170. package/docs/types/types.RoundingPrecision.html +1 -1
  171. package/docs/types/types.SerializerCasing.html +1 -1
  172. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  173. package/docs/types/types.Snakeified.html +1 -1
  174. package/docs/types/types.StrictInterface.html +1 -1
  175. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  176. package/docs/types/types.UpdateableProperties.html +1 -1
  177. package/docs/types/types.ValidationType.html +1 -1
  178. package/docs/types/types.ViewModel.html +2 -2
  179. package/docs/types/types.ViewModelClass.html +1 -1
  180. package/docs/types/types.WeekdayName.html +1 -1
  181. package/docs/types/types.WhereStatementForDream.html +1 -1
  182. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  183. package/docs/variables/index.DreamConst.html +1 -1
  184. package/docs/variables/index.ops.html +1 -1
  185. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  186. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  187. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  188. package/docs/variables/system.primaryKeyTypes.html +1 -1
  189. package/package.json +1 -1
@@ -9,6 +9,7 @@
9
9
  import { CamelCasePlugin, Kysely } from 'kysely';
10
10
  import DreamApp from '../dream-app/index.js';
11
11
  import protectAgainstPollutingAssignment from '../helpers/protectAgainstPollutingAssignment.js';
12
+ import { installDbConnectionLeakDiagnosticsIfEnabled, reportLeakedDbConnections, } from './dbConnectionLeakDiagnostics.js';
12
13
  let connections = {};
13
14
  export default class DreamDbConnection {
14
15
  static getConnection(connectionName, connectionType, dialectProvider) {
@@ -19,6 +20,9 @@ export default class DreamDbConnection {
19
20
  return connection;
20
21
  }
21
22
  const connectionConf = dreamApp.dbConnectionConfig(connectionName, connectionType);
23
+ // Must run before dialectProvider() constructs the pg.Pool. Idempotent and
24
+ // a no-op unless DREAM_DB_LEAK_DIAGNOSTICS is set.
25
+ installDbConnectionLeakDiagnosticsIfEnabled();
22
26
  const dbConn = new Kysely({
23
27
  log(event) {
24
28
  const dreamApp = DreamApp.getOrFail();
@@ -50,11 +54,51 @@ export async function closeAllDbConnections() {
50
54
  }
51
55
  connections = {};
52
56
  }
57
+ /**
58
+ * Upper bound (ms) on how long {@link closeAllConnectionsForConnectionName}
59
+ * waits for a single Kysely connection's underlying pool to drain.
60
+ *
61
+ * `conn.destroy()` resolves to `pg`'s `pool.end()`, which only settles once
62
+ * every checked-out client has been released back to the pool. A client that
63
+ * was leased by a query still in flight when shutdown began — an aborted HTTP
64
+ * request during a SIGTERM drain, a feature-spec whose page is torn down
65
+ * mid-request — is never released, so `pool.end()` blocks forever and takes
66
+ * the whole shutdown with it. Bounding the wait keeps shutdown deterministic;
67
+ * the leaked socket is reaped by the OS when the process exits.
68
+ */
69
+ const DB_CONNECTION_CLOSE_TIMEOUT_MS = 10_000;
53
70
  export async function closeAllConnectionsForConnectionName(connectionName) {
54
71
  const protectedName = protectAgainstPollutingAssignment(connectionName);
55
72
  return await Promise.allSettled(Object.keys(connections[protectedName]).map(async (key) => {
56
73
  const conn = connections[protectedName][key];
57
- await conn.destroy();
74
+ // Remove from the registry first so a subsequent getConnection() builds a
75
+ // fresh pool even if this drain times out and the old pool is abandoned.
58
76
  delete connections[protectedName][key];
77
+ await destroyConnectionWithinTimeout(conn, `${connectionName}:${key}`);
59
78
  }));
60
79
  }
80
+ async function destroyConnectionWithinTimeout(conn, label) {
81
+ let timer;
82
+ const timedOut = Symbol('timedOut');
83
+ const timeout = new Promise(resolve => {
84
+ timer = setTimeout(() => resolve(timedOut), DB_CONNECTION_CLOSE_TIMEOUT_MS);
85
+ // Don't let the timer itself keep the event loop (or `process.exit`) alive.
86
+ timer.unref?.();
87
+ });
88
+ try {
89
+ const result = await Promise.race([conn.destroy(), timeout]);
90
+ if (result === timedOut) {
91
+ DreamApp.logWithLevel('warn', `[dream] timed out after ${DB_CONNECTION_CLOSE_TIMEOUT_MS}ms waiting for db connection "${label}" ` +
92
+ `to close; abandoning the drain so shutdown can proceed. A pooled client was most likely held ` +
93
+ `past shutdown by an in-flight or aborted query. ` +
94
+ `Re-run with NODE_DEBUG=dream to log the acquire stack of the offending query.`);
95
+ // No-op unless NODE_DEBUG=dream; when set, this prints the acquire
96
+ // stack(s) of the client(s) that never got released.
97
+ reportLeakedDbConnections(label);
98
+ }
99
+ }
100
+ finally {
101
+ if (timer)
102
+ clearTimeout(timer);
103
+ }
104
+ }
@@ -0,0 +1,117 @@
1
+ // after building for esm, importing pg using the following:
2
+ //
3
+ // import * as pg from 'pg'
4
+ //
5
+ // will crash. This is difficult to discover, since it only happens
6
+ // when being imported from our esm build.
7
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8
+ // @ts-ignore
9
+ import pg from 'pg';
10
+ import { debuglog } from 'node:util';
11
+ import DreamApp from '../dream-app/index.js';
12
+ /**
13
+ * Opt-in connection-leak diagnostics.
14
+ *
15
+ * `closeAllConnectionsForConnectionName` bounds the shutdown drain, but when it
16
+ * times out it can only say *that* a pooled client was held past shutdown, not
17
+ * *where* it was acquired. Finding the offending query then means hand-patching
18
+ * `pg` — exactly the multi-hour spelunk this is meant to delete.
19
+ *
20
+ * Enable with `NODE_DEBUG=dream` (the same `node:util` debug-channel
21
+ * convention Psychic uses for `NODE_DEBUG=psychic`). Dev/CI only — it patches
22
+ * `pg.Pool.prototype.connect` process-wide and retains references to
23
+ * checked-out clients, so it is never installed unless explicitly asked for.
24
+ * When the close timeout fires, the acquire stack of every still-checked-out
25
+ * client is logged.
26
+ *
27
+ * Like the rest of the ecosystem's `debuglog` usage, the channel is resolved
28
+ * once by Node on first call and cached for the process lifetime — this is a
29
+ * process-level switch, not a runtime toggle.
30
+ */
31
+ // Resolved once at module load (Node caches the channel), mirroring
32
+ // `const debugEnabled = debuglog('psychic').enabled` in Psychic.
33
+ const dreamDebugEnabled = debuglog('dream').enabled;
34
+ // Map (not WeakMap) so entries can be enumerated at report time. Entries are
35
+ // deleted on release, so only genuinely-leaked clients accumulate, and only
36
+ // when diagnostics are explicitly enabled.
37
+ const checkedOutClients = new Map();
38
+ const INSTALLED = Symbol.for('dream:dbLeakDiagnosticsInstalled');
39
+ let enabled = false;
40
+ function track(client, stack) {
41
+ if (!client)
42
+ return;
43
+ checkedOutClients.set(client, { stack: stack ?? '(no stack captured)', since: Date.now() });
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ const c = client;
46
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
47
+ if (c.__dreamReleasePatched)
48
+ return;
49
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
50
+ const originalRelease = c.release;
51
+ if (typeof originalRelease !== 'function')
52
+ return;
53
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
54
+ c.__dreamReleasePatched = true;
55
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
56
+ c.release = function patchedRelease(...args) {
57
+ checkedOutClients.delete(client);
58
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
59
+ return originalRelease.apply(this, args);
60
+ };
61
+ }
62
+ /**
63
+ * Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
64
+ * onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
65
+ */
66
+ export function installDbConnectionLeakDiagnosticsIfEnabled() {
67
+ if (!dreamDebugEnabled)
68
+ return;
69
+ enabled = true;
70
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
71
+ const Pool = pg.Pool;
72
+ if (!Pool?.prototype)
73
+ return;
74
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
75
+ if (Pool.prototype[INSTALLED])
76
+ return;
77
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
78
+ Pool.prototype[INSTALLED] = true;
79
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
80
+ const originalConnect = Pool.prototype.connect;
81
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
82
+ Pool.prototype.connect = function patchedConnect(cb) {
83
+ const acquireStack = new Error('db connection acquired here').stack;
84
+ if (typeof cb === 'function') {
85
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
86
+ return originalConnect.call(this, (err, client, release) => {
87
+ track(client, acquireStack);
88
+ // `track()` replaces `client.release` with an untracking wrapper.
89
+ // Callback-style users call the 3rd `release` arg, which pg captured
90
+ // *before* that swap — so hand them the wrapped one instead, or a
91
+ // correctly-released client would be reported as a false leak.
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
93
+ const wrappedRelease = client ? client.release : release;
94
+ cb(err, client, wrappedRelease);
95
+ });
96
+ }
97
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
98
+ return originalConnect.call(this).then((client) => {
99
+ track(client, acquireStack);
100
+ return client;
101
+ });
102
+ };
103
+ }
104
+ /**
105
+ * If diagnostics are enabled, log the acquire stack of every client still
106
+ * checked out. Called from the shutdown-drain timeout path.
107
+ */
108
+ export function reportLeakedDbConnections(label) {
109
+ if (!enabled || checkedOutClients.size === 0)
110
+ return;
111
+ const now = Date.now();
112
+ const report = Array.from(checkedOutClients.values())
113
+ .map((entry, i) => ` [#${i}] held ${now - entry.since}ms\n${entry.stack}`)
114
+ .join('\n\n');
115
+ DreamApp.logWithLevel('warn', `[dream] connection-leak diagnostics: ${checkedOutClients.size} pg client(s) still checked out ` +
116
+ `when "${label}" hit the close timeout. Acquired at:\n\n${report}`);
117
+ }
@@ -92,16 +92,29 @@ export default class KyselyQueryDriver extends QueryDriverBase {
92
92
  return _db(connectionName || 'default', dbConnectionType, this.dialectProvider(connectionName, dbConnectionType));
93
93
  }
94
94
  static dialectProvider(connectionName, dbConnectionType) {
95
- return (connectionConf) => new PostgresDialect({
96
- pool: new pg.Pool({
95
+ return (connectionConf) => {
96
+ const pool = new pg.Pool({
97
+ // Spread pg passthrough first; Dream's resolved fields follow and
98
+ // always win (per-connection database name, TLS directive).
99
+ ...(connectionConf.pg ?? {}),
97
100
  user: connectionConf.user || '',
98
101
  password: connectionConf.password || '',
99
102
  database: DreamApp.getOrFail().dbName(connectionName, dbConnectionType),
100
103
  host: connectionConf.host || 'localhost',
101
104
  port: connectionConf.port || 5432,
102
105
  ssl: resolvePostgresSsl(connectionConf),
103
- }),
104
- });
106
+ });
107
+ // node-postgres explicitly warns: a Pool that emits 'error' with no
108
+ // listener crashes the Node process. Idle pooled clients emit 'error'
109
+ // on backend restart / failover / a load balancer reaping idle TCP.
110
+ // Log via Dream's logger instead of taking the process down; the pool
111
+ // discards the dead client and recovers on the next acquire.
112
+ pool.on('error', err => {
113
+ DreamApp.getOrFail().logger.error(`[dream] idle pg client error on connection "${connectionName}:${dbConnectionType}" ` +
114
+ `(handled — process kept alive): ${err.stack ?? String(err)}`);
115
+ });
116
+ return new PostgresDialect({ pool });
117
+ };
105
118
  }
106
119
  static async ensureAllMigrationsHaveBeenRun(connectionName) {
107
120
  const migrationsNeedToBeRun = await checkForNeedToBeRunMigrations({
@@ -9,6 +9,7 @@
9
9
  import { CamelCasePlugin, Kysely } from 'kysely';
10
10
  import DreamApp from '../dream-app/index.js';
11
11
  import protectAgainstPollutingAssignment from '../helpers/protectAgainstPollutingAssignment.js';
12
+ import { installDbConnectionLeakDiagnosticsIfEnabled, reportLeakedDbConnections, } from './dbConnectionLeakDiagnostics.js';
12
13
  let connections = {};
13
14
  export default class DreamDbConnection {
14
15
  static getConnection(connectionName, connectionType, dialectProvider) {
@@ -19,6 +20,9 @@ export default class DreamDbConnection {
19
20
  return connection;
20
21
  }
21
22
  const connectionConf = dreamApp.dbConnectionConfig(connectionName, connectionType);
23
+ // Must run before dialectProvider() constructs the pg.Pool. Idempotent and
24
+ // a no-op unless DREAM_DB_LEAK_DIAGNOSTICS is set.
25
+ installDbConnectionLeakDiagnosticsIfEnabled();
22
26
  const dbConn = new Kysely({
23
27
  log(event) {
24
28
  const dreamApp = DreamApp.getOrFail();
@@ -50,11 +54,51 @@ export async function closeAllDbConnections() {
50
54
  }
51
55
  connections = {};
52
56
  }
57
+ /**
58
+ * Upper bound (ms) on how long {@link closeAllConnectionsForConnectionName}
59
+ * waits for a single Kysely connection's underlying pool to drain.
60
+ *
61
+ * `conn.destroy()` resolves to `pg`'s `pool.end()`, which only settles once
62
+ * every checked-out client has been released back to the pool. A client that
63
+ * was leased by a query still in flight when shutdown began — an aborted HTTP
64
+ * request during a SIGTERM drain, a feature-spec whose page is torn down
65
+ * mid-request — is never released, so `pool.end()` blocks forever and takes
66
+ * the whole shutdown with it. Bounding the wait keeps shutdown deterministic;
67
+ * the leaked socket is reaped by the OS when the process exits.
68
+ */
69
+ const DB_CONNECTION_CLOSE_TIMEOUT_MS = 10_000;
53
70
  export async function closeAllConnectionsForConnectionName(connectionName) {
54
71
  const protectedName = protectAgainstPollutingAssignment(connectionName);
55
72
  return await Promise.allSettled(Object.keys(connections[protectedName]).map(async (key) => {
56
73
  const conn = connections[protectedName][key];
57
- await conn.destroy();
74
+ // Remove from the registry first so a subsequent getConnection() builds a
75
+ // fresh pool even if this drain times out and the old pool is abandoned.
58
76
  delete connections[protectedName][key];
77
+ await destroyConnectionWithinTimeout(conn, `${connectionName}:${key}`);
59
78
  }));
60
79
  }
80
+ async function destroyConnectionWithinTimeout(conn, label) {
81
+ let timer;
82
+ const timedOut = Symbol('timedOut');
83
+ const timeout = new Promise(resolve => {
84
+ timer = setTimeout(() => resolve(timedOut), DB_CONNECTION_CLOSE_TIMEOUT_MS);
85
+ // Don't let the timer itself keep the event loop (or `process.exit`) alive.
86
+ timer.unref?.();
87
+ });
88
+ try {
89
+ const result = await Promise.race([conn.destroy(), timeout]);
90
+ if (result === timedOut) {
91
+ DreamApp.logWithLevel('warn', `[dream] timed out after ${DB_CONNECTION_CLOSE_TIMEOUT_MS}ms waiting for db connection "${label}" ` +
92
+ `to close; abandoning the drain so shutdown can proceed. A pooled client was most likely held ` +
93
+ `past shutdown by an in-flight or aborted query. ` +
94
+ `Re-run with NODE_DEBUG=dream to log the acquire stack of the offending query.`);
95
+ // No-op unless NODE_DEBUG=dream; when set, this prints the acquire
96
+ // stack(s) of the client(s) that never got released.
97
+ reportLeakedDbConnections(label);
98
+ }
99
+ }
100
+ finally {
101
+ if (timer)
102
+ clearTimeout(timer);
103
+ }
104
+ }
@@ -0,0 +1,117 @@
1
+ // after building for esm, importing pg using the following:
2
+ //
3
+ // import * as pg from 'pg'
4
+ //
5
+ // will crash. This is difficult to discover, since it only happens
6
+ // when being imported from our esm build.
7
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8
+ // @ts-ignore
9
+ import pg from 'pg';
10
+ import { debuglog } from 'node:util';
11
+ import DreamApp from '../dream-app/index.js';
12
+ /**
13
+ * Opt-in connection-leak diagnostics.
14
+ *
15
+ * `closeAllConnectionsForConnectionName` bounds the shutdown drain, but when it
16
+ * times out it can only say *that* a pooled client was held past shutdown, not
17
+ * *where* it was acquired. Finding the offending query then means hand-patching
18
+ * `pg` — exactly the multi-hour spelunk this is meant to delete.
19
+ *
20
+ * Enable with `NODE_DEBUG=dream` (the same `node:util` debug-channel
21
+ * convention Psychic uses for `NODE_DEBUG=psychic`). Dev/CI only — it patches
22
+ * `pg.Pool.prototype.connect` process-wide and retains references to
23
+ * checked-out clients, so it is never installed unless explicitly asked for.
24
+ * When the close timeout fires, the acquire stack of every still-checked-out
25
+ * client is logged.
26
+ *
27
+ * Like the rest of the ecosystem's `debuglog` usage, the channel is resolved
28
+ * once by Node on first call and cached for the process lifetime — this is a
29
+ * process-level switch, not a runtime toggle.
30
+ */
31
+ // Resolved once at module load (Node caches the channel), mirroring
32
+ // `const debugEnabled = debuglog('psychic').enabled` in Psychic.
33
+ const dreamDebugEnabled = debuglog('dream').enabled;
34
+ // Map (not WeakMap) so entries can be enumerated at report time. Entries are
35
+ // deleted on release, so only genuinely-leaked clients accumulate, and only
36
+ // when diagnostics are explicitly enabled.
37
+ const checkedOutClients = new Map();
38
+ const INSTALLED = Symbol.for('dream:dbLeakDiagnosticsInstalled');
39
+ let enabled = false;
40
+ function track(client, stack) {
41
+ if (!client)
42
+ return;
43
+ checkedOutClients.set(client, { stack: stack ?? '(no stack captured)', since: Date.now() });
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ const c = client;
46
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
47
+ if (c.__dreamReleasePatched)
48
+ return;
49
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
50
+ const originalRelease = c.release;
51
+ if (typeof originalRelease !== 'function')
52
+ return;
53
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
54
+ c.__dreamReleasePatched = true;
55
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
56
+ c.release = function patchedRelease(...args) {
57
+ checkedOutClients.delete(client);
58
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
59
+ return originalRelease.apply(this, args);
60
+ };
61
+ }
62
+ /**
63
+ * Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
64
+ * onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
65
+ */
66
+ export function installDbConnectionLeakDiagnosticsIfEnabled() {
67
+ if (!dreamDebugEnabled)
68
+ return;
69
+ enabled = true;
70
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
71
+ const Pool = pg.Pool;
72
+ if (!Pool?.prototype)
73
+ return;
74
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
75
+ if (Pool.prototype[INSTALLED])
76
+ return;
77
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
78
+ Pool.prototype[INSTALLED] = true;
79
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
80
+ const originalConnect = Pool.prototype.connect;
81
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
82
+ Pool.prototype.connect = function patchedConnect(cb) {
83
+ const acquireStack = new Error('db connection acquired here').stack;
84
+ if (typeof cb === 'function') {
85
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
86
+ return originalConnect.call(this, (err, client, release) => {
87
+ track(client, acquireStack);
88
+ // `track()` replaces `client.release` with an untracking wrapper.
89
+ // Callback-style users call the 3rd `release` arg, which pg captured
90
+ // *before* that swap — so hand them the wrapped one instead, or a
91
+ // correctly-released client would be reported as a false leak.
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
93
+ const wrappedRelease = client ? client.release : release;
94
+ cb(err, client, wrappedRelease);
95
+ });
96
+ }
97
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
98
+ return originalConnect.call(this).then((client) => {
99
+ track(client, acquireStack);
100
+ return client;
101
+ });
102
+ };
103
+ }
104
+ /**
105
+ * If diagnostics are enabled, log the acquire stack of every client still
106
+ * checked out. Called from the shutdown-drain timeout path.
107
+ */
108
+ export function reportLeakedDbConnections(label) {
109
+ if (!enabled || checkedOutClients.size === 0)
110
+ return;
111
+ const now = Date.now();
112
+ const report = Array.from(checkedOutClients.values())
113
+ .map((entry, i) => ` [#${i}] held ${now - entry.since}ms\n${entry.stack}`)
114
+ .join('\n\n');
115
+ DreamApp.logWithLevel('warn', `[dream] connection-leak diagnostics: ${checkedOutClients.size} pg client(s) still checked out ` +
116
+ `when "${label}" hit the close timeout. Acquired at:\n\n${report}`);
117
+ }
@@ -92,16 +92,29 @@ export default class KyselyQueryDriver extends QueryDriverBase {
92
92
  return _db(connectionName || 'default', dbConnectionType, this.dialectProvider(connectionName, dbConnectionType));
93
93
  }
94
94
  static dialectProvider(connectionName, dbConnectionType) {
95
- return (connectionConf) => new PostgresDialect({
96
- pool: new pg.Pool({
95
+ return (connectionConf) => {
96
+ const pool = new pg.Pool({
97
+ // Spread pg passthrough first; Dream's resolved fields follow and
98
+ // always win (per-connection database name, TLS directive).
99
+ ...(connectionConf.pg ?? {}),
97
100
  user: connectionConf.user || '',
98
101
  password: connectionConf.password || '',
99
102
  database: DreamApp.getOrFail().dbName(connectionName, dbConnectionType),
100
103
  host: connectionConf.host || 'localhost',
101
104
  port: connectionConf.port || 5432,
102
105
  ssl: resolvePostgresSsl(connectionConf),
103
- }),
104
- });
106
+ });
107
+ // node-postgres explicitly warns: a Pool that emits 'error' with no
108
+ // listener crashes the Node process. Idle pooled clients emit 'error'
109
+ // on backend restart / failover / a load balancer reaping idle TCP.
110
+ // Log via Dream's logger instead of taking the process down; the pool
111
+ // discards the dead client and recovers on the next acquire.
112
+ pool.on('error', err => {
113
+ DreamApp.getOrFail().logger.error(`[dream] idle pg client error on connection "${connectionName}:${dbConnectionType}" ` +
114
+ `(handled — process kept alive): ${err.stack ?? String(err)}`);
115
+ });
116
+ return new PostgresDialect({ pool });
117
+ };
105
118
  }
106
119
  static async ensureAllMigrationsHaveBeenRun(connectionName) {
107
120
  const migrationsNeedToBeRun = await checkForNeedToBeRunMigrations({
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
3
+ * onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
4
+ */
5
+ export declare function installDbConnectionLeakDiagnosticsIfEnabled(): void;
6
+ /**
7
+ * If diagnostics are enabled, log the acquire stack of every client still
8
+ * checked out. Called from the shutdown-drain timeout path.
9
+ */
10
+ export declare function reportLeakedDbConnections(label: string): void;
@@ -1,5 +1,6 @@
1
1
  import { CompiledQuery } from 'kysely';
2
2
  import type { ConnectionOptions as TlsConnectionOptions } from 'node:tls';
3
+ import type { PoolConfig as PgPoolConfig } from 'pg';
3
4
  import { Context } from 'node:vm';
4
5
  import Dream from '../Dream.js';
5
6
  import QueryDriverBase from '../dream/QueryDriver/Base.js';
@@ -242,6 +243,26 @@ export interface SingleDbCredential {
242
243
  * decision at the call site rather than a silent default.
243
244
  */
244
245
  ssl?: TlsConnectionOptions | false;
246
+ /**
247
+ * pg pool/client options passed straight through to `new pg.Pool(...)`.
248
+ * Dream knows nothing about these fields — pg's own types carry the
249
+ * documentation. Unset ⇒ pg applies its own defaults (backward compatible).
250
+ *
251
+ * Omitted from the passthrough:
252
+ * - `user / password / database / host / port / ssl`: Dream manages these
253
+ * (per-connection name, TLS directive) — hard invariants.
254
+ * - `connectionString`: `pg`'s `ConnectionParameters` re-parses the URL
255
+ * and lets its fields take precedence, bypassing Dream's per-connection
256
+ * database name and TLS directive. Parse `DATABASE_URL` into the discrete
257
+ * `host/port/user/password/name/ssl` fields in `conf/dream.ts` instead.
258
+ * - `min`: node-pg's `pg-pool` does not honor it (silent no-op).
259
+ * - `types / Client / Promise / log / stream`: programmatic, not
260
+ * credential config (`types` is already wired via Dream's parsers).
261
+ *
262
+ * When another database adapter is added, a parallel `mysql?: ...` key
263
+ * (or similar) will appear here — this key is intentionally pg-specific.
264
+ */
265
+ pg?: Omit<PgPoolConfig, 'user' | 'password' | 'database' | 'host' | 'port' | 'ssl' | 'connectionString' | 'min' | 'types' | 'Client' | 'Promise' | 'log' | 'stream'>;
245
266
  }
246
267
  export type DreamLogger = {
247
268
  info: (...args: any[]) => void;