@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.
Files changed (251) hide show
  1. package/dist/cjs/src/Dream.js +9 -5
  2. package/dist/cjs/src/db/errors.js +2 -0
  3. package/dist/cjs/src/decorators/Decorators.js +121 -6
  4. package/dist/cjs/src/decorators/field/sortable/Sortable.js +4 -31
  5. package/dist/cjs/src/decorators/field/sortable/helpers/cascadeReachesEveryRowInSortScope.js +51 -0
  6. package/dist/cjs/src/decorators/field/sortable/helpers/performSortableDestroyWork.js +5 -2
  7. package/dist/cjs/src/decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.js +39 -25
  8. package/dist/cjs/src/decorators/field/sortable/helpers/sortableCascadeEdge.js +32 -0
  9. package/dist/cjs/src/dream/Query.js +37 -13
  10. package/dist/cjs/src/dream/QueryDriver/Base.js +26 -0
  11. package/dist/cjs/src/dream/QueryDriver/Kysely.js +4 -1
  12. package/dist/cjs/src/dream/QueryDriver/Postgres.js +7 -1
  13. package/dist/cjs/src/dream/internal/destroyAssociatedRecords.js +7 -0
  14. package/dist/cjs/src/dream/internal/destroyDream.js +25 -14
  15. package/dist/cjs/src/dream/internal/saveDream.js +9 -0
  16. package/dist/cjs/src/dream/internal/undestroyDream.js +55 -32
  17. package/dist/cjs/src/dream/internal/withConcurrentWriterRetry.js +64 -0
  18. package/dist/cjs/src/errors/CannotSaveMissingDream.js +24 -0
  19. package/dist/cjs/src/errors/SortableBatchRequiresTooManyScopeLocks.js +7 -0
  20. package/dist/cjs/src/errors/associations/MissingRequiredBelongsToAssociation.js +8 -3
  21. package/dist/cjs/src/package-exports/errors.js +10 -1
  22. package/dist/esm/src/Dream.js +9 -5
  23. package/dist/esm/src/db/errors.js +2 -0
  24. package/dist/esm/src/decorators/Decorators.js +121 -6
  25. package/dist/esm/src/decorators/field/sortable/Sortable.js +4 -31
  26. package/dist/esm/src/decorators/field/sortable/helpers/cascadeReachesEveryRowInSortScope.js +51 -0
  27. package/dist/esm/src/decorators/field/sortable/helpers/performSortableDestroyWork.js +5 -2
  28. package/dist/esm/src/decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.js +39 -25
  29. package/dist/esm/src/decorators/field/sortable/helpers/sortableCascadeEdge.js +32 -0
  30. package/dist/esm/src/dream/Query.js +37 -13
  31. package/dist/esm/src/dream/QueryDriver/Base.js +26 -0
  32. package/dist/esm/src/dream/QueryDriver/Kysely.js +4 -1
  33. package/dist/esm/src/dream/QueryDriver/Postgres.js +7 -1
  34. package/dist/esm/src/dream/internal/destroyAssociatedRecords.js +7 -0
  35. package/dist/esm/src/dream/internal/destroyDream.js +25 -14
  36. package/dist/esm/src/dream/internal/saveDream.js +9 -0
  37. package/dist/esm/src/dream/internal/undestroyDream.js +55 -32
  38. package/dist/esm/src/dream/internal/withConcurrentWriterRetry.js +64 -0
  39. package/dist/esm/src/errors/CannotSaveMissingDream.js +24 -0
  40. package/dist/esm/src/errors/SortableBatchRequiresTooManyScopeLocks.js +7 -0
  41. package/dist/esm/src/errors/associations/MissingRequiredBelongsToAssociation.js +8 -3
  42. package/dist/esm/src/package-exports/errors.js +10 -1
  43. package/dist/types/src/Dream.d.ts +9 -5
  44. package/dist/types/src/db/errors.d.ts +2 -0
  45. package/dist/types/src/decorators/Decorators.d.ts +121 -6
  46. package/dist/types/src/decorators/field/sortable/Sortable.d.ts +4 -31
  47. package/dist/types/src/decorators/field/sortable/helpers/cascadeReachesEveryRowInSortScope.d.ts +39 -0
  48. package/dist/types/src/decorators/field/sortable/helpers/performSortableDestroyWork.d.ts +6 -1
  49. package/dist/types/src/decorators/field/sortable/helpers/prepareSortableFieldsForDestroy.d.ts +37 -20
  50. package/dist/types/src/decorators/field/sortable/helpers/sortableCascadeEdge.d.ts +20 -0
  51. package/dist/types/src/dream/Query.d.ts +37 -13
  52. package/dist/types/src/dream/QueryDriver/Base.d.ts +20 -0
  53. package/dist/types/src/dream/QueryDriver/Postgres.d.ts +2 -0
  54. package/dist/types/src/dream/internal/destroyDream.d.ts +3 -1
  55. package/dist/types/src/dream/internal/undestroyDream.d.ts +6 -4
  56. package/dist/types/src/dream/internal/withConcurrentWriterRetry.d.ts +47 -0
  57. package/dist/types/src/errors/CannotSaveMissingDream.d.ts +23 -0
  58. package/dist/types/src/errors/SortableBatchRequiresTooManyScopeLocks.d.ts +7 -0
  59. package/dist/types/src/errors/associations/MissingRequiredBelongsToAssociation.d.ts +1 -0
  60. package/dist/types/src/package-exports/errors.d.ts +1 -1
  61. package/docs/assets/hierarchy.js +1 -1
  62. package/docs/assets/navigation.js +1 -1
  63. package/docs/assets/search.js +1 -1
  64. package/docs/classes/db.DreamMigrationHelpers.html +11 -11
  65. package/docs/classes/db.KyselyQueryDriver.html +55 -43
  66. package/docs/classes/db.PostgresQueryDriver.html +56 -44
  67. package/docs/classes/db.QueryDriverBase.html +54 -42
  68. package/docs/classes/errors.CannotSaveMissingDream.html +47 -0
  69. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  70. package/docs/classes/errors.ColumnOverflow.html +3 -3
  71. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  72. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  73. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  74. package/docs/classes/errors.DecryptionError.html +2 -2
  75. package/docs/classes/errors.DecryptionParseError.html +2 -2
  76. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  77. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  78. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  79. package/docs/classes/errors.InvalidClockTime.html +2 -2
  80. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  81. package/docs/classes/errors.InvalidDateTime.html +2 -2
  82. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  83. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  84. package/docs/classes/errors.NotNullViolation.html +3 -3
  85. package/docs/classes/errors.RecordNotFound.html +3 -3
  86. package/docs/classes/errors.SortableScopeDidNotStabilize.html +3 -3
  87. package/docs/classes/errors.SortableScopeLockWaitTimedOut.html +3 -3
  88. package/docs/classes/errors.ValidationError.html +3 -3
  89. package/docs/classes/index.CalendarDate.html +33 -33
  90. package/docs/classes/index.ClockTime.html +32 -32
  91. package/docs/classes/index.ClockTimeTz.html +35 -35
  92. package/docs/classes/index.DateTime.html +86 -86
  93. package/docs/classes/index.Decorators.html +101 -22
  94. package/docs/classes/index.Dream.html +133 -129
  95. package/docs/classes/index.DreamApp.html +10 -10
  96. package/docs/classes/index.DreamTransaction.html +2 -2
  97. package/docs/classes/index.Env.html +2 -2
  98. package/docs/classes/index.Query.html +88 -67
  99. package/docs/classes/system.CliFileWriter.html +4 -4
  100. package/docs/classes/system.DreamBin.html +2 -2
  101. package/docs/classes/system.DreamCLI.html +7 -7
  102. package/docs/classes/system.DreamImporter.html +2 -2
  103. package/docs/classes/system.DreamLogos.html +2 -2
  104. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  105. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  106. package/docs/classes/system.PathHelpers.html +3 -3
  107. package/docs/classes/utils.Encrypt.html +3 -3
  108. package/docs/classes/utils.Range.html +2 -2
  109. package/docs/functions/db.closeAllDbConnections.html +1 -1
  110. package/docs/functions/db.dreamDbConnections.html +1 -1
  111. package/docs/functions/db.untypedDb.html +1 -1
  112. package/docs/functions/db.validateColumn.html +1 -1
  113. package/docs/functions/db.validateTable.html +1 -1
  114. package/docs/functions/errors.pgErrorType.html +1 -1
  115. package/docs/functions/index.DreamSerializer.html +1 -1
  116. package/docs/functions/index.ObjectSerializer.html +1 -1
  117. package/docs/functions/index.ReplicaSafe.html +1 -1
  118. package/docs/functions/index.STI.html +1 -1
  119. package/docs/functions/index.SoftDelete.html +1 -1
  120. package/docs/functions/utils.camelize.html +1 -1
  121. package/docs/functions/utils.capitalize.html +1 -1
  122. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  123. package/docs/functions/utils.compact.html +1 -1
  124. package/docs/functions/utils.groupBy.html +1 -1
  125. package/docs/functions/utils.hyphenize.html +1 -1
  126. package/docs/functions/utils.intersection.html +1 -1
  127. package/docs/functions/utils.isEmpty.html +1 -1
  128. package/docs/functions/utils.normalizeUnicode.html +1 -1
  129. package/docs/functions/utils.pascalize.html +1 -1
  130. package/docs/functions/utils.percent.html +1 -1
  131. package/docs/functions/utils.range.html +1 -1
  132. package/docs/functions/utils.round.html +1 -1
  133. package/docs/functions/utils.sanitizeString.html +1 -1
  134. package/docs/functions/utils.snakeify.html +1 -1
  135. package/docs/functions/utils.sort.html +1 -1
  136. package/docs/functions/utils.sortBy.html +1 -1
  137. package/docs/functions/utils.sortObjectByKey.html +1 -1
  138. package/docs/functions/utils.sortObjectByValue.html +1 -1
  139. package/docs/functions/utils.uncapitalize.html +1 -1
  140. package/docs/functions/utils.uniq.html +1 -1
  141. package/docs/hierarchy.html +1 -1
  142. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  143. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  144. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  145. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  146. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  147. package/docs/interfaces/types.DecoratorContext.html +2 -2
  148. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  149. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  150. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  151. package/docs/interfaces/types.DurationObject.html +2 -2
  152. package/docs/interfaces/types.EncryptOptions.html +2 -2
  153. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  154. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  155. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  156. package/docs/modules/errors.html +1 -1
  157. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  158. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  159. package/docs/types/openapi.OpenapiFormats.html +1 -1
  160. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  161. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  162. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  163. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  164. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  165. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  166. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  167. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  168. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  169. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  170. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  171. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  172. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  173. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  174. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  175. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  176. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  177. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  178. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  179. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  180. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  181. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  182. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  183. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  184. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  185. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  186. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  187. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  188. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  189. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  190. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  191. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  192. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  193. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  194. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  195. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  196. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  197. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  198. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  199. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  200. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  201. package/docs/types/types.CalendarDateObject.html +1 -1
  202. package/docs/types/types.Camelized.html +1 -1
  203. package/docs/types/types.ClockTimeObject.html +1 -1
  204. package/docs/types/types.DbConnectionType.html +1 -1
  205. package/docs/types/types.DbTypes.html +1 -1
  206. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  207. package/docs/types/types.DreamAttributes.html +1 -1
  208. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  209. package/docs/types/types.DreamClassColumn.html +1 -1
  210. package/docs/types/types.DreamColumn.html +1 -1
  211. package/docs/types/types.DreamColumnNames.html +1 -1
  212. package/docs/types/types.DreamLogLevel.html +1 -1
  213. package/docs/types/types.DreamLogger.html +2 -2
  214. package/docs/types/types.DreamModelSerializerType.html +1 -1
  215. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  216. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  217. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  218. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  219. package/docs/types/types.DreamSerializable.html +1 -1
  220. package/docs/types/types.DreamSerializableArray.html +1 -1
  221. package/docs/types/types.DreamSerializerKey.html +1 -1
  222. package/docs/types/types.DreamSerializers.html +1 -1
  223. package/docs/types/types.DreamVirtualColumns.html +1 -1
  224. package/docs/types/types.DurationUnit.html +1 -1
  225. package/docs/types/types.EncryptAlgorithm.html +1 -1
  226. package/docs/types/types.HasManyStatement.html +1 -1
  227. package/docs/types/types.HasOneStatement.html +1 -1
  228. package/docs/types/types.Hyphenized.html +1 -1
  229. package/docs/types/types.Pascalized.html +1 -1
  230. package/docs/types/types.PrimaryKeyType.html +1 -1
  231. package/docs/types/types.RoundingPrecision.html +1 -1
  232. package/docs/types/types.SerializerCasing.html +1 -1
  233. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  234. package/docs/types/types.Snakeified.html +1 -1
  235. package/docs/types/types.StrictInterface.html +1 -1
  236. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  237. package/docs/types/types.UpdateableProperties.html +1 -1
  238. package/docs/types/types.ValidationType.html +1 -1
  239. package/docs/types/types.ViewModel.html +2 -2
  240. package/docs/types/types.ViewModelClass.html +1 -1
  241. package/docs/types/types.WeekdayName.html +1 -1
  242. package/docs/types/types.WhereStatementForDream.html +1 -1
  243. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  244. package/docs/variables/index.DreamConst.html +1 -1
  245. package/docs/variables/index.ops.html +1 -1
  246. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  247. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  248. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  249. package/docs/variables/system.primaryKeyTypes.html +1 -1
  250. package/package.json +2 -2
  251. package/docs/classes/errors.SortableBatchRequiresTooManyScopeLocks.html +0 -61
@@ -1837,16 +1837,20 @@ export default class Dream {
1837
1837
  * with no gaps, accounting for the scopes specified in the
1838
1838
  * corresponding Sortable decorator.
1839
1839
  *
1840
- * Each sort scope is renumbered in its own transaction, under the same
1841
- * advisory lock every other position-mutating path takes on that scope, from
1842
- * a read of the scope taken inside that lock — so a concurrent create or
1843
- * destroy cannot make the renumbering land on positions it never read.
1840
+ * Each sort scope is renumbered in its own transaction, under the advisory
1841
+ * lock every direct position-mutating path takes on that scope, from a read
1842
+ * of the scope taken inside that lock — so a concurrent create or direct
1843
+ * destroy cannot make the renumbering land on positions it never read. A
1844
+ * cascaded destroy or undestroy takes no scope lock (see `@deco.Sortable`),
1845
+ * so one running at the same moment can still change a scope under `resort`;
1846
+ * a later `resort` repairs whatever that leaves.
1844
1847
  *
1845
1848
  * **Do not call `resort` from inside a transaction you own.** It opens those
1846
1849
  * transactions itself, on connections of its own, so it can neither join
1847
1850
  * yours nor roll back with it: its writes commit on their own. Worse, if your
1848
1851
  * transaction already holds the scope lock for a scope being renumbered — any
1849
- * position-mutating write to that scope earlier in the transaction takes it —
1852
+ * direct position-mutating write to that scope earlier in the transaction
1853
+ * takes it; a cascaded destroy or undestroy takes none —
1850
1854
  * `resort` waits on that lock from the outside, where no deadlock detector
1851
1855
  * can see the cycle, until the wait bound expires and it throws
1852
1856
  * `SortableScopeLockWaitTimedOut`.
@@ -8,6 +8,7 @@
8
8
  // @ts-ignore
9
9
  import pg from 'pg';
10
10
  export const CHECK_VIOLATION = 'CHECK_VIOLATION';
11
+ export const DEADLOCK_DETECTED = 'DEADLOCK_DETECTED';
11
12
  export const FOREIGN_KEY_VIOLATION = 'FOREIGN_KEY_VIOLATION';
12
13
  export const INTEGRITY_CONSTRAINT_VIOLATION = 'INTEGRITY_CONSTRAINT_VIOLATION';
13
14
  export const INVALID_INPUT_SYNTAX = 'INVALID_INPUT_SYNTAX';
@@ -30,6 +31,7 @@ export const PG_ERRORS = {
30
31
  // a statement Postgres cancelled because its `lock_timeout` elapsed while it
31
32
  // waited for a lock — the bound Dream puts on the sortable scope-lock wait
32
33
  '55P03': LOCK_NOT_AVAILABLE,
34
+ '40P01': DEADLOCK_DETECTED,
33
35
  };
34
36
  function pgErrorFromCode(code) {
35
37
  return PG_ERRORS[code] || null;
@@ -147,18 +147,133 @@ export default class Decorators {
147
147
  return Scope(opts);
148
148
  }
149
149
  /**
150
- * The Sortable decorator automatically adjusts the value of the columns
151
- * corresponding to the decorated field.
152
- *
153
- * NOTE: the Sortable decorator may not be used in STI child models (it may be used in the STI base class)
150
+ * Marks an integer column as a sortable position: Dream keeps the positions of
151
+ * every record in a sort scope contiguous, starting at 1, as records are
152
+ * created, moved, destroyed and undestroyed. A concurrent write racing a
153
+ * cascaded destroy or undestroy, under **Cascaded destroy and undestroy**
154
+ * below, is the one thing that can leave a scope with a gap;
155
+ * `Model.resort('position')` closes it.
154
156
  *
155
157
  * ```ts
156
- * class Balloon {
158
+ * class Post extends ApplicationModel {
157
159
  * @deco.Sortable({ scope: 'user' })
158
- * public position: DreamColumn<Balloon, 'position'>
160
+ * public position: number
159
161
  * }
162
+ *
163
+ * await post.update({ position: 2 }) // the records at 2 and above shift up
160
164
  * ```
161
165
  *
166
+ * A position past the end of the scope is clamped to the end, and a position
167
+ * below 1 — or none at all — lands the record at the end.
168
+ *
169
+ * **A save that changes the sort scope ignores a position given alongside it.**
170
+ * The record lands at the end of the scope it moves into, whatever position the
171
+ * same `update` supplied:
172
+ *
173
+ * ```ts
174
+ * await post.update({ user: otherUser, position: 1 })
175
+ * // post is now the last record in otherUser's scope, not the first
176
+ * ```
177
+ *
178
+ * Move it in two saves to place it: `await post.update({ user: otherUser })`,
179
+ * then `await post.update({ position: 1 })`.
180
+ *
181
+ * Sortable requires a query driver that supports advisory transaction locks —
182
+ * the `PostgresQueryDriver` does — since a position write serializes the
183
+ * writers of its sort scope on one, apart from the cascades described below.
184
+ * This concurrency guarantee first shipped
185
+ * in Dream 2.28.0 and begins only after every writer is running a lock-aware
186
+ * release. During the first rolling deployment, older processes take no
187
+ * advisory locks and can still race the upgraded processes.
188
+ *
189
+ * All participating Dream writers of one hot scope serialize. Ordinary saves
190
+ * and destroys with `skipHooks`, direct query writes, raw SQL, and older
191
+ * pre-lock Dream processes bypass Sortable maintenance and do not participate
192
+ * in its locking protocol; a cascaded destroy or undestroy performs its
193
+ * maintenance but does not participate either. A direct undestroy still
194
+ * performs stabilized Sortable maintenance and acquires scope locks with
195
+ * `skipHooks: true`; locked query
196
+ * batches likewise acquire their scope locks during preflight, before any
197
+ * per-record callbacks, even when those callbacks skip hooks. A waiter that
198
+ * enters the protocol holds a pooled connection until the holder finishes or
199
+ * `sortableScopeLockTimeout` expires; sustained contention can therefore
200
+ * produce latency waves, timeouts, and pool starvation. Keep database work
201
+ * inside the lock window short, and avoid cross-region database latency for
202
+ * hot scopes.
203
+ *
204
+ * **Cascaded destroy and undestroy take no scope lock.** A record destroyed or
205
+ * undestroyed because its owner was — reached through a `dependent: 'destroy'`
206
+ * association, or through the undestroy that restores one — does its position
207
+ * work without the advisory lock, so a cascade holds no lock per sort scope it
208
+ * touches, however many scopes that is. Calling `destroy()` or `undestroy()`
209
+ * on a record yourself is unchanged. Where the cascade is destroying the owner
210
+ * of a sort scope — a `HasMany` whose foreign key is one of the field's scope
211
+ * columns, with no condition and not `through`, not polymorphic, whose target
212
+ * is not an STI child and declares no default scope other than
213
+ * `@deco.SoftDelete` — every row of that scope goes with it, and the cascade
214
+ * neither reads nor compacts it. Any other cascaded destroy compacts the
215
+ * scope it vacates, and every cascaded undestroy appends at the end of its
216
+ * scope, exactly as the direct operations do, only without the lock.
217
+ *
218
+ * Without the lock, a writer of the same sort scope during the cascade — a
219
+ * create, a move, a destroy, a `resort` — is not kept out, which costs one of
220
+ * two things. A gap: positions stay unique and correctly ordered, and
221
+ * `Model.resort('position')` closes it. Or two live rows sharing a position,
222
+ * which the deferrable unique constraint refuses when the later of the two
223
+ * transactions reaches COMMIT — as a unique violation, or as a deadlock when
224
+ * both reach it together — so nothing half-done commits and no commit hook
225
+ * runs. A `destroy()` or `undestroy()` whose transaction Dream opened runs the
226
+ * whole operation again on such a refusal, up to three times, before letting
227
+ * the adapter's error escape. Inside a transaction you opened, that error is
228
+ * your COMMIT failing: retry the transaction from the beginning, as for a
229
+ * deadlock. The other writer can lose the same race and fail at its own
230
+ * COMMIT instead, an ordinary `create` or `update` included, and Dream does
231
+ * not run those again: a bounded, occasional failed request in one contended
232
+ * scope is the deliberate price of cascades that hold no locks, where the
233
+ * lock count would otherwise be set by the data. A sort scope with a
234
+ * nullable column has no such backstop unless its constraint is declared
235
+ * `NULLS NOT DISTINCT`, since a plain unique constraint never sees two NULLs
236
+ * as equal.
237
+ *
238
+ * `SortableScopeDidNotStabilize` and `SortableScopeLockWaitTimedOut` are the
239
+ * expected Dream errors an application may choose to retry. Database deadlocks
240
+ * remain native adapter errors (for example PostgreSQL code `40P01` or MySQL
241
+ * errno `1213`) so their driver fields and stacks remain intact. A database
242
+ * deadlock aborts the whole transaction: retry by starting the transaction
243
+ * again from the beginning, never by continuing it or retrying only the failed
244
+ * statement. Recurrent deadlocks call for shorter transactions or a consistent
245
+ * multi-resource acquisition order; they do not by themselves prove Sortable
246
+ * caused the cycle.
247
+ *
248
+ * ```ts
249
+ * import {
250
+ * SortableScopeDidNotStabilize,
251
+ * SortableScopeLockWaitTimedOut,
252
+ * } from '@rvoh/dream/errors'
253
+ *
254
+ * const runTransaction = async () =>
255
+ * await ApplicationModel.transaction(async txn => {
256
+ * // bind every operation in the attempt to txn
257
+ * })
258
+ *
259
+ * try {
260
+ * await runTransaction()
261
+ * } catch (error) {
262
+ * const adapterDeadlock =
263
+ * (error as { code?: string }).code === '40P01' ||
264
+ * (error as { errno?: number }).errno === 1213
265
+ * const retryable =
266
+ * error instanceof SortableScopeDidNotStabilize ||
267
+ * error instanceof SortableScopeLockWaitTimedOut ||
268
+ * adapterDeadlock
269
+ * if (!retryable) throw error
270
+ *
271
+ * await runTransaction() // one whole-transaction retry
272
+ * }
273
+ * ```
274
+ *
275
+ * NOTE: the Sortable decorator may not be used in STI child models (it may be used in the STI base class)
276
+ *
162
277
  * @param opts - Configuration options for the sortable decorator
163
278
  * @param opts.scope - The column, association, or combination thereof which you would like to restrict the incrementing logic to. Can be a single column name, a single belongs-to association name, or an array of column/association names
164
279
  * @returns A Sortable decorator
@@ -1,36 +1,9 @@
1
1
  import scopeArray from './helpers/scopeArray.js';
2
2
  /**
3
- * Marks an integer column as a sortable position: Dream keeps the positions of
4
- * every record in a sort scope contiguous, starting at 1, as records are
5
- * created, moved, destroyed and undestroyed.
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 operation's whole advisory key set, across every
10
- * sortable field, is taken in one sorted stabilized acquisition, with one
11
- * snapshot SELECT covering all fields. Per-field acquisition would take
12
- * multi-field keys in declaration order, which deadlocks against the paths
13
- * that acquire sorted.
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
- * Pre-delete is the only seat available for the snapshot read: on a hard
16
- * destroy the row is gone afterwards, and a soft destroy nulls the position
17
- * columns in the same UPDATE as `deletedAt`. The lock is taken around the read
18
- * so the snapshot cannot be invalidated between the read and the compaction;
19
- * it is transaction-scoped, and `destroyDream` opens a transaction that spans
20
- * the delete and the after-destroy hooks, so one acquisition covers both.
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
- * The per-field compactions (`performSortableDestroyWork`) consume the cached
23
- * snapshots.
24
- *
25
- * @returns false when the snapshot read found no row — this destroy has nothing
26
- * to vacate, and compacting from the instance's remembered position would
27
- * shift a scope some other writer has already closed. The delete's own
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 sortableFields = dream.constructor['sortableFields'];
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
- const { rowExists, snapshots } = await acquireStabilizedSortableScopeLocks(dream, txn, sortableFields);
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 refuses with `SortableBatchRequiresTooManyScopeLocks`
2038
- * rather than letting a long run over a high-cardinality sort scope exhaust
2039
- * `max_locks_per_transaction` for the whole cluster — so a run long enough
2040
- * raises instead of completing. Letting Dream open a transaction per batch
2041
- * releases the keys as it goes and gives each batch the whole budget.
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
- * ignoring the SoftDelete decorator and
2120
- * permanently removing records from the database.
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
  }