@rvoh/dream 2.20.0 → 2.22.0
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/Dream.js +3 -0
- package/dist/cjs/src/cli/index.js +7 -4
- package/dist/cjs/src/dream/DreamClassTransactionBuilder.js +3 -0
- package/dist/cjs/src/dream/Query.js +203 -4
- package/dist/cjs/src/dream/QueryDriver/Base.js +26 -0
- package/dist/cjs/src/dream/QueryDriver/Kysely.js +46 -4
- package/dist/cjs/src/dream/QueryDriver/Postgres.js +21 -0
- package/dist/cjs/src/dream/internal/destroyAssociatedRecords.js +76 -4
- package/dist/cjs/src/dream/internal/destroyDream.js +6 -7
- package/dist/cjs/src/errors/InvalidBatchSize.js +19 -0
- package/dist/cjs/src/errors/NoColumnsToAlterMigration.js +17 -0
- package/dist/cjs/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
- package/dist/cjs/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
- package/dist/cjs/src/errors/UnparseableMigrationColumn.js +16 -0
- package/dist/cjs/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
- package/dist/cjs/src/helpers/cli/generateMigration.js +20 -3
- package/dist/cjs/src/helpers/cli/generateMigrationContent.js +74 -9
- package/dist/cjs/src/ops/index.js +19 -0
- package/dist/esm/src/Dream.js +3 -0
- package/dist/esm/src/cli/index.js +7 -4
- package/dist/esm/src/dream/DreamClassTransactionBuilder.js +3 -0
- package/dist/esm/src/dream/Query.js +203 -4
- package/dist/esm/src/dream/QueryDriver/Base.js +26 -0
- package/dist/esm/src/dream/QueryDriver/Kysely.js +46 -4
- package/dist/esm/src/dream/QueryDriver/Postgres.js +21 -0
- package/dist/esm/src/dream/internal/destroyAssociatedRecords.js +76 -4
- package/dist/esm/src/dream/internal/destroyDream.js +6 -7
- package/dist/esm/src/errors/InvalidBatchSize.js +19 -0
- package/dist/esm/src/errors/NoColumnsToAlterMigration.js +17 -0
- package/dist/esm/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
- package/dist/esm/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
- package/dist/esm/src/errors/UnparseableMigrationColumn.js +16 -0
- package/dist/esm/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
- package/dist/esm/src/helpers/cli/generateMigration.js +20 -3
- package/dist/esm/src/helpers/cli/generateMigrationContent.js +74 -9
- package/dist/esm/src/ops/index.js +19 -0
- package/dist/types/src/Dream.d.ts +3 -0
- package/dist/types/src/cli/index.d.ts +1 -1
- package/dist/types/src/dream/DreamClassTransactionBuilder.d.ts +3 -0
- package/dist/types/src/dream/Query.d.ts +140 -3
- package/dist/types/src/dream/QueryDriver/Base.d.ts +21 -0
- package/dist/types/src/dream/QueryDriver/Kysely.d.ts +1 -0
- package/dist/types/src/dream/QueryDriver/Postgres.d.ts +20 -0
- package/dist/types/src/dream/internal/destroyAssociatedRecords.d.ts +22 -3
- package/dist/types/src/errors/InvalidBatchSize.d.ts +5 -0
- package/dist/types/src/errors/NoColumnsToAlterMigration.d.ts +6 -0
- package/dist/types/src/errors/RowLockIncompatibleWithDistinct.d.ts +3 -0
- package/dist/types/src/errors/RowLockIncompatibleWithJoinLoad.d.ts +3 -0
- package/dist/types/src/errors/UnparseableMigrationColumn.d.ts +5 -0
- package/dist/types/src/errors/ops/ArrayEqualityRequiresArrayColumn.d.ts +8 -0
- package/dist/types/src/helpers/cli/generateMigrationContent.d.ts +11 -1
- package/dist/types/src/ops/index.d.ts +19 -0
- package/dist/types/src/types/associations/shared.d.ts +2 -2
- package/dist/types/src/types/dream.d.ts +8 -0
- package/dist/types/src/types/types/associations/shared.ts +84 -55
- package/dist/types/src/types/types/dream.ts +30 -0
- package/docs/assets/hierarchy.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/db.DreamMigrationHelpers.html +11 -11
- package/docs/classes/db.KyselyQueryDriver.html +55 -41
- package/docs/classes/db.PostgresQueryDriver.html +55 -42
- package/docs/classes/db.QueryDriverBase.html +54 -40
- 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 +127 -125
- package/docs/classes/index.DreamApp.html +10 -10
- package/docs/classes/index.DreamTransaction.html +2 -2
- package/docs/classes/index.Env.html +2 -2
- package/docs/classes/index.Query.html +161 -71
- 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 +38 -23
- 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 +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default class RowLockIncompatibleWithDistinct extends Error {
|
|
2
|
+
get message() {
|
|
3
|
+
// `SELECT DISTINCT ON (...) ... FOR UPDATE` is rejected by the database
|
|
4
|
+
// ("FOR UPDATE is not allowed with DISTINCT clause"), so fail here, with a
|
|
5
|
+
// message that names the two incompatible parts, rather than surfacing a
|
|
6
|
+
// raw driver error from inside a per-batch transaction.
|
|
7
|
+
return `
|
|
8
|
+
An exclusive row lock was requested on a query that also calls \`distinct\`.
|
|
9
|
+
|
|
10
|
+
A locked read cannot be combined with DISTINCT. Drop the \`distinct\` call, or
|
|
11
|
+
drop the lock.
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default class RowLockIncompatibleWithJoinLoad extends Error {
|
|
2
|
+
get message() {
|
|
3
|
+
// A join load builds its own select statement (one row per joined
|
|
4
|
+
// association row, later collapsed in memory), which cannot carry the
|
|
5
|
+
// Query's row lock. Rather than silently performing an unlocked read and
|
|
6
|
+
// dropping the compare-and-set guarantee the caller asked for, refuse.
|
|
7
|
+
return `
|
|
8
|
+
An exclusive row lock was requested on a query that also uses leftJoinPreload.
|
|
9
|
+
|
|
10
|
+
A join-loaded query cannot be locked. Use \`preload\` instead of
|
|
11
|
+
\`leftJoinPreload\`, or drop the lock.
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export default class UnparseableMigrationColumn extends Error {
|
|
2
|
+
declaration;
|
|
3
|
+
constructor(declaration) {
|
|
4
|
+
super();
|
|
5
|
+
this.declaration = declaration;
|
|
6
|
+
}
|
|
7
|
+
get message() {
|
|
8
|
+
return `
|
|
9
|
+
could not determine a column type for the following migration column declaration:
|
|
10
|
+
${this.declaration}
|
|
11
|
+
|
|
12
|
+
Make sure the column includes a type, e.g.:
|
|
13
|
+
${this.declaration.split(':')[0]}:string
|
|
14
|
+
`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export default class ArrayEqualityRequiresArrayColumn extends Error {
|
|
2
|
+
dreamClass;
|
|
3
|
+
column;
|
|
4
|
+
operator;
|
|
5
|
+
constructor(dreamClass, column, operator) {
|
|
6
|
+
super();
|
|
7
|
+
this.dreamClass = dreamClass;
|
|
8
|
+
this.column = column;
|
|
9
|
+
this.operator = operator;
|
|
10
|
+
}
|
|
11
|
+
get message() {
|
|
12
|
+
return `
|
|
13
|
+
Attempting to compare an array for equality against a column that is not an
|
|
14
|
+
array in the database:
|
|
15
|
+
|
|
16
|
+
${this.dreamClass.sanitizedName}#${this.column} (operator: ${this.operator})
|
|
17
|
+
|
|
18
|
+
Whole-array equality (\`=\`, \`!=\`, \`<>\`) is only meaningful for array columns.
|
|
19
|
+
If you meant "is this column one of these values", use \`ops.in([...])\`
|
|
20
|
+
(or a bare array) instead.
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import pluralize from 'pluralize-esm';
|
|
3
|
-
import generateMigrationContent from '../cli/generateMigrationContent.js';
|
|
3
|
+
import generateMigrationContent, { MIGRATION_TABLE_NAME_PLACEHOLDER, } from '../cli/generateMigrationContent.js';
|
|
4
4
|
import primaryKeyType from '../db/primaryKeyType.js';
|
|
5
5
|
import hyphenize from '../hyphenize.js';
|
|
6
6
|
import migrationVersion from '../migrationVersion.js';
|
|
@@ -9,6 +9,18 @@ import dreamPath from '../path/dreamPath.js';
|
|
|
9
9
|
import snakeify from '../snakeify.js';
|
|
10
10
|
import generateStiMigrationContent from './generateStiMigrationContent.js';
|
|
11
11
|
import writeGeneratedFile from './writeGeneratedFile.js';
|
|
12
|
+
/**
|
|
13
|
+
* Equivalent to `migrationName.match(new RegExp(`${marker}(.+)$`))?.[1]`, but
|
|
14
|
+
* without a regex: CodeQL flags `.+` anchored to `$` as a polynomial-ReDoS
|
|
15
|
+
* shape, and a plain `indexOf`/`slice` does the same leftmost-match-to-end
|
|
16
|
+
* job with no backtracking risk.
|
|
17
|
+
*/
|
|
18
|
+
function tableNameFromSuffix(migrationName, marker) {
|
|
19
|
+
const markerIndex = migrationName.indexOf(marker);
|
|
20
|
+
if (markerIndex === -1)
|
|
21
|
+
return undefined;
|
|
22
|
+
return migrationName.slice(markerIndex + marker.length) || undefined;
|
|
23
|
+
}
|
|
12
24
|
export default async function generateMigration({ migrationName, columnsWithTypes, connectionName, fullyQualifiedModelName, fullyQualifiedParentName, tableName: explicitTableName, modelClassName, softDelete = false, }) {
|
|
13
25
|
const migrationsBasePath = connectionName === 'default'
|
|
14
26
|
? path.join(dreamPath('db'), 'migrations')
|
|
@@ -33,12 +45,17 @@ export default async function generateMigration({ migrationName, columnsWithType
|
|
|
33
45
|
});
|
|
34
46
|
}
|
|
35
47
|
else {
|
|
36
|
-
const
|
|
48
|
+
const toTableName = tableNameFromSuffix(migrationName, '-to-');
|
|
49
|
+
const fromTableName = toTableName
|
|
50
|
+
? undefined
|
|
51
|
+
: tableNameFromSuffix(migrationName, '-from-');
|
|
52
|
+
const tableName = toTableName || fromTableName;
|
|
37
53
|
content = generateMigrationContent({
|
|
38
|
-
table: tableName ? pluralize(snakeify(tableName)) :
|
|
54
|
+
table: tableName ? pluralize(snakeify(tableName)) : MIGRATION_TABLE_NAME_PLACEHOLDER,
|
|
39
55
|
columnsWithTypes,
|
|
40
56
|
primaryKeyType: primaryKeyType(connectionName),
|
|
41
57
|
createOrAlter: 'alter',
|
|
58
|
+
alterDirection: fromTableName ? 'remove' : 'add',
|
|
42
59
|
});
|
|
43
60
|
}
|
|
44
61
|
await writeGeneratedFile({
|
|
@@ -2,11 +2,19 @@ import pluralize from 'pluralize-esm';
|
|
|
2
2
|
import lookupModelByGlobalName from '../../dream-app/helpers/lookupModelByGlobalName.js';
|
|
3
3
|
import Query from '../../dream/Query.js';
|
|
4
4
|
import InvalidDecimalFieldPassedToGenerator from '../../errors/InvalidDecimalFieldPassedToGenerator.js';
|
|
5
|
+
import NoColumnsToAlterMigration from '../../errors/NoColumnsToAlterMigration.js';
|
|
6
|
+
import UnparseableMigrationColumn from '../../errors/UnparseableMigrationColumn.js';
|
|
5
7
|
import camelize from '../camelize.js';
|
|
6
8
|
import compact from '../compact.js';
|
|
7
9
|
import globalClassNameFromFullyQualifiedModelName from '../globalClassNameFromFullyQualifiedModelName.js';
|
|
8
10
|
import snakeify from '../snakeify.js';
|
|
9
11
|
import standardizeFullyQualifiedModelName from '../standardizeFullyQualifiedModelName.js';
|
|
12
|
+
// Sentinel table name used by generateMigration.ts when a standalone
|
|
13
|
+
// migration name matches neither a `-to-<table>` nor `-from-<table>` suffix.
|
|
14
|
+
// That path intentionally produces a stub the user is expected to hand-edit
|
|
15
|
+
// (e.g. an index-only migration with no column operations at all), so it's
|
|
16
|
+
// exempt from the "no valid columns" check below.
|
|
17
|
+
export const MIGRATION_TABLE_NAME_PLACEHOLDER = '<table-name>';
|
|
10
18
|
const STI_TYPE_COLUMN_NAME = 'type';
|
|
11
19
|
// deleted_at is deliberately NOT in this list: the SoftDelete default scope's
|
|
12
20
|
// `WHERE deleted_at IS NULL` is unselective on healthy tables, Dream internals
|
|
@@ -15,8 +23,9 @@ const STI_TYPE_COLUMN_NAME = 'type';
|
|
|
15
23
|
// default. See spec/unit/cli/generateMigrationContent.spec.ts
|
|
16
24
|
// ("deleted_at is deliberately NOT indexed") and the CHANGELOG.
|
|
17
25
|
const COLUMNS_TO_INDEX = [STI_TYPE_COLUMN_NAME];
|
|
18
|
-
export default function generateMigrationContent({ connectionName = 'default', table, columnsWithTypes = [], primaryKeyType = 'bigserial', createOrAlter = 'create', stiChildClassName, softDelete = false, } = {}) {
|
|
26
|
+
export default function generateMigrationContent({ connectionName = 'default', table, columnsWithTypes = [], primaryKeyType = 'bigserial', createOrAlter = 'create', alterDirection = 'add', stiChildClassName, softDelete = false, } = {}) {
|
|
19
27
|
const altering = createOrAlter === 'alter';
|
|
28
|
+
const removingInUp = altering && alterDirection === 'remove';
|
|
20
29
|
let requireCitextExtension = false;
|
|
21
30
|
const checkConstraints = [];
|
|
22
31
|
// When creating a new table, we automatically emit `created_at`,
|
|
@@ -65,7 +74,17 @@ export default function generateMigrationContent({ connectionName = 'default', t
|
|
|
65
74
|
// when creating a migration for an STI child, we don't want to include notNull;
|
|
66
75
|
// instead, we'll add a check constraint that uses the STI child class name
|
|
67
76
|
const sqlAttributeType = getAttributeType(attributeType, descriptors);
|
|
68
|
-
if (attributeType === undefined
|
|
77
|
+
if (attributeType === undefined) {
|
|
78
|
+
// In alter mode (both `-to-`/add and `-from-`/remove), a column whose
|
|
79
|
+
// type can't be resolved (e.g. a mistyped declaration with no `:type`
|
|
80
|
+
// segment) must not silently vanish from the generated migration —
|
|
81
|
+
// that's especially dangerous for `-from-` migrations, whose whole
|
|
82
|
+
// premise is describing removed columns so `down` can restore them.
|
|
83
|
+
if (altering)
|
|
84
|
+
throw new UnparseableMigrationColumn(attributeDeclaration);
|
|
85
|
+
return acc;
|
|
86
|
+
}
|
|
87
|
+
if (['hasone', 'hasmany'].includes(processedAttrType))
|
|
69
88
|
return acc;
|
|
70
89
|
if (attributeType === 'citext')
|
|
71
90
|
requireCitextExtension = true;
|
|
@@ -169,6 +188,25 @@ export async function down(db: Kysely<any>): Promise<void> {
|
|
|
169
188
|
}\
|
|
170
189
|
`;
|
|
171
190
|
}
|
|
191
|
+
// An alter migration (either `-to-`/add or `-from-`/remove) with no valid
|
|
192
|
+
// columns to add/drop would otherwise emit a bare `.alterTable(...).execute()`
|
|
193
|
+
// with no column operation at all, which Postgres rejects at migration-run
|
|
194
|
+
// time rather than generation time. Fail loudly here instead.
|
|
195
|
+
//
|
|
196
|
+
// This guard is scoped to the standalone `g:migration` `-to-`/`-from-` flow
|
|
197
|
+
// only (`stiChildClassName` is unset there). `g:sti-child` legitimately
|
|
198
|
+
// calls this function in alter mode with zero `columnsWithTypes` — a
|
|
199
|
+
// zero-attribute STI child is a documented use case (see `g:sti-child`'s
|
|
200
|
+
// help example in src/cli/index.ts), and `generateStiMigrationContent`
|
|
201
|
+
// handles the type-column/check-constraint machinery separately from the
|
|
202
|
+
// `columnDefs` this check inspects, so an empty `columnDefs` there is
|
|
203
|
+
// expected, not a sign of a mistyped/unparseable column list.
|
|
204
|
+
if (altering &&
|
|
205
|
+
columnDefs.length === 0 &&
|
|
206
|
+
table !== MIGRATION_TABLE_NAME_PLACEHOLDER &&
|
|
207
|
+
!stiChildClassName) {
|
|
208
|
+
throw new NoColumnsToAlterMigration(table, alterDirection);
|
|
209
|
+
}
|
|
172
210
|
const citextExtension = requireCitextExtension
|
|
173
211
|
? ` await DreamMigrationHelpers.createExtension(db, 'citext')\n\n`
|
|
174
212
|
: '';
|
|
@@ -179,7 +217,22 @@ export async function down(db: Kysely<any>): Promise<void> {
|
|
|
179
217
|
const newlineIndent = '\n ';
|
|
180
218
|
const newlineDoubleIndent = '\n ';
|
|
181
219
|
const doubleNewlineIndent = '\n\n ';
|
|
182
|
-
|
|
220
|
+
// For a `-from-` migration (`alterDirection: 'remove'`), `down` re-adds the
|
|
221
|
+
// column(s) that `up` removed. The generator has no way to know the
|
|
222
|
+
// column's original default, so a non-optional (`.notNull()`) column with
|
|
223
|
+
// no default emitted here will fail at migration-run time against a table
|
|
224
|
+
// that already has rows. We can't fix that without a lot more heavy
|
|
225
|
+
// lifting (and the risk that comes with it), so flag it with a comment
|
|
226
|
+
// instead of silently generating a `down` that's likely to break.
|
|
227
|
+
const NO_KNOWN_DEFAULT_COMMENT = "// NOTE: the generator doesn't know this column's original default; this addColumn will fail against a table with existing rows unless you add a default by hand.";
|
|
228
|
+
const finalColumnDefs = removingInUp
|
|
229
|
+
? columnDefs.map(def => def.includes('.notNull()') && !def.includes('.defaultTo(')
|
|
230
|
+
? `${NO_KNOWN_DEFAULT_COMMENT}\n ${def}`
|
|
231
|
+
: def)
|
|
232
|
+
: columnDefs;
|
|
233
|
+
const columnDefLines = finalColumnDefs.length
|
|
234
|
+
? newlineDoubleIndent + finalColumnDefs.join(newlineDoubleIndent)
|
|
235
|
+
: '';
|
|
183
236
|
const columnDropLines = columnDrops.length
|
|
184
237
|
? newlineDoubleIndent + columnDrops.join(newlineDoubleIndent) + newlineDoubleIndent
|
|
185
238
|
: '';
|
|
@@ -190,21 +243,33 @@ export async function down(db: Kysely<any>): Promise<void> {
|
|
|
190
243
|
newlineDoubleIndent +
|
|
191
244
|
".addColumn('updated_at', 'timestamp', col => col.notNull())" +
|
|
192
245
|
(emitDeletedAtColumn ? newlineDoubleIndent + ".addColumn('deleted_at', 'timestamp')" : '');
|
|
246
|
+
// For `alterDirection: 'remove'` (a `-from-` migration), the enum *type*
|
|
247
|
+
// itself must never be created or dropped here, even when the column
|
|
248
|
+
// declaration includes inline enum values — enums are frequently reused
|
|
249
|
+
// across columns/tables, so `up` must drop only the column (no `dropType`)
|
|
250
|
+
// and `down` must re-add only the column (no `createType`), identical to
|
|
251
|
+
// referencing an existing enum by name with no values.
|
|
252
|
+
const enumCreateStatements = removingInUp ? '' : generateEnumStatements(columnsWithTypes);
|
|
253
|
+
const enumDropStatements = removingInUp ? '' : generateEnumDropStatements(columnsWithTypes);
|
|
254
|
+
const addColumnsBody = `${citextExtension}${enumCreateStatements} await db.schema
|
|
255
|
+
.${altering ? 'alterTable' : 'createTable'}('${table}')${altering ? '' : newlineDoubleIndent + generateIdStr({ primaryKeyType })}${columnDefLines}${timestampColumnLines}
|
|
256
|
+
.execute()${indexDefs.length ? `\n${newlineIndent}` : ''}${indexDefs.join(doubleNewlineIndent)}${checkConstraints.join('')}`;
|
|
257
|
+
const removeColumnsBody = ` ${altering
|
|
258
|
+
? `await db.schema${newlineDoubleIndent}.alterTable('${table}')${columnDropLines}.execute()`
|
|
259
|
+
: `await db.schema.dropTable('${table}').execute()`}${enumDropStatements}`;
|
|
260
|
+
const upBody = removingInUp ? removeColumnsBody : addColumnsBody;
|
|
261
|
+
const downBody = removingInUp ? addColumnsBody : removeColumnsBody;
|
|
193
262
|
return `\
|
|
194
263
|
${dreamDbImports.length ? `import { ${dreamDbImports.join(', ')} } from '@rvoh/dream/db'\n` : ''}import { ${kyselyImports.join(', ')} } from 'kysely'
|
|
195
264
|
|
|
196
265
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
266
|
export async function up(db: Kysely<any>): Promise<void> {
|
|
198
|
-
${
|
|
199
|
-
.${altering ? 'alterTable' : 'createTable'}('${table}')${altering ? '' : newlineDoubleIndent + generateIdStr({ primaryKeyType })}${columnDefLines}${timestampColumnLines}
|
|
200
|
-
.execute()${indexDefs.length ? `\n${newlineIndent}` : ''}${indexDefs.join(doubleNewlineIndent)}${checkConstraints.join('')}
|
|
267
|
+
${upBody}
|
|
201
268
|
}
|
|
202
269
|
|
|
203
270
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
204
271
|
export async function down(db: Kysely<any>): Promise<void> {
|
|
205
|
-
|
|
206
|
-
? `await db.schema${newlineDoubleIndent}.alterTable('${table}')${columnDropLines}.execute()`
|
|
207
|
-
: `await db.schema.dropTable('${table}').execute()`}${generateEnumDropStatements(columnsWithTypes)}
|
|
272
|
+
${downBody}
|
|
208
273
|
}\
|
|
209
274
|
`;
|
|
210
275
|
}
|
|
@@ -117,11 +117,27 @@ const ops = {
|
|
|
117
117
|
/**
|
|
118
118
|
* Creates an `OpsStatement` using the `=` operator for strict equality.
|
|
119
119
|
*
|
|
120
|
+
* **Array columns:** passing an array means literal array equality — the
|
|
121
|
+
* whole array is bound as a single parameter, so the column must match
|
|
122
|
+
* element-for-element, in order. This is the opt-in counterpart to a bare
|
|
123
|
+
* array in a where clause (`Post.where({ tags: ['a', 'b'] })`), which means
|
|
124
|
+
* `IN`, and to `ops.any`, which tests for containment of a single element.
|
|
125
|
+
* Element types are constrained by the column, so an enum array column
|
|
126
|
+
* rejects a string that is not a member of its enum.
|
|
127
|
+
*
|
|
128
|
+
* Note that PostgreSQL defines no equality operator for `json` (as opposed
|
|
129
|
+
* to `jsonb`), so `ops.equal` against a `json[]` column raises at the
|
|
130
|
+
* database.
|
|
131
|
+
*
|
|
120
132
|
* @param equal - The value the column must equal.
|
|
121
133
|
* @returns An `OpsStatement` using the `=` operator.
|
|
122
134
|
*
|
|
123
135
|
* @example
|
|
124
136
|
* User.where({ role: ops.equal('admin') })
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* // matches only rows whose tags column is exactly ['a', 'b'], in that order
|
|
140
|
+
* Post.where({ tags: ops.equal(['a', 'b']) })
|
|
125
141
|
*/
|
|
126
142
|
equal: (equal) => new OpsStatement('=', equal),
|
|
127
143
|
/**
|
|
@@ -260,6 +276,9 @@ const ops = {
|
|
|
260
276
|
/**
|
|
261
277
|
* Creates an `OpsStatement` using the `!=` operator for inequality.
|
|
262
278
|
*
|
|
279
|
+
* **Array columns:** as with `ops.equal`, passing an array compares against
|
|
280
|
+
* the whole array rather than against its elements.
|
|
281
|
+
*
|
|
263
282
|
* @param equal - The value the column must not equal.
|
|
264
283
|
* @returns An `OpsStatement` using the `!=` operator.
|
|
265
284
|
*
|
package/dist/esm/src/Dream.js
CHANGED
|
@@ -1330,6 +1330,9 @@ export default class Dream {
|
|
|
1330
1330
|
* // User{email: 'goodbye@world'}
|
|
1331
1331
|
* ```
|
|
1332
1332
|
*
|
|
1333
|
+
* Records are always visited in ascending primary key order; any `order`
|
|
1334
|
+
* applied to the query is discarded. See {@link Query.findEach} for why.
|
|
1335
|
+
*
|
|
1333
1336
|
* @param cb - The callback to call for each found record
|
|
1334
1337
|
* @param opts - Optional parameters for batch processing
|
|
1335
1338
|
* @param opts.batchSize - the batch size you wish to collect records in. If not provided, it will default to 1000
|
|
@@ -45,6 +45,8 @@ ${INDENT} - timetz
|
|
|
45
45
|
${INDENT} - timetz[]
|
|
46
46
|
${INDENT} - integer
|
|
47
47
|
${INDENT} - integer[]
|
|
48
|
+
${INDENT} - boolean
|
|
49
|
+
${INDENT} - boolean[]
|
|
48
50
|
${INDENT}
|
|
49
51
|
${INDENT} - decimal:
|
|
50
52
|
${INDENT} - decimal[]:
|
|
@@ -153,16 +155,17 @@ ${INDENT} # Add columns to an existing table (suffix with -to-<table_name> for
|
|
|
153
155
|
${INDENT} pnpm psy g:migration add-timezone-to-users timezone:string
|
|
154
156
|
${INDENT} pnpm psy g:migration add-bio-to-users bio:text:optional avatar_url:string:optional
|
|
155
157
|
${INDENT}
|
|
156
|
-
${INDENT} # Remove columns (suffix with -from-<table_name
|
|
157
|
-
${INDENT}
|
|
158
|
+
${INDENT} # Remove columns (suffix with -from-<table_name>; columns are described with
|
|
159
|
+
${INDENT} # their types, same shorthand as -to-, so the rollback (down) can recreate them)
|
|
160
|
+
${INDENT} pnpm psy g:migration remove-legacy-fields-from-posts legacy_status:string
|
|
158
161
|
${INDENT}
|
|
159
162
|
${INDENT} # General schema change (no table suffix — generates empty up/down methods)
|
|
160
163
|
${INDENT} pnpm psy g:migration create-unique-index-on-invitations`)
|
|
161
164
|
.argument('<migrationName>', `Kebab-case name describing the change. End with -to-<table_name> or -from-<table_name> to auto-generate an alterTable scaffold for that table.
|
|
162
165
|
${INDENT}
|
|
163
166
|
${INDENT}Examples:
|
|
164
|
-
${INDENT} add-phone-to-users
|
|
165
|
-
${INDENT} remove-status-from-posts
|
|
167
|
+
${INDENT} add-phone-to-users phone:encrypted # scaffolds alterTable('users', ...)
|
|
168
|
+
${INDENT} remove-status-from-posts status:string # scaffolds alterTable('posts', ...)
|
|
166
169
|
${INDENT} create-join-table-host-places # empty migration (no table suffix match)`)
|
|
167
170
|
.option('--connection-name <connectionName>', 'the database connection to use for this migration. Only needed for multi-database setups; defaults to "default"')
|
|
168
171
|
.argument('[columnsWithTypes...]', columnsWithTypesDescriptionForMigration)
|
|
@@ -421,6 +421,9 @@ export default class DreamClassTransactionBuilder {
|
|
|
421
421
|
* // User{email: 'goodbye@world'}
|
|
422
422
|
* ```
|
|
423
423
|
*
|
|
424
|
+
* Records are always visited in ascending primary key order; any `order`
|
|
425
|
+
* applied to the query is discarded. See {@link Query.findEach} for why.
|
|
426
|
+
*
|
|
424
427
|
* @param cb - The callback to call for each found record
|
|
425
428
|
* @param options.batchSize - The batch size you wish to collect records in. If not provided, it will default to 1000
|
|
426
429
|
* @returns void
|
|
@@ -3,7 +3,9 @@ import DreamApp from '../dream-app/index.js';
|
|
|
3
3
|
import AssociationDeclaredWithoutAssociatedDreamClass from '../errors/associations/AssociationDeclaredWithoutAssociatedDreamClass.js';
|
|
4
4
|
import CannotCallUndestroyOnANonSoftDeleteModel from '../errors/CannotCallUndestroyOnANonSoftDeleteModel.js';
|
|
5
5
|
import CannotPassAdditionalFieldsToPluckEachAfterCallback from '../errors/CannotPassAdditionalFieldsToPluckEachAfterCallback.js';
|
|
6
|
+
import InvalidBatchSize from '../errors/InvalidBatchSize.js';
|
|
6
7
|
import LeftJoinPreloadIncompatibleWithFindEach from '../errors/LeftJoinPreloadIncompatibleWithFindEach.js';
|
|
8
|
+
import RowLockIncompatibleWithDistinct from '../errors/RowLockIncompatibleWithDistinct.js';
|
|
7
9
|
import MissingRequiredCallbackFunctionToPluckEach from '../errors/MissingRequiredCallbackFunctionToPluckEach.js';
|
|
8
10
|
import NoUpdateAllOnJoins from '../errors/NoUpdateAllOnJoins.js';
|
|
9
11
|
import NoUpdateOnAssociationQuery from '../errors/NoUpdateOnAssociationQuery.js';
|
|
@@ -31,6 +33,10 @@ export default class Query {
|
|
|
31
33
|
*/
|
|
32
34
|
static BATCH_SIZES = {
|
|
33
35
|
FIND_EACH: 1000,
|
|
36
|
+
// deliberately small: every record in a locked destroy batch stays
|
|
37
|
+
// row-locked for the whole batch, so an oversized batch blocks unrelated
|
|
38
|
+
// writers. See Query#destroy's `lock` option.
|
|
39
|
+
LOCKED_DESTROY: 10,
|
|
34
40
|
PLUCK_EACH: 10000,
|
|
35
41
|
PLUCK_EACH_THROUGH: 1000,
|
|
36
42
|
};
|
|
@@ -398,13 +404,30 @@ export default class Query {
|
|
|
398
404
|
* records matching the Query have been fetched.
|
|
399
405
|
*
|
|
400
406
|
* ```ts
|
|
401
|
-
* await User.
|
|
407
|
+
* await User.where({ deletedAt: null }).findEach(user => {
|
|
402
408
|
* DreamApp.log(user)
|
|
403
409
|
* })
|
|
404
410
|
* // User{id: 1}
|
|
405
411
|
* // User{id: 2}
|
|
406
412
|
* ```
|
|
407
413
|
*
|
|
414
|
+
* IMPORTANT: `findEach` always iterates in ascending primary key order.
|
|
415
|
+
* Any `order` applied to the Query is discarded; passing one has no effect
|
|
416
|
+
* on the order records are visited in.
|
|
417
|
+
*
|
|
418
|
+
* This is not a limitation that can be lifted. `findEach` guarantees that
|
|
419
|
+
* every matching record is visited exactly once, and it delivers that by
|
|
420
|
+
* paginating on a keyset: each batch selects the next `batchSize` records
|
|
421
|
+
* with a primary key greater than the last record of the previous batch.
|
|
422
|
+
* That guarantee only holds over a stable, unique key. Ordering by a
|
|
423
|
+
* mutable, non-unique column would mean a record whose sort value changed
|
|
424
|
+
* partway through the iteration could be skipped entirely or visited twice.
|
|
425
|
+
* ({@link Query.cursorPaginate} can honor an arbitrary order precisely
|
|
426
|
+
* because a paginated UI tolerates that anomaly; a full traversal cannot.)
|
|
427
|
+
*
|
|
428
|
+
* If you need records processed in a particular order, load them with a
|
|
429
|
+
* regular ordered query instead of `findEach`.
|
|
430
|
+
*
|
|
408
431
|
* @param cb - The callback to call for each found record
|
|
409
432
|
* @param __namedParameters - Options for batch processing
|
|
410
433
|
* @param __namedParameters.batchSize - The batch size you wish to collect records in. If not provided, it will default to 1000
|
|
@@ -1741,21 +1764,109 @@ export default class Query {
|
|
|
1741
1764
|
* // 12
|
|
1742
1765
|
* ```
|
|
1743
1766
|
*
|
|
1767
|
+
* ## Guarded (compare-and-set) destroy
|
|
1768
|
+
*
|
|
1769
|
+
* By default, `destroy` reads the matching records and then destroys each of
|
|
1770
|
+
* them, so a concurrent writer can change a record in between — the record is
|
|
1771
|
+
* destroyed even though it no longer matches the Query that selected it.
|
|
1772
|
+
* Passing `lock: true` closes that window, making `destroy` a compare-and-set
|
|
1773
|
+
* the way `update(..., { skipHooks: true })` already is:
|
|
1774
|
+
*
|
|
1775
|
+
* ```ts
|
|
1776
|
+
* const destroyed = await Order.where({ status: 'pending' }).destroy({ lock: true })
|
|
1777
|
+
* // 0 — someone else moved every one of them out of 'pending' first
|
|
1778
|
+
* ```
|
|
1779
|
+
*
|
|
1780
|
+
* With `lock: true`, records are processed in batches, and each batch runs in
|
|
1781
|
+
* its own transaction which first re-selects that batch's records with an
|
|
1782
|
+
* exclusive row lock and then destroys them before committing. Because the
|
|
1783
|
+
* database re-checks the Query's conditions after acquiring each row lock,
|
|
1784
|
+
* a record a concurrent transaction has already moved out of the Query drops
|
|
1785
|
+
* out of the locked read and is never destroyed. The returned count is the
|
|
1786
|
+
* number of records actually claimed, so a caller can detect that it lost a
|
|
1787
|
+
* race by comparing the count against what it expected.
|
|
1788
|
+
*
|
|
1789
|
+
* Things to know before relying on it:
|
|
1790
|
+
*
|
|
1791
|
+
* - **The guarantee is per batch, not set-wide.** A run spanning more than one
|
|
1792
|
+
* batch can win a race in batch 1 and lose one in batch 2; nothing holds the
|
|
1793
|
+
* whole matched set still for the duration of the run. Sharing one
|
|
1794
|
+
* transaction across every batch — by threading a transaction onto the Query
|
|
1795
|
+
* itself, `Order.query().txn(txn).where({ ... }).destroy({ lock: true })` —
|
|
1796
|
+
* buys two things: the destroys become all-or-nothing (a rollback undoes the
|
|
1797
|
+
* whole run), and the row locks taken by every batch are held until you
|
|
1798
|
+
* commit. It does **not** extend the compare-and-set to the part of the set
|
|
1799
|
+
* the run has not reached yet: under READ COMMITTED each statement takes a
|
|
1800
|
+
* fresh snapshot, so a record moved out of the Query between batch 1 and
|
|
1801
|
+
* batch 2 still drops out of batch 2's locked read. A genuinely set-wide
|
|
1802
|
+
* guarantee needs REPEATABLE READ / SERIALIZABLE, or a single locking
|
|
1803
|
+
* statement over the whole set.
|
|
1804
|
+
* - **Only a transaction carried by the Query is shared.** Merely wrapping the
|
|
1805
|
+
* call in `Model.transaction(...)` does not thread that transaction onto the
|
|
1806
|
+
* Query — `Dream.transaction` establishes no ambient transaction, it only
|
|
1807
|
+
* hands a `txn` to your callback — so each batch would open its own
|
|
1808
|
+
* transaction on a different pooled connection. Worse, if the surrounding
|
|
1809
|
+
* transaction has already written to any of the matched rows, each batch's
|
|
1810
|
+
* locked read blocks on locks the surrounding transaction holds while that
|
|
1811
|
+
* transaction awaits the destroy: a cycle across two connections, which
|
|
1812
|
+
* Postgres's deadlock detector cannot see, so it hangs rather than aborting.
|
|
1813
|
+
* Always pass the transaction with `.txn(txn)`.
|
|
1814
|
+
* - **Behavior above READ COMMITTED differs.** The drop-out-of-the-result
|
|
1815
|
+
* behavior described above is READ COMMITTED (Postgres's default). Under
|
|
1816
|
+
* REPEATABLE READ or SERIALIZABLE, the same race raises a serialization
|
|
1817
|
+
* failure instead of silently returning a lower count. Both are safe; only
|
|
1818
|
+
* one of them is quiet.
|
|
1819
|
+
* - **`batchSize` defaults to 10 here**, not `findEach`'s 1000, and the
|
|
1820
|
+
* default is deliberately small. Every row in a batch stays locked for the
|
|
1821
|
+
* whole batch, including the time spent running each record's hooks and
|
|
1822
|
+
* `dependent: 'destroy'` cascade, so an oversized batch blocks unrelated
|
|
1823
|
+
* writers touching those rows and surfaces as timeouts somewhere else in the
|
|
1824
|
+
* application, which is very hard to trace back to this destroy. Too small a
|
|
1825
|
+
* batch, by contrast, only slows down this call, which you can see and fix.
|
|
1826
|
+
* Lower it further for models with deep `dependent: 'destroy'` cascades or
|
|
1827
|
+
* expensive destroy hooks.
|
|
1828
|
+
* - **A batch waits, without bound, for whoever holds the row.** The locked
|
|
1829
|
+
* read is a plain `FOR UPDATE`: if another transaction already holds a lock
|
|
1830
|
+
* on one of the batch's rows, this call blocks until that transaction ends,
|
|
1831
|
+
* holding its own transaction and its pooled connection open the whole
|
|
1832
|
+
* time. Dream sets no `lock_timeout` or `statement_timeout`, so a
|
|
1833
|
+
* long-running writer on the other side stalls the run indefinitely and, at
|
|
1834
|
+
* scale, ties up connections. If that is a risk in your application, set a
|
|
1835
|
+
* `lock_timeout` on the connection so a stuck batch fails instead of
|
|
1836
|
+
* hanging.
|
|
1837
|
+
* - **If you are destroying a large set and do not need compare-and-set, do
|
|
1838
|
+
* not pass `lock`.** Plain `destroy()` (hooks and cascade, no locking) or
|
|
1839
|
+
* {@link Query.delete | delete} (a single statement, no hooks or cascade)
|
|
1840
|
+
* are the right tools for bulk removal.
|
|
1841
|
+
*
|
|
1744
1842
|
* @param __namedParameters - Options for destroying the instance
|
|
1745
1843
|
* @param __namedParameters.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
|
|
1746
1844
|
* @param __namedParameters.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
|
|
1845
|
+
* @param __namedParameters.lock - If true, each batch is re-selected with an exclusive row lock inside its own transaction before being destroyed, making the destroy a compare-and-set. Defaults to false
|
|
1846
|
+
* @param __namedParameters.batchSize - The number of records to process per batch. Must be a positive integer. Defaults to 10 when `lock` is true, and to 1000 otherwise
|
|
1747
1847
|
* @param __namedParameters.bypassAllDefaultScopes - If true, bypasses all default scopes when cascade destroying. Defaults to false
|
|
1748
1848
|
* @param __namedParameters.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
|
|
1749
1849
|
* @returns The number of records that were removed
|
|
1850
|
+
* @throws InvalidBatchSize if `batchSize` is not a positive integer
|
|
1750
1851
|
*/
|
|
1751
|
-
async destroy({ skipHooks, cascade, } = {}) {
|
|
1852
|
+
async destroy({ skipHooks, cascade, lock, batchSize, } = {}) {
|
|
1752
1853
|
let counter = 0;
|
|
1854
|
+
// resolve the default before validating: `??` would let an explicit 0
|
|
1855
|
+
// through, and `limit(0)` means "no limit", which would turn a batched
|
|
1856
|
+
// destroy into a single unbounded pass (and, with `lock`, a lock over the
|
|
1857
|
+
// entire matched set)
|
|
1858
|
+
const resolvedBatchSize = batchSize ?? (lock ? Query.BATCH_SIZES.LOCKED_DESTROY : Query.BATCH_SIZES.FIND_EACH);
|
|
1859
|
+
if (!Number.isInteger(resolvedBatchSize) || resolvedBatchSize < 1)
|
|
1860
|
+
throw new InvalidBatchSize(batchSize);
|
|
1753
1861
|
const options = {
|
|
1754
1862
|
bypassAllDefaultScopes: this.bypassAllDefaultScopes,
|
|
1755
1863
|
defaultScopesToBypass: this.defaultScopesToBypass,
|
|
1756
1864
|
skipHooks,
|
|
1757
1865
|
cascade,
|
|
1758
1866
|
};
|
|
1867
|
+
if (lock) {
|
|
1868
|
+
return await this.lockedDestroy(options, resolvedBatchSize);
|
|
1869
|
+
}
|
|
1759
1870
|
await this.findEach(async (result) => {
|
|
1760
1871
|
const subquery = this.dreamTransaction
|
|
1761
1872
|
? result.txn(this.dreamTransaction)
|
|
@@ -1767,9 +1878,94 @@ export default class Query {
|
|
|
1767
1878
|
await subquery.destroy(options);
|
|
1768
1879
|
}
|
|
1769
1880
|
counter++;
|
|
1881
|
+
}, {
|
|
1882
|
+
batchSize: resolvedBatchSize,
|
|
1770
1883
|
});
|
|
1771
1884
|
return counter;
|
|
1772
1885
|
}
|
|
1886
|
+
/**
|
|
1887
|
+
* @internal
|
|
1888
|
+
*
|
|
1889
|
+
* The compare-and-set implementation behind `destroy({ lock: true })`.
|
|
1890
|
+
*
|
|
1891
|
+
* Each batch is handled in two steps inside one transaction:
|
|
1892
|
+
*
|
|
1893
|
+
* 1. an unlocked pluck of the next `batchSize` primary keys, in ascending
|
|
1894
|
+
* primary key order, which establishes the batch's window (its keyset
|
|
1895
|
+
* cursor) and tells us how many records were *attempted*. Only the keys are
|
|
1896
|
+
* read: hydrating and preloading these records would duplicate the work
|
|
1897
|
+
* step 2 does over the same rows; then
|
|
1898
|
+
* 2. a locked re-read restricted to exactly those primary keys, which
|
|
1899
|
+
* re-applies every condition on the Query while acquiring an exclusive row
|
|
1900
|
+
* lock on each row it returns. Records a concurrent transaction has moved
|
|
1901
|
+
* out of the Query since step 1 do not come back, and are the records this
|
|
1902
|
+
* run lost the race for.
|
|
1903
|
+
*
|
|
1904
|
+
* The count comes from step 2's result set, which is why it reports records
|
|
1905
|
+
* actually claimed rather than records attempted. (One thing it does not
|
|
1906
|
+
* account for, because it never has: a `beforeDestroy` hook that sets
|
|
1907
|
+
* `_preventDeletion` leaves the record in place but still counts.)
|
|
1908
|
+
*
|
|
1909
|
+
* The two-step shape is what keeps iteration correct. If the locked read both
|
|
1910
|
+
* claimed rows and advanced the cursor, a batch whose records all lost the
|
|
1911
|
+
* race would come back empty and end the run early, silently skipping every
|
|
1912
|
+
* record after it. Advancing the cursor from step 1 instead means losing a
|
|
1913
|
+
* race skips only the records that were lost.
|
|
1914
|
+
*
|
|
1915
|
+
* When the Query already carries a transaction, that transaction is used for
|
|
1916
|
+
* every batch rather than opening one per batch, so the caller's transaction
|
|
1917
|
+
* boundary wins and the locks are held until the caller commits.
|
|
1918
|
+
*/
|
|
1919
|
+
async lockedDestroy(options, batchSize) {
|
|
1920
|
+
if (this.joinLoadActivated)
|
|
1921
|
+
throw new LeftJoinPreloadIncompatibleWithFindEach();
|
|
1922
|
+
// the driver seam refuses this combination too, but catching it here means
|
|
1923
|
+
// failing before any transaction is opened or any row is touched
|
|
1924
|
+
if (this.distinctColumn)
|
|
1925
|
+
throw new RowLockIncompatibleWithDistinct();
|
|
1926
|
+
const primaryKey = this.dreamInstance['_primaryKey'];
|
|
1927
|
+
const orderedQuery = this.order(null)
|
|
1928
|
+
.order(this.namespacedPrimaryKey)
|
|
1929
|
+
.limit(batchSize);
|
|
1930
|
+
let counter = 0;
|
|
1931
|
+
// the cursor is compared against a sentinel rather than tested for
|
|
1932
|
+
// truthiness, since a primary key of 0 is a legitimate cursor value that
|
|
1933
|
+
// would otherwise reset the window to the start of the set
|
|
1934
|
+
let lastId = undefined;
|
|
1935
|
+
let attemptedCount = 0;
|
|
1936
|
+
do {
|
|
1937
|
+
const batchQuery = lastId === undefined
|
|
1938
|
+
? orderedQuery
|
|
1939
|
+
: orderedQuery.where({ [primaryKey]: ops.greaterThan(lastId) });
|
|
1940
|
+
const destroyBatch = async (txn) => {
|
|
1941
|
+
const candidateIds = await batchQuery.txn(txn).pluck(this.namespacedPrimaryKey);
|
|
1942
|
+
if (!candidateIds.length)
|
|
1943
|
+
return { attempted: 0, claimed: 0, lastId };
|
|
1944
|
+
const claimed = await this.dbDriverInstance(batchQuery.txn(txn).where({ [primaryKey]: candidateIds })).takeAll({ lock: true });
|
|
1945
|
+
for (const record of claimed) {
|
|
1946
|
+
const inTransaction = record.txn(txn);
|
|
1947
|
+
if (this.shouldReallyDestroy) {
|
|
1948
|
+
await inTransaction.reallyDestroy(options);
|
|
1949
|
+
}
|
|
1950
|
+
else {
|
|
1951
|
+
await inTransaction.destroy(options);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return {
|
|
1955
|
+
attempted: candidateIds.length,
|
|
1956
|
+
claimed: claimed.length,
|
|
1957
|
+
lastId: candidateIds.at(-1),
|
|
1958
|
+
};
|
|
1959
|
+
};
|
|
1960
|
+
const results = this.dreamTransaction
|
|
1961
|
+
? await destroyBatch(this.dreamTransaction)
|
|
1962
|
+
: await this.dreamClass.transaction(async (txn) => await destroyBatch(txn));
|
|
1963
|
+
counter += results.claimed;
|
|
1964
|
+
attemptedCount = results.attempted;
|
|
1965
|
+
lastId = results.lastId;
|
|
1966
|
+
} while (attemptedCount > 0 && attemptedCount === batchSize);
|
|
1967
|
+
return counter;
|
|
1968
|
+
}
|
|
1773
1969
|
/**
|
|
1774
1970
|
* Destroys all records matching the Query,
|
|
1775
1971
|
* ignoring the SoftDelete decorator and
|
|
@@ -1791,12 +1987,15 @@ export default class Query {
|
|
|
1791
1987
|
* @param __namedParameters - Options for destroying the instance
|
|
1792
1988
|
* @param __namedParameters.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
|
|
1793
1989
|
* @param __namedParameters.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
|
|
1990
|
+
* @param __namedParameters.lock - If true, each batch is re-selected with an exclusive row lock inside its own transaction before being destroyed, making the destroy a compare-and-set. See {@link Query.destroy} for the full semantics. Defaults to false
|
|
1991
|
+
* @param __namedParameters.batchSize - The number of records to process per batch. Must be a positive integer. Defaults to 10 when `lock` is true, and to 1000 otherwise
|
|
1794
1992
|
* @param __namedParameters.bypassAllDefaultScopes - If true, bypasses all default scopes when cascade destroying. Defaults to false
|
|
1795
1993
|
* @param __namedParameters.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
|
|
1796
1994
|
* @returns The number of records that were removed
|
|
1995
|
+
* @throws InvalidBatchSize if `batchSize` is not a positive integer
|
|
1797
1996
|
*/
|
|
1798
|
-
async reallyDestroy({ skipHooks, cascade, } = {}) {
|
|
1799
|
-
return await this.clone({ shouldReallyDestroy: true }).destroy({ skipHooks, cascade });
|
|
1997
|
+
async reallyDestroy({ skipHooks, cascade, lock, batchSize, } = {}) {
|
|
1998
|
+
return await this.clone({ shouldReallyDestroy: true }).destroy({ skipHooks, cascade, lock, batchSize });
|
|
1800
1999
|
}
|
|
1801
2000
|
/**
|
|
1802
2001
|
* Undestroys a SoftDelete model, unsetting
|