@rvoh/dream 2.20.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/dist/cjs/src/Dream.js +3 -0
  2. package/dist/cjs/src/cli/index.js +7 -4
  3. package/dist/cjs/src/dream/DreamClassTransactionBuilder.js +3 -0
  4. package/dist/cjs/src/dream/Query.js +203 -4
  5. package/dist/cjs/src/dream/QueryDriver/Base.js +26 -0
  6. package/dist/cjs/src/dream/QueryDriver/Kysely.js +46 -4
  7. package/dist/cjs/src/dream/QueryDriver/Postgres.js +21 -0
  8. package/dist/cjs/src/dream/internal/destroyAssociatedRecords.js +76 -4
  9. package/dist/cjs/src/dream/internal/destroyDream.js +6 -7
  10. package/dist/cjs/src/errors/InvalidBatchSize.js +19 -0
  11. package/dist/cjs/src/errors/NoColumnsToAlterMigration.js +17 -0
  12. package/dist/cjs/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
  13. package/dist/cjs/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
  14. package/dist/cjs/src/errors/UnparseableMigrationColumn.js +16 -0
  15. package/dist/cjs/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
  16. package/dist/cjs/src/helpers/cli/generateMigration.js +20 -3
  17. package/dist/cjs/src/helpers/cli/generateMigrationContent.js +74 -9
  18. package/dist/cjs/src/ops/index.js +19 -0
  19. package/dist/esm/src/Dream.js +3 -0
  20. package/dist/esm/src/cli/index.js +7 -4
  21. package/dist/esm/src/dream/DreamClassTransactionBuilder.js +3 -0
  22. package/dist/esm/src/dream/Query.js +203 -4
  23. package/dist/esm/src/dream/QueryDriver/Base.js +26 -0
  24. package/dist/esm/src/dream/QueryDriver/Kysely.js +46 -4
  25. package/dist/esm/src/dream/QueryDriver/Postgres.js +21 -0
  26. package/dist/esm/src/dream/internal/destroyAssociatedRecords.js +76 -4
  27. package/dist/esm/src/dream/internal/destroyDream.js +6 -7
  28. package/dist/esm/src/errors/InvalidBatchSize.js +19 -0
  29. package/dist/esm/src/errors/NoColumnsToAlterMigration.js +17 -0
  30. package/dist/esm/src/errors/RowLockIncompatibleWithDistinct.js +14 -0
  31. package/dist/esm/src/errors/RowLockIncompatibleWithJoinLoad.js +14 -0
  32. package/dist/esm/src/errors/UnparseableMigrationColumn.js +16 -0
  33. package/dist/esm/src/errors/ops/ArrayEqualityRequiresArrayColumn.js +23 -0
  34. package/dist/esm/src/helpers/cli/generateMigration.js +20 -3
  35. package/dist/esm/src/helpers/cli/generateMigrationContent.js +74 -9
  36. package/dist/esm/src/ops/index.js +19 -0
  37. package/dist/types/src/Dream.d.ts +3 -0
  38. package/dist/types/src/cli/index.d.ts +1 -1
  39. package/dist/types/src/dream/DreamClassTransactionBuilder.d.ts +3 -0
  40. package/dist/types/src/dream/Query.d.ts +140 -3
  41. package/dist/types/src/dream/QueryDriver/Base.d.ts +21 -0
  42. package/dist/types/src/dream/QueryDriver/Kysely.d.ts +1 -0
  43. package/dist/types/src/dream/QueryDriver/Postgres.d.ts +20 -0
  44. package/dist/types/src/dream/internal/destroyAssociatedRecords.d.ts +22 -3
  45. package/dist/types/src/errors/InvalidBatchSize.d.ts +5 -0
  46. package/dist/types/src/errors/NoColumnsToAlterMigration.d.ts +6 -0
  47. package/dist/types/src/errors/RowLockIncompatibleWithDistinct.d.ts +3 -0
  48. package/dist/types/src/errors/RowLockIncompatibleWithJoinLoad.d.ts +3 -0
  49. package/dist/types/src/errors/UnparseableMigrationColumn.d.ts +5 -0
  50. package/dist/types/src/errors/ops/ArrayEqualityRequiresArrayColumn.d.ts +8 -0
  51. package/dist/types/src/helpers/cli/generateMigrationContent.d.ts +11 -1
  52. package/dist/types/src/ops/index.d.ts +19 -0
  53. package/dist/types/src/types/associations/shared.d.ts +2 -2
  54. package/dist/types/src/types/dream.d.ts +8 -0
  55. package/dist/types/src/types/types/associations/shared.ts +84 -55
  56. package/dist/types/src/types/types/dream.ts +30 -0
  57. package/docs/assets/hierarchy.js +1 -1
  58. package/docs/assets/search.js +1 -1
  59. package/docs/classes/db.DreamMigrationHelpers.html +11 -11
  60. package/docs/classes/db.KyselyQueryDriver.html +55 -41
  61. package/docs/classes/db.PostgresQueryDriver.html +55 -42
  62. package/docs/classes/db.QueryDriverBase.html +54 -40
  63. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  64. package/docs/classes/errors.ColumnOverflow.html +3 -3
  65. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  66. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  67. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  68. package/docs/classes/errors.DecryptionError.html +2 -2
  69. package/docs/classes/errors.DecryptionParseError.html +2 -2
  70. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  71. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  72. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  73. package/docs/classes/errors.InvalidClockTime.html +2 -2
  74. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  75. package/docs/classes/errors.InvalidDateTime.html +2 -2
  76. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  77. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  78. package/docs/classes/errors.NotNullViolation.html +3 -3
  79. package/docs/classes/errors.RecordNotFound.html +3 -3
  80. package/docs/classes/errors.ValidationError.html +3 -3
  81. package/docs/classes/index.CalendarDate.html +33 -33
  82. package/docs/classes/index.ClockTime.html +32 -32
  83. package/docs/classes/index.ClockTimeTz.html +35 -35
  84. package/docs/classes/index.DateTime.html +86 -86
  85. package/docs/classes/index.Decorators.html +19 -19
  86. package/docs/classes/index.Dream.html +127 -125
  87. package/docs/classes/index.DreamApp.html +10 -10
  88. package/docs/classes/index.DreamTransaction.html +2 -2
  89. package/docs/classes/index.Env.html +2 -2
  90. package/docs/classes/index.Query.html +161 -71
  91. package/docs/classes/system.CliFileWriter.html +4 -4
  92. package/docs/classes/system.DreamBin.html +2 -2
  93. package/docs/classes/system.DreamCLI.html +7 -7
  94. package/docs/classes/system.DreamImporter.html +2 -2
  95. package/docs/classes/system.DreamLogos.html +2 -2
  96. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  97. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  98. package/docs/classes/system.PathHelpers.html +3 -3
  99. package/docs/classes/utils.Encrypt.html +3 -3
  100. package/docs/classes/utils.Range.html +2 -2
  101. package/docs/functions/db.closeAllDbConnections.html +1 -1
  102. package/docs/functions/db.dreamDbConnections.html +1 -1
  103. package/docs/functions/db.untypedDb.html +1 -1
  104. package/docs/functions/db.validateColumn.html +1 -1
  105. package/docs/functions/db.validateTable.html +1 -1
  106. package/docs/functions/errors.pgErrorType.html +1 -1
  107. package/docs/functions/index.DreamSerializer.html +1 -1
  108. package/docs/functions/index.ObjectSerializer.html +1 -1
  109. package/docs/functions/index.ReplicaSafe.html +1 -1
  110. package/docs/functions/index.STI.html +1 -1
  111. package/docs/functions/index.SoftDelete.html +1 -1
  112. package/docs/functions/utils.camelize.html +1 -1
  113. package/docs/functions/utils.capitalize.html +1 -1
  114. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  115. package/docs/functions/utils.compact.html +1 -1
  116. package/docs/functions/utils.groupBy.html +1 -1
  117. package/docs/functions/utils.hyphenize.html +1 -1
  118. package/docs/functions/utils.intersection.html +1 -1
  119. package/docs/functions/utils.isEmpty.html +1 -1
  120. package/docs/functions/utils.normalizeUnicode.html +1 -1
  121. package/docs/functions/utils.pascalize.html +1 -1
  122. package/docs/functions/utils.percent.html +1 -1
  123. package/docs/functions/utils.range.html +1 -1
  124. package/docs/functions/utils.round.html +1 -1
  125. package/docs/functions/utils.sanitizeString.html +1 -1
  126. package/docs/functions/utils.snakeify.html +1 -1
  127. package/docs/functions/utils.sort.html +1 -1
  128. package/docs/functions/utils.sortBy.html +1 -1
  129. package/docs/functions/utils.sortObjectByKey.html +1 -1
  130. package/docs/functions/utils.sortObjectByValue.html +1 -1
  131. package/docs/functions/utils.uncapitalize.html +1 -1
  132. package/docs/functions/utils.uniq.html +1 -1
  133. package/docs/hierarchy.html +1 -1
  134. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  135. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  136. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  137. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  138. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  139. package/docs/interfaces/types.DecoratorContext.html +2 -2
  140. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  141. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  142. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  143. package/docs/interfaces/types.DurationObject.html +2 -2
  144. package/docs/interfaces/types.EncryptOptions.html +2 -2
  145. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  146. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  147. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  148. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  149. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  150. package/docs/types/openapi.OpenapiFormats.html +1 -1
  151. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  152. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  153. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  154. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  155. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  156. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  157. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  158. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  159. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  160. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  161. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  162. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  163. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  164. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  165. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  166. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  167. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  168. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  169. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  170. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  171. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  172. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  173. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  174. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  175. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  176. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  177. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  178. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  179. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  180. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  181. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  182. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  183. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  184. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  185. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  186. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  187. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  188. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  189. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  190. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  191. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  192. package/docs/types/types.CalendarDateObject.html +1 -1
  193. package/docs/types/types.Camelized.html +1 -1
  194. package/docs/types/types.ClockTimeObject.html +1 -1
  195. package/docs/types/types.DbConnectionType.html +1 -1
  196. package/docs/types/types.DbTypes.html +1 -1
  197. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  198. package/docs/types/types.DreamAttributes.html +1 -1
  199. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  200. package/docs/types/types.DreamClassColumn.html +1 -1
  201. package/docs/types/types.DreamColumn.html +1 -1
  202. package/docs/types/types.DreamColumnNames.html +1 -1
  203. package/docs/types/types.DreamLogLevel.html +1 -1
  204. package/docs/types/types.DreamLogger.html +2 -2
  205. package/docs/types/types.DreamModelSerializerType.html +1 -1
  206. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  207. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  208. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  209. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  210. package/docs/types/types.DreamSerializable.html +1 -1
  211. package/docs/types/types.DreamSerializableArray.html +1 -1
  212. package/docs/types/types.DreamSerializerKey.html +1 -1
  213. package/docs/types/types.DreamSerializers.html +1 -1
  214. package/docs/types/types.DreamVirtualColumns.html +1 -1
  215. package/docs/types/types.DurationUnit.html +1 -1
  216. package/docs/types/types.EncryptAlgorithm.html +1 -1
  217. package/docs/types/types.HasManyStatement.html +1 -1
  218. package/docs/types/types.HasOneStatement.html +1 -1
  219. package/docs/types/types.Hyphenized.html +1 -1
  220. package/docs/types/types.Pascalized.html +1 -1
  221. package/docs/types/types.PrimaryKeyType.html +1 -1
  222. package/docs/types/types.RoundingPrecision.html +1 -1
  223. package/docs/types/types.SerializerCasing.html +1 -1
  224. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  225. package/docs/types/types.Snakeified.html +1 -1
  226. package/docs/types/types.StrictInterface.html +1 -1
  227. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  228. package/docs/types/types.UpdateableProperties.html +1 -1
  229. package/docs/types/types.ValidationType.html +1 -1
  230. package/docs/types/types.ViewModel.html +2 -2
  231. package/docs/types/types.ViewModelClass.html +1 -1
  232. package/docs/types/types.WeekdayName.html +1 -1
  233. package/docs/types/types.WhereStatementForDream.html +1 -1
  234. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  235. package/docs/variables/index.DreamConst.html +1 -1
  236. package/docs/variables/index.ops.html +38 -23
  237. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  238. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  239. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  240. package/docs/variables/system.primaryKeyTypes.html +1 -1
  241. package/package.json +1 -1
@@ -1330,6 +1330,9 @@ export default class Dream {
1330
1330
  * // User{email: 'goodbye@world'}
1331
1331
  * ```
1332
1332
  *
1333
+ * Records are always visited in ascending primary key order; any `order`
1334
+ * applied to the query is discarded. See {@link Query.findEach} for why.
1335
+ *
1333
1336
  * @param cb - The callback to call for each found record
1334
1337
  * @param opts - Optional parameters for batch processing
1335
1338
  * @param opts.batchSize - the batch size you wish to collect records in. If not provided, it will default to 1000
@@ -45,6 +45,8 @@ ${INDENT} - timetz
45
45
  ${INDENT} - timetz[]
46
46
  ${INDENT} - integer
47
47
  ${INDENT} - integer[]
48
+ ${INDENT} - boolean
49
+ ${INDENT} - boolean[]
48
50
  ${INDENT}
49
51
  ${INDENT} - decimal:
50
52
  ${INDENT} - decimal[]:
@@ -153,16 +155,17 @@ ${INDENT} # Add columns to an existing table (suffix with -to-<table_name> for
153
155
  ${INDENT} pnpm psy g:migration add-timezone-to-users timezone:string
154
156
  ${INDENT} pnpm psy g:migration add-bio-to-users bio:text:optional avatar_url:string:optional
155
157
  ${INDENT}
156
- ${INDENT} # Remove columns (suffix with -from-<table_name>)
157
- ${INDENT} pnpm psy g:migration remove-legacy-fields-from-posts
158
+ ${INDENT} # Remove columns (suffix with -from-<table_name>; columns are described with
159
+ ${INDENT} # their types, same shorthand as -to-, so the rollback (down) can recreate them)
160
+ ${INDENT} pnpm psy g:migration remove-legacy-fields-from-posts legacy_status:string
158
161
  ${INDENT}
159
162
  ${INDENT} # General schema change (no table suffix — generates empty up/down methods)
160
163
  ${INDENT} pnpm psy g:migration create-unique-index-on-invitations`)
161
164
  .argument('<migrationName>', `Kebab-case name describing the change. End with -to-<table_name> or -from-<table_name> to auto-generate an alterTable scaffold for that table.
162
165
  ${INDENT}
163
166
  ${INDENT}Examples:
164
- ${INDENT} add-phone-to-users # scaffolds alterTable('users', ...)
165
- ${INDENT} remove-status-from-posts # scaffolds alterTable('posts', ...)
167
+ ${INDENT} add-phone-to-users phone:encrypted # scaffolds alterTable('users', ...)
168
+ ${INDENT} remove-status-from-posts status:string # scaffolds alterTable('posts', ...)
166
169
  ${INDENT} create-join-table-host-places # empty migration (no table suffix match)`)
167
170
  .option('--connection-name <connectionName>', 'the database connection to use for this migration. Only needed for multi-database setups; defaults to "default"')
168
171
  .argument('[columnsWithTypes...]', columnsWithTypesDescriptionForMigration)
@@ -421,6 +421,9 @@ export default class DreamClassTransactionBuilder {
421
421
  * // User{email: 'goodbye@world'}
422
422
  * ```
423
423
  *
424
+ * Records are always visited in ascending primary key order; any `order`
425
+ * applied to the query is discarded. See {@link Query.findEach} for why.
426
+ *
424
427
  * @param cb - The callback to call for each found record
425
428
  * @param options.batchSize - The batch size you wish to collect records in. If not provided, it will default to 1000
426
429
  * @returns void
@@ -3,7 +3,9 @@ import DreamApp from '../dream-app/index.js';
3
3
  import AssociationDeclaredWithoutAssociatedDreamClass from '../errors/associations/AssociationDeclaredWithoutAssociatedDreamClass.js';
4
4
  import CannotCallUndestroyOnANonSoftDeleteModel from '../errors/CannotCallUndestroyOnANonSoftDeleteModel.js';
5
5
  import CannotPassAdditionalFieldsToPluckEachAfterCallback from '../errors/CannotPassAdditionalFieldsToPluckEachAfterCallback.js';
6
+ import InvalidBatchSize from '../errors/InvalidBatchSize.js';
6
7
  import LeftJoinPreloadIncompatibleWithFindEach from '../errors/LeftJoinPreloadIncompatibleWithFindEach.js';
8
+ import RowLockIncompatibleWithDistinct from '../errors/RowLockIncompatibleWithDistinct.js';
7
9
  import MissingRequiredCallbackFunctionToPluckEach from '../errors/MissingRequiredCallbackFunctionToPluckEach.js';
8
10
  import NoUpdateAllOnJoins from '../errors/NoUpdateAllOnJoins.js';
9
11
  import NoUpdateOnAssociationQuery from '../errors/NoUpdateOnAssociationQuery.js';
@@ -31,6 +33,10 @@ export default class Query {
31
33
  */
32
34
  static BATCH_SIZES = {
33
35
  FIND_EACH: 1000,
36
+ // deliberately small: every record in a locked destroy batch stays
37
+ // row-locked for the whole batch, so an oversized batch blocks unrelated
38
+ // writers. See Query#destroy's `lock` option.
39
+ LOCKED_DESTROY: 10,
34
40
  PLUCK_EACH: 10000,
35
41
  PLUCK_EACH_THROUGH: 1000,
36
42
  };
@@ -398,13 +404,30 @@ export default class Query {
398
404
  * records matching the Query have been fetched.
399
405
  *
400
406
  * ```ts
401
- * await User.order('id').findEach(user => {
407
+ * await User.where({ deletedAt: null }).findEach(user => {
402
408
  * DreamApp.log(user)
403
409
  * })
404
410
  * // User{id: 1}
405
411
  * // User{id: 2}
406
412
  * ```
407
413
  *
414
+ * IMPORTANT: `findEach` always iterates in ascending primary key order.
415
+ * Any `order` applied to the Query is discarded; passing one has no effect
416
+ * on the order records are visited in.
417
+ *
418
+ * This is not a limitation that can be lifted. `findEach` guarantees that
419
+ * every matching record is visited exactly once, and it delivers that by
420
+ * paginating on a keyset: each batch selects the next `batchSize` records
421
+ * with a primary key greater than the last record of the previous batch.
422
+ * That guarantee only holds over a stable, unique key. Ordering by a
423
+ * mutable, non-unique column would mean a record whose sort value changed
424
+ * partway through the iteration could be skipped entirely or visited twice.
425
+ * ({@link Query.cursorPaginate} can honor an arbitrary order precisely
426
+ * because a paginated UI tolerates that anomaly; a full traversal cannot.)
427
+ *
428
+ * If you need records processed in a particular order, load them with a
429
+ * regular ordered query instead of `findEach`.
430
+ *
408
431
  * @param cb - The callback to call for each found record
409
432
  * @param __namedParameters - Options for batch processing
410
433
  * @param __namedParameters.batchSize - The batch size you wish to collect records in. If not provided, it will default to 1000
@@ -1741,21 +1764,109 @@ export default class Query {
1741
1764
  * // 12
1742
1765
  * ```
1743
1766
  *
1767
+ * ## Guarded (compare-and-set) destroy
1768
+ *
1769
+ * By default, `destroy` reads the matching records and then destroys each of
1770
+ * them, so a concurrent writer can change a record in between — the record is
1771
+ * destroyed even though it no longer matches the Query that selected it.
1772
+ * Passing `lock: true` closes that window, making `destroy` a compare-and-set
1773
+ * the way `update(..., { skipHooks: true })` already is:
1774
+ *
1775
+ * ```ts
1776
+ * const destroyed = await Order.where({ status: 'pending' }).destroy({ lock: true })
1777
+ * // 0 — someone else moved every one of them out of 'pending' first
1778
+ * ```
1779
+ *
1780
+ * With `lock: true`, records are processed in batches, and each batch runs in
1781
+ * its own transaction which first re-selects that batch's records with an
1782
+ * exclusive row lock and then destroys them before committing. Because the
1783
+ * database re-checks the Query's conditions after acquiring each row lock,
1784
+ * a record a concurrent transaction has already moved out of the Query drops
1785
+ * out of the locked read and is never destroyed. The returned count is the
1786
+ * number of records actually claimed, so a caller can detect that it lost a
1787
+ * race by comparing the count against what it expected.
1788
+ *
1789
+ * Things to know before relying on it:
1790
+ *
1791
+ * - **The guarantee is per batch, not set-wide.** A run spanning more than one
1792
+ * batch can win a race in batch 1 and lose one in batch 2; nothing holds the
1793
+ * whole matched set still for the duration of the run. Sharing one
1794
+ * transaction across every batch — by threading a transaction onto the Query
1795
+ * itself, `Order.query().txn(txn).where({ ... }).destroy({ lock: true })` —
1796
+ * buys two things: the destroys become all-or-nothing (a rollback undoes the
1797
+ * whole run), and the row locks taken by every batch are held until you
1798
+ * commit. It does **not** extend the compare-and-set to the part of the set
1799
+ * the run has not reached yet: under READ COMMITTED each statement takes a
1800
+ * fresh snapshot, so a record moved out of the Query between batch 1 and
1801
+ * batch 2 still drops out of batch 2's locked read. A genuinely set-wide
1802
+ * guarantee needs REPEATABLE READ / SERIALIZABLE, or a single locking
1803
+ * statement over the whole set.
1804
+ * - **Only a transaction carried by the Query is shared.** Merely wrapping the
1805
+ * call in `Model.transaction(...)` does not thread that transaction onto the
1806
+ * Query — `Dream.transaction` establishes no ambient transaction, it only
1807
+ * hands a `txn` to your callback — so each batch would open its own
1808
+ * transaction on a different pooled connection. Worse, if the surrounding
1809
+ * transaction has already written to any of the matched rows, each batch's
1810
+ * locked read blocks on locks the surrounding transaction holds while that
1811
+ * transaction awaits the destroy: a cycle across two connections, which
1812
+ * Postgres's deadlock detector cannot see, so it hangs rather than aborting.
1813
+ * Always pass the transaction with `.txn(txn)`.
1814
+ * - **Behavior above READ COMMITTED differs.** The drop-out-of-the-result
1815
+ * behavior described above is READ COMMITTED (Postgres's default). Under
1816
+ * REPEATABLE READ or SERIALIZABLE, the same race raises a serialization
1817
+ * failure instead of silently returning a lower count. Both are safe; only
1818
+ * one of them is quiet.
1819
+ * - **`batchSize` defaults to 10 here**, not `findEach`'s 1000, and the
1820
+ * default is deliberately small. Every row in a batch stays locked for the
1821
+ * whole batch, including the time spent running each record's hooks and
1822
+ * `dependent: 'destroy'` cascade, so an oversized batch blocks unrelated
1823
+ * writers touching those rows and surfaces as timeouts somewhere else in the
1824
+ * application, which is very hard to trace back to this destroy. Too small a
1825
+ * batch, by contrast, only slows down this call, which you can see and fix.
1826
+ * Lower it further for models with deep `dependent: 'destroy'` cascades or
1827
+ * expensive destroy hooks.
1828
+ * - **A batch waits, without bound, for whoever holds the row.** The locked
1829
+ * read is a plain `FOR UPDATE`: if another transaction already holds a lock
1830
+ * on one of the batch's rows, this call blocks until that transaction ends,
1831
+ * holding its own transaction and its pooled connection open the whole
1832
+ * time. Dream sets no `lock_timeout` or `statement_timeout`, so a
1833
+ * long-running writer on the other side stalls the run indefinitely and, at
1834
+ * scale, ties up connections. If that is a risk in your application, set a
1835
+ * `lock_timeout` on the connection so a stuck batch fails instead of
1836
+ * hanging.
1837
+ * - **If you are destroying a large set and do not need compare-and-set, do
1838
+ * not pass `lock`.** Plain `destroy()` (hooks and cascade, no locking) or
1839
+ * {@link Query.delete | delete} (a single statement, no hooks or cascade)
1840
+ * are the right tools for bulk removal.
1841
+ *
1744
1842
  * @param __namedParameters - Options for destroying the instance
1745
1843
  * @param __namedParameters.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
1746
1844
  * @param __namedParameters.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
1845
+ * @param __namedParameters.lock - If true, each batch is re-selected with an exclusive row lock inside its own transaction before being destroyed, making the destroy a compare-and-set. Defaults to false
1846
+ * @param __namedParameters.batchSize - The number of records to process per batch. Must be a positive integer. Defaults to 10 when `lock` is true, and to 1000 otherwise
1747
1847
  * @param __namedParameters.bypassAllDefaultScopes - If true, bypasses all default scopes when cascade destroying. Defaults to false
1748
1848
  * @param __namedParameters.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
1749
1849
  * @returns The number of records that were removed
1850
+ * @throws InvalidBatchSize if `batchSize` is not a positive integer
1750
1851
  */
1751
- async destroy({ skipHooks, cascade, } = {}) {
1852
+ async destroy({ skipHooks, cascade, lock, batchSize, } = {}) {
1752
1853
  let counter = 0;
1854
+ // resolve the default before validating: `??` would let an explicit 0
1855
+ // through, and `limit(0)` means "no limit", which would turn a batched
1856
+ // destroy into a single unbounded pass (and, with `lock`, a lock over the
1857
+ // entire matched set)
1858
+ const resolvedBatchSize = batchSize ?? (lock ? Query.BATCH_SIZES.LOCKED_DESTROY : Query.BATCH_SIZES.FIND_EACH);
1859
+ if (!Number.isInteger(resolvedBatchSize) || resolvedBatchSize < 1)
1860
+ throw new InvalidBatchSize(batchSize);
1753
1861
  const options = {
1754
1862
  bypassAllDefaultScopes: this.bypassAllDefaultScopes,
1755
1863
  defaultScopesToBypass: this.defaultScopesToBypass,
1756
1864
  skipHooks,
1757
1865
  cascade,
1758
1866
  };
1867
+ if (lock) {
1868
+ return await this.lockedDestroy(options, resolvedBatchSize);
1869
+ }
1759
1870
  await this.findEach(async (result) => {
1760
1871
  const subquery = this.dreamTransaction
1761
1872
  ? result.txn(this.dreamTransaction)
@@ -1767,9 +1878,94 @@ export default class Query {
1767
1878
  await subquery.destroy(options);
1768
1879
  }
1769
1880
  counter++;
1881
+ }, {
1882
+ batchSize: resolvedBatchSize,
1770
1883
  });
1771
1884
  return counter;
1772
1885
  }
1886
+ /**
1887
+ * @internal
1888
+ *
1889
+ * The compare-and-set implementation behind `destroy({ lock: true })`.
1890
+ *
1891
+ * Each batch is handled in two steps inside one transaction:
1892
+ *
1893
+ * 1. an unlocked pluck of the next `batchSize` primary keys, in ascending
1894
+ * primary key order, which establishes the batch's window (its keyset
1895
+ * cursor) and tells us how many records were *attempted*. Only the keys are
1896
+ * read: hydrating and preloading these records would duplicate the work
1897
+ * step 2 does over the same rows; then
1898
+ * 2. a locked re-read restricted to exactly those primary keys, which
1899
+ * re-applies every condition on the Query while acquiring an exclusive row
1900
+ * lock on each row it returns. Records a concurrent transaction has moved
1901
+ * out of the Query since step 1 do not come back, and are the records this
1902
+ * run lost the race for.
1903
+ *
1904
+ * The count comes from step 2's result set, which is why it reports records
1905
+ * actually claimed rather than records attempted. (One thing it does not
1906
+ * account for, because it never has: a `beforeDestroy` hook that sets
1907
+ * `_preventDeletion` leaves the record in place but still counts.)
1908
+ *
1909
+ * The two-step shape is what keeps iteration correct. If the locked read both
1910
+ * claimed rows and advanced the cursor, a batch whose records all lost the
1911
+ * race would come back empty and end the run early, silently skipping every
1912
+ * record after it. Advancing the cursor from step 1 instead means losing a
1913
+ * race skips only the records that were lost.
1914
+ *
1915
+ * When the Query already carries a transaction, that transaction is used for
1916
+ * every batch rather than opening one per batch, so the caller's transaction
1917
+ * boundary wins and the locks are held until the caller commits.
1918
+ */
1919
+ async lockedDestroy(options, batchSize) {
1920
+ if (this.joinLoadActivated)
1921
+ throw new LeftJoinPreloadIncompatibleWithFindEach();
1922
+ // the driver seam refuses this combination too, but catching it here means
1923
+ // failing before any transaction is opened or any row is touched
1924
+ if (this.distinctColumn)
1925
+ throw new RowLockIncompatibleWithDistinct();
1926
+ const primaryKey = this.dreamInstance['_primaryKey'];
1927
+ const orderedQuery = this.order(null)
1928
+ .order(this.namespacedPrimaryKey)
1929
+ .limit(batchSize);
1930
+ let counter = 0;
1931
+ // the cursor is compared against a sentinel rather than tested for
1932
+ // truthiness, since a primary key of 0 is a legitimate cursor value that
1933
+ // would otherwise reset the window to the start of the set
1934
+ let lastId = undefined;
1935
+ let attemptedCount = 0;
1936
+ do {
1937
+ const batchQuery = lastId === undefined
1938
+ ? orderedQuery
1939
+ : orderedQuery.where({ [primaryKey]: ops.greaterThan(lastId) });
1940
+ const destroyBatch = async (txn) => {
1941
+ const candidateIds = await batchQuery.txn(txn).pluck(this.namespacedPrimaryKey);
1942
+ if (!candidateIds.length)
1943
+ return { attempted: 0, claimed: 0, lastId };
1944
+ const claimed = await this.dbDriverInstance(batchQuery.txn(txn).where({ [primaryKey]: candidateIds })).takeAll({ lock: true });
1945
+ for (const record of claimed) {
1946
+ const inTransaction = record.txn(txn);
1947
+ if (this.shouldReallyDestroy) {
1948
+ await inTransaction.reallyDestroy(options);
1949
+ }
1950
+ else {
1951
+ await inTransaction.destroy(options);
1952
+ }
1953
+ }
1954
+ return {
1955
+ attempted: candidateIds.length,
1956
+ claimed: claimed.length,
1957
+ lastId: candidateIds.at(-1),
1958
+ };
1959
+ };
1960
+ const results = this.dreamTransaction
1961
+ ? await destroyBatch(this.dreamTransaction)
1962
+ : await this.dreamClass.transaction(async (txn) => await destroyBatch(txn));
1963
+ counter += results.claimed;
1964
+ attemptedCount = results.attempted;
1965
+ lastId = results.lastId;
1966
+ } while (attemptedCount > 0 && attemptedCount === batchSize);
1967
+ return counter;
1968
+ }
1773
1969
  /**
1774
1970
  * Destroys all records matching the Query,
1775
1971
  * ignoring the SoftDelete decorator and
@@ -1791,12 +1987,15 @@ export default class Query {
1791
1987
  * @param __namedParameters - Options for destroying the instance
1792
1988
  * @param __namedParameters.skipHooks - If true, skips applying model hooks during the destroy operation. Defaults to false
1793
1989
  * @param __namedParameters.cascade - If false, skips destroying associations marked `dependent: 'destroy'`. Defaults to true
1990
+ * @param __namedParameters.lock - If true, each batch is re-selected with an exclusive row lock inside its own transaction before being destroyed, making the destroy a compare-and-set. See {@link Query.destroy} for the full semantics. Defaults to false
1991
+ * @param __namedParameters.batchSize - The number of records to process per batch. Must be a positive integer. Defaults to 10 when `lock` is true, and to 1000 otherwise
1794
1992
  * @param __namedParameters.bypassAllDefaultScopes - If true, bypasses all default scopes when cascade destroying. Defaults to false
1795
1993
  * @param __namedParameters.defaultScopesToBypass - An array of default scope names to bypass when cascade destroying. Defaults to an empty array
1796
1994
  * @returns The number of records that were removed
1995
+ * @throws InvalidBatchSize if `batchSize` is not a positive integer
1797
1996
  */
1798
- async reallyDestroy({ skipHooks, cascade, } = {}) {
1799
- return await this.clone({ shouldReallyDestroy: true }).destroy({ skipHooks, cascade });
1997
+ async reallyDestroy({ skipHooks, cascade, lock, batchSize, } = {}) {
1998
+ return await this.clone({ shouldReallyDestroy: true }).destroy({ skipHooks, cascade, lock, batchSize });
1800
1999
  }
1801
2000
  /**
1802
2001
  * Undestroys a SoftDelete model, unsetting
@@ -256,6 +256,32 @@ export default class QueryDriverBase {
256
256
  options = {}) {
257
257
  throw new Error('implement takeAll in child class');
258
258
  }
259
+ /**
260
+ * @internal
261
+ *
262
+ * Adapter seam for exclusive row locking. Given the select statement about to
263
+ * be executed, return a statement that additionally acquires an exclusive
264
+ * lock on each row it returns, held until the enclosing transaction commits
265
+ * or rolls back (Postgres: `SELECT ... FOR UPDATE OF <table>`).
266
+ *
267
+ * This is only ever reached when a caller opts into a locked read (today,
268
+ * `Query#destroy({ lock: true })`), and only from inside a transaction. The
269
+ * base implementation throws so that an adapter which has not implemented row
270
+ * locking fails loud rather than silently performing an unlocked read and
271
+ * quietly dropping the compare-and-set guarantee the caller asked for.
272
+ *
273
+ * @param kyselyQuery - the select statement to lock
274
+ * @param tableAlias - the alias of the Query's base table; only rows of that
275
+ * table are locked, so a joined query does not lock unrelated rows and does
276
+ * not fail on the nullable side of an outer join
277
+ */
278
+ applyRowLock(
279
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
280
+ kyselyQuery,
281
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
282
+ tableAlias) {
283
+ throw new Error('implement applyRowLock in child class');
284
+ }
259
285
  /**
260
286
  * Retrieves the max value of the specified column
261
287
  * for this Query
@@ -29,6 +29,9 @@ import MissingThroughAssociationSource from '../../errors/associations/MissingTh
29
29
  import CannotNegateSimilarityClause from '../../errors/CannotNegateSimilarityClause.js';
30
30
  import CannotNamespaceAssociationFilterToAnotherTable from '../../errors/CannotNamespaceAssociationFilterToAnotherTable.js';
31
31
  import CannotPassUndefinedAsAValueToAWhereClause from '../../errors/CannotPassUndefinedAsAValueToAWhereClause.js';
32
+ import ArrayEqualityRequiresArrayColumn from '../../errors/ops/ArrayEqualityRequiresArrayColumn.js';
33
+ import RowLockIncompatibleWithDistinct from '../../errors/RowLockIncompatibleWithDistinct.js';
34
+ import RowLockIncompatibleWithJoinLoad from '../../errors/RowLockIncompatibleWithJoinLoad.js';
32
35
  import CheckConstraintViolation from '../../errors/db/CheckConstraintViolation.js';
33
36
  import ColumnOverflow from '../../errors/db/ColumnOverflow.js';
34
37
  import DataTypeColumnTypeMismatch from '../../errors/db/DataTypeColumnTypeMismatch.js';
@@ -39,6 +42,7 @@ import ASTSchemaBuilder from '../../helpers/cli/ASTSchemaBuilder.js';
39
42
  import generateMigration from '../../helpers/cli/generateMigration.js';
40
43
  import compact from '../../helpers/compact.js';
41
44
  import { normalizeDataForDb } from '../../helpers/db/normalizeDataForDb.js';
45
+ import isDatabaseArrayColumn from '../../helpers/db/types/isDatabaseArrayColumn.js';
42
46
  import EnvInternal from '../../helpers/EnvInternal.js';
43
47
  import groupBy from '../../helpers/groupBy.js';
44
48
  import isEmpty from '../../helpers/isEmpty.js';
@@ -412,9 +416,22 @@ export default class KyselyQueryDriver extends QueryDriverBase {
412
416
  * @returns an array of dreams
413
417
  */
414
418
  async takeAll(options = {}) {
415
- if (this.query['joinLoadActivated'])
419
+ if (this.query['joinLoadActivated']) {
420
+ // executeJoinLoad builds its own select and has nowhere to put a row
421
+ // lock, so a locked read here has to fail rather than quietly return
422
+ // unlocked rows and drop the guarantee the caller asked for
423
+ if (options.lock)
424
+ throw new RowLockIncompatibleWithJoinLoad();
416
425
  return await this.executeJoinLoad(options);
417
- const kyselyQuery = this.buildSelect(options);
426
+ }
427
+ // `SELECT DISTINCT ON (...) ... FOR UPDATE` is rejected by the database, so
428
+ // fail loudly here instead of emitting SQL that cannot run
429
+ if (options.lock && this.query['distinctColumn'])
430
+ throw new RowLockIncompatibleWithDistinct();
431
+ let kyselyQuery = this.buildSelect(options);
432
+ // row locking is adapter-specific, so it goes through the query driver seam
433
+ if (options.lock)
434
+ kyselyQuery = this.applyRowLock(kyselyQuery, this.query['baseSqlAlias']);
418
435
  const results = await executeDatabaseQuery(kyselyQuery, 'execute');
419
436
  const theAll = results.map(r => this.dbResultToDreamInstance(r, this.dreamClass));
420
437
  await this.applyPreload(this.query['preloadStatements'], this.query['preloadOnStatements'], theAll);
@@ -1484,8 +1501,8 @@ export default class KyselyQueryDriver extends QueryDriverBase {
1484
1501
  b = '=';
1485
1502
  c = val;
1486
1503
  }
1487
- c = this.normalizedWhereValue(dreamClass, attr, c);
1488
- c2 = this.normalizedWhereValue(dreamClass, attr, c2);
1504
+ c = maybeBindArrayAsSingleValue(dreamClass, a, b, this.normalizedWhereValue(dreamClass, attr, c));
1505
+ c2 = maybeBindArrayAsSingleValue(dreamClass, a2, b2, this.normalizedWhereValue(dreamClass, attr, c2));
1489
1506
  if (a && c === undefined)
1490
1507
  throw new CannotPassUndefinedAsAValueToAWhereClause(this.dreamClass, a);
1491
1508
  if (a2 && c2 === undefined)
@@ -2525,6 +2542,31 @@ export function resolvePostgresSsl(connectionConf) {
2525
2542
  return { rejectUnauthorized: false };
2526
2543
  return false;
2527
2544
  }
2545
+ const EQUALITY_OPERATORS_TAKING_A_WHOLE_ARRAY_VALUE = ['=', '!=', '<>'];
2546
+ /**
2547
+ * Kysely renders a raw Javascript array passed as the right-hand side of a
2548
+ * comparison as a value list (`($1, $2)`), which is what `in` / `not in` need,
2549
+ * but which Postgres rejects for `=` against an array column
2550
+ * (`operator does not exist: text[] = record`). For the equality operators,
2551
+ * bind the array as a single parameter so that `ops.equal([...])` against an
2552
+ * array column means literal array equality (`"col" = $1`).
2553
+ *
2554
+ * The rewrite is gated on the column actually being a database array column.
2555
+ * An array reaching an equality operator against a *scalar* column is a
2556
+ * mistake, and binding it as a single value would turn that mistake into a
2557
+ * clause that matches nothing (`"name" = '{}'`) instead of one that raises, so
2558
+ * it throws instead.
2559
+ */
2560
+ function maybeBindArrayAsSingleValue(dreamClass, column, operator, val) {
2561
+ if (!Array.isArray(val))
2562
+ return val;
2563
+ if (!EQUALITY_OPERATORS_TAKING_A_WHOLE_ARRAY_VALUE.includes(operator))
2564
+ return val;
2565
+ const isolatedColumn = maybeNamespacedColumnNameToColumnName(column ?? '');
2566
+ if (!isDatabaseArrayColumn(dreamClass, isolatedColumn))
2567
+ throw new ArrayEqualityRequiresArrayColumn(dreamClass, isolatedColumn, operator);
2568
+ return sql.val(val);
2569
+ }
2528
2570
  function shouldSoftDelete(dream, reallyDestroy) {
2529
2571
  const dreamClass = dream.constructor;
2530
2572
  return dreamClass['softDelete'] && !reallyDestroy;
@@ -198,6 +198,27 @@ export default class PostgresQueryDriver extends KyselyQueryDriver {
198
198
  await client.end();
199
199
  DreamCLI.logger.logEndProgress();
200
200
  }
201
+ /**
202
+ * @internal
203
+ *
204
+ * Postgres implementation of the row-locking seam: appends
205
+ * `FOR UPDATE OF "<tableAlias>"` to the select, so every row the statement
206
+ * returns is exclusively locked until the enclosing transaction ends.
207
+ *
208
+ * Scoping the lock to the Query's own base table (rather than a bare
209
+ * `FOR UPDATE`) means a query that joins other tables neither locks rows the
210
+ * caller never asked about nor raises Postgres's "FOR UPDATE cannot be
211
+ * applied to the nullable side of an outer join".
212
+ *
213
+ * This is a waiting lock: neither `NOWAIT` nor `SKIP LOCKED` is applied, so a
214
+ * statement that collides with another transaction's lock blocks until that
215
+ * transaction ends. Callers that cannot afford an unbounded wait should set a
216
+ * `lock_timeout` on the connection; see the guarded-destroy notes on
217
+ * `Query#destroy`.
218
+ */
219
+ applyRowLock(kyselyQuery, tableAlias) {
220
+ return kyselyQuery.forUpdate(tableAlias);
221
+ }
201
222
  static supportsParallelTestDatabases = true;
202
223
  /**
203
224
  * @internal
@@ -1,20 +1,69 @@
1
+ import loadDependentDestroyTree from './loadDependentDestroyTree.js';
2
+ /**
3
+ * @internal
4
+ *
5
+ * Marks a Dream instance as one whose `dependent: 'destroy'` associations were
6
+ * hydrated by this cascade's own call to `loadDependentDestroyTree`, using the
7
+ * same destroy options the cascade is running under.
8
+ *
9
+ * Associations a caller happened to preload themselves (e.g.
10
+ * `Post.preload('comments')`) are deliberately NOT trusted: they may have been
11
+ * loaded under different default scopes than the destroy requires — most
12
+ * importantly, `reallyDestroy` strips the soft-delete scope, so a
13
+ * caller-preloaded association could be missing soft-deleted records that the
14
+ * cascade must destroy.
15
+ */
16
+ const CASCADE_LOADED = Symbol.for('dream:dependentDestroyTreeLoaded');
1
17
  /**
2
18
  * @internal
3
19
  *
4
20
  * Destroys all HasOne/HasMany associations on this
5
21
  * dream that are marked as `dependent: 'destroy'`.
6
22
  *
7
- * Expects associations to be preloaded onto the dream instance
8
- * via `loadDependentDestroyTree`. Iterates loaded associations
9
- * directly instead of querying the database.
23
+ * The whole dependent-destroy tree is loaded ONCE, at the root of the destroy
24
+ * operation, by `loadDependentDestroyTree`. Descendants reached by recursing
25
+ * through this function consume those already-hydrated associations rather
26
+ * than re-querying their own subtrees.
27
+ *
28
+ * A load still happens here when an association is not hydrated. That covers
29
+ * two cases:
30
+ *
31
+ * 1. the root of the operation, whose associations have not been loaded yet
32
+ * 2. a node past `RECURSIVE_DESTROY_PRELOAD_MAX_REPEATS` levels of
33
+ * self-reference, which the root's preload could not reach
34
+ *
35
+ * The second case is why the check is per-association-hydration rather than a
36
+ * "descendants are already loaded" flag: such a flag would truncate cascades
37
+ * deeper than the preload horizon.
38
+ *
39
+ * NOTE: reusing a hydrated subtree is only sound because the same `options`
40
+ * are forwarded unchanged all the way down the cascade (see the recursive
41
+ * `destroy`/`reallyDestroy` calls below), so every node's associations were
42
+ * loaded under the scopes this node would have used. Nothing in the types
43
+ * enforces that; if `options` ever start varying by depth, this reuse has to
44
+ * be revisited.
10
45
  */
11
46
  export default async function destroyAssociatedRecords(dream, txn, options) {
12
47
  const dreamClass = dream.constructor;
13
48
  const { reallyDestroy } = options;
49
+ // `loadDependentDestroyTree` hydrates and returns a CLONE rather than
50
+ // mutating the receiver, so the result must be assigned back — otherwise
51
+ // each association in the loop below would trigger its own reload.
52
+ let dreamWithAssociations = dream;
53
+ let alreadyLoadedTree = false;
14
54
  for (const associationName of dreamClass['dependentDestroyAssociationNames']()) {
15
- const loaded = dream[associationName];
55
+ if (!alreadyLoadedTree && !cascadeLoaded(dreamWithAssociations, associationName)) {
56
+ dreamWithAssociations = await loadDependentDestroyTree(dreamWithAssociations, txn, {
57
+ reallyDestroy,
58
+ bypassAllDefaultScopes: options.bypassAllDefaultScopes ?? false,
59
+ defaultScopesToBypass: options.defaultScopesToBypass ?? [],
60
+ });
61
+ alreadyLoadedTree = true;
62
+ }
63
+ const loaded = dreamWithAssociations[associationName];
16
64
  const records = Array.isArray(loaded) ? loaded : loaded ? [loaded] : [];
17
65
  for (const record of records) {
66
+ markCascadeLoaded(record);
18
67
  if (reallyDestroy) {
19
68
  await record.txn(txn).reallyDestroy(options);
20
69
  }
@@ -24,3 +73,26 @@ export default async function destroyAssociatedRecords(dream, txn, options) {
24
73
  }
25
74
  }
26
75
  }
76
+ /**
77
+ * @internal
78
+ *
79
+ * True when this dream was hydrated by this cascade's own preload AND the
80
+ * given association is hydrated on it.
81
+ */
82
+ function cascadeLoaded(dream, associationName) {
83
+ if (!dream[CASCADE_LOADED])
84
+ return false;
85
+ return dream.loaded(associationName);
86
+ }
87
+ /**
88
+ * @internal
89
+ *
90
+ * A plain assignment rather than `Object.defineProperty`: the key is a symbol,
91
+ * so it is already excluded from `Object.keys`, `for...in` and
92
+ * `JSON.stringify` without an explicit descriptor, and this runs once per node
93
+ * of a potentially wide cascade tree.
94
+ */
95
+ function markCascadeLoaded(dream) {
96
+ ;
97
+ dream[CASCADE_LOADED] = true;
98
+ }
@@ -1,6 +1,5 @@
1
1
  import DreamApp from '../../dream-app/index.js';
2
2
  import destroyAssociatedRecords from './destroyAssociatedRecords.js';
3
- import loadDependentDestroyTree from './loadDependentDestroyTree.js';
4
3
  import runHooksFor from './runHooksFor.js';
5
4
  /**
6
5
  * @internal
@@ -31,12 +30,12 @@ export default async function destroyDream(dream, txn = null, options) {
31
30
  async function destroyDreamWithTransaction(dream, txn, options) {
32
31
  const { cascade, reallyDestroy, skipHooks } = options;
33
32
  if (cascade) {
34
- const dreamWithAssociations = await loadDependentDestroyTree(dream, txn, {
35
- reallyDestroy,
36
- bypassAllDefaultScopes: options.bypassAllDefaultScopes ?? false,
37
- defaultScopesToBypass: options.defaultScopesToBypass ?? [],
38
- });
39
- await destroyAssociatedRecords(dreamWithAssociations, txn, options);
33
+ // NOTE: the dependent-destroy tree is loaded lazily, inside
34
+ // destroyAssociatedRecords, so that a cascaded descendant whose
35
+ // associations the root already preloaded does not re-query its own
36
+ // subtree. `dream` itself is intentionally never replaced by the loaded
37
+ // clone: the hooks and the delete below operate on the original.
38
+ await destroyAssociatedRecords(dream, txn, options);
40
39
  }
41
40
  if (!skipHooks) {
42
41
  await runHooksFor('beforeDestroy', dream, true, null, txn);
@@ -0,0 +1,19 @@
1
+ export default class InvalidBatchSize extends Error {
2
+ batchSize;
3
+ constructor(batchSize) {
4
+ super();
5
+ this.batchSize = batchSize;
6
+ }
7
+ get message() {
8
+ return `
9
+ Invalid batchSize provided.
10
+
11
+ batchSize given:
12
+ ${String(this.batchSize)}
13
+
14
+ batchSize must be a positive integer. A batchSize of 0 is especially dangerous,
15
+ since a limit of 0 is treated as "no limit", which would collapse a batched
16
+ operation into a single unbounded pass over the entire matched set.
17
+ `;
18
+ }
19
+ }
@@ -0,0 +1,17 @@
1
+ export default class NoColumnsToAlterMigration extends Error {
2
+ table;
3
+ alterDirection;
4
+ constructor(table, alterDirection) {
5
+ super();
6
+ this.table = table;
7
+ this.alterDirection = alterDirection;
8
+ }
9
+ get message() {
10
+ return `
11
+ no valid columns to ${this.alterDirection === 'remove' ? 'drop from' : 'add to'} table '${this.table}' in this alter migration.
12
+
13
+ Pass at least one column declaration with a resolvable type, e.g.:
14
+ name:string
15
+ `;
16
+ }
17
+ }