@rvoh/dream 2.13.0 → 2.14.0-beta.1

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 (224) hide show
  1. package/dist/cjs/src/Dream.js +4 -4
  2. package/dist/cjs/src/db/DreamDbConnection.js +9 -3
  3. package/dist/cjs/src/db/dbConnectionLeakDiagnostics.js +0 -14
  4. package/dist/cjs/src/db/migration-helpers/DreamMigrationHelpers.js +131 -0
  5. package/dist/cjs/src/db/testDatabasePool.js +182 -0
  6. package/dist/cjs/src/dream/QueryDriver/Base.js +29 -0
  7. package/dist/cjs/src/dream/QueryDriver/Kysely.js +56 -22
  8. package/dist/cjs/src/dream/QueryDriver/Postgres.js +76 -1
  9. package/dist/cjs/src/dream/QueryDriver/helpers/pg/dropDb.js +3 -1
  10. package/dist/cjs/src/dream-app/index.js +113 -6
  11. package/dist/cjs/src/helpers/areEqual.js +1 -1
  12. package/dist/cjs/src/helpers/loadRepl.js +4 -0
  13. package/dist/cjs/src/helpers/range.js +1 -1
  14. package/dist/esm/src/Dream.js +4 -4
  15. package/dist/esm/src/db/DreamDbConnection.js +9 -3
  16. package/dist/esm/src/db/dbConnectionLeakDiagnostics.js +0 -14
  17. package/dist/esm/src/db/migration-helpers/DreamMigrationHelpers.js +131 -0
  18. package/dist/esm/src/db/testDatabasePool.js +182 -0
  19. package/dist/esm/src/dream/QueryDriver/Base.js +29 -0
  20. package/dist/esm/src/dream/QueryDriver/Kysely.js +56 -22
  21. package/dist/esm/src/dream/QueryDriver/Postgres.js +76 -1
  22. package/dist/esm/src/dream/QueryDriver/helpers/pg/dropDb.js +3 -1
  23. package/dist/esm/src/dream-app/index.js +113 -6
  24. package/dist/esm/src/helpers/areEqual.js +1 -1
  25. package/dist/esm/src/helpers/loadRepl.js +4 -0
  26. package/dist/esm/src/helpers/range.js +1 -1
  27. package/dist/types/src/Dream.d.ts +4 -4
  28. package/dist/types/src/db/migration-helpers/DreamMigrationHelpers.d.ts +108 -4
  29. package/dist/types/src/db/testDatabasePool.d.ts +42 -0
  30. package/dist/types/src/dream/QueryDriver/Base.d.ts +47 -0
  31. package/dist/types/src/dream/QueryDriver/Kysely.d.ts +11 -0
  32. package/dist/types/src/dream/QueryDriver/Postgres.d.ts +19 -0
  33. package/dist/types/src/dream-app/index.d.ts +64 -1
  34. package/dist/types/src/helpers/areEqual.d.ts +1 -1
  35. package/dist/types/src/helpers/range.d.ts +8 -4
  36. package/dist/types/src/types/utils.d.ts +1 -1
  37. package/dist/types/src/types/utils.ts +1 -1
  38. package/dist/types/src/types/variadic.d.ts +1 -1
  39. package/dist/types/src/types/variadic.ts +1 -1
  40. package/docs/assets/hierarchy.js +1 -1
  41. package/docs/assets/search.js +1 -1
  42. package/docs/classes/db.DreamMigrationHelpers.html +65 -9
  43. package/docs/classes/db.KyselyQueryDriver.html +47 -32
  44. package/docs/classes/db.PostgresQueryDriver.html +52 -33
  45. package/docs/classes/db.QueryDriverBase.html +46 -31
  46. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  47. package/docs/classes/errors.ColumnOverflow.html +3 -3
  48. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  49. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  50. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  51. package/docs/classes/errors.DecryptionError.html +2 -2
  52. package/docs/classes/errors.DecryptionParseError.html +2 -2
  53. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  54. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  55. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  56. package/docs/classes/errors.InvalidClockTime.html +2 -2
  57. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  58. package/docs/classes/errors.InvalidDateTime.html +2 -2
  59. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  60. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  61. package/docs/classes/errors.NotNullViolation.html +3 -3
  62. package/docs/classes/errors.RecordNotFound.html +3 -3
  63. package/docs/classes/errors.ValidationError.html +3 -3
  64. package/docs/classes/index.CalendarDate.html +33 -33
  65. package/docs/classes/index.ClockTime.html +32 -32
  66. package/docs/classes/index.ClockTimeTz.html +35 -35
  67. package/docs/classes/index.DateTime.html +86 -86
  68. package/docs/classes/index.Decorators.html +19 -19
  69. package/docs/classes/index.Dream.html +118 -118
  70. package/docs/classes/index.DreamApp.html +42 -6
  71. package/docs/classes/index.DreamTransaction.html +2 -2
  72. package/docs/classes/index.Env.html +2 -2
  73. package/docs/classes/index.Query.html +57 -57
  74. package/docs/classes/system.CliFileWriter.html +4 -4
  75. package/docs/classes/system.DreamBin.html +2 -2
  76. package/docs/classes/system.DreamCLI.html +7 -7
  77. package/docs/classes/system.DreamImporter.html +2 -2
  78. package/docs/classes/system.DreamLogos.html +2 -2
  79. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  80. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  81. package/docs/classes/system.PathHelpers.html +3 -3
  82. package/docs/classes/utils.Encrypt.html +3 -3
  83. package/docs/classes/utils.Range.html +2 -2
  84. package/docs/functions/db.closeAllDbConnections.html +1 -1
  85. package/docs/functions/db.dreamDbConnections.html +1 -1
  86. package/docs/functions/db.untypedDb.html +1 -1
  87. package/docs/functions/db.validateColumn.html +1 -1
  88. package/docs/functions/db.validateTable.html +1 -1
  89. package/docs/functions/errors.pgErrorType.html +1 -1
  90. package/docs/functions/index.DreamSerializer.html +1 -1
  91. package/docs/functions/index.ObjectSerializer.html +1 -1
  92. package/docs/functions/index.ReplicaSafe.html +1 -1
  93. package/docs/functions/index.STI.html +1 -1
  94. package/docs/functions/index.SoftDelete.html +1 -1
  95. package/docs/functions/utils.camelize.html +1 -1
  96. package/docs/functions/utils.capitalize.html +1 -1
  97. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  98. package/docs/functions/utils.compact.html +1 -1
  99. package/docs/functions/utils.groupBy.html +1 -1
  100. package/docs/functions/utils.hyphenize.html +1 -1
  101. package/docs/functions/utils.intersection.html +1 -1
  102. package/docs/functions/utils.isEmpty.html +1 -1
  103. package/docs/functions/utils.normalizeUnicode.html +1 -1
  104. package/docs/functions/utils.pascalize.html +1 -1
  105. package/docs/functions/utils.percent.html +1 -1
  106. package/docs/functions/utils.range.html +1 -1
  107. package/docs/functions/utils.round.html +1 -1
  108. package/docs/functions/utils.sanitizeString.html +1 -1
  109. package/docs/functions/utils.snakeify.html +1 -1
  110. package/docs/functions/utils.sort.html +1 -1
  111. package/docs/functions/utils.sortBy.html +1 -1
  112. package/docs/functions/utils.sortObjectByKey.html +1 -1
  113. package/docs/functions/utils.sortObjectByValue.html +1 -1
  114. package/docs/functions/utils.uncapitalize.html +1 -1
  115. package/docs/functions/utils.uniq.html +1 -1
  116. package/docs/hierarchy.html +1 -1
  117. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  118. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  119. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  120. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  121. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  122. package/docs/interfaces/types.DecoratorContext.html +2 -2
  123. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  124. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  125. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  126. package/docs/interfaces/types.DurationObject.html +2 -2
  127. package/docs/interfaces/types.EncryptOptions.html +2 -2
  128. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  129. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  130. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  131. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  132. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  133. package/docs/types/openapi.OpenapiFormats.html +1 -1
  134. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  135. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  136. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  137. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  138. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  139. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  140. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  141. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  142. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  143. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  144. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  145. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  146. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  147. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  148. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  149. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  150. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  151. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  152. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  153. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  154. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  155. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  156. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  157. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  158. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  159. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  160. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  161. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  162. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  163. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  164. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  165. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  166. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  167. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  168. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  169. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  170. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  171. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  172. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  173. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  174. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  175. package/docs/types/types.CalendarDateObject.html +1 -1
  176. package/docs/types/types.Camelized.html +1 -1
  177. package/docs/types/types.ClockTimeObject.html +1 -1
  178. package/docs/types/types.DbConnectionType.html +1 -1
  179. package/docs/types/types.DbTypes.html +1 -1
  180. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  181. package/docs/types/types.DreamAttributes.html +1 -1
  182. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  183. package/docs/types/types.DreamClassColumn.html +1 -1
  184. package/docs/types/types.DreamColumn.html +1 -1
  185. package/docs/types/types.DreamColumnNames.html +1 -1
  186. package/docs/types/types.DreamLogLevel.html +1 -1
  187. package/docs/types/types.DreamLogger.html +2 -2
  188. package/docs/types/types.DreamModelSerializerType.html +1 -1
  189. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  190. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  191. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  192. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  193. package/docs/types/types.DreamSerializable.html +1 -1
  194. package/docs/types/types.DreamSerializableArray.html +1 -1
  195. package/docs/types/types.DreamSerializerKey.html +1 -1
  196. package/docs/types/types.DreamSerializers.html +1 -1
  197. package/docs/types/types.DreamVirtualColumns.html +1 -1
  198. package/docs/types/types.DurationUnit.html +1 -1
  199. package/docs/types/types.EncryptAlgorithm.html +1 -1
  200. package/docs/types/types.HasManyStatement.html +1 -1
  201. package/docs/types/types.HasOneStatement.html +1 -1
  202. package/docs/types/types.Hyphenized.html +1 -1
  203. package/docs/types/types.Pascalized.html +1 -1
  204. package/docs/types/types.PrimaryKeyType.html +1 -1
  205. package/docs/types/types.RoundingPrecision.html +1 -1
  206. package/docs/types/types.SerializerCasing.html +1 -1
  207. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  208. package/docs/types/types.Snakeified.html +1 -1
  209. package/docs/types/types.StrictInterface.html +1 -1
  210. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  211. package/docs/types/types.UpdateableProperties.html +1 -1
  212. package/docs/types/types.ValidationType.html +1 -1
  213. package/docs/types/types.ViewModel.html +2 -2
  214. package/docs/types/types.ViewModelClass.html +1 -1
  215. package/docs/types/types.WeekdayName.html +1 -1
  216. package/docs/types/types.WhereStatementForDream.html +1 -1
  217. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  218. package/docs/variables/index.DreamConst.html +1 -1
  219. package/docs/variables/index.ops.html +1 -1
  220. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  221. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  222. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  223. package/docs/variables/system.primaryKeyTypes.html +1 -1
  224. package/package.json +4 -3
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Per-live-worker test-database pool. Each worker claims a free database from a
3
+ * pre-created pool by holding a process-lifetime, auto-releasing lock; the
4
+ * adapter-specific lock primitive lives behind the query-driver seam
5
+ * ({@link TestDatabaseLockSession}), so this module stays driver-agnostic.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * Under vitest (`pool: 'forks'`, `isolate: true`) `VITEST_POOL_ID` is a
10
+ * *reusable slot id* (1..maxWorkers) that vitest frees and reassigns across
11
+ * worker processes. vitest does not await `runner.stop()` before reusing a
12
+ * slot, so a retired-but-still-terminating worker overlaps the new worker that
13
+ * reused its slot. When the test database is keyed off that reusable slot
14
+ * (`<base>_<VITEST_POOL_ID>`), the two overlapping processes share one
15
+ * database, and the newcomer's `beforeEach(truncate)` wipes the other's
16
+ * in-flight rows -> records vanish mid-test -> intermittent failures.
17
+ *
18
+ * ## The fix
19
+ *
20
+ * Tie database ownership to *process liveness* instead of vitest's slot
21
+ * accounting. Each live worker claims a free database from a pre-created pool
22
+ * by taking a lock on a dedicated, long-lived connection (the
23
+ * {@link TestDatabaseLockSession}, supplied by the default connection's query
24
+ * driver). The lock auto-releases the instant the worker's connection drops on
25
+ * process exit, so a reused vitest slot can never collide: the new worker
26
+ * probes for and claims a *different* free database, and only ever reuses an
27
+ * index whose previous owner has genuinely exited.
28
+ *
29
+ * The pool is `<base>` (index 1, unsuffixed) plus `<base>_2 .. <base>_K`,
30
+ * pre-created at `db:migrate` / `db:reset` time as `TEMPLATE <base>` clones.
31
+ * Pool size `K = 2 * max(1, parallelTests) + margin` covers N active workers
32
+ * plus up to N still-terminating workers, with margin for transient overlap.
33
+ *
34
+ * The lock primitive itself — and its key derivation — is the driver's
35
+ * concern: Postgres maps `(namespace, index)` to a two-`int4` advisory lock,
36
+ * MySQL to a `GET_LOCK` name. This orchestrator only ever speaks the neutral
37
+ * `(namespace, index)` contract.
38
+ */
39
+ // Extra databases beyond `2 * max(1, parallelTests)` to absorb transient
40
+ // overlap (e.g. several workers terminating at once on a slow machine).
41
+ const POOL_MARGIN = 2;
42
+ // How long to keep re-probing for a free database when every pool index is
43
+ // momentarily claimed, before failing loud. A lingering worker's lock frees
44
+ // within milliseconds of its process exiting, so this only ever waits out a
45
+ // brief handoff window.
46
+ const WAIT_FOR_FREE_TIMEOUT_MS = 5_000;
47
+ const WAIT_FOR_FREE_POLL_INTERVAL_MS = 50;
48
+ // Module-level singleton: one per Node process === one per vitest worker. A
49
+ // fresh worker process starts with a clean slate and claims its own index.
50
+ const state = {
51
+ index: null,
52
+ claimPromise: null,
53
+ lockSession: null,
54
+ };
55
+ /**
56
+ * The number of databases in the pre-created pool (including the unsuffixed
57
+ * base at index 1). Shared by the CLI pool-creation / drop loops and the claim
58
+ * probe so they always agree on the index range.
59
+ */
60
+ export function testDatabasePoolSize(parallelTests) {
61
+ const n = typeof parallelTests === 'number' && Number.isFinite(parallelTests) && parallelTests > 0
62
+ ? Math.floor(parallelTests)
63
+ : 1;
64
+ return 2 * Math.max(1, n) + POOL_MARGIN;
65
+ }
66
+ /**
67
+ * The database name for a pool index given the un-suffixed base name. Index 1
68
+ * is the base itself (no suffix); higher indexes are `<base>_<index>`. This is
69
+ * the single naming rule, used by both the claim path and the CLI loops.
70
+ */
71
+ export function testDatabaseNameForIndex(baseName, index) {
72
+ return index <= 1 ? baseName : `${baseName}_${index}`;
73
+ }
74
+ /**
75
+ * Sync read of the already-claimed pool index, or null if no claim has
76
+ * completed yet in this process. `DreamApp.dbName()` (synchronous, called when
77
+ * Kysely builds a pool) relies on the claim having been awaited earlier in the
78
+ * worker — `DreamApp.init()` awaits {@link claimTestDatabase} before the first
79
+ * connection is built, and `truncate` awaits it too — so by the time any
80
+ * connection resolves its database name the index is populated.
81
+ */
82
+ export function claimedTestDatabaseIndexOrNull() {
83
+ return state.index;
84
+ }
85
+ function delay(ms) {
86
+ return new Promise(resolve => {
87
+ const timer = setTimeout(resolve, ms);
88
+ timer.unref?.();
89
+ });
90
+ }
91
+ /**
92
+ * Claim a free pool database for this worker (idempotent). On first call it
93
+ * opens the default driver's dedicated lock session, probes
94
+ * {@link TestDatabaseLockSession.tryAcquire} across the pool indexes until one
95
+ * succeeds, and caches the claimed index for the rest of the process.
96
+ * Subsequent calls (and concurrent callers) return the same index.
97
+ *
98
+ * Throws a loud, actionable error if the pool is genuinely exhausted after the
99
+ * wait-for-free window — telling the reader to recreate the pool with
100
+ * `<packageManager> psy db:reset`.
101
+ */
102
+ export async function claimTestDatabase(dreamApp) {
103
+ if (state.index !== null)
104
+ return state.index;
105
+ if (state.claimPromise)
106
+ return state.claimPromise;
107
+ state.claimPromise = performClaim(dreamApp);
108
+ try {
109
+ state.index = await state.claimPromise;
110
+ return state.index;
111
+ }
112
+ finally {
113
+ state.claimPromise = null;
114
+ }
115
+ }
116
+ async function performClaim(dreamApp) {
117
+ const creds = dreamApp.dbCredentialsFor('default')?.primary ?? dreamApp.dbCredentialsFor('default')?.replica;
118
+ if (!creds?.name)
119
+ throw new Error('[dream] cannot claim a test database: the "default" connection has no resolvable primary db name');
120
+ // The base db name doubles as the lock namespace: the driver hashes it into
121
+ // its own lock key so two apps sharing one server reserve disjoint indexes.
122
+ const baseName = creds.name;
123
+ const poolSize = testDatabasePoolSize(dreamApp.parallelTests);
124
+ const driver = dreamApp.dbConnectionQueryDriverClass('default');
125
+ const session = await driver.openTestDatabaseLockSession('default');
126
+ state.lockSession = session;
127
+ const deadline = nowMs() + WAIT_FOR_FREE_TIMEOUT_MS;
128
+ for (;;) {
129
+ for (let index = 1; index <= poolSize; index++) {
130
+ if (await session.tryAcquire(baseName, index))
131
+ return index;
132
+ }
133
+ if (nowMs() >= deadline)
134
+ break;
135
+ await delay(WAIT_FOR_FREE_POLL_INTERVAL_MS);
136
+ }
137
+ // Genuine exhaustion: every pool index is held and none freed within the
138
+ // wait window. Fail loud with concrete remediation rather than hanging or
139
+ // silently sharing a database.
140
+ await session.release().catch(() => undefined);
141
+ state.lockSession = null;
142
+ throw new Error(`[dream] test-database pool exhausted: all ${poolSize} databases in the "${baseName}" ` +
143
+ `pool are claimed by live workers and none freed within ${WAIT_FOR_FREE_TIMEOUT_MS}ms. ` +
144
+ `This usually means the pool is undersized for the current parallelism, or stale locks are ` +
145
+ `lingering. Recreate the pool with:\n\n ${dbResetCommand(dreamApp)}\n\n` +
146
+ `If it recurs under heavy parallelism, raise DREAM_PARALLEL_TESTS (which widens the pool).`);
147
+ }
148
+ function dbResetCommand(dreamApp) {
149
+ switch (dreamApp.packageManager) {
150
+ case 'npm':
151
+ return 'npm run psy db:reset';
152
+ case 'yarn':
153
+ return 'yarn psy db:reset';
154
+ case 'bun':
155
+ return 'bun run psy db:reset';
156
+ case 'deno':
157
+ return 'deno task psy db:reset';
158
+ case 'pnpm':
159
+ default:
160
+ return 'pnpm psy db:reset';
161
+ }
162
+ }
163
+ function nowMs() {
164
+ // Date.now() rather than performance.now(); we only need coarse wall-clock
165
+ // for the wait-for-free deadline.
166
+ return Date.now();
167
+ }
168
+ /**
169
+ * Test-only escape hatch for dream's own spec suite: release the claim and
170
+ * close the lock session so a test can exercise the claim machinery in
171
+ * isolation. Not part of the public API.
172
+ *
173
+ * @internal
174
+ */
175
+ export async function __resetTestDatabaseClaimForSpec() {
176
+ const session = state.lockSession;
177
+ state.index = null;
178
+ state.claimPromise = null;
179
+ state.lockSession = null;
180
+ if (session)
181
+ await session.release().catch(() => undefined);
182
+ }
@@ -164,6 +164,35 @@ export default class QueryDriverBase {
164
164
  static async setDatabaseTypeParsers(connectionName) { }
165
165
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
166
166
  static async duplicateDatabase(connectionName) { }
167
+ /**
168
+ * @internal
169
+ *
170
+ * Whether this driver implements the per-worker test-database claim seam
171
+ * ({@link openTestDatabaseLockSession}). Adapters that can hold a
172
+ * process-lifetime, auto-releasing lock to reserve one database from the
173
+ * test pool override this to `true`. It defaults to `false` so that a new
174
+ * adapter fails loud under vitest rather than silently sharing a single
175
+ * database across overlapping workers — see `src/db/testDatabasePool.ts`.
176
+ */
177
+ static supportsParallelTestDatabases = false;
178
+ /**
179
+ * @internal
180
+ *
181
+ * Open a dedicated, process-lifetime lock session used to claim one database
182
+ * from the per-worker test pool. Only ever called when
183
+ * {@link supportsParallelTestDatabases} is `true`; the base default throws.
184
+ *
185
+ * The returned session wraps a single dedicated connection: the orchestrator
186
+ * (`src/db/testDatabasePool.ts`) probes many pool indexes on the one session
187
+ * via `tryAcquire` and keeps whichever it acquires, holding it for the
188
+ * worker's lifetime.
189
+ */
190
+ // eslint-disable-next-line @typescript-eslint/require-await
191
+ static async openTestDatabaseLockSession(
192
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
193
+ connectionName) {
194
+ throw new Error('override openTestDatabaseLockSession in child class');
195
+ }
167
196
  // eslint-disable-next-line @typescript-eslint/require-await
168
197
  static async getColumnData(
169
198
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -1007,7 +1007,7 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1007
1007
  joinAndStatements: query['innerJoinAndStatements'],
1008
1008
  dreamClass: query['dreamClass'],
1009
1009
  // As recursivelyJoin progresses through the chain of associations that
1010
- // the developer has inclued in a join statement (e.g. `leftJoinPreload('hello as ho', 'world as wd')`),
1010
+ // the developer has included in a join statement (e.g. `leftJoinPreload('hello as ho', 'world as wd')`),
1011
1011
  // previousTableAlias will be modified so that the join statement can properly reference
1012
1012
  // what we are joining on. When an `associationQuery` is the start of a join, then `baseSqlAlias` is set,
1013
1013
  // this is passed into the very beginning of recursivelyJoin.
@@ -1022,7 +1022,7 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1022
1022
  joinAndStatements: query['leftJoinAndStatements'],
1023
1023
  dreamClass: query['dreamClass'],
1024
1024
  // As recursivelyJoin progresses through the chain of associations that
1025
- // the developer has inclued in a join statement (e.g. `leftJoinPreload('hello as ho', 'world as wd')`),
1025
+ // the developer has included in a join statement (e.g. `leftJoinPreload('hello as ho', 'world as wd')`),
1026
1026
  // previousTableAlias will be modified so that the join statement can properly reference
1027
1027
  // what we are joining on. When an `associationQuery` is the start of a join, then `baseSqlAlias` is set,
1028
1028
  // this is passed into the very beginning of recursivelyJoin.
@@ -1367,6 +1367,19 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1367
1367
  namespaceColumn(column, alias = this.query['baseSqlAlias']) {
1368
1368
  return namespaceColumn(column, alias);
1369
1369
  }
1370
+ /**
1371
+ * @internal
1372
+ *
1373
+ * Builds the table expression passed to Kysely's `innerJoin`/`leftJoin` for an
1374
+ * association. When the alias already snakeifies to the table name, Kysely's
1375
+ * CamelCasePlugin emits the correct identifier from the alias alone, so no
1376
+ * explicit `as` clause is needed; otherwise we join the table aliased as `alias`.
1377
+ *
1378
+ * @returns A table expression string, e.g. `"beautiful_balloons as balloon"`
1379
+ */
1380
+ aliasedJoinTableExpression(tableName, alias) {
1381
+ return snakeify(alias) === tableName ? alias : `${tableName} as ${alias}`;
1382
+ }
1370
1383
  checkForQueryViolations() {
1371
1384
  const invalidWhereNotClauses = this.similarityStatementBuilder().whereNotStatementsWithSimilarityClauses();
1372
1385
  if (invalidWhereNotClauses.length) {
@@ -1502,12 +1515,19 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1502
1515
  */
1503
1516
  joinsBridgeThroughAssociations({ query, dreamClassTheAssociationIsDefinedOn, throughAssociation,
1504
1517
  /**
1505
- * If the join statement includes an explicit alias, e.g.,
1506
- * `leftJoinPreload('hello as ho', 'world as wd')`
1507
- * then explicitAlias is set (e.g., to 'ho' the first time that `recursivelyJoin`
1508
- * calls this method and to 'ho' the second time `recursivelyJoin` is called)
1509
- * If only implicit aliasing happens (based on association name)
1510
- * (e.g. `leftJoinPreload('hello', 'world')`), then explicitAlias is null
1518
+ * The table alias to use for the association being joined.
1519
+ *
1520
+ * When the join statement includes an explicit alias, e.g.
1521
+ * `leftJoinPreload('hello as ho', 'world as wd')`, explicitAlias is that
1522
+ * alias ('ho' for `hello`, 'wd' for `world`).
1523
+ *
1524
+ * When no explicit alias is given (e.g. `leftJoinPreload('hello', 'world')`),
1525
+ * `recursivelyJoin` passes the association name itself ('hello', 'world') as
1526
+ * the alias, so explicitAlias is still defined.
1527
+ *
1528
+ * explicitAlias is only `undefined` for the intermediate join tables that
1529
+ * `joinsBridgeThroughAssociations` synthesizes while bridging a `through`
1530
+ * association — those tables are never aliased by the developer.
1511
1531
  */
1512
1532
  explicitAlias,
1513
1533
  /**
@@ -1628,12 +1648,19 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1628
1648
  }
1629
1649
  applyOneJoin({ query, dreamClass, association,
1630
1650
  /**
1631
- * If the join statement includes an explicit alias, e.g.,
1632
- * `leftJoinPreload('hello as ho', 'world as wd')`
1633
- * then explicitAlias is set (e.g., to 'ho' the first time that `recursivelyJoin`
1634
- * calls this method and to 'ho' the second time `recursivelyJoin` is called)
1635
- * If only implicit aliasing happens (based on association name)
1636
- * (e.g. `leftJoinPreload('hello', 'world')`), then explicitAlias is null
1651
+ * The table alias to use for the association being joined.
1652
+ *
1653
+ * When the join statement includes an explicit alias, e.g.
1654
+ * `leftJoinPreload('hello as ho', 'world as wd')`, explicitAlias is that
1655
+ * alias ('ho' for `hello`, 'wd' for `world`).
1656
+ *
1657
+ * When no explicit alias is given (e.g. `leftJoinPreload('hello', 'world')`),
1658
+ * `recursivelyJoin` passes the association name itself ('hello', 'world') as
1659
+ * the alias, so explicitAlias is still defined.
1660
+ *
1661
+ * explicitAlias is only `undefined` for the intermediate join tables that
1662
+ * `joinsBridgeThroughAssociations` synthesizes while bridging a `through`
1663
+ * association — those tables are never aliased by the developer.
1637
1664
  */
1638
1665
  explicitAlias,
1639
1666
  /**
@@ -1672,12 +1699,19 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1672
1699
  }
1673
1700
  addAssociationJoinStatementToQuery({ query, dreamClass, association,
1674
1701
  /**
1675
- * If the join statement includes an explicit alias, e.g.,
1676
- * `leftJoinPreload('hello as ho', 'world as wd')`
1677
- * then explicitAlias is set (e.g., to 'ho' the first time that `recursivelyJoin`
1678
- * calls this method and to 'ho' the second time `recursivelyJoin` is called)
1679
- * If only implicit aliasing happens (based on association name)
1680
- * (e.g. `leftJoinPreload('hello', 'world')`), then explicitAlias is null
1702
+ * The table alias to use for the association being joined.
1703
+ *
1704
+ * When the join statement includes an explicit alias, e.g.
1705
+ * `leftJoinPreload('hello as ho', 'world as wd')`, explicitAlias is that
1706
+ * alias ('ho' for `hello`, 'wd' for `world`).
1707
+ *
1708
+ * When no explicit alias is given (e.g. `leftJoinPreload('hello', 'world')`),
1709
+ * `recursivelyJoin` passes the association name itself ('hello', 'world') as
1710
+ * the alias, so explicitAlias is still defined.
1711
+ *
1712
+ * explicitAlias is only `undefined` for the intermediate join tables that
1713
+ * `joinsBridgeThroughAssociations` synthesizes while bridging a `through`
1714
+ * association — those tables are never aliased by the developer.
1681
1715
  */
1682
1716
  explicitAlias,
1683
1717
  /**
@@ -1714,7 +1748,7 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1714
1748
  leftJoinStatements: this.query['leftJoinStatements'],
1715
1749
  });
1716
1750
  const to = (dreamClassThroughAssociationWantsToHydrate ?? association.modelCB()).table;
1717
- const joinTableExpression = snakeify(currentTableAlias) === to ? currentTableAlias : `${to} as ${currentTableAlias}`;
1751
+ const joinTableExpression = this.aliasedJoinTableExpression(to, currentTableAlias);
1718
1752
  query = query[(joinType === 'inner' ? 'innerJoin' : 'leftJoin')](joinTableExpression, (join) => {
1719
1753
  join = join.onRef(this.namespaceColumn(association.foreignKey(), previousTableAlias), '=', this.namespaceColumn(association.primaryKey(undefined, {
1720
1754
  associatedClassOverride: dreamClassThroughAssociationWantsToHydrate,
@@ -1747,7 +1781,7 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1747
1781
  }
1748
1782
  else {
1749
1783
  const to = association.modelCB().table;
1750
- const joinTableExpression = snakeify(currentTableAlias) === to ? currentTableAlias : `${to} as ${currentTableAlias}`;
1784
+ const joinTableExpression = this.aliasedJoinTableExpression(to, currentTableAlias);
1751
1785
  query = query[(joinType === 'inner' ? 'innerJoin' : 'leftJoin')](joinTableExpression, (join) => {
1752
1786
  join = join.onRef(this.namespaceColumn(association.primaryKey(), previousTableAlias), '=', this.namespaceColumn(association.foreignKey(), currentTableAlias));
1753
1787
  if (association.polymorphic) {
@@ -13,12 +13,35 @@ import { isPrimitiveDataType } from '../../db/dataTypes.js';
13
13
  import DreamApp from '../../dream-app/index.js';
14
14
  import camelize from '../../helpers/camelize.js';
15
15
  import { findCitextArrayOid, findCorrespondingArrayOid, findEnumArrayOids, parsePostgresBigint, parsePostgresDate, parsePostgresDatetime, parsePostgresDatetimeTz, parsePostgresDecimal, parsePostgresTime, parsePostgresTimeTz, } from '../../helpers/customPgParsers.js';
16
+ import { testDatabasePoolSize } from '../../db/testDatabasePool.js';
16
17
  import EnvInternal from '../../helpers/EnvInternal.js';
17
18
  import createDb from './helpers/pg/createDb.js';
18
19
  import _dropDb from './helpers/pg/dropDb.js';
19
20
  import loadPgClient from './helpers/pg/loadPgClient.js';
20
21
  import KyselyQueryDriver from './Kysely.js';
21
22
  const pgTypes = pg.types;
23
+ // Fixed dream-pool namespace constant for the advisory-lock `key1`. Chosen to
24
+ // sit high in the int4 range and be recognizable in `pg_locks` (classid/objid)
25
+ // when debugging. XORed with a per-namespace hash below so two apps sharing one
26
+ // Postgres cluster reserve disjoint index spaces.
27
+ const DREAM_TEST_POOL_LOCK_NAMESPACE = 0x44_52_4d_00 | 0; // "DRM\0"
28
+ // Aggressive TCP keepalive on the dedicated lock-holding connection. Insurance
29
+ // for remote / CI Postgres and abrupt kills where a FIN might be lost: it lets
30
+ // Postgres reap the dead backend (and release the lock) even when the OS never
31
+ // delivered a clean socket close. For a local kill the lock releases on EOF
32
+ // regardless of this.
33
+ const LOCK_CONNECTION_KEEPALIVE_INITIAL_DELAY_MS = 1_000;
34
+ function int32Hash(value) {
35
+ let hash = 0;
36
+ for (let i = 0; i < value.length; i++) {
37
+ hash = (Math.imul(hash, 31) + value.charCodeAt(i)) | 0;
38
+ }
39
+ return hash;
40
+ }
41
+ function advisoryLockKey1(namespace) {
42
+ // Stays within int4 via the `| 0` coercions.
43
+ return (DREAM_TEST_POOL_LOCK_NAMESPACE ^ int32Hash(namespace)) | 0;
44
+ }
22
45
  export default class PostgresQueryDriver extends KyselyQueryDriver {
23
46
  /**
24
47
  * create the database. Must respond to the NODE_ENV value.
@@ -161,7 +184,12 @@ export default class PostgresQueryDriver extends KyselyQueryDriver {
161
184
  await client.query(`DROP DATABASE IF EXISTS ${replicaTestWorkerDatabaseName};`);
162
185
  await client.query(`CREATE DATABASE ${replicaTestWorkerDatabaseName} TEMPLATE ${dbConf.name};`);
163
186
  }
164
- for (let i = 2; i <= parallelTests; i++) {
187
+ // The pool spans `<base>` (the migration template, also claimable) plus
188
+ // `<base>_2 .. <base>_K`. Each live test worker claims one member for its
189
+ // lifetime via an advisory lock, so the pool must be wide enough to cover
190
+ // active + still-terminating workers — see src/db/testDatabasePool.ts.
191
+ const poolSize = testDatabasePoolSize(parallelTests);
192
+ for (let i = 2; i <= poolSize; i++) {
165
193
  const workerDatabaseName = `${dbConf.name}_${i}`;
166
194
  DreamCLI.logger.logContinueProgress(`creating duplicate test database ${workerDatabaseName} for concurrent tests...`, { logPrefix: ' ├ [db]', logPrefixColor: 'greenBright' });
167
195
  await client.query(`DROP DATABASE IF EXISTS ${workerDatabaseName};`);
@@ -170,4 +198,51 @@ export default class PostgresQueryDriver extends KyselyQueryDriver {
170
198
  await client.end();
171
199
  DreamCLI.logger.logEndProgress();
172
200
  }
201
+ static supportsParallelTestDatabases = true;
202
+ /**
203
+ * @internal
204
+ *
205
+ * Postgres implementation of the per-worker test-database claim seam (see
206
+ * {@link TestDatabaseLockSession} and `src/db/testDatabasePool.ts`).
207
+ *
208
+ * Postgres advisory locks are global to the cluster (not scoped to a single
209
+ * database), so one dedicated connection — to the `postgres` maintenance
210
+ * database — can reserve a pool index for the whole cluster. We use the
211
+ * two-`int4` form `pg_try_advisory_lock(key1, key2)`, whose lock space is
212
+ * distinct from the single-`bigint` form `pg_advisory_lock(bigint)` that
213
+ * application code typically uses, so a claim can never clash with an
214
+ * app-level advisory lock. `key1` folds a hash of the lock namespace (the
215
+ * base database name) into a fixed dream-pool constant; `key2` is the pool
216
+ * index. The lock auto-releases when the connection drops on process exit.
217
+ */
218
+ static async openTestDatabaseLockSession(connectionName) {
219
+ const dreamApp = DreamApp.getOrFail();
220
+ const creds = dreamApp.dbCredentialsFor(connectionName)?.primary ?? dreamApp.dbCredentialsFor(connectionName)?.replica;
221
+ if (!creds?.name)
222
+ throw new Error(`[dream] cannot open a test-database lock session: the "${connectionName}" connection has no resolvable primary db name`);
223
+ const client = new pg.Client({
224
+ host: creds.host || 'localhost',
225
+ port: creds.port,
226
+ // The maintenance database always exists and we never lock it —
227
+ // advisory locks are cluster-wide.
228
+ database: 'postgres',
229
+ user: creds.user,
230
+ password: creds.password,
231
+ keepAlive: true,
232
+ keepAliveInitialDelayMillis: LOCK_CONNECTION_KEEPALIVE_INITIAL_DELAY_MS,
233
+ });
234
+ await client.connect();
235
+ return {
236
+ async tryAcquire(namespace, index) {
237
+ const result = await client.query('SELECT pg_try_advisory_lock($1::int4, $2::int4) AS locked', [
238
+ advisoryLockKey1(namespace),
239
+ index,
240
+ ]);
241
+ return result.rows[0]?.locked === true;
242
+ },
243
+ async release() {
244
+ await client.end().catch(() => undefined);
245
+ },
246
+ };
247
+ }
173
248
  }
@@ -1,4 +1,5 @@
1
1
  import DreamCLI from '../../../../cli/index.js';
2
+ import { testDatabasePoolSize } from '../../../../db/testDatabasePool.js';
2
3
  import DreamApp from '../../../../dream-app/index.js';
3
4
  import EnvInternal from '../../../../helpers/EnvInternal.js';
4
5
  import loadPgClient from './loadPgClient.js';
@@ -25,7 +26,8 @@ async function maybeDropDuplicateDatabases(client, dbName) {
25
26
  DreamCLI.logger.logContinueProgress(`dropping fake replica test database ${replicaTestWorkerDatabaseName}`, { logPrefix: ' ├ [db]', logPrefixColor: 'greenBright' });
26
27
  await client.query(`DROP DATABASE IF EXISTS ${replicaTestWorkerDatabaseName};`);
27
28
  }
28
- for (let i = 2; i <= parallelTests; i++) {
29
+ const poolSize = testDatabasePoolSize(parallelTests);
30
+ for (let i = 2; i <= poolSize; i++) {
29
31
  const workerDatabaseName = `${dbName}_${i}`;
30
32
  DreamCLI.logger.logContinueProgress(`dropping duplicate test database ${workerDatabaseName}`, {
31
33
  logPrefix: ' ├ [db]',
@@ -1,4 +1,5 @@
1
1
  import * as util from 'node:util';
2
+ import { claimedTestDatabaseIndexOrNull, claimTestDatabase, testDatabaseNameForIndex, } from '../db/testDatabasePool.js';
2
3
  import validateTable from '../db/validators/validateTable.js';
3
4
  import Query from '../dream/Query.js';
4
5
  import PostgresQueryDriver from '../dream/QueryDriver/Postgres.js';
@@ -47,8 +48,12 @@ export default class DreamApp {
47
48
  if (!dreamApp.serializers)
48
49
  setCachedSerializers({});
49
50
  cacheDreamApp(dreamApp);
50
- if (!opts.bypassDbConnectionsDuringInit)
51
+ if (!opts.bypassDbConnectionsDuringInit) {
52
+ // Claim this worker's pool database before any connection is built, so
53
+ // the synchronous dbName() always resolves to the claimed database.
54
+ await dreamApp.ensureTestDatabaseClaimed();
51
55
  await this.setDatabaseTypeParsers(dreamApp);
56
+ }
52
57
  await deferCb?.(dreamApp);
53
58
  for (const plugin of dreamApp.plugins) {
54
59
  await plugin(dreamApp);
@@ -289,9 +294,56 @@ A new key can also be generated from the CLI:
289
294
  get serializers() {
290
295
  return getSerializersOrFail();
291
296
  }
297
+ /**
298
+ * The database name to connect to for a given connection. In dev/prod this is
299
+ * simply the configured name. Under vitest (test env) it is the per-worker
300
+ * database claimed from the pool — see {@link testDatabaseClaimEnabled} and
301
+ * `src/db/testDatabasePool.ts`.
302
+ *
303
+ * This is synchronous because Kysely builds its `pg.Pool` synchronously. It
304
+ * reads the already-claimed pool index; the claim is awaited earlier in the
305
+ * worker — `DreamApp.init()` calls {@link claimTestDatabase} before the first
306
+ * connection is built, and `dream-spec-helpers`' `truncate` awaits
307
+ * {@link testDatabaseName} — so the index is always populated by the time any
308
+ * connection resolves its name.
309
+ */
292
310
  dbName(connectionName, connection) {
293
311
  const conf = this.dbConnectionConfig(connectionName, connection);
294
- return this.parallelDatabasesEnabled ? `${conf.name}_${process.env.VITEST_POOL_ID}` : conf.name;
312
+ if (!this.runtimeNeedsTestDatabaseClaim)
313
+ return conf.name;
314
+ this.requireTestDatabaseClaimSupport();
315
+ const index = claimedTestDatabaseIndexOrNull();
316
+ if (index === null)
317
+ throw new Error(`[dream] dbName("${connectionName}", "${connection}") was resolved before this worker claimed ` +
318
+ `a test database. The pool claim must be awaited first (DreamApp.init / truncate do this). ` +
319
+ `This indicates a database connection was opened before app initialization completed.`);
320
+ return testDatabaseNameForIndex(conf.name, index);
321
+ }
322
+ /**
323
+ * Single source of truth for the claimed test-database name, used both by
324
+ * dream's connection resolution and by `dream-spec-helpers`' `truncate`
325
+ * (which opens its own `pg.Client` and so must read — not recompute — the
326
+ * claim). Awaits the pool claim if it has not happened yet in this worker.
327
+ */
328
+ async testDatabaseName(connectionName, connection) {
329
+ const conf = this.dbConnectionConfig(connectionName, connection);
330
+ if (!this.runtimeNeedsTestDatabaseClaim)
331
+ return conf.name;
332
+ this.requireTestDatabaseClaimSupport();
333
+ const index = await claimTestDatabase(this);
334
+ return testDatabaseNameForIndex(conf.name, index);
335
+ }
336
+ /**
337
+ * Claim this worker's pool database if it hasn't been claimed yet. Idempotent
338
+ * and a no-op outside the test-under-vitest path. Called by `DreamApp.init()`
339
+ * before any connection is built so the synchronous {@link dbName} always
340
+ * sees a populated index.
341
+ */
342
+ async ensureTestDatabaseClaimed() {
343
+ if (!this.runtimeNeedsTestDatabaseClaim)
344
+ return;
345
+ this.requireTestDatabaseClaimSupport();
346
+ await claimTestDatabase(this);
295
347
  }
296
348
  dbConnectionConfig(connectionName, connection) {
297
349
  const conf = this.dbCredentialsFor(connectionName)?.[connection] || this.dbCredentialsFor(connectionName)?.primary;
@@ -322,10 +374,57 @@ A new key can also be generated from the CLI:
322
374
  hasReplicaConfig(connectionName) {
323
375
  return !!this.dbCredentials[connectionName]?.replica;
324
376
  }
325
- get parallelDatabasesEnabled() {
326
- return (!!this.parallelTests &&
327
- !Number.isNaN(Number(process.env.VITEST_POOL_ID)) &&
328
- Number(process.env.VITEST_POOL_ID) > 1);
377
+ /**
378
+ * Whether the runtime *requires* a per-worker test-database claim. Gated on
379
+ * test env *under vitest* (not on `parallelTests > 1`): the slot-reuse
380
+ * collision reproduces even serially, because vitest respawns a process per
381
+ * file and the lingering process overlaps the new one on the same database.
382
+ * CLI db tasks (`db:migrate`, `db:reset`, …) do not run under vitest, so they
383
+ * keep the unsuffixed name and operate on the whole pool explicitly.
384
+ *
385
+ * This is independent of whether the default driver can actually claim — when
386
+ * the need is present but the capability is absent, the claim path fails loud
387
+ * (see {@link requireTestDatabaseClaimSupport}) rather than silently sharing
388
+ * one database.
389
+ */
390
+ get runtimeNeedsTestDatabaseClaim() {
391
+ return EnvInternal.isTest && runningUnderVitest();
392
+ }
393
+ /**
394
+ * Whether the default connection's query driver implements the per-worker
395
+ * test-database claim seam (`openTestDatabaseLockSession`). The claim is keyed
396
+ * off the **default** connection's capability so a Postgres-default app with a
397
+ * MySQL secondary keeps working (the secondary borrows the Postgres-claimed
398
+ * index for its database name).
399
+ */
400
+ get defaultConnectionSupportsParallelTestDatabases() {
401
+ return this.dbConnectionQueryDriverClass('default').supportsParallelTestDatabases;
402
+ }
403
+ /**
404
+ * Whether the per-worker test-database pool claim is active: needed by the
405
+ * runtime *and* supported by the default connection's driver. Consumers that
406
+ * key behavior off the claimed index (connection-cache suffixing) use this;
407
+ * the claim entry points additionally fail loud when the need is present but
408
+ * support is absent.
409
+ */
410
+ get testDatabaseClaimEnabled() {
411
+ return this.runtimeNeedsTestDatabaseClaim && this.defaultConnectionSupportsParallelTestDatabases;
412
+ }
413
+ /**
414
+ * Throw an actionable error when the runtime needs a per-worker test-database
415
+ * claim but the default connection's driver cannot provide one — rather than
416
+ * silently sharing a single database across overlapping vitest workers (which
417
+ * reintroduces the flake this whole mechanism exists to fix).
418
+ */
419
+ requireTestDatabaseClaimSupport() {
420
+ if (this.defaultConnectionSupportsParallelTestDatabases)
421
+ return;
422
+ const driverName = this.dbConnectionQueryDriverClass('default').name;
423
+ throw new Error(`[dream] running under vitest requires a per-worker test database, but the "default" connection's ` +
424
+ `query driver (${driverName}) does not support parallel test databases. Implement the claim seam ` +
425
+ `on that driver — set \`supportsParallelTestDatabases = true\` and override ` +
426
+ `\`openTestDatabaseLockSession\` (see PostgresQueryDriver / the test-app MysqlQueryDriver for ` +
427
+ `reference) — or do not run this adapter under vitest.`);
329
428
  }
330
429
  async load(resourceType, resourcePath, importCb) {
331
430
  switch (resourceType) {
@@ -436,6 +535,14 @@ function loggerArgToString(arg) {
436
535
  }
437
536
  return JSON.stringify(util.inspect(arg, { depth: 6 }));
438
537
  }
538
+ /**
539
+ * Whether the current process is a vitest worker. vitest sets `VITEST=true` in
540
+ * every worker, and also exposes the per-worker `VITEST_POOL_ID` /
541
+ * `VITEST_WORKER_ID`. CLI db tasks run outside vitest, so this is false there.
542
+ */
543
+ function runningUnderVitest() {
544
+ return process.env.VITEST === 'true' || process.env.VITEST_POOL_ID != null;
545
+ }
439
546
  export const DreamAppAllowedPackageManagersEnumValues = ['pnpm', 'yarn', 'npm', 'bun', 'deno'];
440
547
  // GeneratorImportStyles are used by CLI generators to determine how
441
548
  // to style import suffixes. When integrating with other apps, this
@@ -4,7 +4,7 @@ import CalendarDate from '../utils/datetime/CalendarDate.js';
4
4
  import { DateTime } from '../utils/datetime/DateTime.js';
5
5
  /**
6
6
  * Performs a deep equality check between two values, supporting primitives, arrays,
7
- * Dream instances, CalendarDate, DateTime, and objects.
7
+ * Dream instances, CalendarDate, DateTime, ClockTime, ClockTimeTz, and objects.
8
8
  *
9
9
  * Examples:
10
10
  * areEqual(undefined, undefined) // true
@@ -2,6 +2,8 @@ import DreamApp from '../dream-app/index.js';
2
2
  import Encrypt from '../encrypt/index.js';
3
3
  import ops from '../ops/index.js';
4
4
  import CalendarDate from '../utils/datetime/CalendarDate.js';
5
+ import ClockTime from '../utils/datetime/ClockTime.js';
6
+ import ClockTimeTz from '../utils/datetime/ClockTimeTz.js';
5
7
  import { DateTime } from '../utils/datetime/DateTime.js';
6
8
  import camelize from './camelize.js';
7
9
  import capitalize from './capitalize.js';
@@ -19,6 +21,8 @@ import uniq from './uniq.js';
19
21
  export default async function loadRepl(context) {
20
22
  const dreamApp = DreamApp.getOrFail();
21
23
  context.CalendarDate = CalendarDate;
24
+ context.ClockTime = ClockTime;
25
+ context.ClockTimeTz = ClockTimeTz;
22
26
  context.DateTime = DateTime;
23
27
  context.Encrypt = Encrypt;
24
28
  context.camelize = camelize;