@rvoh/dream 2.21.1 → 2.23.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 (248) hide show
  1. package/dist/cjs/src/Dream.js +71 -23
  2. package/dist/cjs/src/dream/DreamClassTransactionBuilder.js +3 -0
  3. package/dist/cjs/src/dream/Query.js +242 -12
  4. package/dist/cjs/src/dream/QueryDriver/Base.js +26 -0
  5. package/dist/cjs/src/dream/QueryDriver/Kysely.js +46 -4
  6. package/dist/cjs/src/dream/QueryDriver/Postgres.js +21 -0
  7. package/dist/cjs/src/dream/internal/buildSerializerPreloadPaths.js +93 -20
  8. package/dist/cjs/src/dream/internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js +18 -10
  9. package/dist/cjs/src/dream/internal/destroyAssociatedRecords.js +76 -4
  10. package/dist/cjs/src/dream/internal/destroyDream.js +6 -7
  11. package/dist/cjs/src/dream/internal/mergeSerializerAssociationEdges.js +75 -0
  12. package/dist/cjs/src/dream/internal/printSerializerHierarchyLevel.js +50 -8
  13. package/dist/cjs/src/dream/internal/resolveSerializerAssociationEdges.js +79 -14
  14. package/dist/cjs/src/errors/InvalidBatchSize.js +19 -0
  15. package/dist/cjs/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
  16. package/dist/cjs/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
  17. package/dist/cjs/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
  18. package/dist/cjs/src/errors/serializers/NoSerializersResolvedForKey.js +30 -0
  19. package/dist/cjs/src/ops/index.js +19 -0
  20. package/dist/cjs/src/serializer/helpers/inferSerializerFromDreamOrViewModel.js +22 -0
  21. package/dist/cjs/src/serializer/helpers/serializerForAssociatedClass.js +13 -0
  22. package/dist/esm/src/Dream.js +71 -23
  23. package/dist/esm/src/dream/DreamClassTransactionBuilder.js +3 -0
  24. package/dist/esm/src/dream/Query.js +242 -12
  25. package/dist/esm/src/dream/QueryDriver/Base.js +26 -0
  26. package/dist/esm/src/dream/QueryDriver/Kysely.js +46 -4
  27. package/dist/esm/src/dream/QueryDriver/Postgres.js +21 -0
  28. package/dist/esm/src/dream/internal/buildSerializerPreloadPaths.js +93 -20
  29. package/dist/esm/src/dream/internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js +18 -10
  30. package/dist/esm/src/dream/internal/destroyAssociatedRecords.js +76 -4
  31. package/dist/esm/src/dream/internal/destroyDream.js +6 -7
  32. package/dist/esm/src/dream/internal/mergeSerializerAssociationEdges.js +75 -0
  33. package/dist/esm/src/dream/internal/printSerializerHierarchyLevel.js +50 -8
  34. package/dist/esm/src/dream/internal/resolveSerializerAssociationEdges.js +79 -14
  35. package/dist/esm/src/errors/InvalidBatchSize.js +19 -0
  36. package/dist/esm/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
  37. package/dist/esm/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
  38. package/dist/esm/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
  39. package/dist/esm/src/errors/serializers/NoSerializersResolvedForKey.js +30 -0
  40. package/dist/esm/src/ops/index.js +19 -0
  41. package/dist/esm/src/serializer/helpers/inferSerializerFromDreamOrViewModel.js +22 -0
  42. package/dist/esm/src/serializer/helpers/serializerForAssociatedClass.js +13 -0
  43. package/dist/types/src/Dream.d.ts +12 -0
  44. package/dist/types/src/dream/DreamClassTransactionBuilder.d.ts +3 -0
  45. package/dist/types/src/dream/Query.d.ts +150 -3
  46. package/dist/types/src/dream/QueryDriver/Base.d.ts +21 -0
  47. package/dist/types/src/dream/QueryDriver/Kysely.d.ts +1 -0
  48. package/dist/types/src/dream/QueryDriver/Postgres.d.ts +20 -0
  49. package/dist/types/src/dream/internal/destroyAssociatedRecords.d.ts +22 -3
  50. package/dist/types/src/dream/internal/mergeSerializerAssociationEdges.d.ts +69 -0
  51. package/dist/types/src/dream/internal/printSerializerHierarchyLevel.d.ts +23 -5
  52. package/dist/types/src/dream/internal/resolveSerializerAssociationEdges.d.ts +27 -1
  53. package/dist/types/src/errors/InvalidBatchSize.d.ts +5 -0
  54. package/dist/types/src/errors/RowLockIncompatibleWithDistinct.d.ts +3 -0
  55. package/dist/types/src/errors/RowLockIncompatibleWithJoinLoad.d.ts +3 -0
  56. package/dist/types/src/errors/ops/ArrayEqualityRequiresArrayColumn.d.ts +8 -0
  57. package/dist/types/src/errors/serializers/NoSerializersResolvedForKey.d.ts +18 -0
  58. package/dist/types/src/ops/index.d.ts +19 -0
  59. package/dist/types/src/serializer/helpers/inferSerializerFromDreamOrViewModel.d.ts +16 -0
  60. package/dist/types/src/types/associations/shared.d.ts +2 -2
  61. package/dist/types/src/types/dream.d.ts +8 -0
  62. package/dist/types/src/types/types/associations/shared.ts +84 -55
  63. package/dist/types/src/types/types/dream.ts +30 -0
  64. package/docs/assets/hierarchy.js +1 -1
  65. package/docs/assets/search.js +1 -1
  66. package/docs/classes/db.DreamMigrationHelpers.html +11 -11
  67. package/docs/classes/db.KyselyQueryDriver.html +55 -41
  68. package/docs/classes/db.PostgresQueryDriver.html +55 -42
  69. package/docs/classes/db.QueryDriverBase.html +54 -40
  70. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  71. package/docs/classes/errors.ColumnOverflow.html +3 -3
  72. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  73. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  74. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  75. package/docs/classes/errors.DecryptionError.html +2 -2
  76. package/docs/classes/errors.DecryptionParseError.html +2 -2
  77. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  78. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  79. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  80. package/docs/classes/errors.InvalidClockTime.html +2 -2
  81. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  82. package/docs/classes/errors.InvalidDateTime.html +2 -2
  83. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  84. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  85. package/docs/classes/errors.NotNullViolation.html +3 -3
  86. package/docs/classes/errors.RecordNotFound.html +3 -3
  87. package/docs/classes/errors.ValidationError.html +3 -3
  88. package/docs/classes/index.CalendarDate.html +33 -33
  89. package/docs/classes/index.ClockTime.html +32 -32
  90. package/docs/classes/index.ClockTimeTz.html +35 -35
  91. package/docs/classes/index.DateTime.html +86 -86
  92. package/docs/classes/index.Decorators.html +19 -19
  93. package/docs/classes/index.Dream.html +127 -125
  94. package/docs/classes/index.DreamApp.html +10 -10
  95. package/docs/classes/index.DreamTransaction.html +2 -2
  96. package/docs/classes/index.Env.html +2 -2
  97. package/docs/classes/index.Query.html +161 -71
  98. package/docs/classes/system.CliFileWriter.html +4 -4
  99. package/docs/classes/system.DreamBin.html +2 -2
  100. package/docs/classes/system.DreamCLI.html +7 -7
  101. package/docs/classes/system.DreamImporter.html +2 -2
  102. package/docs/classes/system.DreamLogos.html +2 -2
  103. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  104. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  105. package/docs/classes/system.PathHelpers.html +3 -3
  106. package/docs/classes/utils.Encrypt.html +3 -3
  107. package/docs/classes/utils.Range.html +2 -2
  108. package/docs/functions/db.closeAllDbConnections.html +1 -1
  109. package/docs/functions/db.dreamDbConnections.html +1 -1
  110. package/docs/functions/db.untypedDb.html +1 -1
  111. package/docs/functions/db.validateColumn.html +1 -1
  112. package/docs/functions/db.validateTable.html +1 -1
  113. package/docs/functions/errors.pgErrorType.html +1 -1
  114. package/docs/functions/index.DreamSerializer.html +1 -1
  115. package/docs/functions/index.ObjectSerializer.html +1 -1
  116. package/docs/functions/index.ReplicaSafe.html +1 -1
  117. package/docs/functions/index.STI.html +1 -1
  118. package/docs/functions/index.SoftDelete.html +1 -1
  119. package/docs/functions/utils.camelize.html +1 -1
  120. package/docs/functions/utils.capitalize.html +1 -1
  121. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  122. package/docs/functions/utils.compact.html +1 -1
  123. package/docs/functions/utils.groupBy.html +1 -1
  124. package/docs/functions/utils.hyphenize.html +1 -1
  125. package/docs/functions/utils.intersection.html +1 -1
  126. package/docs/functions/utils.isEmpty.html +1 -1
  127. package/docs/functions/utils.normalizeUnicode.html +1 -1
  128. package/docs/functions/utils.pascalize.html +1 -1
  129. package/docs/functions/utils.percent.html +1 -1
  130. package/docs/functions/utils.range.html +1 -1
  131. package/docs/functions/utils.round.html +1 -1
  132. package/docs/functions/utils.sanitizeString.html +1 -1
  133. package/docs/functions/utils.snakeify.html +1 -1
  134. package/docs/functions/utils.sort.html +1 -1
  135. package/docs/functions/utils.sortBy.html +1 -1
  136. package/docs/functions/utils.sortObjectByKey.html +1 -1
  137. package/docs/functions/utils.sortObjectByValue.html +1 -1
  138. package/docs/functions/utils.uncapitalize.html +1 -1
  139. package/docs/functions/utils.uniq.html +1 -1
  140. package/docs/hierarchy.html +1 -1
  141. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  142. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  143. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  144. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  145. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  146. package/docs/interfaces/types.DecoratorContext.html +2 -2
  147. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  148. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  149. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  150. package/docs/interfaces/types.DurationObject.html +2 -2
  151. package/docs/interfaces/types.EncryptOptions.html +2 -2
  152. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  153. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  154. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  155. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  156. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  157. package/docs/types/openapi.OpenapiFormats.html +1 -1
  158. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  159. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  160. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  161. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  162. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  163. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  164. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  165. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  166. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  167. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  168. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  169. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  170. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  171. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  172. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  173. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  174. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  175. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  176. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  177. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  178. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  179. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  180. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  181. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  182. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  183. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  184. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  185. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  186. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  187. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  188. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  189. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  190. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  191. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  192. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  193. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  194. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  195. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  196. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  197. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  198. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  199. package/docs/types/types.CalendarDateObject.html +1 -1
  200. package/docs/types/types.Camelized.html +1 -1
  201. package/docs/types/types.ClockTimeObject.html +1 -1
  202. package/docs/types/types.DbConnectionType.html +1 -1
  203. package/docs/types/types.DbTypes.html +1 -1
  204. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  205. package/docs/types/types.DreamAttributes.html +1 -1
  206. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  207. package/docs/types/types.DreamClassColumn.html +1 -1
  208. package/docs/types/types.DreamColumn.html +1 -1
  209. package/docs/types/types.DreamColumnNames.html +1 -1
  210. package/docs/types/types.DreamLogLevel.html +1 -1
  211. package/docs/types/types.DreamLogger.html +2 -2
  212. package/docs/types/types.DreamModelSerializerType.html +1 -1
  213. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  214. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  215. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  216. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  217. package/docs/types/types.DreamSerializable.html +1 -1
  218. package/docs/types/types.DreamSerializableArray.html +1 -1
  219. package/docs/types/types.DreamSerializerKey.html +1 -1
  220. package/docs/types/types.DreamSerializers.html +1 -1
  221. package/docs/types/types.DreamVirtualColumns.html +1 -1
  222. package/docs/types/types.DurationUnit.html +1 -1
  223. package/docs/types/types.EncryptAlgorithm.html +1 -1
  224. package/docs/types/types.HasManyStatement.html +1 -1
  225. package/docs/types/types.HasOneStatement.html +1 -1
  226. package/docs/types/types.Hyphenized.html +1 -1
  227. package/docs/types/types.Pascalized.html +1 -1
  228. package/docs/types/types.PrimaryKeyType.html +1 -1
  229. package/docs/types/types.RoundingPrecision.html +1 -1
  230. package/docs/types/types.SerializerCasing.html +1 -1
  231. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  232. package/docs/types/types.Snakeified.html +1 -1
  233. package/docs/types/types.StrictInterface.html +1 -1
  234. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  235. package/docs/types/types.UpdateableProperties.html +1 -1
  236. package/docs/types/types.ValidationType.html +1 -1
  237. package/docs/types/types.ViewModel.html +2 -2
  238. package/docs/types/types.ViewModelClass.html +1 -1
  239. package/docs/types/types.WeekdayName.html +1 -1
  240. package/docs/types/types.WhereStatementForDream.html +1 -1
  241. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  242. package/docs/variables/index.DreamConst.html +1 -1
  243. package/docs/variables/index.ops.html +38 -23
  244. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  245. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  246. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  247. package/docs/variables/system.primaryKeyTypes.html +1 -1
  248. package/package.json +1 -1
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Raised when resolving a serializer key against a class yields an empty set of serializers.
3
+ *
4
+ * This is an internal-invariant failure, not a misconfiguration: an unresolvable serializer key
5
+ * raises `MissingSerializersDefinitionForKey` (and a class with no `serializers` getter at all
6
+ * raises `MissingSerializersDefinition`) while it is being resolved, and STI expansion always
7
+ * yields at least one class, so there is no supported application shape that reaches this. It is
8
+ * typed rather than a bare `Error` so that it can be asserted on, and so that the two callers that
9
+ * guard the invariant raise the same thing.
10
+ */
11
+ export default class NoSerializersResolvedForKey extends Error {
12
+ classDef;
13
+ serializerKey;
14
+ constructor(classDef, serializerKey) {
15
+ super();
16
+ this.classDef = classDef;
17
+ this.serializerKey = serializerKey;
18
+ }
19
+ get message() {
20
+ const className = this.classDef === null || this.classDef === undefined
21
+ ? String(this.classDef)
22
+ : (this.classDef.globalName ?? this.classDef.name);
23
+ return `
24
+ No serializers resolved for \`${className}\` under serializer key \`${this.serializerKey}\`
25
+
26
+ This should be impossible — a serializer key that cannot be resolved throws while it is being
27
+ resolved, before reaching here — and indicates a bug in Dream rather than a problem with your
28
+ application. Please report it.`;
29
+ }
30
+ }
@@ -117,11 +117,27 @@ const ops = {
117
117
  /**
118
118
  * Creates an `OpsStatement` using the `=` operator for strict equality.
119
119
  *
120
+ * **Array columns:** passing an array means literal array equality — the
121
+ * whole array is bound as a single parameter, so the column must match
122
+ * element-for-element, in order. This is the opt-in counterpart to a bare
123
+ * array in a where clause (`Post.where({ tags: ['a', 'b'] })`), which means
124
+ * `IN`, and to `ops.any`, which tests for containment of a single element.
125
+ * Element types are constrained by the column, so an enum array column
126
+ * rejects a string that is not a member of its enum.
127
+ *
128
+ * Note that PostgreSQL defines no equality operator for `json` (as opposed
129
+ * to `jsonb`), so `ops.equal` against a `json[]` column raises at the
130
+ * database.
131
+ *
120
132
  * @param equal - The value the column must equal.
121
133
  * @returns An `OpsStatement` using the `=` operator.
122
134
  *
123
135
  * @example
124
136
  * User.where({ role: ops.equal('admin') })
137
+ *
138
+ * @example
139
+ * // matches only rows whose tags column is exactly ['a', 'b'], in that order
140
+ * Post.where({ tags: ops.equal(['a', 'b']) })
125
141
  */
126
142
  equal: (equal) => new OpsStatement('=', equal),
127
143
  /**
@@ -260,6 +276,9 @@ const ops = {
260
276
  /**
261
277
  * Creates an `OpsStatement` using the `!=` operator for inequality.
262
278
  *
279
+ * **Array columns:** as with `ops.equal`, passing an array compares against
280
+ * the whole array rather than against its elements.
281
+ *
263
282
  * @param equal - The value the column must not equal.
264
283
  * @returns An `OpsStatement` using the `!=` operator.
265
284
  *
@@ -3,6 +3,7 @@ import MissingSerializersDefinition from '../../errors/serializers/MissingSerial
3
3
  import MissingSerializersDefinitionForKey from '../../errors/serializers/MissingSerializersDefinitionForKey.js';
4
4
  import NoGlobalSerializerForSpecifiedKey from '../../errors/serializers/NoGlobalSerializerForSpecifiedKey.js';
5
5
  import NonDreamSerializerDerivedFromGlobalSerializerForSpecifiedKey from '../../errors/serializers/NonDreamSerializerDerivedFromGlobalSerializerForSpecifiedKey.js';
6
+ import NoSerializersResolvedForKey from '../../errors/serializers/NoSerializersResolvedForKey.js';
6
7
  import compact from '../../helpers/compact.js';
7
8
  import expandStiClasses from '../../helpers/sti/expandStiClasses.js';
8
9
  import uniq from '../../helpers/uniq.js';
@@ -35,3 +36,24 @@ export function inferSerializersFromDreamClassOrViewModelClass(classDef, seriali
35
36
  const serializers = classes.map(classDef => inferSerializerFromDreamOrViewModel(classDef.prototype, serializerKey));
36
37
  return uniq(compact(serializers));
37
38
  }
39
+ /**
40
+ * As `inferSerializersFromDreamClassOrViewModelClass`, but guarantees a non-empty result.
41
+ *
42
+ * The guard is unreachable today, and deliberately not phrased as "no serializer found for key": a
43
+ * serializer key that a class does not register throws `MissingSerializersDefinitionForKey` (and a
44
+ * class with no `serializers` getter at all throws `MissingSerializersDefinition`) from inside the
45
+ * inference above, and `expandStiClasses` always yields at least one class. It exists only so that
46
+ * a future change to any of those cannot turn an unresolvable serializer key into a silently empty
47
+ * serializer set — which, for the callers below, means a silently empty preload set and a
48
+ * `NonLoadedAssociation` at render rather than an error naming the real problem.
49
+ *
50
+ * Used by everything that walks a serializer graph rooted on a class rather than an instance —
51
+ * preload-path building and serialization display — so that they raise one typed error between
52
+ * them.
53
+ */
54
+ export function inferSerializersFromDreamClassOrViewModelClassOrFail(classDef, serializerKey) {
55
+ const serializers = inferSerializersFromDreamClassOrViewModelClass(classDef, serializerKey);
56
+ if (serializers.length === 0)
57
+ throw new NoSerializersResolvedForKey(classDef, serializerKey);
58
+ return serializers;
59
+ }
@@ -15,5 +15,18 @@ export function serializerForAssociatedClass(object, associationName, options) {
15
15
  const associatedClass = association.modelCB();
16
16
  if (Array.isArray(associatedClass))
17
17
  throw new Error('rendersOne flatten is incompatible with a polymorphic belongs-to association');
18
+ // Taking the first is only a narrowing when `associatedClass` is an STI base, where
19
+ // inferSerializersFromDreamClassOrViewModelClass returns one serializer per child sorted by
20
+ // sanitizedName. It is correct here because there is no associated row: this function is reached
21
+ // only from SerializerRenderer's `flatten: true` branch for a *null* associated object, so no
22
+ // child of the base is the right one — the row that would have decided is absent. (rendersMany
23
+ // never reaches here and does not accept `flatten`.)
24
+ //
25
+ // It is observable, though, so it is not "nothing happens": the picked serializer is invoked and
26
+ // its output spread into the parent's payload, so the parent JSON carries the
27
+ // alphabetically-first child's flattened keys as nulls, and omits keys only a later-sorting child
28
+ // would flatten. Unioning every child's serializer here — which is what buildSerializerPreloadPaths
29
+ // does for preload paths — is meaningless: exactly one set of keys can be flattened into the
30
+ // payload.
18
31
  return inferSerializersFromDreamClassOrViewModelClass(associatedClass, options.serializerKey)[0] ?? null;
19
32
  }
@@ -19,9 +19,9 @@ import destroyDream from './dream/internal/destroyDream.js';
19
19
  import { destroyOptions, reallyDestroyOptions, undestroyOptions, } from './dream/internal/destroyOptions.js';
20
20
  import ensureSTITypeFieldIsSet from './dream/internal/ensureSTITypeFieldIsSet.js';
21
21
  import findOrCreateBy from './dream/internal/findOrCreateBy.js';
22
+ import mergeSerializerAssociationEdges from './dream/internal/mergeSerializerAssociationEdges.js';
22
23
  import printSerializerHierarchyLevel from './dream/internal/printSerializerHierarchyLevel.js';
23
24
  import reload from './dream/internal/reload.js';
24
- import resolveSerializerAssociationEdges from './dream/internal/resolveSerializerAssociationEdges.js';
25
25
  import runValidations from './dream/internal/runValidations.js';
26
26
  import saveDream from './dream/internal/saveDream.js';
27
27
  import { DEFAULT_BYPASS_ALL_DEFAULT_SCOPES, DEFAULT_DEFAULT_SCOPES_TO_BYPASS, DEFAULT_SKIP_HOOKS, } from './dream/internal/scopeHelpers.js';
@@ -48,7 +48,7 @@ import areEqual from './helpers/areEqual.js';
48
48
  import cloneDeepSafe from './helpers/cloneDeepSafe.js';
49
49
  import isJsonColumn from './helpers/db/types/isJsonColumn.js';
50
50
  import notEqual from './helpers/notEqual.js';
51
- import { inferSerializersFromDreamClassOrViewModelClass } from './serializer/helpers/inferSerializerFromDreamOrViewModel.js';
51
+ import { inferSerializersFromDreamClassOrViewModelClassOrFail } from './serializer/helpers/inferSerializerFromDreamOrViewModel.js';
52
52
  export default class Dream {
53
53
  DB;
54
54
  /**
@@ -542,44 +542,89 @@ export default class Dream {
542
542
  }
543
543
  static serializationMap(serializerKey) {
544
544
  const key = serializerKey || 'default';
545
- const serializer = inferSerializersFromDreamClassOrViewModelClass(this, key)[0] ?? null;
546
- if (!serializer)
547
- throw new Error(`unable to find serializer with key: ${key}`);
548
- return this.recursiveSerializationMap(serializer);
545
+ // Deliberately one serializer: this maps a single serializer's view of the class, and taking
546
+ // the first is only a narrowing when `this` is an STI base (where
547
+ // inferSerializersFromDreamClassOrViewModelClass returns one serializer per child, sorted by
548
+ // sanitizedName). That narrowing is deliberate and differs from `buildSerializerPreloadPaths`
549
+ // and `displaySerialization` below, both of which union every child's serializer because they
550
+ // answer "what does a query load / display for this class", not "what does one serializer
551
+ // render". Nothing in src/ calls
552
+ // this method; it exists as the single-serializer entry point onto recursiveSerializationMap and
553
+ // is exercised by spec/unit/dream/serializationMap.spec.ts, which pins the narrowing on an STI
554
+ // base so that the divergence from displaySerialization is executable rather than only
555
+ // described here. A caller that wants the union for an STI base wants displaySerialization's
556
+ // shape, not this one.
557
+ //
558
+ // Note that the narrowing is to one serializer of *this* class only: every association this
559
+ // serializer renders is still walked with the full set of serializers its target resolves to,
560
+ // so an STI base reached as a target below is unioned.
561
+ const serializer = inferSerializersFromDreamClassOrViewModelClassOrFail(this, key)[0];
562
+ return this.recursiveSerializationMap([serializer]);
549
563
  }
550
564
  static displaySerialization(serializerKey) {
551
565
  const key = serializerKey || 'default';
552
- const serializer = inferSerializersFromDreamClassOrViewModelClass(this, key)[0] ?? null;
553
- if (!serializer)
554
- throw new Error(`unable to find serializer with key: ${key}`);
566
+ /**
567
+ * When `this` is an STI base, this is one serializer per STI child (deduped), since each child
568
+ * may register a different serializer under `key`. Every one is walked, because this method
569
+ * backs `psy i:serialization`, whose purpose is verifying what gets preloaded — and
570
+ * `preloadFor` unions the preload paths of every child's serializer. Walking only one child's
571
+ * serializer would print a strict subset of what the query actually loads.
572
+ */
573
+ // ...OrFail, rather than the plain inference, so that an empty serializer set raises
574
+ // NoSerializersResolvedForKey instead of silently printing a class name with nothing under it.
575
+ // That guard is unreachable today and is documented where it lives; it is shared with
576
+ // buildSerializerPreloadPaths so that the display and the preload walk raise the same thing.
577
+ const serializers = inferSerializersFromDreamClassOrViewModelClassOrFail(this, key);
555
578
  // eslint-disable-next-line no-console
556
579
  console.log(yoctocolors.cyan(this.sanitizedName));
557
- // eslint-disable-next-line no-console
558
- console.log(yoctocolors.gray(serializer.globalName));
559
- return this.recursiveSerializationMap(serializer, {
580
+ // One line per serializer: for an STI base the displayed tree is the union across its children,
581
+ // so naming a single child's serializer under the base's own name would misattribute the tree.
582
+ serializers.forEach(serializer => {
583
+ // eslint-disable-next-line no-console
584
+ console.log(yoctocolors.gray(serializer.globalName));
585
+ });
586
+ return this.recursiveSerializationMap(serializers, {
560
587
  forDisplay: true,
561
588
  });
562
589
  }
563
- static recursiveSerializationMap(serializer, { forDisplay = false, forDisplayDepth = 0, repeatedAssociationTracker = {}, } = {}) {
590
+ /**
591
+ * @internal
592
+ *
593
+ * Maps the association tree reachable from `serializers` — every serializer that can render this
594
+ * class at this point in the walk, which is more than one when this class is an STI base. The
595
+ * serializers are merged into one edge set (see mergeSerializerAssociationEdges) rather than
596
+ * walked one at a time, so an association several of them render is visited once and the nested
597
+ * trees of a shared target merge instead of clobbering each other.
598
+ */
599
+ static recursiveSerializationMap(serializers, { forDisplay = false, forDisplayDepth = 0, repeatedAssociationTracker = {}, } = {}) {
564
600
  const depthTrackerId = this.globalName;
565
601
  repeatedAssociationTracker[depthTrackerId] ??= 0;
566
602
  if (repeatedAssociationTracker[depthTrackerId] + 1 > RECURSIVE_SERIALIZATION_MAX_REPEATS)
567
603
  return {};
568
604
  repeatedAssociationTracker[depthTrackerId]++;
569
- const edges = resolveSerializerAssociationEdges(this, serializer);
605
+ const edges = mergeSerializerAssociationEdges(this, serializers);
570
606
  const mappedAssociations = edges.reduce((accumulator, edge) => {
607
+ // An edge with no targets terminates: either a delegatedAttribute (never printed, and
608
+ // carrying `targets: []` by construction) or an association resolving to no serializer. Its
609
+ // nestedSerializerInfo is `{}` because this reduce never runs.
571
610
  const innerAssociationSerializerInfo = edge.targets.reduce((innerAccumulator, target) => {
572
- if (forDisplay && edge.type !== 'delegatedAttribute') {
573
- printSerializerHierarchyLevel({
574
- serializerAssociationType: edge.type,
575
- serializerAssociationName: edge.serializerAssociationName,
576
- associationSerializer: target.serializer,
577
- forDisplayDepth,
578
- });
611
+ if (forDisplay) {
612
+ // One heading per (association, target class), naming every serializer that renders that
613
+ // target underneath it, and then — below all of them — the union of their association
614
+ // trees, which is what `preloadFor` loads through this association.
615
+ //
616
+ // The recursion below is a single merged walk of every serializer of the target, so
617
+ // there is one tree, not one per child. Grouping the serializer names under a single
618
+ // heading is what keeps that honest — repeating the heading per serializer would leave
619
+ // the merged tree indented directly beneath the last one's name and attribute every
620
+ // sibling's associations to it.
621
+ printSerializerHierarchyLevel({ targetSerializers: target.serializers, forDisplayDepth });
579
622
  }
580
623
  return {
581
624
  ...innerAccumulator,
582
- ...target.dreamClass['recursiveSerializationMap'](target.serializer, {
625
+ // recursed once per target class with *all* of that class's serializers, so their nested
626
+ // trees merge rather than the last one winning a shallow spread
627
+ ...target.dreamClass['recursiveSerializationMap'](target.serializers.map(({ serializer }) => serializer), {
583
628
  forDisplay,
584
629
  forDisplayDepth: forDisplayDepth + 1,
585
630
  repeatedAssociationTracker,
@@ -588,7 +633,7 @@ export default class Dream {
588
633
  }, {});
589
634
  accumulator[edge.associationAs] = {
590
635
  parentDreamClass: this,
591
- nestedSerializerInfo: edge.type === 'delegatedAttribute' ? {} : innerAssociationSerializerInfo,
636
+ nestedSerializerInfo: innerAssociationSerializerInfo,
592
637
  };
593
638
  return accumulator;
594
639
  }, {});
@@ -1330,6 +1375,9 @@ export default class Dream {
1330
1375
  * // User{email: 'goodbye@world'}
1331
1376
  * ```
1332
1377
  *
1378
+ * Records are always visited in ascending primary key order; any `order`
1379
+ * applied to the query is discarded. See {@link Query.findEach} for why.
1380
+ *
1333
1381
  * @param cb - The callback to call for each found record
1334
1382
  * @param opts - Optional parameters for batch processing
1335
1383
  * @param opts.batchSize - the batch size you wish to collect records in. If not provided, it will default to 1000
@@ -421,6 +421,9 @@ export default class DreamClassTransactionBuilder {
421
421
  * // User{email: 'goodbye@world'}
422
422
  * ```
423
423
  *
424
+ * Records are always visited in ascending primary key order; any `order`
425
+ * applied to the query is discarded. See {@link Query.findEach} for why.
426
+ *
424
427
  * @param cb - The callback to call for each found record
425
428
  * @param options.batchSize - The batch size you wish to collect records in. If not provided, it will default to 1000
426
429
  * @returns void
@@ -3,7 +3,9 @@ import DreamApp from '../dream-app/index.js';
3
3
  import AssociationDeclaredWithoutAssociatedDreamClass from '../errors/associations/AssociationDeclaredWithoutAssociatedDreamClass.js';
4
4
  import CannotCallUndestroyOnANonSoftDeleteModel from '../errors/CannotCallUndestroyOnANonSoftDeleteModel.js';
5
5
  import CannotPassAdditionalFieldsToPluckEachAfterCallback from '../errors/CannotPassAdditionalFieldsToPluckEachAfterCallback.js';
6
+ import InvalidBatchSize from '../errors/InvalidBatchSize.js';
6
7
  import LeftJoinPreloadIncompatibleWithFindEach from '../errors/LeftJoinPreloadIncompatibleWithFindEach.js';
8
+ import RowLockIncompatibleWithDistinct from '../errors/RowLockIncompatibleWithDistinct.js';
7
9
  import MissingRequiredCallbackFunctionToPluckEach from '../errors/MissingRequiredCallbackFunctionToPluckEach.js';
8
10
  import NoUpdateAllOnJoins from '../errors/NoUpdateAllOnJoins.js';
9
11
  import NoUpdateOnAssociationQuery from '../errors/NoUpdateOnAssociationQuery.js';
@@ -20,8 +22,26 @@ import ops from '../ops/index.js';
20
22
  import buildSerializerPreloadPaths from './internal/buildSerializerPreloadPaths.js';
21
23
  import computedPaginatePage from './internal/computedPaginatePage.js';
22
24
  import convertDreamClassAndAssociationNameTupleArrayToPreloadArgs from './internal/convertDreamClassAndAssociationNameTupleArrayToPreloadArgs.js';
23
- // Cache for serializer preload-path results, keyed by "${globalName}:${serializerKey}"
24
- const extractedNestedPathsCache = new Map();
25
+ /**
26
+ * Cache for serializer preload-path results, keyed on the root class itself and then the serializer
27
+ * key. Keying on the class rather than its global name is what makes the cache self-invalidating:
28
+ * loading a fresh set of model classes into the process yields new class objects, so entries
29
+ * resolved against the previous ones become unreachable and collectable rather than being served
30
+ * under a name that now means something else.
31
+ */
32
+ let extractedNestedPathsCache = new WeakMap();
33
+ /**
34
+ * @internal
35
+ *
36
+ * Drops every memoized preload path. Only needed when a class object that stays in the process has
37
+ * its `serializers` getter replaced, which the cache cannot detect — specs that install or remove
38
+ * one around an example. Replacing the model classes themselves needs no call, since the cache
39
+ * keys on the class object. See `clearResolvedSerializerAssociationEdgesCache`, which has the same
40
+ * contract and is cleared alongside it.
41
+ */
42
+ export function clearSerializerPreloadPathsCache() {
43
+ extractedNestedPathsCache = new WeakMap();
44
+ }
25
45
  export default class Query {
26
46
  /**
27
47
  * @internal
@@ -31,6 +51,10 @@ export default class Query {
31
51
  */
32
52
  static BATCH_SIZES = {
33
53
  FIND_EACH: 1000,
54
+ // deliberately small: every record in a locked destroy batch stays
55
+ // row-locked for the whole batch, so an oversized batch blocks unrelated
56
+ // writers. See Query#destroy's `lock` option.
57
+ LOCKED_DESTROY: 10,
34
58
  PLUCK_EACH: 10000,
35
59
  PLUCK_EACH_THROUGH: 1000,
36
60
  };
@@ -398,13 +422,30 @@ export default class Query {
398
422
  * records matching the Query have been fetched.
399
423
  *
400
424
  * ```ts
401
- * await User.order('id').findEach(user => {
425
+ * await User.where({ deletedAt: null }).findEach(user => {
402
426
  * DreamApp.log(user)
403
427
  * })
404
428
  * // User{id: 1}
405
429
  * // User{id: 2}
406
430
  * ```
407
431
  *
432
+ * IMPORTANT: `findEach` always iterates in ascending primary key order.
433
+ * Any `order` applied to the Query is discarded; passing one has no effect
434
+ * on the order records are visited in.
435
+ *
436
+ * This is not a limitation that can be lifted. `findEach` guarantees that
437
+ * every matching record is visited exactly once, and it delivers that by
438
+ * paginating on a keyset: each batch selects the next `batchSize` records
439
+ * with a primary key greater than the last record of the previous batch.
440
+ * That guarantee only holds over a stable, unique key. Ordering by a
441
+ * mutable, non-unique column would mean a record whose sort value changed
442
+ * partway through the iteration could be skipped entirely or visited twice.
443
+ * ({@link Query.cursorPaginate} can honor an arbitrary order precisely
444
+ * because a paginated UI tolerates that anomaly; a full traversal cannot.)
445
+ *
446
+ * If you need records processed in a particular order, load them with a
447
+ * regular ordered query instead of `findEach`.
448
+ *
408
449
  * @param cb - The callback to call for each found record
409
450
  * @param __namedParameters - Options for batch processing
410
451
  * @param __namedParameters.batchSize - The batch size you wish to collect records in. If not provided, it will default to 1000
@@ -537,17 +578,30 @@ export default class Query {
537
578
  * @returns A Query with all serialization associations preloaded
538
579
  */
539
580
  preloadFor(serializerKey, modifierFn) {
540
- const cacheKey = `${this.dreamClass.globalName}:${serializerKey ?? 'default'}`;
541
- let preloadArgs = extractedNestedPathsCache.get(cacheKey);
581
+ const cacheKey = serializerKey ?? 'default';
582
+ let pathsBySerializerKey = extractedNestedPathsCache.get(this.dreamClass);
583
+ if (!pathsBySerializerKey) {
584
+ pathsBySerializerKey = new Map();
585
+ extractedNestedPathsCache.set(this.dreamClass, pathsBySerializerKey);
586
+ }
587
+ let preloadArgs = pathsBySerializerKey.get(cacheKey);
542
588
  if (!preloadArgs) {
543
589
  preloadArgs = buildSerializerPreloadPaths(this.dreamClass, serializerKey);
544
- extractedNestedPathsCache.set(cacheKey, preloadArgs);
590
+ pathsBySerializerKey.set(cacheKey, preloadArgs);
545
591
  }
546
- let query = this;
592
+ // Rather than calling `preload` once per path (which deep clones the accumulating preload tree
593
+ // on every call, making this quadratic in the number of paths), clone once, flesh out every
594
+ // path against the same accumulators, and clone the Query once at the end. Iteration order is
595
+ // preserved, so a later path's `and` still wins over an earlier path's for a shared prefix.
596
+ const preloadStatements = cloneDeepSafe(this.preloadStatements);
597
+ const preloadOnStatements = cloneDeepSafe(this.preloadOnStatements);
547
598
  preloadArgs.forEach(dreamClassAndAssociationNameTupleArray => {
548
- query = query.preload(...convertDreamClassAndAssociationNameTupleArrayToPreloadArgs(dreamClassAndAssociationNameTupleArray, modifierFn));
599
+ const args = convertDreamClassAndAssociationNameTupleArrayToPreloadArgs(dreamClassAndAssociationNameTupleArray, modifierFn);
600
+ // `modifierFn` returning 'omit' prunes the path from the omitted association down, which can
601
+ // leave nothing to preload; `fleshOutJoinStatements` treats an empty arg list as a no-op.
602
+ this.fleshOutJoinStatements([], preloadStatements, preloadOnStatements, null, args);
549
603
  });
550
- return query;
604
+ return this.clone({ preloadStatements, preloadOnStatements });
551
605
  }
552
606
  /**
553
607
  * Returns a new Query instance, with the provided
@@ -1741,21 +1795,109 @@ export default class Query {
1741
1795
  * // 12
1742
1796
  * ```
1743
1797
  *
1798
+ * ## Guarded (compare-and-set) destroy
1799
+ *
1800
+ * By default, `destroy` reads the matching records and then destroys each of
1801
+ * them, so a concurrent writer can change a record in between — the record is
1802
+ * destroyed even though it no longer matches the Query that selected it.
1803
+ * Passing `lock: true` closes that window, making `destroy` a compare-and-set
1804
+ * the way `update(..., { skipHooks: true })` already is:
1805
+ *
1806
+ * ```ts
1807
+ * const destroyed = await Order.where({ status: 'pending' }).destroy({ lock: true })
1808
+ * // 0 — someone else moved every one of them out of 'pending' first
1809
+ * ```
1810
+ *
1811
+ * With `lock: true`, records are processed in batches, and each batch runs in
1812
+ * its own transaction which first re-selects that batch's records with an
1813
+ * exclusive row lock and then destroys them before committing. Because the
1814
+ * database re-checks the Query's conditions after acquiring each row lock,
1815
+ * a record a concurrent transaction has already moved out of the Query drops
1816
+ * out of the locked read and is never destroyed. The returned count is the
1817
+ * number of records actually claimed, so a caller can detect that it lost a
1818
+ * race by comparing the count against what it expected.
1819
+ *
1820
+ * Things to know before relying on it:
1821
+ *
1822
+ * - **The guarantee is per batch, not set-wide.** A run spanning more than one
1823
+ * batch can win a race in batch 1 and lose one in batch 2; nothing holds the
1824
+ * whole matched set still for the duration of the run. Sharing one
1825
+ * transaction across every batch — by threading a transaction onto the Query
1826
+ * itself, `Order.query().txn(txn).where({ ... }).destroy({ lock: true })` —
1827
+ * buys two things: the destroys become all-or-nothing (a rollback undoes the
1828
+ * whole run), and the row locks taken by every batch are held until you
1829
+ * commit. It does **not** extend the compare-and-set to the part of the set
1830
+ * the run has not reached yet: under READ COMMITTED each statement takes a
1831
+ * fresh snapshot, so a record moved out of the Query between batch 1 and
1832
+ * batch 2 still drops out of batch 2's locked read. A genuinely set-wide
1833
+ * guarantee needs REPEATABLE READ / SERIALIZABLE, or a single locking
1834
+ * statement over the whole set.
1835
+ * - **Only a transaction carried by the Query is shared.** Merely wrapping the
1836
+ * call in `Model.transaction(...)` does not thread that transaction onto the
1837
+ * Query — `Dream.transaction` establishes no ambient transaction, it only
1838
+ * hands a `txn` to your callback — so each batch would open its own
1839
+ * transaction on a different pooled connection. Worse, if the surrounding
1840
+ * transaction has already written to any of the matched rows, each batch's
1841
+ * locked read blocks on locks the surrounding transaction holds while that
1842
+ * transaction awaits the destroy: a cycle across two connections, which
1843
+ * Postgres's deadlock detector cannot see, so it hangs rather than aborting.
1844
+ * Always pass the transaction with `.txn(txn)`.
1845
+ * - **Behavior above READ COMMITTED differs.** The drop-out-of-the-result
1846
+ * behavior described above is READ COMMITTED (Postgres's default). Under
1847
+ * REPEATABLE READ or SERIALIZABLE, the same race raises a serialization
1848
+ * failure instead of silently returning a lower count. Both are safe; only
1849
+ * one of them is quiet.
1850
+ * - **`batchSize` defaults to 10 here**, not `findEach`'s 1000, and the
1851
+ * default is deliberately small. Every row in a batch stays locked for the
1852
+ * whole batch, including the time spent running each record's hooks and
1853
+ * `dependent: 'destroy'` cascade, so an oversized batch blocks unrelated
1854
+ * writers touching those rows and surfaces as timeouts somewhere else in the
1855
+ * application, which is very hard to trace back to this destroy. Too small a
1856
+ * batch, by contrast, only slows down this call, which you can see and fix.
1857
+ * Lower it further for models with deep `dependent: 'destroy'` cascades or
1858
+ * expensive destroy hooks.
1859
+ * - **A batch waits, without bound, for whoever holds the row.** The locked
1860
+ * read is a plain `FOR UPDATE`: if another transaction already holds a lock
1861
+ * on one of the batch's rows, this call blocks until that transaction ends,
1862
+ * holding its own transaction and its pooled connection open the whole
1863
+ * time. Dream sets no `lock_timeout` or `statement_timeout`, so a
1864
+ * long-running writer on the other side stalls the run indefinitely and, at
1865
+ * scale, ties up connections. If that is a risk in your application, set a
1866
+ * `lock_timeout` on the connection so a stuck batch fails instead of
1867
+ * hanging.
1868
+ * - **If you are destroying a large set and do not need compare-and-set, do
1869
+ * not pass `lock`.** Plain `destroy()` (hooks and cascade, no locking) or
1870
+ * {@link Query.delete | delete} (a single statement, no hooks or cascade)
1871
+ * are the right tools for bulk removal.
1872
+ *
1744
1873
  * @param __namedParameters - Options for destroying the instance
1745
1874
  * @param __namedParameters.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
1746
1875
  * @param __namedParameters.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
1876
+ * @param __namedParameters.lock - If true, each batch is re-selected with an exclusive row lock inside its own transaction before being destroyed, making the destroy a compare-and-set. Defaults to false
1877
+ * @param __namedParameters.batchSize - The number of records to process per batch. Must be a positive integer. Defaults to 10 when `lock` is true, and to 1000 otherwise
1747
1878
  * @param __namedParameters.bypassAllDefaultScopes - If true, bypasses all default scopes when cascade destroying. Defaults to false
1748
1879
  * @param __namedParameters.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
1749
1880
  * @returns The number of records that were removed
1881
+ * @throws InvalidBatchSize if `batchSize` is not a positive integer
1750
1882
  */
1751
- async destroy({ skipHooks, cascade, } = {}) {
1883
+ async destroy({ skipHooks, cascade, lock, batchSize, } = {}) {
1752
1884
  let counter = 0;
1885
+ // resolve the default before validating: `??` would let an explicit 0
1886
+ // through, and `limit(0)` means "no limit", which would turn a batched
1887
+ // destroy into a single unbounded pass (and, with `lock`, a lock over the
1888
+ // entire matched set)
1889
+ const resolvedBatchSize = batchSize ?? (lock ? Query.BATCH_SIZES.LOCKED_DESTROY : Query.BATCH_SIZES.FIND_EACH);
1890
+ if (!Number.isInteger(resolvedBatchSize) || resolvedBatchSize < 1)
1891
+ throw new InvalidBatchSize(batchSize);
1753
1892
  const options = {
1754
1893
  bypassAllDefaultScopes: this.bypassAllDefaultScopes,
1755
1894
  defaultScopesToBypass: this.defaultScopesToBypass,
1756
1895
  skipHooks,
1757
1896
  cascade,
1758
1897
  };
1898
+ if (lock) {
1899
+ return await this.lockedDestroy(options, resolvedBatchSize);
1900
+ }
1759
1901
  await this.findEach(async (result) => {
1760
1902
  const subquery = this.dreamTransaction
1761
1903
  ? result.txn(this.dreamTransaction)
@@ -1767,9 +1909,94 @@ export default class Query {
1767
1909
  await subquery.destroy(options);
1768
1910
  }
1769
1911
  counter++;
1912
+ }, {
1913
+ batchSize: resolvedBatchSize,
1770
1914
  });
1771
1915
  return counter;
1772
1916
  }
1917
+ /**
1918
+ * @internal
1919
+ *
1920
+ * The compare-and-set implementation behind `destroy({ lock: true })`.
1921
+ *
1922
+ * Each batch is handled in two steps inside one transaction:
1923
+ *
1924
+ * 1. an unlocked pluck of the next `batchSize` primary keys, in ascending
1925
+ * primary key order, which establishes the batch's window (its keyset
1926
+ * cursor) and tells us how many records were *attempted*. Only the keys are
1927
+ * read: hydrating and preloading these records would duplicate the work
1928
+ * step 2 does over the same rows; then
1929
+ * 2. a locked re-read restricted to exactly those primary keys, which
1930
+ * re-applies every condition on the Query while acquiring an exclusive row
1931
+ * lock on each row it returns. Records a concurrent transaction has moved
1932
+ * out of the Query since step 1 do not come back, and are the records this
1933
+ * run lost the race for.
1934
+ *
1935
+ * The count comes from step 2's result set, which is why it reports records
1936
+ * actually claimed rather than records attempted. (One thing it does not
1937
+ * account for, because it never has: a `beforeDestroy` hook that sets
1938
+ * `_preventDeletion` leaves the record in place but still counts.)
1939
+ *
1940
+ * The two-step shape is what keeps iteration correct. If the locked read both
1941
+ * claimed rows and advanced the cursor, a batch whose records all lost the
1942
+ * race would come back empty and end the run early, silently skipping every
1943
+ * record after it. Advancing the cursor from step 1 instead means losing a
1944
+ * race skips only the records that were lost.
1945
+ *
1946
+ * When the Query already carries a transaction, that transaction is used for
1947
+ * every batch rather than opening one per batch, so the caller's transaction
1948
+ * boundary wins and the locks are held until the caller commits.
1949
+ */
1950
+ async lockedDestroy(options, batchSize) {
1951
+ if (this.joinLoadActivated)
1952
+ throw new LeftJoinPreloadIncompatibleWithFindEach();
1953
+ // the driver seam refuses this combination too, but catching it here means
1954
+ // failing before any transaction is opened or any row is touched
1955
+ if (this.distinctColumn)
1956
+ throw new RowLockIncompatibleWithDistinct();
1957
+ const primaryKey = this.dreamInstance['_primaryKey'];
1958
+ const orderedQuery = this.order(null)
1959
+ .order(this.namespacedPrimaryKey)
1960
+ .limit(batchSize);
1961
+ let counter = 0;
1962
+ // the cursor is compared against a sentinel rather than tested for
1963
+ // truthiness, since a primary key of 0 is a legitimate cursor value that
1964
+ // would otherwise reset the window to the start of the set
1965
+ let lastId = undefined;
1966
+ let attemptedCount = 0;
1967
+ do {
1968
+ const batchQuery = lastId === undefined
1969
+ ? orderedQuery
1970
+ : orderedQuery.where({ [primaryKey]: ops.greaterThan(lastId) });
1971
+ const destroyBatch = async (txn) => {
1972
+ const candidateIds = await batchQuery.txn(txn).pluck(this.namespacedPrimaryKey);
1973
+ if (!candidateIds.length)
1974
+ return { attempted: 0, claimed: 0, lastId };
1975
+ const claimed = await this.dbDriverInstance(batchQuery.txn(txn).where({ [primaryKey]: candidateIds })).takeAll({ lock: true });
1976
+ for (const record of claimed) {
1977
+ const inTransaction = record.txn(txn);
1978
+ if (this.shouldReallyDestroy) {
1979
+ await inTransaction.reallyDestroy(options);
1980
+ }
1981
+ else {
1982
+ await inTransaction.destroy(options);
1983
+ }
1984
+ }
1985
+ return {
1986
+ attempted: candidateIds.length,
1987
+ claimed: claimed.length,
1988
+ lastId: candidateIds.at(-1),
1989
+ };
1990
+ };
1991
+ const results = this.dreamTransaction
1992
+ ? await destroyBatch(this.dreamTransaction)
1993
+ : await this.dreamClass.transaction(async (txn) => await destroyBatch(txn));
1994
+ counter += results.claimed;
1995
+ attemptedCount = results.attempted;
1996
+ lastId = results.lastId;
1997
+ } while (attemptedCount > 0 && attemptedCount === batchSize);
1998
+ return counter;
1999
+ }
1773
2000
  /**
1774
2001
  * Destroys all records matching the Query,
1775
2002
  * ignoring the SoftDelete decorator and
@@ -1791,12 +2018,15 @@ export default class Query {
1791
2018
  * @param __namedParameters - Options for destroying the instance
1792
2019
  * @param __namedParameters.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
1793
2020
  * @param __namedParameters.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
2021
+ * @param __namedParameters.lock - If true, each batch is re-selected with an exclusive row lock inside its own transaction before being destroyed, making the destroy a compare-and-set. See {@link Query.destroy} for the full semantics. Defaults to false
2022
+ * @param __namedParameters.batchSize - The number of records to process per batch. Must be a positive integer. Defaults to 10 when `lock` is true, and to 1000 otherwise
1794
2023
  * @param __namedParameters.bypassAllDefaultScopes - If true, bypasses all default scopes when cascade destroying. Defaults to false
1795
2024
  * @param __namedParameters.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
1796
2025
  * @returns The number of records that were removed
2026
+ * @throws InvalidBatchSize if `batchSize` is not a positive integer
1797
2027
  */
1798
- async reallyDestroy({ skipHooks, cascade, } = {}) {
1799
- return await this.clone({ shouldReallyDestroy: true }).destroy({ skipHooks, cascade });
2028
+ async reallyDestroy({ skipHooks, cascade, lock, batchSize, } = {}) {
2029
+ return await this.clone({ shouldReallyDestroy: true }).destroy({ skipHooks, cascade, lock, batchSize });
1800
2030
  }
1801
2031
  /**
1802
2032
  * Undestroys a SoftDelete model, unsetting