@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
@@ -0,0 +1,14 @@
1
+ export default class RowLockIncompatibleWithDistinct extends Error {
2
+ get message() {
3
+ // `SELECT DISTINCT ON (...) ... FOR UPDATE` is rejected by the database
4
+ // ("FOR UPDATE is not allowed with DISTINCT clause"), so fail here, with a
5
+ // message that names the two incompatible parts, rather than surfacing a
6
+ // raw driver error from inside a per-batch transaction.
7
+ return `
8
+ An exclusive row lock was requested on a query that also calls \`distinct\`.
9
+
10
+ A locked read cannot be combined with DISTINCT. Drop the \`distinct\` call, or
11
+ drop the lock.
12
+ `;
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ export default class RowLockIncompatibleWithJoinLoad extends Error {
2
+ get message() {
3
+ // A join load builds its own select statement (one row per joined
4
+ // association row, later collapsed in memory), which cannot carry the
5
+ // Query's row lock. Rather than silently performing an unlocked read and
6
+ // dropping the compare-and-set guarantee the caller asked for, refuse.
7
+ return `
8
+ An exclusive row lock was requested on a query that also uses leftJoinPreload.
9
+
10
+ A join-loaded query cannot be locked. Use \`preload\` instead of
11
+ \`leftJoinPreload\`, or drop the lock.
12
+ `;
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ export default class UnparseableMigrationColumn extends Error {
2
+ declaration;
3
+ constructor(declaration) {
4
+ super();
5
+ this.declaration = declaration;
6
+ }
7
+ get message() {
8
+ return `
9
+ could not determine a column type for the following migration column declaration:
10
+ ${this.declaration}
11
+
12
+ Make sure the column includes a type, e.g.:
13
+ ${this.declaration.split(':')[0]}:string
14
+ `;
15
+ }
16
+ }
@@ -0,0 +1,23 @@
1
+ export default class ArrayEqualityRequiresArrayColumn extends Error {
2
+ dreamClass;
3
+ column;
4
+ operator;
5
+ constructor(dreamClass, column, operator) {
6
+ super();
7
+ this.dreamClass = dreamClass;
8
+ this.column = column;
9
+ this.operator = operator;
10
+ }
11
+ get message() {
12
+ return `
13
+ Attempting to compare an array for equality against a column that is not an
14
+ array in the database:
15
+
16
+ ${this.dreamClass.sanitizedName}#${this.column} (operator: ${this.operator})
17
+
18
+ Whole-array equality (\`=\`, \`!=\`, \`<>\`) is only meaningful for array columns.
19
+ If you meant "is this column one of these values", use \`ops.in([...])\`
20
+ (or a bare array) instead.
21
+ `;
22
+ }
23
+ }
@@ -1,6 +1,6 @@
1
1
  import * as path from 'node:path';
2
2
  import pluralize from 'pluralize-esm';
3
- import generateMigrationContent from '../cli/generateMigrationContent.js';
3
+ import generateMigrationContent, { MIGRATION_TABLE_NAME_PLACEHOLDER, } from '../cli/generateMigrationContent.js';
4
4
  import primaryKeyType from '../db/primaryKeyType.js';
5
5
  import hyphenize from '../hyphenize.js';
6
6
  import migrationVersion from '../migrationVersion.js';
@@ -9,6 +9,18 @@ import dreamPath from '../path/dreamPath.js';
9
9
  import snakeify from '../snakeify.js';
10
10
  import generateStiMigrationContent from './generateStiMigrationContent.js';
11
11
  import writeGeneratedFile from './writeGeneratedFile.js';
12
+ /**
13
+ * Equivalent to `migrationName.match(new RegExp(`${marker}(.+)$`))?.[1]`, but
14
+ * without a regex: CodeQL flags `.+` anchored to `$` as a polynomial-ReDoS
15
+ * shape, and a plain `indexOf`/`slice` does the same leftmost-match-to-end
16
+ * job with no backtracking risk.
17
+ */
18
+ function tableNameFromSuffix(migrationName, marker) {
19
+ const markerIndex = migrationName.indexOf(marker);
20
+ if (markerIndex === -1)
21
+ return undefined;
22
+ return migrationName.slice(markerIndex + marker.length) || undefined;
23
+ }
12
24
  export default async function generateMigration({ migrationName, columnsWithTypes, connectionName, fullyQualifiedModelName, fullyQualifiedParentName, tableName: explicitTableName, modelClassName, softDelete = false, }) {
13
25
  const migrationsBasePath = connectionName === 'default'
14
26
  ? path.join(dreamPath('db'), 'migrations')
@@ -33,12 +45,17 @@ export default async function generateMigration({ migrationName, columnsWithType
33
45
  });
34
46
  }
35
47
  else {
36
- const tableName = migrationName.match(/-to-(.+)$/)?.[1];
48
+ const toTableName = tableNameFromSuffix(migrationName, '-to-');
49
+ const fromTableName = toTableName
50
+ ? undefined
51
+ : tableNameFromSuffix(migrationName, '-from-');
52
+ const tableName = toTableName || fromTableName;
37
53
  content = generateMigrationContent({
38
- table: tableName ? pluralize(snakeify(tableName)) : '<table-name>',
54
+ table: tableName ? pluralize(snakeify(tableName)) : MIGRATION_TABLE_NAME_PLACEHOLDER,
39
55
  columnsWithTypes,
40
56
  primaryKeyType: primaryKeyType(connectionName),
41
57
  createOrAlter: 'alter',
58
+ alterDirection: fromTableName ? 'remove' : 'add',
42
59
  });
43
60
  }
44
61
  await writeGeneratedFile({
@@ -2,11 +2,19 @@ import pluralize from 'pluralize-esm';
2
2
  import lookupModelByGlobalName from '../../dream-app/helpers/lookupModelByGlobalName.js';
3
3
  import Query from '../../dream/Query.js';
4
4
  import InvalidDecimalFieldPassedToGenerator from '../../errors/InvalidDecimalFieldPassedToGenerator.js';
5
+ import NoColumnsToAlterMigration from '../../errors/NoColumnsToAlterMigration.js';
6
+ import UnparseableMigrationColumn from '../../errors/UnparseableMigrationColumn.js';
5
7
  import camelize from '../camelize.js';
6
8
  import compact from '../compact.js';
7
9
  import globalClassNameFromFullyQualifiedModelName from '../globalClassNameFromFullyQualifiedModelName.js';
8
10
  import snakeify from '../snakeify.js';
9
11
  import standardizeFullyQualifiedModelName from '../standardizeFullyQualifiedModelName.js';
12
+ // Sentinel table name used by generateMigration.ts when a standalone
13
+ // migration name matches neither a `-to-<table>` nor `-from-<table>` suffix.
14
+ // That path intentionally produces a stub the user is expected to hand-edit
15
+ // (e.g. an index-only migration with no column operations at all), so it's
16
+ // exempt from the "no valid columns" check below.
17
+ export const MIGRATION_TABLE_NAME_PLACEHOLDER = '<table-name>';
10
18
  const STI_TYPE_COLUMN_NAME = 'type';
11
19
  // deleted_at is deliberately NOT in this list: the SoftDelete default scope's
12
20
  // `WHERE deleted_at IS NULL` is unselective on healthy tables, Dream internals
@@ -15,8 +23,9 @@ const STI_TYPE_COLUMN_NAME = 'type';
15
23
  // default. See spec/unit/cli/generateMigrationContent.spec.ts
16
24
  // ("deleted_at is deliberately NOT indexed") and the CHANGELOG.
17
25
  const COLUMNS_TO_INDEX = [STI_TYPE_COLUMN_NAME];
18
- export default function generateMigrationContent({ connectionName = 'default', table, columnsWithTypes = [], primaryKeyType = 'bigserial', createOrAlter = 'create', stiChildClassName, softDelete = false, } = {}) {
26
+ export default function generateMigrationContent({ connectionName = 'default', table, columnsWithTypes = [], primaryKeyType = 'bigserial', createOrAlter = 'create', alterDirection = 'add', stiChildClassName, softDelete = false, } = {}) {
19
27
  const altering = createOrAlter === 'alter';
28
+ const removingInUp = altering && alterDirection === 'remove';
20
29
  let requireCitextExtension = false;
21
30
  const checkConstraints = [];
22
31
  // When creating a new table, we automatically emit `created_at`,
@@ -65,7 +74,17 @@ export default function generateMigrationContent({ connectionName = 'default', t
65
74
  // when creating a migration for an STI child, we don't want to include notNull;
66
75
  // instead, we'll add a check constraint that uses the STI child class name
67
76
  const sqlAttributeType = getAttributeType(attributeType, descriptors);
68
- if (attributeType === undefined || ['hasone', 'hasmany'].includes(processedAttrType))
77
+ if (attributeType === undefined) {
78
+ // In alter mode (both `-to-`/add and `-from-`/remove), a column whose
79
+ // type can't be resolved (e.g. a mistyped declaration with no `:type`
80
+ // segment) must not silently vanish from the generated migration —
81
+ // that's especially dangerous for `-from-` migrations, whose whole
82
+ // premise is describing removed columns so `down` can restore them.
83
+ if (altering)
84
+ throw new UnparseableMigrationColumn(attributeDeclaration);
85
+ return acc;
86
+ }
87
+ if (['hasone', 'hasmany'].includes(processedAttrType))
69
88
  return acc;
70
89
  if (attributeType === 'citext')
71
90
  requireCitextExtension = true;
@@ -169,6 +188,25 @@ export async function down(db: Kysely<any>): Promise<void> {
169
188
  }\
170
189
  `;
171
190
  }
191
+ // An alter migration (either `-to-`/add or `-from-`/remove) with no valid
192
+ // columns to add/drop would otherwise emit a bare `.alterTable(...).execute()`
193
+ // with no column operation at all, which Postgres rejects at migration-run
194
+ // time rather than generation time. Fail loudly here instead.
195
+ //
196
+ // This guard is scoped to the standalone `g:migration` `-to-`/`-from-` flow
197
+ // only (`stiChildClassName` is unset there). `g:sti-child` legitimately
198
+ // calls this function in alter mode with zero `columnsWithTypes` — a
199
+ // zero-attribute STI child is a documented use case (see `g:sti-child`'s
200
+ // help example in src/cli/index.ts), and `generateStiMigrationContent`
201
+ // handles the type-column/check-constraint machinery separately from the
202
+ // `columnDefs` this check inspects, so an empty `columnDefs` there is
203
+ // expected, not a sign of a mistyped/unparseable column list.
204
+ if (altering &&
205
+ columnDefs.length === 0 &&
206
+ table !== MIGRATION_TABLE_NAME_PLACEHOLDER &&
207
+ !stiChildClassName) {
208
+ throw new NoColumnsToAlterMigration(table, alterDirection);
209
+ }
172
210
  const citextExtension = requireCitextExtension
173
211
  ? ` await DreamMigrationHelpers.createExtension(db, 'citext')\n\n`
174
212
  : '';
@@ -179,7 +217,22 @@ export async function down(db: Kysely<any>): Promise<void> {
179
217
  const newlineIndent = '\n ';
180
218
  const newlineDoubleIndent = '\n ';
181
219
  const doubleNewlineIndent = '\n\n ';
182
- const columnDefLines = columnDefs.length ? newlineDoubleIndent + columnDefs.join(newlineDoubleIndent) : '';
220
+ // For a `-from-` migration (`alterDirection: 'remove'`), `down` re-adds the
221
+ // column(s) that `up` removed. The generator has no way to know the
222
+ // column's original default, so a non-optional (`.notNull()`) column with
223
+ // no default emitted here will fail at migration-run time against a table
224
+ // that already has rows. We can't fix that without a lot more heavy
225
+ // lifting (and the risk that comes with it), so flag it with a comment
226
+ // instead of silently generating a `down` that's likely to break.
227
+ const NO_KNOWN_DEFAULT_COMMENT = "// NOTE: the generator doesn't know this column's original default; this addColumn will fail against a table with existing rows unless you add a default by hand.";
228
+ const finalColumnDefs = removingInUp
229
+ ? columnDefs.map(def => def.includes('.notNull()') && !def.includes('.defaultTo(')
230
+ ? `${NO_KNOWN_DEFAULT_COMMENT}\n ${def}`
231
+ : def)
232
+ : columnDefs;
233
+ const columnDefLines = finalColumnDefs.length
234
+ ? newlineDoubleIndent + finalColumnDefs.join(newlineDoubleIndent)
235
+ : '';
183
236
  const columnDropLines = columnDrops.length
184
237
  ? newlineDoubleIndent + columnDrops.join(newlineDoubleIndent) + newlineDoubleIndent
185
238
  : '';
@@ -190,21 +243,33 @@ export async function down(db: Kysely<any>): Promise<void> {
190
243
  newlineDoubleIndent +
191
244
  ".addColumn('updated_at', 'timestamp', col => col.notNull())" +
192
245
  (emitDeletedAtColumn ? newlineDoubleIndent + ".addColumn('deleted_at', 'timestamp')" : '');
246
+ // For `alterDirection: 'remove'` (a `-from-` migration), the enum *type*
247
+ // itself must never be created or dropped here, even when the column
248
+ // declaration includes inline enum values — enums are frequently reused
249
+ // across columns/tables, so `up` must drop only the column (no `dropType`)
250
+ // and `down` must re-add only the column (no `createType`), identical to
251
+ // referencing an existing enum by name with no values.
252
+ const enumCreateStatements = removingInUp ? '' : generateEnumStatements(columnsWithTypes);
253
+ const enumDropStatements = removingInUp ? '' : generateEnumDropStatements(columnsWithTypes);
254
+ const addColumnsBody = `${citextExtension}${enumCreateStatements} await db.schema
255
+ .${altering ? 'alterTable' : 'createTable'}('${table}')${altering ? '' : newlineDoubleIndent + generateIdStr({ primaryKeyType })}${columnDefLines}${timestampColumnLines}
256
+ .execute()${indexDefs.length ? `\n${newlineIndent}` : ''}${indexDefs.join(doubleNewlineIndent)}${checkConstraints.join('')}`;
257
+ const removeColumnsBody = ` ${altering
258
+ ? `await db.schema${newlineDoubleIndent}.alterTable('${table}')${columnDropLines}.execute()`
259
+ : `await db.schema.dropTable('${table}').execute()`}${enumDropStatements}`;
260
+ const upBody = removingInUp ? removeColumnsBody : addColumnsBody;
261
+ const downBody = removingInUp ? addColumnsBody : removeColumnsBody;
193
262
  return `\
194
263
  ${dreamDbImports.length ? `import { ${dreamDbImports.join(', ')} } from '@rvoh/dream/db'\n` : ''}import { ${kyselyImports.join(', ')} } from 'kysely'
195
264
 
196
265
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
197
266
  export async function up(db: Kysely<any>): Promise<void> {
198
- ${citextExtension}${generateEnumStatements(columnsWithTypes)} await db.schema
199
- .${altering ? 'alterTable' : 'createTable'}('${table}')${altering ? '' : newlineDoubleIndent + generateIdStr({ primaryKeyType })}${columnDefLines}${timestampColumnLines}
200
- .execute()${indexDefs.length ? `\n${newlineIndent}` : ''}${indexDefs.join(doubleNewlineIndent)}${checkConstraints.join('')}
267
+ ${upBody}
201
268
  }
202
269
 
203
270
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
271
  export async function down(db: Kysely<any>): Promise<void> {
205
- ${altering
206
- ? `await db.schema${newlineDoubleIndent}.alterTable('${table}')${columnDropLines}.execute()`
207
- : `await db.schema.dropTable('${table}').execute()`}${generateEnumDropStatements(columnsWithTypes)}
272
+ ${downBody}
208
273
  }\
209
274
  `;
210
275
  }
@@ -117,11 +117,27 @@ const ops = {
117
117
  /**
118
118
  * Creates an `OpsStatement` using the `=` operator for strict equality.
119
119
  *
120
+ * **Array columns:** passing an array means literal array equality — the
121
+ * whole array is bound as a single parameter, so the column must match
122
+ * element-for-element, in order. This is the opt-in counterpart to a bare
123
+ * array in a where clause (`Post.where({ tags: ['a', 'b'] })`), which means
124
+ * `IN`, and to `ops.any`, which tests for containment of a single element.
125
+ * Element types are constrained by the column, so an enum array column
126
+ * rejects a string that is not a member of its enum.
127
+ *
128
+ * Note that PostgreSQL defines no equality operator for `json` (as opposed
129
+ * to `jsonb`), so `ops.equal` against a `json[]` column raises at the
130
+ * database.
131
+ *
120
132
  * @param equal - The value the column must equal.
121
133
  * @returns An `OpsStatement` using the `=` operator.
122
134
  *
123
135
  * @example
124
136
  * User.where({ role: ops.equal('admin') })
137
+ *
138
+ * @example
139
+ * // matches only rows whose tags column is exactly ['a', 'b'], in that order
140
+ * Post.where({ tags: ops.equal(['a', 'b']) })
125
141
  */
126
142
  equal: (equal) => new OpsStatement('=', equal),
127
143
  /**
@@ -260,6 +276,9 @@ const ops = {
260
276
  /**
261
277
  * Creates an `OpsStatement` using the `!=` operator for inequality.
262
278
  *
279
+ * **Array columns:** as with `ops.equal`, passing an array compares against
280
+ * the whole array rather than against its elements.
281
+ *
263
282
  * @param equal - The value the column must not equal.
264
283
  * @returns An `OpsStatement` using the `!=` operator.
265
284
  *
@@ -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