@rvoh/dream 2.13.1 → 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.
- package/dist/cjs/src/db/DreamDbConnection.js +9 -3
- package/dist/cjs/src/db/dbConnectionLeakDiagnostics.js +0 -14
- package/dist/cjs/src/db/migration-helpers/DreamMigrationHelpers.js +131 -0
- package/dist/cjs/src/db/testDatabasePool.js +182 -0
- package/dist/cjs/src/dream/QueryDriver/Base.js +29 -0
- package/dist/cjs/src/dream/QueryDriver/Postgres.js +76 -1
- package/dist/cjs/src/dream/QueryDriver/helpers/pg/dropDb.js +3 -1
- package/dist/cjs/src/dream-app/index.js +113 -6
- package/dist/cjs/src/helpers/areEqual.js +1 -1
- package/dist/cjs/src/helpers/loadRepl.js +4 -0
- package/dist/esm/src/db/DreamDbConnection.js +9 -3
- package/dist/esm/src/db/dbConnectionLeakDiagnostics.js +0 -14
- package/dist/esm/src/db/migration-helpers/DreamMigrationHelpers.js +131 -0
- package/dist/esm/src/db/testDatabasePool.js +182 -0
- package/dist/esm/src/dream/QueryDriver/Base.js +29 -0
- package/dist/esm/src/dream/QueryDriver/Postgres.js +76 -1
- package/dist/esm/src/dream/QueryDriver/helpers/pg/dropDb.js +3 -1
- package/dist/esm/src/dream-app/index.js +113 -6
- package/dist/esm/src/helpers/areEqual.js +1 -1
- package/dist/esm/src/helpers/loadRepl.js +4 -0
- package/dist/types/src/db/migration-helpers/DreamMigrationHelpers.d.ts +105 -1
- package/dist/types/src/db/testDatabasePool.d.ts +42 -0
- package/dist/types/src/dream/QueryDriver/Base.d.ts +47 -0
- package/dist/types/src/dream/QueryDriver/Postgres.d.ts +19 -0
- package/dist/types/src/dream-app/index.d.ts +64 -1
- package/dist/types/src/helpers/areEqual.d.ts +1 -1
- package/dist/types/src/helpers/range.d.ts +8 -4
- package/docs/assets/hierarchy.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/db.DreamMigrationHelpers.html +65 -9
- package/docs/classes/db.KyselyQueryDriver.html +47 -32
- package/docs/classes/db.PostgresQueryDriver.html +52 -33
- package/docs/classes/db.QueryDriverBase.html +46 -31
- package/docs/classes/errors.CheckConstraintViolation.html +3 -3
- package/docs/classes/errors.ColumnOverflow.html +3 -3
- package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
- package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
- package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
- package/docs/classes/errors.DecryptionError.html +2 -2
- package/docs/classes/errors.DecryptionParseError.html +2 -2
- package/docs/classes/errors.DecryptionRotationError.html +3 -3
- package/docs/classes/errors.GlobalNameNotSet.html +3 -3
- package/docs/classes/errors.InvalidCalendarDate.html +2 -2
- package/docs/classes/errors.InvalidClockTime.html +2 -2
- package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
- package/docs/classes/errors.InvalidDateTime.html +2 -2
- package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
- package/docs/classes/errors.NonLoadedAssociation.html +3 -3
- package/docs/classes/errors.NotNullViolation.html +3 -3
- package/docs/classes/errors.RecordNotFound.html +3 -3
- package/docs/classes/errors.ValidationError.html +3 -3
- package/docs/classes/index.CalendarDate.html +33 -33
- package/docs/classes/index.ClockTime.html +32 -32
- package/docs/classes/index.ClockTimeTz.html +35 -35
- package/docs/classes/index.DateTime.html +86 -86
- package/docs/classes/index.Decorators.html +19 -19
- package/docs/classes/index.Dream.html +118 -118
- package/docs/classes/index.DreamApp.html +42 -6
- package/docs/classes/index.DreamTransaction.html +2 -2
- package/docs/classes/index.Env.html +2 -2
- package/docs/classes/index.Query.html +57 -57
- package/docs/classes/system.CliFileWriter.html +4 -4
- package/docs/classes/system.DreamBin.html +2 -2
- package/docs/classes/system.DreamCLI.html +7 -7
- package/docs/classes/system.DreamImporter.html +2 -2
- package/docs/classes/system.DreamLogos.html +2 -2
- package/docs/classes/system.DreamSerializerBuilder.html +11 -11
- package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
- package/docs/classes/system.PathHelpers.html +3 -3
- package/docs/classes/utils.Encrypt.html +3 -3
- package/docs/classes/utils.Range.html +2 -2
- package/docs/functions/db.closeAllDbConnections.html +1 -1
- package/docs/functions/db.dreamDbConnections.html +1 -1
- package/docs/functions/db.untypedDb.html +1 -1
- package/docs/functions/db.validateColumn.html +1 -1
- package/docs/functions/db.validateTable.html +1 -1
- package/docs/functions/errors.pgErrorType.html +1 -1
- package/docs/functions/index.DreamSerializer.html +1 -1
- package/docs/functions/index.ObjectSerializer.html +1 -1
- package/docs/functions/index.ReplicaSafe.html +1 -1
- package/docs/functions/index.STI.html +1 -1
- package/docs/functions/index.SoftDelete.html +1 -1
- package/docs/functions/utils.camelize.html +1 -1
- package/docs/functions/utils.capitalize.html +1 -1
- package/docs/functions/utils.cloneDeepSafe.html +1 -1
- package/docs/functions/utils.compact.html +1 -1
- package/docs/functions/utils.groupBy.html +1 -1
- package/docs/functions/utils.hyphenize.html +1 -1
- package/docs/functions/utils.intersection.html +1 -1
- package/docs/functions/utils.isEmpty.html +1 -1
- package/docs/functions/utils.normalizeUnicode.html +1 -1
- package/docs/functions/utils.pascalize.html +1 -1
- package/docs/functions/utils.percent.html +1 -1
- package/docs/functions/utils.range.html +1 -1
- package/docs/functions/utils.round.html +1 -1
- package/docs/functions/utils.sanitizeString.html +1 -1
- package/docs/functions/utils.snakeify.html +1 -1
- package/docs/functions/utils.sort.html +1 -1
- package/docs/functions/utils.sortBy.html +1 -1
- package/docs/functions/utils.sortObjectByKey.html +1 -1
- package/docs/functions/utils.sortObjectByValue.html +1 -1
- package/docs/functions/utils.uncapitalize.html +1 -1
- package/docs/functions/utils.uniq.html +1 -1
- package/docs/hierarchy.html +1 -1
- package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
- package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
- package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
- package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
- package/docs/interfaces/types.BelongsToStatement.html +2 -2
- package/docs/interfaces/types.DecoratorContext.html +2 -2
- package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
- package/docs/interfaces/types.DreamAppOpts.html +2 -2
- package/docs/interfaces/types.DreamDbConfig.html +5 -5
- package/docs/interfaces/types.DurationObject.html +2 -2
- package/docs/interfaces/types.EncryptOptions.html +2 -2
- package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
- package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
- package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
- package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
- package/docs/types/openapi.OpenapiAllTypes.html +1 -1
- package/docs/types/openapi.OpenapiFormats.html +1 -1
- package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
- package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
- package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
- package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
- package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
- package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
- package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
- package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
- package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
- package/docs/types/openapi.OpenapiSchemaString.html +1 -1
- package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
- package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
- package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
- package/docs/types/openapi.OpenapiTypeField.html +1 -1
- package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
- package/docs/types/types.CalendarDateDurationUnit.html +1 -1
- package/docs/types/types.CalendarDateObject.html +1 -1
- package/docs/types/types.Camelized.html +1 -1
- package/docs/types/types.ClockTimeObject.html +1 -1
- package/docs/types/types.DbConnectionType.html +1 -1
- package/docs/types/types.DbTypes.html +1 -1
- package/docs/types/types.DreamAssociationMetadata.html +1 -1
- package/docs/types/types.DreamAttributes.html +1 -1
- package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
- package/docs/types/types.DreamClassColumn.html +1 -1
- package/docs/types/types.DreamColumn.html +1 -1
- package/docs/types/types.DreamColumnNames.html +1 -1
- package/docs/types/types.DreamLogLevel.html +1 -1
- package/docs/types/types.DreamLogger.html +2 -2
- package/docs/types/types.DreamModelSerializerType.html +1 -1
- package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
- package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
- package/docs/types/types.DreamParamSafeAttributes.html +1 -1
- package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
- package/docs/types/types.DreamSerializable.html +1 -1
- package/docs/types/types.DreamSerializableArray.html +1 -1
- package/docs/types/types.DreamSerializerKey.html +1 -1
- package/docs/types/types.DreamSerializers.html +1 -1
- package/docs/types/types.DreamVirtualColumns.html +1 -1
- package/docs/types/types.DurationUnit.html +1 -1
- package/docs/types/types.EncryptAlgorithm.html +1 -1
- package/docs/types/types.HasManyStatement.html +1 -1
- package/docs/types/types.HasOneStatement.html +1 -1
- package/docs/types/types.Hyphenized.html +1 -1
- package/docs/types/types.Pascalized.html +1 -1
- package/docs/types/types.PrimaryKeyType.html +1 -1
- package/docs/types/types.RoundingPrecision.html +1 -1
- package/docs/types/types.SerializerCasing.html +1 -1
- package/docs/types/types.SimpleObjectSerializerType.html +1 -1
- package/docs/types/types.Snakeified.html +1 -1
- package/docs/types/types.StrictInterface.html +1 -1
- package/docs/types/types.UpdateableAssociationProperties.html +1 -1
- package/docs/types/types.UpdateableProperties.html +1 -1
- package/docs/types/types.ValidationType.html +1 -1
- package/docs/types/types.ViewModel.html +2 -2
- package/docs/types/types.ViewModelClass.html +1 -1
- package/docs/types/types.WeekdayName.html +1 -1
- package/docs/types/types.WhereStatementForDream.html +1 -1
- package/docs/types/types.WhereStatementForDreamClass.html +1 -1
- package/docs/variables/index.DreamConst.html +1 -1
- package/docs/variables/index.ops.html +1 -1
- package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
- package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
- package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
- package/docs/variables/system.primaryKeyTypes.html +1 -1
- package/package.json +2 -2
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// @ts-ignore
|
|
9
9
|
import { CamelCasePlugin, Kysely } from 'kysely';
|
|
10
10
|
import DreamApp from '../dream-app/index.js';
|
|
11
|
+
import { claimedTestDatabaseIndexOrNull } from './testDatabasePool.js';
|
|
11
12
|
import protectAgainstPollutingAssignment from '../helpers/protectAgainstPollutingAssignment.js';
|
|
12
13
|
import { installDbConnectionLeakDiagnosticsIfEnabled, reportLeakedDbConnections, } from './dbConnectionLeakDiagnostics.js';
|
|
13
14
|
let connections = {};
|
|
@@ -39,9 +40,14 @@ export default class DreamDbConnection {
|
|
|
39
40
|
return dbConn;
|
|
40
41
|
}
|
|
41
42
|
static getConnectionTypeName(connectionType) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// The connection cache is per Node process (per vitest worker), and a worker
|
|
44
|
+
// only ever uses its single claimed pool index, so suffixing the cache key by
|
|
45
|
+
// that index keeps connections cached per claimed database without colliding
|
|
46
|
+
// across the (now process-scoped) registry.
|
|
47
|
+
if (!DreamApp.getOrFail().testDatabaseClaimEnabled)
|
|
48
|
+
return connectionType;
|
|
49
|
+
const index = claimedTestDatabaseIndexOrNull();
|
|
50
|
+
return index === null ? connectionType : `${connectionType}_${index}`;
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
export function dreamDbConnections() {
|
|
@@ -41,21 +41,15 @@ function track(client, stack) {
|
|
|
41
41
|
if (!client)
|
|
42
42
|
return;
|
|
43
43
|
checkedOutClients.set(client, { stack: stack ?? '(no stack captured)', since: Date.now() });
|
|
44
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
44
|
const c = client;
|
|
46
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
47
45
|
if (c.__dreamReleasePatched)
|
|
48
46
|
return;
|
|
49
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
50
47
|
const originalRelease = c.release;
|
|
51
48
|
if (typeof originalRelease !== 'function')
|
|
52
49
|
return;
|
|
53
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
54
50
|
c.__dreamReleasePatched = true;
|
|
55
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
56
51
|
c.release = function patchedRelease(...args) {
|
|
57
52
|
checkedOutClients.delete(client);
|
|
58
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
59
53
|
return originalRelease.apply(this, args);
|
|
60
54
|
};
|
|
61
55
|
}
|
|
@@ -67,34 +61,26 @@ export function installDbConnectionLeakDiagnosticsIfEnabled() {
|
|
|
67
61
|
if (!dreamDebugEnabled)
|
|
68
62
|
return;
|
|
69
63
|
enabled = true;
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
71
64
|
const Pool = pg.Pool;
|
|
72
65
|
if (!Pool?.prototype)
|
|
73
66
|
return;
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
75
67
|
if (Pool.prototype[INSTALLED])
|
|
76
68
|
return;
|
|
77
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
78
69
|
Pool.prototype[INSTALLED] = true;
|
|
79
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|
80
70
|
const originalConnect = Pool.prototype.connect;
|
|
81
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
82
71
|
Pool.prototype.connect = function patchedConnect(cb) {
|
|
83
72
|
const acquireStack = new Error('db connection acquired here').stack;
|
|
84
73
|
if (typeof cb === 'function') {
|
|
85
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
86
74
|
return originalConnect.call(this, (err, client, release) => {
|
|
87
75
|
track(client, acquireStack);
|
|
88
76
|
// `track()` replaces `client.release` with an untracking wrapper.
|
|
89
77
|
// Callback-style users call the 3rd `release` arg, which pg captured
|
|
90
78
|
// *before* that swap — so hand them the wrapped one instead, or a
|
|
91
79
|
// 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
80
|
const wrappedRelease = client ? client.release : release;
|
|
94
81
|
cb(err, client, wrappedRelease);
|
|
95
82
|
});
|
|
96
83
|
}
|
|
97
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
98
84
|
return originalConnect.call(this).then((client) => {
|
|
99
85
|
track(client, acquireStack);
|
|
100
86
|
return client;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sql } from 'kysely';
|
|
2
|
+
import InternalEncrypt from '../../encrypt/InternalEncrypt.js';
|
|
2
3
|
export default class DreamMigrationHelpers {
|
|
3
4
|
/**
|
|
4
5
|
* Rename a table and its associated primary key index and sequence.
|
|
@@ -195,6 +196,136 @@ export default class DreamMigrationHelpers {
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Convert an existing plaintext column into the encrypted-backed form expected by
|
|
201
|
+
* the `@Encrypted` decorator.
|
|
202
|
+
*
|
|
203
|
+
* This renames `column` to `encrypted_<column>`, widens it to `text`, and rewrites
|
|
204
|
+
* every non-null value with the AES-GCM ciphertext produced by the exact same code
|
|
205
|
+
* path the decorator's setter uses (`InternalEncrypt.encryptColumn`). After it runs,
|
|
206
|
+
* the column holds real ciphertext that the decorator's getter can decrypt — which is
|
|
207
|
+
* what a bare column rename does **not** do (renaming a plaintext column to
|
|
208
|
+
* `encrypted_<column>` and decorating the property leaves plaintext in the column, so
|
|
209
|
+
* the getter throws `DecryptionError`).
|
|
210
|
+
*
|
|
211
|
+
* The encryption key and algorithm come from the application's encryption config
|
|
212
|
+
* (`DreamApp` `encryption.columns.current`); if encryption is not configured this
|
|
213
|
+
* throws `MissingColumnEncryptionOpts`. Null values are left null.
|
|
214
|
+
*
|
|
215
|
+
* Intended for text/string columns (the common case is text -> encrypted text). The
|
|
216
|
+
* column is widened to `text` before reading, so values are encrypted as their text
|
|
217
|
+
* form; the inverse `decryptColumn` can restore a non-text type via its `columnType`
|
|
218
|
+
* option.
|
|
219
|
+
*
|
|
220
|
+
* ```ts
|
|
221
|
+
* // plaintext `phone` column -> encrypted `encrypted_phone` text column
|
|
222
|
+
* await DreamMigrationHelpers.encryptColumn(db, { table: 'users', column: 'phone' })
|
|
223
|
+
* ```
|
|
224
|
+
*
|
|
225
|
+
* **Rewrites rows one at a time** (each value needs a fresh random IV computed in Node,
|
|
226
|
+
* so the rewrite cannot be a single SQL `UPDATE`), reading them in keyset batches of
|
|
227
|
+
* `batchSize` to bound memory. It still holds the table for the migration's duration; on
|
|
228
|
+
* very large tables do not use this helper — write your own batched / online migration.
|
|
229
|
+
*
|
|
230
|
+
* **Drop any index on the column first.** Per-row updates pay index-maintenance cost on
|
|
231
|
+
* every write, and an index over ciphertext is useless anyway (encrypted values are not
|
|
232
|
+
* queryable). Remove the index before this migration and do not re-add it.
|
|
233
|
+
*
|
|
234
|
+
* @param db - The Kysely database object passed into the migration up/down function
|
|
235
|
+
* @param options - Configuration options
|
|
236
|
+
* @param options.table - The name of the table
|
|
237
|
+
* @param options.column - The current (plaintext) column name
|
|
238
|
+
* @param options.encryptedColumnName - The target encrypted column name. Defaults to `encrypted_<column>`, matching the `@Encrypted` decorator's default; pass this when the decorator was given a custom encrypted column name.
|
|
239
|
+
* @param options.primaryKey - The primary key column used to keyset-paginate and target each row's update. Defaults to `id`.
|
|
240
|
+
* @param options.batchSize - How many rows to read per batch. Defaults to `1000`.
|
|
241
|
+
*/
|
|
242
|
+
static async encryptColumn(db, { table, column, encryptedColumnName = `encrypted_${column}`, primaryKey = 'id', batchSize = 1000, }) {
|
|
243
|
+
await db.schema.alterTable(table).renameColumn(column, encryptedColumnName).execute();
|
|
244
|
+
await db.schema
|
|
245
|
+
.alterTable(table)
|
|
246
|
+
.alterColumn(encryptedColumnName, col => col.setDataType('text'))
|
|
247
|
+
.execute();
|
|
248
|
+
await this.transformColumnInBatches(db, { table, column: encryptedColumnName, primaryKey, batchSize }, value => InternalEncrypt.encryptColumn(value));
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Inverse of {@link DreamMigrationHelpers.encryptColumn}: decrypt an
|
|
252
|
+
* `encrypted_<column>` column back to plaintext and rename it to `column`.
|
|
253
|
+
*
|
|
254
|
+
* Every non-null value is decrypted with the same path the decorator's getter uses
|
|
255
|
+
* (`InternalEncrypt.decryptColumn`, which honors both the `current` and `legacy`
|
|
256
|
+
* encryption keys), then the column is renamed back. Called with the same `table` and
|
|
257
|
+
* `column`, this exactly reverses `encryptColumn`.
|
|
258
|
+
*
|
|
259
|
+
* By default the column is left as `text`, because the original column type cannot be
|
|
260
|
+
* recovered from the encrypted state. Pass `columnType` to restore a specific type
|
|
261
|
+
* (e.g. `'integer'`); the conversion runs `ALTER COLUMN ... TYPE <columnType> USING
|
|
262
|
+
* <column>::<columnType>`.
|
|
263
|
+
*
|
|
264
|
+
* ```ts
|
|
265
|
+
* await DreamMigrationHelpers.decryptColumn(db, { table: 'users', column: 'phone' })
|
|
266
|
+
*
|
|
267
|
+
* // restore the original column type as part of the inverse
|
|
268
|
+
* await DreamMigrationHelpers.decryptColumn(db, { table: 'users', column: 'age', columnType: 'integer' })
|
|
269
|
+
* ```
|
|
270
|
+
*
|
|
271
|
+
* The same per-row, batched, table-locking caveat as `encryptColumn` applies.
|
|
272
|
+
*
|
|
273
|
+
* @param db - The Kysely database object passed into the migration up/down function
|
|
274
|
+
* @param options - Configuration options
|
|
275
|
+
* @param options.table - The name of the table
|
|
276
|
+
* @param options.column - The target (plaintext) column name to rename back to
|
|
277
|
+
* @param options.encryptedColumnName - The current encrypted column name. Defaults to `encrypted_<column>`.
|
|
278
|
+
* @param options.primaryKey - The primary key column used to keyset-paginate and target each row's update. Defaults to `id`.
|
|
279
|
+
* @param options.batchSize - How many rows to read per batch. Defaults to `1000`.
|
|
280
|
+
* @param options.columnType - When provided, the restored column is converted to this type. When omitted, the column is left as `text`.
|
|
281
|
+
*/
|
|
282
|
+
static async decryptColumn(db, { table, column, encryptedColumnName = `encrypted_${column}`, primaryKey = 'id', batchSize = 1000, columnType, }) {
|
|
283
|
+
await this.transformColumnInBatches(db, { table, column: encryptedColumnName, primaryKey, batchSize }, value => InternalEncrypt.decryptColumn(value));
|
|
284
|
+
await db.schema.alterTable(table).renameColumn(encryptedColumnName, column).execute();
|
|
285
|
+
if (columnType !== undefined) {
|
|
286
|
+
await sql `
|
|
287
|
+
ALTER TABLE ${sql.table(table)}
|
|
288
|
+
ALTER COLUMN ${sql.ref(column)}
|
|
289
|
+
TYPE ${sql.raw(columnType)}
|
|
290
|
+
USING ${sql.ref(column)}::${sql.raw(columnType)};
|
|
291
|
+
`.execute(db);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Walk every non-null value of `column` in keyset batches of `batchSize`, applying
|
|
296
|
+
* `transform` in Node and writing the result back one row at a time. Keyset pagination
|
|
297
|
+
* on `primaryKey` (`WHERE pk > last ORDER BY pk`) bounds memory to a single batch and
|
|
298
|
+
* guarantees forward progress, so a row is never read — or transformed — twice (which
|
|
299
|
+
* matters because the transform is not idempotent: re-encrypting ciphertext would
|
|
300
|
+
* double-encrypt, and re-decrypting plaintext would throw).
|
|
301
|
+
*
|
|
302
|
+
* Reads alias the selected columns to fixed keys via `sql.ref(...).as(...)` so a
|
|
303
|
+
* `CamelCasePlugin` on the connection cannot rename the result keys.
|
|
304
|
+
*/
|
|
305
|
+
static async transformColumnInBatches(db, { table, column, primaryKey, batchSize, }, transform) {
|
|
306
|
+
let lastPrimaryKey;
|
|
307
|
+
for (;;) {
|
|
308
|
+
let query = db
|
|
309
|
+
.selectFrom(table)
|
|
310
|
+
.select([sql.ref(primaryKey).as('pk'), sql.ref(column).as('val')])
|
|
311
|
+
.where(column, 'is not', null)
|
|
312
|
+
.orderBy(primaryKey)
|
|
313
|
+
.limit(batchSize);
|
|
314
|
+
if (lastPrimaryKey !== undefined)
|
|
315
|
+
query = query.where(primaryKey, '>', lastPrimaryKey);
|
|
316
|
+
const rows = await query.execute();
|
|
317
|
+
if (rows.length === 0)
|
|
318
|
+
break;
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
await db
|
|
321
|
+
.updateTable(table)
|
|
322
|
+
.set({ [column]: transform(row.val) })
|
|
323
|
+
.where(primaryKey, '=', row.pk)
|
|
324
|
+
.execute();
|
|
325
|
+
lastPrimaryKey = row.pk;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
198
329
|
}
|
|
199
330
|
async function getEnumValues(db, enumName) {
|
|
200
331
|
const response = await sql `SELECT unnest(enum_range(NULL::${sql.raw(enumName)}))`.execute(db);
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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]',
|