@rvoh/dream 2.30.1 → 2.32.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 +9 -5
- package/dist/cjs/src/db/errors.js +2 -0
- package/dist/cjs/src/decorators/Decorators.js +121 -6
- package/dist/cjs/src/decorators/field/sortable/Sortable.js +4 -31
- package/dist/cjs/src/decorators/field/sortable/helpers/cascadeReachesEveryRowInSortScope.js +51 -0
- package/dist/cjs/src/decorators/field/sortable/helpers/performSortableDestroyWork.js +5 -2
- package/dist/cjs/src/decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.js +39 -25
- package/dist/cjs/src/decorators/field/sortable/helpers/sortableCascadeEdge.js +32 -0
- package/dist/cjs/src/dream/Query.js +37 -13
- package/dist/cjs/src/dream/QueryDriver/Base.js +26 -0
- package/dist/cjs/src/dream/QueryDriver/Kysely.js +4 -1
- package/dist/cjs/src/dream/QueryDriver/Postgres.js +7 -1
- package/dist/cjs/src/dream/internal/destroyAssociatedRecords.js +7 -0
- package/dist/cjs/src/dream/internal/destroyDream.js +25 -14
- package/dist/cjs/src/dream/internal/saveDream.js +9 -0
- package/dist/cjs/src/dream/internal/undestroyDream.js +55 -32
- package/dist/cjs/src/dream/internal/withConcurrentWriterRetry.js +64 -0
- package/dist/cjs/src/errors/CannotSaveMissingDream.js +24 -0
- package/dist/cjs/src/errors/SortableBatchRequiresTooManyScopeLocks.js +7 -0
- package/dist/cjs/src/errors/associations/MissingRequiredBelongsToAssociation.js +8 -3
- package/dist/cjs/src/package-exports/errors.js +10 -1
- package/dist/esm/src/Dream.js +9 -5
- package/dist/esm/src/db/errors.js +2 -0
- package/dist/esm/src/decorators/Decorators.js +121 -6
- package/dist/esm/src/decorators/field/sortable/Sortable.js +4 -31
- package/dist/esm/src/decorators/field/sortable/helpers/cascadeReachesEveryRowInSortScope.js +51 -0
- package/dist/esm/src/decorators/field/sortable/helpers/performSortableDestroyWork.js +5 -2
- package/dist/esm/src/decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.js +39 -25
- package/dist/esm/src/decorators/field/sortable/helpers/sortableCascadeEdge.js +32 -0
- package/dist/esm/src/dream/Query.js +37 -13
- package/dist/esm/src/dream/QueryDriver/Base.js +26 -0
- package/dist/esm/src/dream/QueryDriver/Kysely.js +4 -1
- package/dist/esm/src/dream/QueryDriver/Postgres.js +7 -1
- package/dist/esm/src/dream/internal/destroyAssociatedRecords.js +7 -0
- package/dist/esm/src/dream/internal/destroyDream.js +25 -14
- package/dist/esm/src/dream/internal/saveDream.js +9 -0
- package/dist/esm/src/dream/internal/undestroyDream.js +55 -32
- package/dist/esm/src/dream/internal/withConcurrentWriterRetry.js +64 -0
- package/dist/esm/src/errors/CannotSaveMissingDream.js +24 -0
- package/dist/esm/src/errors/SortableBatchRequiresTooManyScopeLocks.js +7 -0
- package/dist/esm/src/errors/associations/MissingRequiredBelongsToAssociation.js +8 -3
- package/dist/esm/src/package-exports/errors.js +10 -1
- package/dist/types/src/Dream.d.ts +9 -5
- package/dist/types/src/db/errors.d.ts +2 -0
- package/dist/types/src/decorators/Decorators.d.ts +121 -6
- package/dist/types/src/decorators/field/sortable/Sortable.d.ts +4 -31
- package/dist/types/src/decorators/field/sortable/helpers/cascadeReachesEveryRowInSortScope.d.ts +39 -0
- package/dist/types/src/decorators/field/sortable/helpers/performSortableDestroyWork.d.ts +6 -1
- package/dist/types/src/decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.d.ts +37 -20
- package/dist/types/src/decorators/field/sortable/helpers/sortableCascadeEdge.d.ts +20 -0
- package/dist/types/src/dream/Query.d.ts +37 -13
- package/dist/types/src/dream/QueryDriver/Base.d.ts +20 -0
- package/dist/types/src/dream/QueryDriver/Postgres.d.ts +2 -0
- package/dist/types/src/dream/internal/destroyDream.d.ts +3 -1
- package/dist/types/src/dream/internal/undestroyDream.d.ts +6 -4
- package/dist/types/src/dream/internal/withConcurrentWriterRetry.d.ts +47 -0
- package/dist/types/src/errors/CannotSaveMissingDream.d.ts +23 -0
- package/dist/types/src/errors/SortableBatchRequiresTooManyScopeLocks.d.ts +7 -0
- package/dist/types/src/errors/associations/MissingRequiredBelongsToAssociation.d.ts +1 -0
- package/dist/types/src/package-exports/errors.d.ts +1 -1
- package/docs/assets/hierarchy.js +1 -1
- package/docs/assets/navigation.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 -43
- package/docs/classes/db.PostgresQueryDriver.html +56 -44
- package/docs/classes/db.QueryDriverBase.html +54 -42
- package/docs/classes/errors.CannotSaveMissingDream.html +47 -0
- 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.SortableScopeDidNotStabilize.html +3 -3
- package/docs/classes/errors.SortableScopeLockWaitTimedOut.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 +101 -22
- package/docs/classes/index.Dream.html +133 -129
- 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 +88 -67
- 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/modules/errors.html +1 -1
- 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
- package/docs/classes/errors.SortableBatchRequiresTooManyScopeLocks.html +0 -61
|
@@ -1,36 +1,9 @@
|
|
|
1
1
|
import scopeArray from './helpers/scopeArray.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* ```ts
|
|
8
|
-
* class Post extends ApplicationModel {
|
|
9
|
-
* @deco.Sortable({ scope: 'user' })
|
|
10
|
-
* public position: number
|
|
11
|
-
* }
|
|
12
|
-
*
|
|
13
|
-
* await post.update({ position: 2 }) // the records at 2 and above shift up
|
|
14
|
-
* ```
|
|
15
|
-
*
|
|
16
|
-
* A position past the end of the scope is clamped to the end, and a position
|
|
17
|
-
* below 1 — or none at all — lands the record at the end.
|
|
18
|
-
*
|
|
19
|
-
* **A save that changes the sort scope ignores a position given alongside it.**
|
|
20
|
-
* The record lands at the end of the scope it moves into, whatever position the
|
|
21
|
-
* same `update` supplied:
|
|
22
|
-
*
|
|
23
|
-
* ```ts
|
|
24
|
-
* await post.update({ user: otherUser, position: 1 })
|
|
25
|
-
* // post is now the last record in otherUser's scope, not the first
|
|
26
|
-
* ```
|
|
27
|
-
*
|
|
28
|
-
* Move it in two saves to place it: `await post.update({ user: otherUser })`,
|
|
29
|
-
* then `await post.update({ position: 1 })`.
|
|
30
|
-
*
|
|
31
|
-
* Sortable requires a query driver that supports advisory transaction locks —
|
|
32
|
-
* the `PostgresQueryDriver` does — since every position write serializes the
|
|
33
|
-
* writers of its sort scope on one.
|
|
3
|
+
* The implementation behind `@deco.Sortable`. The decorator's semantics —
|
|
4
|
+
* position clamping, what a scope-changing save does with a position given
|
|
5
|
+
* alongside it, and the advisory-lock concurrency protocol — are documented on
|
|
6
|
+
* `Decorators#Sortable`, which is where a consumer reads them.
|
|
34
7
|
*/
|
|
35
8
|
export default function Sortable(opts = {}) {
|
|
36
9
|
return function (_, context) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SOFT_DELETE_SCOPE_NAME } from '../../../class/SoftDelete.js';
|
|
2
|
+
import sortableScopeColumns from './sortableScopeColumns.js';
|
|
3
|
+
/**
|
|
4
|
+
* @internal
|
|
5
|
+
*
|
|
6
|
+
* Whether a cascade reaching this record through `edge` reaches, and so
|
|
7
|
+
* destroys, every live row of the sortable field's sort scope. Such a scope is
|
|
8
|
+
* left with no survivor, so
|
|
9
|
+
* the compaction a destroy performs would close a vacancy nothing can observe,
|
|
10
|
+
* and the cascade skips it along with the snapshot read it needs. Every clause
|
|
11
|
+
* fails closed: a shape not positively recognized here compacts as a direct
|
|
12
|
+
* destroy does, unlocked, which costs statements and never a lock.
|
|
13
|
+
*
|
|
14
|
+
* The edge is the `dependent: 'destroy'` association the cascade destroyed
|
|
15
|
+
* through, the only kind the caller passes, and the answer is about that edge
|
|
16
|
+
* alone: another dependent edge of the same owner may reach the rows this one
|
|
17
|
+
* does not, and this fails closed rather than look for it.
|
|
18
|
+
*
|
|
19
|
+
* 1. The edge is a `HasMany`. A `HasOne` reaches one row of a scope that may
|
|
20
|
+
* hold others.
|
|
21
|
+
* 2. The edge is not polymorphic, is not a `through` association, and carries
|
|
22
|
+
* no `and`, `andNot`, `andAny`, `selfAnd` or `selfAndNot`. Each of those
|
|
23
|
+
* reaches a subset of the rows sharing the foreign key, and the rest can
|
|
24
|
+
* stay live: a soft-deleted owner's row remains, so no foreign key forces
|
|
25
|
+
* them out, and they keep their positions in a scope the cascade did not
|
|
26
|
+
* empty. (A hard destroy under a database foreign key would have refused to
|
|
27
|
+
* leave them, and compacting there is the harmless direction to be wrong in.)
|
|
28
|
+
* 3. The target is not an STI child, whose rows share a sort scope with its
|
|
29
|
+
* siblings' rows.
|
|
30
|
+
* 4. The target declares no default scope other than SoftDelete, since any
|
|
31
|
+
* other hides rows from the cascade's own load. A soft-deleted row holds no
|
|
32
|
+
* position and so is in no sort scope to begin with.
|
|
33
|
+
* 5. The edge's foreign key is one of the field's scope columns: every row the
|
|
34
|
+
* cascade destroys carries the owner's key, and so does every row of any sort
|
|
35
|
+
* scope that includes that column. Further scope members only partition that
|
|
36
|
+
* same set.
|
|
37
|
+
*/
|
|
38
|
+
export default function cascadeReachesEveryRowInSortScope(dream, config, edge) {
|
|
39
|
+
if (edge.type !== 'HasMany')
|
|
40
|
+
return false;
|
|
41
|
+
if (edge.polymorphic || edge.through)
|
|
42
|
+
return false;
|
|
43
|
+
if (edge.and || edge.andNot || edge.andAny || edge.selfAnd || edge.selfAndNot)
|
|
44
|
+
return false;
|
|
45
|
+
const dreamClass = dream.constructor;
|
|
46
|
+
if (dreamClass['isSTIChild'])
|
|
47
|
+
return false;
|
|
48
|
+
if (!dreamClass['scopes'].default.every(scope => scope.method === SOFT_DELETE_SCOPE_NAME))
|
|
49
|
+
return false;
|
|
50
|
+
return sortableScopeColumns(dream, config.scope).includes(edge.foreignKey());
|
|
51
|
+
}
|
|
@@ -10,10 +10,13 @@ import afterSortableDestroy from '../hooks/afterSortableDestroy.js';
|
|
|
10
10
|
* `afterDestroy` hook — never registered as a hook among them — so every
|
|
11
11
|
* after-destroy hook observes the compacted scope regardless of where it was
|
|
12
12
|
* declared, exactly as every after-save hook observes the computed position.
|
|
13
|
+
*
|
|
14
|
+
* @param sortableFields - the fields the preparation phase read a snapshot for,
|
|
15
|
+
* which on a cascaded destroy excludes every field whose whole sort scope the
|
|
16
|
+
* cascade is destroying
|
|
13
17
|
*/
|
|
14
|
-
export default async function performSortableDestroyWork(dream, txn) {
|
|
18
|
+
export default async function performSortableDestroyWork(dream, txn, sortableFields) {
|
|
15
19
|
const dreamClass = dream.constructor;
|
|
16
|
-
const sortableFields = dreamClass['sortableFields'];
|
|
17
20
|
for (const { positionField, scope } of sortableFields) {
|
|
18
21
|
const query = dreamClass.query().removeDefaultScope(STI_SCOPE_NAME).txn(txn);
|
|
19
22
|
await afterSortableDestroy({ dream, positionField, query, scope, txn });
|
|
@@ -1,40 +1,54 @@
|
|
|
1
1
|
import acquireStabilizedSortableScopeLocks from './acquireStabilizedSortableScopeLocks.js';
|
|
2
|
+
import cascadeReachesEveryRowInSortScope from './cascadeReachesEveryRowInSortScope.js';
|
|
2
3
|
import clearCachedSortableValues from './clearCachedSortableValues.js';
|
|
3
|
-
import { cacheSortableSnapshots, clearSortableSnapshot } from './sortableSnapshot.js';
|
|
4
|
+
import { cacheSortableSnapshots, clearSortableSnapshot, readSortableSnapshots } from './sortableSnapshot.js';
|
|
4
5
|
/**
|
|
5
6
|
* @internal
|
|
6
7
|
*
|
|
7
8
|
* The sortable preparation phase of a destroy. Called from `destroyDream` after
|
|
8
9
|
* every user `beforeDestroy` hook has run and before the delete — a phase, not
|
|
9
|
-
* a hook among them — so the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* a hook among them — so the row's real position and scope values are read in
|
|
11
|
+
* one snapshot SELECT covering every field while the row still exists.
|
|
12
|
+
* Pre-delete is the only seat for that read: on a hard destroy the row is gone
|
|
13
|
+
* afterwards, and a soft destroy nulls the position columns in the same UPDATE
|
|
14
|
+
* as `deletedAt`. The per-field compactions (`performSortableDestroyWork`)
|
|
15
|
+
* consume the cached snapshots.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the delete and the after-destroy
|
|
17
|
+
* A direct destroy takes the operation's whole advisory key set — every
|
|
18
|
+
* sortable field — in one sorted stabilized acquisition around that read, so
|
|
19
|
+
* the snapshot cannot be invalidated before the compaction; per-field
|
|
20
|
+
* acquisition would take multi-field keys in declaration order and deadlock
|
|
21
|
+
* against the paths that acquire sorted. The lock is transaction-scoped, and
|
|
22
|
+
* `destroyDream` opens a transaction spanning the delete and the after-destroy
|
|
23
|
+
* hooks, so one acquisition covers both.
|
|
21
24
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* affected-row count is the stronger signal (a writer outside the sortable
|
|
29
|
-
* path can remove the row after this read), so the caller checks both.
|
|
25
|
+
* A cascaded destroy — one reached through a `dependent: 'destroy'` association,
|
|
26
|
+
* which passes the edge it arrived by — takes no scope lock: a cascade would
|
|
27
|
+
* otherwise hold one per distinct sort scope it touches until the root
|
|
28
|
+
* transaction commits. It reads the same snapshot unlocked and compacts from
|
|
29
|
+
* it, and skips both for a field whose whole sort scope the cascade is
|
|
30
|
+
* destroying, since nothing survives there to observe a compaction.
|
|
30
31
|
*/
|
|
31
|
-
export default async function prepareSortableFieldsForDestroy(dream, txn) {
|
|
32
|
-
const
|
|
32
|
+
export default async function prepareSortableFieldsForDestroy(dream, txn, cascadeEdge) {
|
|
33
|
+
const allSortableFields = dream.constructor['sortableFields'];
|
|
34
|
+
const sortableFields = cascadeEdge
|
|
35
|
+
? allSortableFields.filter(config => !cascadeReachesEveryRowInSortScope(dream, config, cascadeEdge))
|
|
36
|
+
: allSortableFields;
|
|
33
37
|
if (!sortableFields.length)
|
|
34
|
-
return true;
|
|
35
|
-
|
|
38
|
+
return { rowExists: true, sortableFields };
|
|
39
|
+
// A field the predicate does not exempt is compacted *unlocked* on a cascade,
|
|
40
|
+
// by design: the predicate decides only what work is skipped, never whether a
|
|
41
|
+
// lock is taken. Falling back to the lock for such a field would reintroduce
|
|
42
|
+
// a lock count set by the data — one per distinct surviving scope, held to
|
|
43
|
+
// the root COMMIT, drawn from the cluster-wide lock table — with no way for
|
|
44
|
+
// the application to avoid it. The concurrent-writer races the missing lock
|
|
45
|
+
// exposes are accepted and documented on `@deco.Sortable`, and are what
|
|
46
|
+
// `withConcurrentWriterRetry` exists for.
|
|
47
|
+
const { rowExists, snapshots } = cascadeEdge
|
|
48
|
+
? await readSortableSnapshots(dream, txn, sortableFields)
|
|
49
|
+
: await acquireStabilizedSortableScopeLocks(dream, txn, sortableFields);
|
|
36
50
|
cacheSortableSnapshots(dream, snapshots);
|
|
37
|
-
return rowExists;
|
|
51
|
+
return { rowExists, sortableFields };
|
|
38
52
|
}
|
|
39
53
|
/**
|
|
40
54
|
* @internal
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A symbol-keyed assignment, like the `CASCADE_LOADED` marker in
|
|
3
|
+
* `destroyAssociatedRecords`: excluded from `Object.keys`, `for...in` and
|
|
4
|
+
* `JSON.stringify` without a descriptor, and cheap on a wide cascade tree.
|
|
5
|
+
*/
|
|
6
|
+
const SORTABLE_CASCADE_EDGE = Symbol.for('dream:sortableCascadeEdge');
|
|
7
|
+
/**
|
|
8
|
+
* @internal
|
|
9
|
+
*
|
|
10
|
+
* Marks a record a `dependent: 'destroy'` cascade is about to destroy or
|
|
11
|
+
* undestroy, with the association the cascade reached it through. The record's
|
|
12
|
+
* own destroy or undestroy consumes the mark and does its sortable work without
|
|
13
|
+
* the scope lock a direct operation takes.
|
|
14
|
+
*/
|
|
15
|
+
export function markSortableCascadeEdge(dream, association) {
|
|
16
|
+
;
|
|
17
|
+
dream[SORTABLE_CASCADE_EDGE] = association;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* @internal
|
|
21
|
+
*
|
|
22
|
+
* The association a cascade reached this record through, or null on a direct
|
|
23
|
+
* destroy or undestroy. Consuming removes the mark, so a later direct operation
|
|
24
|
+
* on the same instance cannot inherit a cascade's optimism.
|
|
25
|
+
*/
|
|
26
|
+
export function consumeSortableCascadeEdge(dream) {
|
|
27
|
+
const association = dream[SORTABLE_CASCADE_EDGE];
|
|
28
|
+
if (association === undefined)
|
|
29
|
+
return null;
|
|
30
|
+
delete dream[SORTABLE_CASCADE_EDGE];
|
|
31
|
+
return association;
|
|
32
|
+
}
|
|
@@ -1914,13 +1914,15 @@ export default class Query {
|
|
|
1914
1914
|
* {@link Query.delete | delete} (a single statement, no hooks or cascade)
|
|
1915
1915
|
* are the right tools for bulk removal.
|
|
1916
1916
|
*
|
|
1917
|
+
* To bypass a default scope, chain `removeDefaultScope` onto the Query
|
|
1918
|
+
* before calling this method; it is Query state, not an option, and it
|
|
1919
|
+
* carries through to the cascade.
|
|
1920
|
+
*
|
|
1917
1921
|
* @param options - Options for destroying the instance
|
|
1918
1922
|
* @param options.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
|
|
1919
1923
|
* @param options.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
|
|
1920
1924
|
* @param options.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
|
|
1921
1925
|
* @param options.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
|
|
1922
|
-
* @param options.bypassAllDefaultScopes - If true, bypasses user-removable default scopes when cascade destroying; an STI child's reserved discriminator remains enforced, including when associations are loaded. Defaults to false
|
|
1923
|
-
* @param options.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
|
|
1924
1926
|
* @returns The number of records that were removed
|
|
1925
1927
|
* @throws InvalidBatchSize if `batchSize` is not a positive integer
|
|
1926
1928
|
* @throws BatchingIncompatibleWithLimitOrOffset if the query carries a `limit` or `offset`
|
|
@@ -2034,11 +2036,12 @@ export default class Query {
|
|
|
2034
2036
|
* transaction ends, so every batch's scope keys are still held when the
|
|
2035
2037
|
* next one preflights. That accumulation is bounded — the preflight counts
|
|
2036
2038
|
* the keys the transaction already holds along with the ones the batch
|
|
2037
|
-
* would add, and
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
* releases the keys as it goes and gives
|
|
2039
|
+
* would add, and raises rather than letting a long run over a
|
|
2040
|
+
* high-cardinality sort scope exhaust `max_locks_per_transaction` for the
|
|
2041
|
+
* whole cluster — so a run long enough raises instead of completing. That
|
|
2042
|
+
* refusal is a sizing failure to fix, not a condition to catch: letting
|
|
2043
|
+
* Dream open a transaction per batch releases the keys as it goes and gives
|
|
2044
|
+
* each batch the whole budget, and a smaller `batchSize` takes fewer.
|
|
2042
2045
|
* - the preflight covers **the claimed rows' own scopes only**. A
|
|
2043
2046
|
* `dependent: 'destroy'` cascade under `destroy({ lock: true })` destroys
|
|
2044
2047
|
* child records of other tables, and a sortable child takes its own table's
|
|
@@ -2116,8 +2119,18 @@ export default class Query {
|
|
|
2116
2119
|
}
|
|
2117
2120
|
/**
|
|
2118
2121
|
* Destroys all records matching the Query,
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2122
|
+
* permanently removing each matched record from
|
|
2123
|
+
* the database instead of soft deleting it.
|
|
2124
|
+
*
|
|
2125
|
+
* The SoftDelete decorator is bypassed when
|
|
2126
|
+
* deleting each matched record, not when
|
|
2127
|
+
* matching: the Query selects under its default
|
|
2128
|
+
* scopes, `dream:SoftDelete` among them, so on a
|
|
2129
|
+
* SoftDelete model it matches only records that
|
|
2130
|
+
* have not already been soft deleted. Reaching
|
|
2131
|
+
* soft deleted records takes
|
|
2132
|
+
* `removeDefaultScope('dream:SoftDelete')` on
|
|
2133
|
+
* the Query.
|
|
2121
2134
|
*
|
|
2122
2135
|
* Calls model hooks and applies cascade destroy
|
|
2123
2136
|
* to associations with `dependent: 'destroy'`,
|
|
@@ -2132,17 +2145,26 @@ export default class Query {
|
|
|
2132
2145
|
* // 12
|
|
2133
2146
|
* ```
|
|
2134
2147
|
*
|
|
2148
|
+
* ```ts
|
|
2149
|
+
* await User.removeDefaultScope('dream:SoftDelete')
|
|
2150
|
+
* .where({ email: ops.ilike('%burpcollaborator%') })
|
|
2151
|
+
* .reallyDestroy()
|
|
2152
|
+
* // 12
|
|
2153
|
+
* ```
|
|
2154
|
+
*
|
|
2135
2155
|
* A `limit` or `offset` on the Query is incompatible with `reallyDestroy`,
|
|
2136
2156
|
* exactly as it is with {@link Query.destroy}: a limit- or offset-carrying
|
|
2137
2157
|
* Query throws at runtime.
|
|
2138
2158
|
*
|
|
2159
|
+
* To bypass a default scope, chain `removeDefaultScope` onto the Query
|
|
2160
|
+
* before calling this method; it is Query state, not an option, and it
|
|
2161
|
+
* carries through to the cascade.
|
|
2162
|
+
*
|
|
2139
2163
|
* @param options - Options for destroying the instance
|
|
2140
2164
|
* @param options.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
|
|
2141
2165
|
* @param options.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
|
|
2142
2166
|
* @param options.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
|
|
2143
2167
|
* @param options.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
|
|
2144
|
-
* @param options.bypassAllDefaultScopes - If true, bypasses user-removable default scopes when cascade destroying; an STI child's reserved discriminator remains enforced, including when associations are loaded. Defaults to false
|
|
2145
|
-
* @param options.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
|
|
2146
2168
|
* @returns The number of records that were removed
|
|
2147
2169
|
* @throws InvalidBatchSize if `batchSize` is not a positive integer
|
|
2148
2170
|
* @throws BatchingIncompatibleWithLimitOrOffset if the query carries a `limit` or `offset`
|
|
@@ -2168,11 +2190,13 @@ export default class Query {
|
|
|
2168
2190
|
* re-applied to every window — so a limit- or offset-carrying Query throws
|
|
2169
2191
|
* at runtime.
|
|
2170
2192
|
*
|
|
2193
|
+
* To bypass a default scope, chain `removeDefaultScope` onto the Query
|
|
2194
|
+
* before calling this method; it is Query state, not an option, and it
|
|
2195
|
+
* carries through to the cascade. The SoftDelete scope is always bypassed.
|
|
2196
|
+
*
|
|
2171
2197
|
* @param options - Options for undestroying the instance
|
|
2172
2198
|
* @param options.skipHooks - If true, skips applying model hooks during the undestroy operation. Defaults to false
|
|
2173
2199
|
* @param options.cascade - If false, skips undestroying associations marked `dependent: 'destroy'`. Defaults to true
|
|
2174
|
-
* @param options.bypassAllDefaultScopes - If true, bypasses user-removable default scopes when cascade undestroying; an STI child's reserved discriminator remains enforced, including when associations are loaded. Defaults to false
|
|
2175
|
-
* @param options.defaultScopesToBypass - An array of default scope names to bypass when cascade undestroying (soft delete is always bypassed). Defaults to an empty array
|
|
2176
2200
|
* @returns The number of records that were removed
|
|
2177
2201
|
* @throws BatchingIncompatibleWithLimitOrOffset if the query carries a `limit` or `offset`
|
|
2178
2202
|
*/
|
|
@@ -214,6 +214,32 @@ export default class QueryDriverBase {
|
|
|
214
214
|
* than silently racing on position values.
|
|
215
215
|
*/
|
|
216
216
|
static supportsAdvisoryTransactionLocks = false;
|
|
217
|
+
/**
|
|
218
|
+
* @internal
|
|
219
|
+
*
|
|
220
|
+
* Whether `error` is the database refusing a write for violating a unique
|
|
221
|
+
* constraint. `withConcurrentWriterRetry` asks this of an error raised at
|
|
222
|
+
* COMMIT, which is where a `DEFERRABLE INITIALLY DEFERRED` constraint — the
|
|
223
|
+
* one `DreamMigrationHelpers.addDeferrableUniqueConstraint` declares — reports
|
|
224
|
+
* a collision. A driver that does not recognize one reports false, and nothing
|
|
225
|
+
* is retried.
|
|
226
|
+
*/
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
228
|
+
static isUniqueConstraintViolation(error) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* @internal
|
|
233
|
+
*
|
|
234
|
+
* Whether `error` is the database aborting a transaction to break a deadlock
|
|
235
|
+
* with another. `withConcurrentWriterRetry` reruns a transaction of Dream's
|
|
236
|
+
* own that was aborted this way. A driver that does not recognize one reports
|
|
237
|
+
* false, and nothing is retried.
|
|
238
|
+
*/
|
|
239
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
240
|
+
static isDeadlock(error) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
217
243
|
/**
|
|
218
244
|
* @internal
|
|
219
245
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
8
8
|
// @ts-ignore
|
|
9
9
|
import pg from 'pg';
|
|
10
|
-
import { PostgresDialect, sql, } from 'kysely';
|
|
10
|
+
import { NoResultError, PostgresDialect, sql, } from 'kysely';
|
|
11
11
|
import pluralize from 'pluralize-esm';
|
|
12
12
|
import { CliFileWriter } from '../../cli/CliFileWriter.js';
|
|
13
13
|
import DreamCLI from '../../cli/index.js';
|
|
@@ -30,6 +30,7 @@ import MissingThroughAssociationSource from '../../errors/associations/MissingTh
|
|
|
30
30
|
import CannotNegateSimilarityClause from '../../errors/CannotNegateSimilarityClause.js';
|
|
31
31
|
import CannotNamespaceAssociationFilterToAnotherTable from '../../errors/CannotNamespaceAssociationFilterToAnotherTable.js';
|
|
32
32
|
import CannotPassUndefinedAsAValueToAWhereClause from '../../errors/CannotPassUndefinedAsAValueToAWhereClause.js';
|
|
33
|
+
import CannotSaveMissingDream from '../../errors/CannotSaveMissingDream.js';
|
|
33
34
|
import ArrayEqualityRequiresArrayColumn from '../../errors/ops/ArrayEqualityRequiresArrayColumn.js';
|
|
34
35
|
import RowLockIncompatibleWithDistinct from '../../errors/RowLockIncompatibleWithDistinct.js';
|
|
35
36
|
import RowLockIncompatibleWithJoinLoad from '../../errors/RowLockIncompatibleWithJoinLoad.js';
|
|
@@ -806,6 +807,8 @@ export default class KyselyQueryDriver extends QueryDriverBase {
|
|
|
806
807
|
}
|
|
807
808
|
}
|
|
808
809
|
catch (error) {
|
|
810
|
+
if (dream.isPersisted && error instanceof NoResultError)
|
|
811
|
+
throw new CannotSaveMissingDream(dream);
|
|
809
812
|
switch (pgErrorType(error)) {
|
|
810
813
|
case COLUMN_OVERFLOW:
|
|
811
814
|
throw new ColumnOverflow(error instanceof Error ? error : new Error('database column overflow'));
|
|
@@ -10,7 +10,7 @@ import pg from 'pg';
|
|
|
10
10
|
import { sql } from 'kysely';
|
|
11
11
|
import DreamCLI from '../../cli/index.js';
|
|
12
12
|
import { isPrimitiveDataType } from '../../db/dataTypes.js';
|
|
13
|
-
import { LOCK_NOT_AVAILABLE, pgErrorType } from '../../db/errors.js';
|
|
13
|
+
import { DEADLOCK_DETECTED, LOCK_NOT_AVAILABLE, pgErrorType, UNIQUE_VIOLATION } from '../../db/errors.js';
|
|
14
14
|
import DreamApp from '../../dream-app/index.js';
|
|
15
15
|
import SortableScopeLockWaitTimedOut from '../../errors/SortableScopeLockWaitTimedOut.js';
|
|
16
16
|
import camelize from '../../helpers/camelize.js';
|
|
@@ -222,6 +222,12 @@ export default class PostgresQueryDriver extends KyselyQueryDriver {
|
|
|
222
222
|
return kyselyQuery.forUpdate(tableAlias);
|
|
223
223
|
}
|
|
224
224
|
static supportsAdvisoryTransactionLocks = true;
|
|
225
|
+
static isUniqueConstraintViolation(error) {
|
|
226
|
+
return pgErrorType(error) === UNIQUE_VIOLATION;
|
|
227
|
+
}
|
|
228
|
+
static isDeadlock(error) {
|
|
229
|
+
return pgErrorType(error) === DEADLOCK_DETECTED;
|
|
230
|
+
}
|
|
225
231
|
/**
|
|
226
232
|
* @internal
|
|
227
233
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markSortableCascadeEdge, } from '../../decorators/field/sortable/helpers/sortableCascadeEdge.js';
|
|
1
2
|
import loadDependentDestroyTree from './loadDependentDestroyTree.js';
|
|
2
3
|
/**
|
|
3
4
|
* @internal
|
|
@@ -51,6 +52,7 @@ export default async function destroyAssociatedRecords(dream, txn, options) {
|
|
|
51
52
|
// each association in the loop below would trigger its own reload.
|
|
52
53
|
let dreamWithAssociations = dream;
|
|
53
54
|
let alreadyLoadedTree = false;
|
|
55
|
+
const associationMetadataMap = dreamClass['associationMetadataMap']();
|
|
54
56
|
for (const associationName of dreamClass['dependentDestroyAssociationNames']()) {
|
|
55
57
|
if (!alreadyLoadedTree && !cascadeLoaded(dreamWithAssociations, associationName)) {
|
|
56
58
|
dreamWithAssociations = await loadDependentDestroyTree(dreamWithAssociations, txn, {
|
|
@@ -62,8 +64,13 @@ export default async function destroyAssociatedRecords(dream, txn, options) {
|
|
|
62
64
|
}
|
|
63
65
|
const loaded = dreamWithAssociations[associationName];
|
|
64
66
|
const records = Array.isArray(loaded) ? loaded : loaded ? [loaded] : [];
|
|
67
|
+
const association = associationMetadataMap[associationName];
|
|
65
68
|
for (const record of records) {
|
|
66
69
|
markCascadeLoaded(record);
|
|
70
|
+
// a cascaded destroy does its sortable work without the scope lock, and
|
|
71
|
+
// which edge reached the record decides what it compacts
|
|
72
|
+
if (association)
|
|
73
|
+
markSortableCascadeEdge(record, association);
|
|
67
74
|
if (reallyDestroy) {
|
|
68
75
|
await record.txn(txn).reallyDestroy(options);
|
|
69
76
|
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import performSortableDestroyWork from '../../decorators/field/sortable/helpers/performSortableDestroyWork.js';
|
|
2
2
|
import prepareSortableFieldsForDestroy, { clearSortableFieldsForDestroy, } from '../../decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.js';
|
|
3
|
+
import { consumeSortableCascadeEdge } from '../../decorators/field/sortable/helpers/sortableCascadeEdge.js';
|
|
3
4
|
import DreamApp from '../../dream-app/index.js';
|
|
4
5
|
import destroyAssociatedRecords from './destroyAssociatedRecords.js';
|
|
5
6
|
import runHooksFor from './runHooksFor.js';
|
|
7
|
+
import withConcurrentWriterRetry from './withConcurrentWriterRetry.js';
|
|
6
8
|
/**
|
|
7
9
|
* @internal
|
|
8
10
|
*
|
|
9
11
|
* Destroys the Dream and any `dependent: 'destroy'` associations
|
|
10
12
|
* within a transaction. If a transaction is passed, it will be used.
|
|
11
|
-
* Otherwise, a new transaction will be created automatically
|
|
13
|
+
* Otherwise, a new transaction will be created automatically, and the
|
|
14
|
+
* whole destroy is run again if that transaction is undone by a concurrent
|
|
15
|
+
* writer (see `withConcurrentWriterRetry`).
|
|
12
16
|
* If any of the nested associations fails to destroy, then this
|
|
13
17
|
* record will also fail to destroy. If skipHooks is true, model hooks
|
|
14
18
|
* will be bypassed.
|
|
@@ -19,7 +23,7 @@ export default async function destroyDream(dream, txn = null, options) {
|
|
|
19
23
|
}
|
|
20
24
|
else {
|
|
21
25
|
const dreamClass = dream.constructor;
|
|
22
|
-
return await dreamClass
|
|
26
|
+
return await withConcurrentWriterRetry(dreamClass, async (txn) => await destroyDreamWithTransaction(dream, txn, options));
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
/**
|
|
@@ -31,6 +35,10 @@ export default async function destroyDream(dream, txn = null, options) {
|
|
|
31
35
|
*/
|
|
32
36
|
async function destroyDreamWithTransaction(dream, txn, options) {
|
|
33
37
|
const { cascade, reallyDestroy, skipHooks } = options;
|
|
38
|
+
// The association a `dependent: 'destroy'` cascade reached this record
|
|
39
|
+
// through, or null on a direct destroy. Consumed first, before a hook or the
|
|
40
|
+
// cascade below can re-enter this function for the same instance.
|
|
41
|
+
const cascadeEdge = consumeSortableCascadeEdge(dream);
|
|
34
42
|
if (!skipHooks)
|
|
35
43
|
await runHooksFor('beforeDestroy', dream, true, null, txn);
|
|
36
44
|
// `preventDeletion` is fail-closed: a veto returns before the
|
|
@@ -51,19 +59,22 @@ async function destroyDreamWithTransaction(dream, txn, options) {
|
|
|
51
59
|
await destroyAssociatedRecords(dream, txn, options);
|
|
52
60
|
}
|
|
53
61
|
// Sortable preparation runs as a phase after every user beforeDestroy hook,
|
|
54
|
-
// never as a hook among them: the
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// scope
|
|
59
|
-
// not of the transaction it runs in: under
|
|
60
|
-
// the batch's whole key set is already held,
|
|
61
|
-
// claimed. A destroy that skips hooks performs no
|
|
62
|
-
// cascaded descendant's hook has vetoed will not
|
|
63
|
-
// locks or a snapshot.
|
|
62
|
+
// never as a hook among them: the row's real position and scope values are
|
|
63
|
+
// read in one snapshot SELECT while the row still exists, under the
|
|
64
|
+
// destroy's whole advisory key set on a direct destroy and under no scope
|
|
65
|
+
// lock on a cascaded one. It sits after the cascade so a direct destroy does
|
|
66
|
+
// not hold its scope locks across every descendant's destroy — true of the
|
|
67
|
+
// destroy itself, not of the transaction it runs in: under
|
|
68
|
+
// `Query#destroy({ lock: true })` the batch's whole key set is already held,
|
|
69
|
+
// taken before any row was claimed. A destroy that skips hooks performs no
|
|
70
|
+
// compaction, and one a cascaded descendant's hook has vetoed will not
|
|
71
|
+
// delete, so neither needs locks or a snapshot.
|
|
64
72
|
let rowFoundBeforeDelete = true;
|
|
73
|
+
let compactedSortableFields = [];
|
|
65
74
|
if (!skipHooks && !dream['_preventDeletion']) {
|
|
66
|
-
|
|
75
|
+
const preparation = await prepareSortableFieldsForDestroy(dream, txn, cascadeEdge);
|
|
76
|
+
rowFoundBeforeDelete = preparation.rowExists;
|
|
77
|
+
compactedSortableFields = preparation.sortableFields;
|
|
67
78
|
}
|
|
68
79
|
const rowsRemoved = await maybeDestroyDream(dream, txn, reallyDestroy);
|
|
69
80
|
// `preventDeletion` can still arrive after the veto check above has passed:
|
|
@@ -95,7 +106,7 @@ async function destroyDreamWithTransaction(dream, txn, options) {
|
|
|
95
106
|
// among them, so every user afterDestroy hook observes the compacted scope
|
|
96
107
|
// regardless of where it was declared — the destroy-side counterpart of
|
|
97
108
|
// performSortablePositionWork running before the after-save hooks.
|
|
98
|
-
await performSortableDestroyWork(dream, txn);
|
|
109
|
+
await performSortableDestroyWork(dream, txn, compactedSortableFields);
|
|
99
110
|
await runHooksFor('afterDestroy', dream, true, null, txn);
|
|
100
111
|
await runHooksFor('afterDestroyCommit', dream, true, null, txn);
|
|
101
112
|
}
|
|
@@ -3,6 +3,7 @@ import { clearSortableSaveState } from '../../decorators/field/sortable/helpers/
|
|
|
3
3
|
import performSortablePositionWork from '../../decorators/field/sortable/helpers/performSortablePositionWork.js';
|
|
4
4
|
import prepareSortableFieldsForSave from '../../decorators/field/sortable/helpers/prepareSortableFieldsForSave.js';
|
|
5
5
|
import { cacheSortableSnapshots } from '../../decorators/field/sortable/helpers/sortableSnapshot.js';
|
|
6
|
+
import CannotSaveMissingDream from '../../errors/CannotSaveMissingDream.js';
|
|
6
7
|
import ValidationError from '../../errors/ValidationError.js';
|
|
7
8
|
import sqlAttributes from '../../helpers/sqlAttributes.js';
|
|
8
9
|
import { DateTime } from '../../utils/datetime/DateTime.js';
|
|
@@ -114,6 +115,14 @@ async function writeDream(dream, txn, { alreadyPersisted, sortableFieldConfigs,
|
|
|
114
115
|
dream.createdAt = now;
|
|
115
116
|
if (!dream.dirtyAttributes().updatedAt && dream.columns().has('updatedAt'))
|
|
116
117
|
dream.updatedAt = now;
|
|
118
|
+
// The scope-lock read above positively found the row gone, so there is
|
|
119
|
+
// nothing to update. Raised here rather than after the write because the
|
|
120
|
+
// write below would not reach the database at all on a position-only save,
|
|
121
|
+
// whose attributes Sortable has already cleared; a save that does carry data
|
|
122
|
+
// raises the equivalent diagnostic from the driver, which sees the update
|
|
123
|
+
// match no row.
|
|
124
|
+
if (alreadyPersisted && !rowFoundBeforeWrite)
|
|
125
|
+
throw new CannotSaveMissingDream(dream);
|
|
117
126
|
const hasUnsavedData = !!Object.keys(sqlAttributes(dream)).length;
|
|
118
127
|
// BeforeSave/Update actions may clear all the data that we intended to save, leaving us with
|
|
119
128
|
// an invalid update command. The Sortable decorator is an example of this.
|