@rvoh/dream 2.28.0 → 2.28.2

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 (207) hide show
  1. package/dist/cjs/src/Dream.js +31 -1
  2. package/dist/cjs/src/decorators/field/Encrypted.js +12 -0
  3. package/dist/cjs/src/decorators/field/sortable/Sortable.js +10 -0
  4. package/dist/cjs/src/dream/Query.js +10 -3
  5. package/dist/cjs/src/encrypt/assertNoEncryptedColumnWrites.js +62 -0
  6. package/dist/cjs/src/errors/encrypt/CannotSetEncryptedColumnInQueryUpdate.js +31 -0
  7. package/dist/cjs/src/helpers/cli/generateMigration.js +1 -1
  8. package/dist/cjs/src/helpers/cli/resolveIgnoredColumns.js +2 -2
  9. package/dist/cjs/src/helpers/cli/writeGeneratedFile.js +1 -1
  10. package/dist/esm/src/Dream.js +31 -1
  11. package/dist/esm/src/decorators/field/Encrypted.js +12 -0
  12. package/dist/esm/src/decorators/field/sortable/Sortable.js +10 -0
  13. package/dist/esm/src/dream/Query.js +10 -3
  14. package/dist/esm/src/encrypt/assertNoEncryptedColumnWrites.js +62 -0
  15. package/dist/esm/src/errors/encrypt/CannotSetEncryptedColumnInQueryUpdate.js +31 -0
  16. package/dist/esm/src/helpers/cli/generateMigration.js +1 -1
  17. package/dist/esm/src/helpers/cli/resolveIgnoredColumns.js +2 -2
  18. package/dist/esm/src/helpers/cli/writeGeneratedFile.js +1 -1
  19. package/dist/types/src/Dream.d.ts +24 -0
  20. package/dist/types/src/dream/Query.d.ts +6 -2
  21. package/dist/types/src/encrypt/assertNoEncryptedColumnWrites.d.ts +27 -0
  22. package/dist/types/src/errors/encrypt/CannotSetEncryptedColumnInQueryUpdate.d.ts +8 -0
  23. package/docs/classes/db.DreamMigrationHelpers.html +11 -11
  24. package/docs/classes/db.KyselyQueryDriver.html +42 -42
  25. package/docs/classes/db.PostgresQueryDriver.html +43 -43
  26. package/docs/classes/db.QueryDriverBase.html +41 -41
  27. package/docs/classes/errors.CheckConstraintViolation.html +3 -3
  28. package/docs/classes/errors.ColumnOverflow.html +3 -3
  29. package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
  30. package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
  31. package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
  32. package/docs/classes/errors.DecryptionError.html +2 -2
  33. package/docs/classes/errors.DecryptionParseError.html +2 -2
  34. package/docs/classes/errors.DecryptionRotationError.html +3 -3
  35. package/docs/classes/errors.GlobalNameNotSet.html +3 -3
  36. package/docs/classes/errors.InvalidCalendarDate.html +2 -2
  37. package/docs/classes/errors.InvalidClockTime.html +2 -2
  38. package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
  39. package/docs/classes/errors.InvalidDateTime.html +2 -2
  40. package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
  41. package/docs/classes/errors.NonLoadedAssociation.html +3 -3
  42. package/docs/classes/errors.NotNullViolation.html +3 -3
  43. package/docs/classes/errors.RecordNotFound.html +3 -3
  44. package/docs/classes/errors.SortableBatchRequiresTooManyScopeLocks.html +3 -3
  45. package/docs/classes/errors.SortableScopeDidNotStabilize.html +3 -3
  46. package/docs/classes/errors.SortableScopeLockWaitTimedOut.html +3 -3
  47. package/docs/classes/errors.ValidationError.html +3 -3
  48. package/docs/classes/index.CalendarDate.html +33 -33
  49. package/docs/classes/index.ClockTime.html +32 -32
  50. package/docs/classes/index.ClockTimeTz.html +35 -35
  51. package/docs/classes/index.DateTime.html +86 -86
  52. package/docs/classes/index.Decorators.html +19 -19
  53. package/docs/classes/index.Dream.html +124 -124
  54. package/docs/classes/index.DreamApp.html +10 -10
  55. package/docs/classes/index.DreamTransaction.html +2 -2
  56. package/docs/classes/index.Env.html +2 -2
  57. package/docs/classes/index.Query.html +95 -79
  58. package/docs/classes/system.CliFileWriter.html +4 -4
  59. package/docs/classes/system.DreamBin.html +2 -2
  60. package/docs/classes/system.DreamCLI.html +7 -7
  61. package/docs/classes/system.DreamImporter.html +2 -2
  62. package/docs/classes/system.DreamLogos.html +2 -2
  63. package/docs/classes/system.DreamSerializerBuilder.html +11 -11
  64. package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
  65. package/docs/classes/system.PathHelpers.html +3 -3
  66. package/docs/classes/utils.Encrypt.html +3 -3
  67. package/docs/classes/utils.Range.html +2 -2
  68. package/docs/functions/db.closeAllDbConnections.html +1 -1
  69. package/docs/functions/db.dreamDbConnections.html +1 -1
  70. package/docs/functions/db.untypedDb.html +1 -1
  71. package/docs/functions/db.validateColumn.html +1 -1
  72. package/docs/functions/db.validateTable.html +1 -1
  73. package/docs/functions/errors.pgErrorType.html +1 -1
  74. package/docs/functions/index.DreamSerializer.html +1 -1
  75. package/docs/functions/index.ObjectSerializer.html +1 -1
  76. package/docs/functions/index.ReplicaSafe.html +1 -1
  77. package/docs/functions/index.STI.html +1 -1
  78. package/docs/functions/index.SoftDelete.html +1 -1
  79. package/docs/functions/utils.camelize.html +1 -1
  80. package/docs/functions/utils.capitalize.html +1 -1
  81. package/docs/functions/utils.cloneDeepSafe.html +1 -1
  82. package/docs/functions/utils.compact.html +1 -1
  83. package/docs/functions/utils.groupBy.html +1 -1
  84. package/docs/functions/utils.hyphenize.html +1 -1
  85. package/docs/functions/utils.intersection.html +1 -1
  86. package/docs/functions/utils.isEmpty.html +1 -1
  87. package/docs/functions/utils.normalizeUnicode.html +1 -1
  88. package/docs/functions/utils.pascalize.html +1 -1
  89. package/docs/functions/utils.percent.html +1 -1
  90. package/docs/functions/utils.range.html +1 -1
  91. package/docs/functions/utils.round.html +1 -1
  92. package/docs/functions/utils.sanitizeString.html +1 -1
  93. package/docs/functions/utils.snakeify.html +1 -1
  94. package/docs/functions/utils.sort.html +1 -1
  95. package/docs/functions/utils.sortBy.html +1 -1
  96. package/docs/functions/utils.sortObjectByKey.html +1 -1
  97. package/docs/functions/utils.sortObjectByValue.html +1 -1
  98. package/docs/functions/utils.uncapitalize.html +1 -1
  99. package/docs/functions/utils.uniq.html +1 -1
  100. package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
  101. package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
  102. package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
  103. package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
  104. package/docs/interfaces/types.BelongsToStatement.html +2 -2
  105. package/docs/interfaces/types.DecoratorContext.html +2 -2
  106. package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
  107. package/docs/interfaces/types.DreamAppOpts.html +2 -2
  108. package/docs/interfaces/types.DreamDbConfig.html +5 -5
  109. package/docs/interfaces/types.DurationObject.html +2 -2
  110. package/docs/interfaces/types.EncryptOptions.html +2 -2
  111. package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
  112. package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
  113. package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
  114. package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
  115. package/docs/types/openapi.OpenapiAllTypes.html +1 -1
  116. package/docs/types/openapi.OpenapiFormats.html +1 -1
  117. package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
  118. package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
  119. package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
  120. package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
  121. package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
  122. package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
  123. package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
  124. package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
  125. package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
  126. package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
  127. package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
  128. package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
  129. package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
  130. package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
  131. package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
  132. package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
  133. package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
  134. package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
  135. package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
  136. package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
  137. package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
  138. package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
  139. package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
  140. package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
  141. package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
  142. package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
  143. package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
  144. package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
  145. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
  146. package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
  147. package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
  148. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
  149. package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
  150. package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
  151. package/docs/types/openapi.OpenapiSchemaString.html +1 -1
  152. package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
  153. package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
  154. package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
  155. package/docs/types/openapi.OpenapiTypeField.html +1 -1
  156. package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
  157. package/docs/types/types.CalendarDateDurationUnit.html +1 -1
  158. package/docs/types/types.CalendarDateObject.html +1 -1
  159. package/docs/types/types.Camelized.html +1 -1
  160. package/docs/types/types.ClockTimeObject.html +1 -1
  161. package/docs/types/types.DbConnectionType.html +1 -1
  162. package/docs/types/types.DbTypes.html +1 -1
  163. package/docs/types/types.DreamAssociationMetadata.html +1 -1
  164. package/docs/types/types.DreamAttributes.html +1 -1
  165. package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
  166. package/docs/types/types.DreamClassColumn.html +1 -1
  167. package/docs/types/types.DreamColumn.html +1 -1
  168. package/docs/types/types.DreamColumnNames.html +1 -1
  169. package/docs/types/types.DreamLogLevel.html +1 -1
  170. package/docs/types/types.DreamLogger.html +2 -2
  171. package/docs/types/types.DreamModelSerializerType.html +1 -1
  172. package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
  173. package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
  174. package/docs/types/types.DreamParamSafeAttributes.html +1 -1
  175. package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
  176. package/docs/types/types.DreamSerializable.html +1 -1
  177. package/docs/types/types.DreamSerializableArray.html +1 -1
  178. package/docs/types/types.DreamSerializerKey.html +1 -1
  179. package/docs/types/types.DreamSerializers.html +1 -1
  180. package/docs/types/types.DreamVirtualColumns.html +1 -1
  181. package/docs/types/types.DurationUnit.html +1 -1
  182. package/docs/types/types.EncryptAlgorithm.html +1 -1
  183. package/docs/types/types.HasManyStatement.html +1 -1
  184. package/docs/types/types.HasOneStatement.html +1 -1
  185. package/docs/types/types.Hyphenized.html +1 -1
  186. package/docs/types/types.Pascalized.html +1 -1
  187. package/docs/types/types.PrimaryKeyType.html +1 -1
  188. package/docs/types/types.RoundingPrecision.html +1 -1
  189. package/docs/types/types.SerializerCasing.html +1 -1
  190. package/docs/types/types.SimpleObjectSerializerType.html +1 -1
  191. package/docs/types/types.Snakeified.html +1 -1
  192. package/docs/types/types.StrictInterface.html +1 -1
  193. package/docs/types/types.UpdateableAssociationProperties.html +1 -1
  194. package/docs/types/types.UpdateableProperties.html +1 -1
  195. package/docs/types/types.ValidationType.html +1 -1
  196. package/docs/types/types.ViewModel.html +2 -2
  197. package/docs/types/types.ViewModelClass.html +1 -1
  198. package/docs/types/types.WeekdayName.html +1 -1
  199. package/docs/types/types.WhereStatementForDream.html +1 -1
  200. package/docs/types/types.WhereStatementForDreamClass.html +1 -1
  201. package/docs/variables/index.DreamConst.html +1 -1
  202. package/docs/variables/index.ops.html +1 -1
  203. package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
  204. package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
  205. package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
  206. package/docs/variables/system.primaryKeyTypes.html +1 -1
  207. package/package.json +3 -3
@@ -273,6 +273,15 @@ export default class Dream {
273
273
  * assignment simply ensures that it is always an array rather than undefined)
274
274
  */
275
275
  static explicitUnsafeParamColumns = Object.freeze([]);
276
+ /**
277
+ * @internal
278
+ *
279
+ * Model storage for encrypted attribute metadata, set on the inheriting class when
280
+ * using the Encrypted decorator (this default assignment simply ensures that it is
281
+ * always an array rather than undefined,
282
+ * freezing ensures that we never modify the static array on the inherited Dream class)
283
+ */
284
+ static encryptedAttributes = Object.freeze([]);
276
285
  /**
277
286
  * @internal
278
287
  *
@@ -367,6 +376,23 @@ export default class Dream {
367
376
  static get isSTIBase() {
368
377
  return !!this.extendedBy?.length && !this.isSTIChild;
369
378
  }
379
+ /**
380
+ * @internal
381
+ *
382
+ * This class together with every STI class below it, however deeply nested. These
383
+ * are the classes whose rows a query rooted at this class can reach, since an STI
384
+ * child's rows are a subset of its base's.
385
+ *
386
+ * The classes *above* this one are deliberately excluded, as are its siblings: a
387
+ * column that only a sibling declares is meaningless for the rows this class
388
+ * matches, so treating it as this class's business protects nothing and costs a
389
+ * wider walk on every call.
390
+ *
391
+ * @returns An array of Dream classes
392
+ */
393
+ static selfAndStiDescendants() {
394
+ return [this, ...(this.extendedBy ?? []).flatMap(dreamClass => dreamClass.selfAndStiDescendants())];
395
+ }
370
396
  static stiSiblings() {
371
397
  const stiBase = this.stiBaseClassOrOwnClass;
372
398
  if (!stiBase.isSTIBase)
@@ -691,6 +717,10 @@ export default class Dream {
691
717
  return defaultParams;
692
718
  }
693
719
  static defaultParamSafeColumns() {
720
+ // `columns()` is table scoped, so it offers this class every column of the shared
721
+ // STI table, including those an STI child marks unsafe. A column unsafe for a child
722
+ // is unsafe here, because a query rooted at this class reaches that child's rows.
723
+ const explicitUnsafeParamColumns = new Set(this.selfAndStiDescendants().flatMap(dreamClass => [...dreamClass.explicitUnsafeParamColumns]));
694
724
  const columns = [...this.columns()].filter(column => {
695
725
  if (this.prototype._primaryKey === column)
696
726
  return false;
@@ -700,7 +730,7 @@ export default class Dream {
700
730
  return false;
701
731
  if (this.prototype._deletedAtField === column)
702
732
  return false;
703
- if (this.explicitUnsafeParamColumns.includes(column))
733
+ if (explicitUnsafeParamColumns.has(column))
704
734
  return false;
705
735
  if (this.isBelongsToAssociationForeignKey(column, { includeStiSiblings: true }))
706
736
  return false;
@@ -41,6 +41,18 @@ export default function Encrypted(encryptedColumnName) {
41
41
  }
42
42
  ;
43
43
  dreamClass['explicitUnsafeParamColumns'].push(encryptedKey);
44
+ if (!Object.getOwnPropertyDescriptor(dreamClass, 'encryptedAttributes')) {
45
+ // This pattern allows `encryptedAttributes` on a base STI class and on
46
+ // child STI classes. The new `encryptedAttributes` property will be created
47
+ // on the child STI class, but it will include all the `encryptedAttributes`
48
+ // already declared on the base STI class.
49
+ dreamClass['encryptedAttributes'] = [...dreamClass['encryptedAttributes']];
50
+ }
51
+ ;
52
+ dreamClass['encryptedAttributes'].push({
53
+ property: key,
54
+ encryptedColumnName: encryptedKey,
55
+ });
44
56
  const dreamPrototype = Object.getPrototypeOf(this);
45
57
  Object.defineProperty(dreamPrototype, key, {
46
58
  get() {
@@ -47,6 +47,16 @@ export default function Sortable(opts = {}) {
47
47
  // already declared on the base STI class.
48
48
  dreamClass['sortableFields'] = [...dreamClass['sortableFields']];
49
49
  }
50
+ // an STI child inherits its base's sortable fields via the copy above, and
51
+ // the base's field initializers run again while the child is globally
52
+ // initialized, so without this guard the child registers a second config
53
+ // for the same position field whenever the base is initialized first
54
+ // (models are globally initialized in filesystem order). Every duplicate
55
+ // config repeats the child's position work on each save, so positions
56
+ // advance by the number of duplicates instead of by 1
57
+ if (dreamClass['sortableFields'].some(conf => conf.positionField === key)) {
58
+ return;
59
+ }
50
60
  // the decorator registers nothing but this metadata: none of a sortable
51
61
  // field's runtime work runs as hooks. A save's preparation and position
52
62
  // write run as phases in `saveDream` (`prepareSortableFieldsForSave`,
@@ -2,6 +2,7 @@ import { SOFT_DELETE_SCOPE_NAME } from '../decorators/class/SoftDelete.js';
2
2
  import acquireStabilizedSortableBatchLocks from '../decorators/field/sortable/helpers/acquireStabilizedSortableBatchLocks.js';
3
3
  import { invalidateSortableRowCache } from '../decorators/field/sortable/helpers/sortableRowCache.js';
4
4
  import DreamApp from '../dream-app/index.js';
5
+ import assertNoEncryptedColumnWrites from '../encrypt/assertNoEncryptedColumnWrites.js';
5
6
  import AssociationDeclaredWithoutAssociatedDreamClass from '../errors/associations/AssociationDeclaredWithoutAssociatedDreamClass.js';
6
7
  import CannotCallUndestroyOnANonSoftDeleteModel from '../errors/CannotCallUndestroyOnANonSoftDeleteModel.js';
7
8
  import CannotPassAdditionalFieldsToPluckEachAfterCallback from '../errors/CannotPassAdditionalFieldsToPluckEachAfterCallback.js';
@@ -2056,7 +2057,7 @@ export default class Query {
2056
2057
  // truthiness, since a primary key of 0 is a legitimate cursor value that
2057
2058
  // would otherwise reset the window to the start of the set
2058
2059
  let lastId = undefined;
2059
- let attemptedCount = 0;
2060
+ let attemptedCount;
2060
2061
  do {
2061
2062
  const batchQuery = lastId === undefined
2062
2063
  ? orderedQuery
@@ -2188,7 +2189,7 @@ export default class Query {
2188
2189
  * use {@link Query.destroy} instead.
2189
2190
  *
2190
2191
  * ```ts
2191
- * await User.where({ email: ops.ilike('%burpcollaborator%').delete() })
2192
+ * await User.where({ email: ops.ilike('%burpcollaborator%') }).delete()
2192
2193
  * // 12
2193
2194
  * ```
2194
2195
  *
@@ -2316,7 +2317,13 @@ export default class Query {
2316
2317
  }, sortableIncomingAttributes);
2317
2318
  }
2318
2319
  async updateWithoutCallingModelHooks(attributes) {
2319
- return await this.dbDriverInstance().update(attributes);
2320
+ // the guard and the driver must read the same values, so the caller's object
2321
+ // is materialized once here and both read the snapshot
2322
+ const snapshot = { ...attributes };
2323
+ // this path compiles to a single UPDATE statement and never instantiates a
2324
+ // model, so the setter that encrypts an @Encrypted property never runs
2325
+ assertNoEncryptedColumnWrites(this.dreamClass, snapshot);
2326
+ return await this.dbDriverInstance().update(snapshot);
2320
2327
  }
2321
2328
  invertOrder() {
2322
2329
  let query = this.clone({ order: null });
@@ -0,0 +1,62 @@
1
+ import CannotSetEncryptedColumnInQueryUpdate from '../errors/encrypt/CannotSetEncryptedColumnInQueryUpdate.js';
2
+ import { snakeifyString } from '../helpers/snakeify.js';
3
+ /**
4
+ * @internal
5
+ *
6
+ * Throws when `attributes` names an @Encrypted backing column with anything other
7
+ * than null. Guards the write paths that never instantiate a model, and so never
8
+ * reach the throwing setter the Encrypted decorator installs for that column.
9
+ *
10
+ * `encryptedAttributes` is class-scoped, but a query rooted at an STI base reaches
11
+ * every one of its children's rows on the shared table, so `dreamClass` and every STI
12
+ * class below it are searched for backing columns.
13
+ *
14
+ * The search deliberately stops there rather than covering the whole hierarchy. A query
15
+ * rooted at an STI child matches only that child's rows, and a backing column only a
16
+ * sibling declares holds nothing meaningful for them: writing plaintext into it corrupts
17
+ * no ciphertext, because there is none to corrupt. Converting such a row to the sibling
18
+ * type is what has to clear that column, and it has to clear it whatever this guard did.
19
+ * Widening the walk to the siblings therefore buys no protection, and this guard runs on
20
+ * every single-statement update, so keep it narrow.
21
+ *
22
+ * Keys are matched in their snakeified form, since Kysely's CamelCasePlugin
23
+ * resolves both the camelCase and the snake_case spelling of a key to the same
24
+ * physical column.
25
+ *
26
+ * null is permitted: it clears the column, and there is no plaintext to mangle.
27
+ */
28
+ export default function assertNoEncryptedColumnWrites(dreamClass, attributes) {
29
+ const encryptedAttributes = encryptedAttributesInStiDescendants(dreamClass);
30
+ if (encryptedAttributes.size === 0)
31
+ return;
32
+ Object.keys(attributes).forEach(key => {
33
+ const encryptedAttribute = encryptedAttributes.get(snakeifyString(key));
34
+ if (encryptedAttribute === undefined)
35
+ return;
36
+ if (attributes[key] === null)
37
+ return;
38
+ throw new CannotSetEncryptedColumnInQueryUpdate(encryptedAttribute.declaringClass, encryptedAttribute.encryptedColumnName, encryptedAttribute.property);
39
+ });
40
+ }
41
+ /**
42
+ * @internal
43
+ *
44
+ * Every @Encrypted backing column declared on `dreamClass` or on an STI class below it,
45
+ * keyed by the snakeified backing column name.
46
+ */
47
+ function encryptedAttributesInStiDescendants(dreamClass) {
48
+ const encryptedAttributes = new Map();
49
+ dreamClass['selfAndStiDescendants']().forEach(stiClass => {
50
+ stiClass['encryptedAttributes'].forEach(({ property, encryptedColumnName }) => {
51
+ const snakeifiedColumnName = snakeifyString(encryptedColumnName);
52
+ if (encryptedAttributes.has(snakeifiedColumnName))
53
+ return;
54
+ encryptedAttributes.set(snakeifiedColumnName, {
55
+ property,
56
+ encryptedColumnName,
57
+ declaringClass: stiClass,
58
+ });
59
+ });
60
+ });
61
+ return encryptedAttributes;
62
+ }
@@ -0,0 +1,31 @@
1
+ export default class CannotSetEncryptedColumnInQueryUpdate extends Error {
2
+ dreamClass;
3
+ encryptedColumnName;
4
+ encryptedProperty;
5
+ constructor(dreamClass, encryptedColumnName, encryptedProperty) {
6
+ super();
7
+ this.dreamClass = dreamClass;
8
+ this.encryptedColumnName = encryptedColumnName;
9
+ this.encryptedProperty = encryptedProperty;
10
+ }
11
+ get message() {
12
+ return `
13
+ Cannot write a non-null value to an @Encrypted backing column from a query-level update.
14
+
15
+ Dream class: ${this.dreamClass.sanitizedName}
16
+ Encrypted backing column: ${this.encryptedColumnName}
17
+ Property to write instead: ${this.encryptedProperty}
18
+
19
+ \`update(attributes, { skipHooks: true })\` compiles to a single UPDATE statement and
20
+ never instantiates a model, so nothing encrypts the value on its way to the column:
21
+ "${this.encryptedColumnName}" would be written as plaintext, exactly as passed.
22
+
23
+ Write through the property instead, so that Dream encrypts the value:
24
+
25
+ await ${this.dreamClass.sanitizedName}.where({ ... }).update({ ${this.encryptedProperty}: 'value' })
26
+ await ${this.dreamClass.sanitizedName}.where({ ... }).update({ ${this.encryptedProperty}: 'value' }, { lock: true, skipHooks: true })
27
+
28
+ Setting "${this.encryptedColumnName}" to null is permitted, and is how this path clears the column.
29
+ `;
30
+ }
31
+ }
@@ -52,7 +52,7 @@ export default async function generateMigration({ migrationName, columnsWithType
52
52
  : path.join(dreamPath('db'), 'migrations', connectionName);
53
53
  const fileName = `${migrationVersion()}-${hyphenize(migrationName).replace(/\//g, '-')}.ts`;
54
54
  const isSTI = !!fullyQualifiedParentName;
55
- let content = '';
55
+ let content;
56
56
  if (isSTI) {
57
57
  content = generateStiMigrationContent({
58
58
  table: validTableNameOrThrow(migrationName, snakeify(pluralize(pascalizePath(fullyQualifiedParentName)))),
@@ -137,8 +137,8 @@ function guardAssociationColumns(ignoredColumns, tableName, allModels) {
137
137
  // pass too, and these guards never see the association's foreign key;
138
138
  // the InvalidComputedForeignKey / ExplicitForeignKeyRequired errors
139
139
  // swallowed here resurface at runtime as the backstop.
140
- let foreignKey = null;
141
- let foreignKeyTypeColumn = null;
140
+ let foreignKey;
141
+ let foreignKeyTypeColumn;
142
142
  try {
143
143
  foreignKey = associationMetaData.foreignKey();
144
144
  foreignKeyTypeColumn = associationMetaData.polymorphic
@@ -21,6 +21,6 @@ export default async function writeGeneratedFile({ dreamPathKey, basePath, fileN
21
21
 
22
22
  Does this file already exist? Here is the error that was raised:
23
23
  ${error.message}
24
- `);
24
+ `, { cause: error });
25
25
  }
26
26
  }
@@ -273,6 +273,15 @@ export default class Dream {
273
273
  * assignment simply ensures that it is always an array rather than undefined)
274
274
  */
275
275
  static explicitUnsafeParamColumns = Object.freeze([]);
276
+ /**
277
+ * @internal
278
+ *
279
+ * Model storage for encrypted attribute metadata, set on the inheriting class when
280
+ * using the Encrypted decorator (this default assignment simply ensures that it is
281
+ * always an array rather than undefined,
282
+ * freezing ensures that we never modify the static array on the inherited Dream class)
283
+ */
284
+ static encryptedAttributes = Object.freeze([]);
276
285
  /**
277
286
  * @internal
278
287
  *
@@ -367,6 +376,23 @@ export default class Dream {
367
376
  static get isSTIBase() {
368
377
  return !!this.extendedBy?.length && !this.isSTIChild;
369
378
  }
379
+ /**
380
+ * @internal
381
+ *
382
+ * This class together with every STI class below it, however deeply nested. These
383
+ * are the classes whose rows a query rooted at this class can reach, since an STI
384
+ * child's rows are a subset of its base's.
385
+ *
386
+ * The classes *above* this one are deliberately excluded, as are its siblings: a
387
+ * column that only a sibling declares is meaningless for the rows this class
388
+ * matches, so treating it as this class's business protects nothing and costs a
389
+ * wider walk on every call.
390
+ *
391
+ * @returns An array of Dream classes
392
+ */
393
+ static selfAndStiDescendants() {
394
+ return [this, ...(this.extendedBy ?? []).flatMap(dreamClass => dreamClass.selfAndStiDescendants())];
395
+ }
370
396
  static stiSiblings() {
371
397
  const stiBase = this.stiBaseClassOrOwnClass;
372
398
  if (!stiBase.isSTIBase)
@@ -691,6 +717,10 @@ export default class Dream {
691
717
  return defaultParams;
692
718
  }
693
719
  static defaultParamSafeColumns() {
720
+ // `columns()` is table scoped, so it offers this class every column of the shared
721
+ // STI table, including those an STI child marks unsafe. A column unsafe for a child
722
+ // is unsafe here, because a query rooted at this class reaches that child's rows.
723
+ const explicitUnsafeParamColumns = new Set(this.selfAndStiDescendants().flatMap(dreamClass => [...dreamClass.explicitUnsafeParamColumns]));
694
724
  const columns = [...this.columns()].filter(column => {
695
725
  if (this.prototype._primaryKey === column)
696
726
  return false;
@@ -700,7 +730,7 @@ export default class Dream {
700
730
  return false;
701
731
  if (this.prototype._deletedAtField === column)
702
732
  return false;
703
- if (this.explicitUnsafeParamColumns.includes(column))
733
+ if (explicitUnsafeParamColumns.has(column))
704
734
  return false;
705
735
  if (this.isBelongsToAssociationForeignKey(column, { includeStiSiblings: true }))
706
736
  return false;
@@ -41,6 +41,18 @@ export default function Encrypted(encryptedColumnName) {
41
41
  }
42
42
  ;
43
43
  dreamClass['explicitUnsafeParamColumns'].push(encryptedKey);
44
+ if (!Object.getOwnPropertyDescriptor(dreamClass, 'encryptedAttributes')) {
45
+ // This pattern allows `encryptedAttributes` on a base STI class and on
46
+ // child STI classes. The new `encryptedAttributes` property will be created
47
+ // on the child STI class, but it will include all the `encryptedAttributes`
48
+ // already declared on the base STI class.
49
+ dreamClass['encryptedAttributes'] = [...dreamClass['encryptedAttributes']];
50
+ }
51
+ ;
52
+ dreamClass['encryptedAttributes'].push({
53
+ property: key,
54
+ encryptedColumnName: encryptedKey,
55
+ });
44
56
  const dreamPrototype = Object.getPrototypeOf(this);
45
57
  Object.defineProperty(dreamPrototype, key, {
46
58
  get() {
@@ -47,6 +47,16 @@ export default function Sortable(opts = {}) {
47
47
  // already declared on the base STI class.
48
48
  dreamClass['sortableFields'] = [...dreamClass['sortableFields']];
49
49
  }
50
+ // an STI child inherits its base's sortable fields via the copy above, and
51
+ // the base's field initializers run again while the child is globally
52
+ // initialized, so without this guard the child registers a second config
53
+ // for the same position field whenever the base is initialized first
54
+ // (models are globally initialized in filesystem order). Every duplicate
55
+ // config repeats the child's position work on each save, so positions
56
+ // advance by the number of duplicates instead of by 1
57
+ if (dreamClass['sortableFields'].some(conf => conf.positionField === key)) {
58
+ return;
59
+ }
50
60
  // the decorator registers nothing but this metadata: none of a sortable
51
61
  // field's runtime work runs as hooks. A save's preparation and position
52
62
  // write run as phases in `saveDream` (`prepareSortableFieldsForSave`,
@@ -2,6 +2,7 @@ import { SOFT_DELETE_SCOPE_NAME } from '../decorators/class/SoftDelete.js';
2
2
  import acquireStabilizedSortableBatchLocks from '../decorators/field/sortable/helpers/acquireStabilizedSortableBatchLocks.js';
3
3
  import { invalidateSortableRowCache } from '../decorators/field/sortable/helpers/sortableRowCache.js';
4
4
  import DreamApp from '../dream-app/index.js';
5
+ import assertNoEncryptedColumnWrites from '../encrypt/assertNoEncryptedColumnWrites.js';
5
6
  import AssociationDeclaredWithoutAssociatedDreamClass from '../errors/associations/AssociationDeclaredWithoutAssociatedDreamClass.js';
6
7
  import CannotCallUndestroyOnANonSoftDeleteModel from '../errors/CannotCallUndestroyOnANonSoftDeleteModel.js';
7
8
  import CannotPassAdditionalFieldsToPluckEachAfterCallback from '../errors/CannotPassAdditionalFieldsToPluckEachAfterCallback.js';
@@ -2056,7 +2057,7 @@ export default class Query {
2056
2057
  // truthiness, since a primary key of 0 is a legitimate cursor value that
2057
2058
  // would otherwise reset the window to the start of the set
2058
2059
  let lastId = undefined;
2059
- let attemptedCount = 0;
2060
+ let attemptedCount;
2060
2061
  do {
2061
2062
  const batchQuery = lastId === undefined
2062
2063
  ? orderedQuery
@@ -2188,7 +2189,7 @@ export default class Query {
2188
2189
  * use {@link Query.destroy} instead.
2189
2190
  *
2190
2191
  * ```ts
2191
- * await User.where({ email: ops.ilike('%burpcollaborator%').delete() })
2192
+ * await User.where({ email: ops.ilike('%burpcollaborator%') }).delete()
2192
2193
  * // 12
2193
2194
  * ```
2194
2195
  *
@@ -2316,7 +2317,13 @@ export default class Query {
2316
2317
  }, sortableIncomingAttributes);
2317
2318
  }
2318
2319
  async updateWithoutCallingModelHooks(attributes) {
2319
- return await this.dbDriverInstance().update(attributes);
2320
+ // the guard and the driver must read the same values, so the caller's object
2321
+ // is materialized once here and both read the snapshot
2322
+ const snapshot = { ...attributes };
2323
+ // this path compiles to a single UPDATE statement and never instantiates a
2324
+ // model, so the setter that encrypts an @Encrypted property never runs
2325
+ assertNoEncryptedColumnWrites(this.dreamClass, snapshot);
2326
+ return await this.dbDriverInstance().update(snapshot);
2320
2327
  }
2321
2328
  invertOrder() {
2322
2329
  let query = this.clone({ order: null });
@@ -0,0 +1,62 @@
1
+ import CannotSetEncryptedColumnInQueryUpdate from '../errors/encrypt/CannotSetEncryptedColumnInQueryUpdate.js';
2
+ import { snakeifyString } from '../helpers/snakeify.js';
3
+ /**
4
+ * @internal
5
+ *
6
+ * Throws when `attributes` names an @Encrypted backing column with anything other
7
+ * than null. Guards the write paths that never instantiate a model, and so never
8
+ * reach the throwing setter the Encrypted decorator installs for that column.
9
+ *
10
+ * `encryptedAttributes` is class-scoped, but a query rooted at an STI base reaches
11
+ * every one of its children's rows on the shared table, so `dreamClass` and every STI
12
+ * class below it are searched for backing columns.
13
+ *
14
+ * The search deliberately stops there rather than covering the whole hierarchy. A query
15
+ * rooted at an STI child matches only that child's rows, and a backing column only a
16
+ * sibling declares holds nothing meaningful for them: writing plaintext into it corrupts
17
+ * no ciphertext, because there is none to corrupt. Converting such a row to the sibling
18
+ * type is what has to clear that column, and it has to clear it whatever this guard did.
19
+ * Widening the walk to the siblings therefore buys no protection, and this guard runs on
20
+ * every single-statement update, so keep it narrow.
21
+ *
22
+ * Keys are matched in their snakeified form, since Kysely's CamelCasePlugin
23
+ * resolves both the camelCase and the snake_case spelling of a key to the same
24
+ * physical column.
25
+ *
26
+ * null is permitted: it clears the column, and there is no plaintext to mangle.
27
+ */
28
+ export default function assertNoEncryptedColumnWrites(dreamClass, attributes) {
29
+ const encryptedAttributes = encryptedAttributesInStiDescendants(dreamClass);
30
+ if (encryptedAttributes.size === 0)
31
+ return;
32
+ Object.keys(attributes).forEach(key => {
33
+ const encryptedAttribute = encryptedAttributes.get(snakeifyString(key));
34
+ if (encryptedAttribute === undefined)
35
+ return;
36
+ if (attributes[key] === null)
37
+ return;
38
+ throw new CannotSetEncryptedColumnInQueryUpdate(encryptedAttribute.declaringClass, encryptedAttribute.encryptedColumnName, encryptedAttribute.property);
39
+ });
40
+ }
41
+ /**
42
+ * @internal
43
+ *
44
+ * Every @Encrypted backing column declared on `dreamClass` or on an STI class below it,
45
+ * keyed by the snakeified backing column name.
46
+ */
47
+ function encryptedAttributesInStiDescendants(dreamClass) {
48
+ const encryptedAttributes = new Map();
49
+ dreamClass['selfAndStiDescendants']().forEach(stiClass => {
50
+ stiClass['encryptedAttributes'].forEach(({ property, encryptedColumnName }) => {
51
+ const snakeifiedColumnName = snakeifyString(encryptedColumnName);
52
+ if (encryptedAttributes.has(snakeifiedColumnName))
53
+ return;
54
+ encryptedAttributes.set(snakeifiedColumnName, {
55
+ property,
56
+ encryptedColumnName,
57
+ declaringClass: stiClass,
58
+ });
59
+ });
60
+ });
61
+ return encryptedAttributes;
62
+ }
@@ -0,0 +1,31 @@
1
+ export default class CannotSetEncryptedColumnInQueryUpdate extends Error {
2
+ dreamClass;
3
+ encryptedColumnName;
4
+ encryptedProperty;
5
+ constructor(dreamClass, encryptedColumnName, encryptedProperty) {
6
+ super();
7
+ this.dreamClass = dreamClass;
8
+ this.encryptedColumnName = encryptedColumnName;
9
+ this.encryptedProperty = encryptedProperty;
10
+ }
11
+ get message() {
12
+ return `
13
+ Cannot write a non-null value to an @Encrypted backing column from a query-level update.
14
+
15
+ Dream class: ${this.dreamClass.sanitizedName}
16
+ Encrypted backing column: ${this.encryptedColumnName}
17
+ Property to write instead: ${this.encryptedProperty}
18
+
19
+ \`update(attributes, { skipHooks: true })\` compiles to a single UPDATE statement and
20
+ never instantiates a model, so nothing encrypts the value on its way to the column:
21
+ "${this.encryptedColumnName}" would be written as plaintext, exactly as passed.
22
+
23
+ Write through the property instead, so that Dream encrypts the value:
24
+
25
+ await ${this.dreamClass.sanitizedName}.where({ ... }).update({ ${this.encryptedProperty}: 'value' })
26
+ await ${this.dreamClass.sanitizedName}.where({ ... }).update({ ${this.encryptedProperty}: 'value' }, { lock: true, skipHooks: true })
27
+
28
+ Setting "${this.encryptedColumnName}" to null is permitted, and is how this path clears the column.
29
+ `;
30
+ }
31
+ }
@@ -52,7 +52,7 @@ export default async function generateMigration({ migrationName, columnsWithType
52
52
  : path.join(dreamPath('db'), 'migrations', connectionName);
53
53
  const fileName = `${migrationVersion()}-${hyphenize(migrationName).replace(/\//g, '-')}.ts`;
54
54
  const isSTI = !!fullyQualifiedParentName;
55
- let content = '';
55
+ let content;
56
56
  if (isSTI) {
57
57
  content = generateStiMigrationContent({
58
58
  table: validTableNameOrThrow(migrationName, snakeify(pluralize(pascalizePath(fullyQualifiedParentName)))),
@@ -137,8 +137,8 @@ function guardAssociationColumns(ignoredColumns, tableName, allModels) {
137
137
  // pass too, and these guards never see the association's foreign key;
138
138
  // the InvalidComputedForeignKey / ExplicitForeignKeyRequired errors
139
139
  // swallowed here resurface at runtime as the backstop.
140
- let foreignKey = null;
141
- let foreignKeyTypeColumn = null;
140
+ let foreignKey;
141
+ let foreignKeyTypeColumn;
142
142
  try {
143
143
  foreignKey = associationMetaData.foreignKey();
144
144
  foreignKeyTypeColumn = associationMetaData.polymorphic
@@ -21,6 +21,6 @@ export default async function writeGeneratedFile({ dreamPathKey, basePath, fileN
21
21
 
22
22
  Does this file already exist? Here is the error that was raised:
23
23
  ${error.message}
24
- `);
24
+ `, { cause: error });
25
25
  }
26
26
  }
@@ -197,6 +197,15 @@ export default class Dream {
197
197
  * assignment simply ensures that it is always an array rather than undefined)
198
198
  */
199
199
  private static explicitUnsafeParamColumns;
200
+ /**
201
+ * @internal
202
+ *
203
+ * Model storage for encrypted attribute metadata, set on the inheriting class when
204
+ * using the Encrypted decorator (this default assignment simply ensures that it is
205
+ * always an array rather than undefined,
206
+ * freezing ensures that we never modify the static array on the inherited Dream class)
207
+ */
208
+ private static encryptedAttributes;
200
209
  /**
201
210
  * @internal
202
211
  *
@@ -283,6 +292,21 @@ export default class Dream {
283
292
  * @returns boolean
284
293
  */
285
294
  private static get isSTIBase();
295
+ /**
296
+ * @internal
297
+ *
298
+ * This class together with every STI class below it, however deeply nested. These
299
+ * are the classes whose rows a query rooted at this class can reach, since an STI
300
+ * child's rows are a subset of its base's.
301
+ *
302
+ * The classes *above* this one are deliberately excluded, as are its siblings: a
303
+ * column that only a sibling declares is meaningless for the rows this class
304
+ * matches, so treating it as this class's business protects nothing and costs a
305
+ * wider walk on every call.
306
+ *
307
+ * @returns An array of Dream classes
308
+ */
309
+ private static selfAndStiDescendants;
286
310
  private static stiSiblings;
287
311
  /**
288
312
  * @internal
@@ -1521,7 +1521,7 @@ export default class Query<DreamInstance extends Dream, QueryTypeOpts extends Re
1521
1521
  * use {@link Query.destroy} instead.
1522
1522
  *
1523
1523
  * ```ts
1524
- * await User.where({ email: ops.ilike('%burpcollaborator%').delete() })
1524
+ * await User.where({ email: ops.ilike('%burpcollaborator%') }).delete()
1525
1525
  * // 12
1526
1526
  * ```
1527
1527
  *
@@ -1692,7 +1692,10 @@ export default class Query<DreamInstance extends Dream, QueryTypeOpts extends Re
1692
1692
  * hanging.
1693
1693
  * - **`skipHooks` composes with `lock`, but stays on the per-instance
1694
1694
  * path.** With an attributes object and no `lock`, `skipHooks: true`
1695
- * issues a single `UPDATE ... WHERE` statement that writes raw columns.
1695
+ * issues a single `UPDATE ... WHERE` statement that writes raw columns —
1696
+ * with one refusal: it will not write a non-null value into an
1697
+ * `@Encrypted` backing column, since nothing on that path encrypts it, and
1698
+ * throws instead; `null`, which clears the column, is permitted.
1696
1699
  * With `lock: true`, `skipHooks: true` skips the model hooks on each
1697
1700
  * claimed record but still writes through the instance, so custom setters
1698
1701
  * — including the setters encrypted attributes encrypt through — run as
@@ -1715,6 +1718,7 @@ export default class Query<DreamInstance extends Dream, QueryTypeOpts extends Re
1715
1718
  * @throws InvalidBatchSize if `batchSize` is not a positive integer
1716
1719
  * @throws MissingRequiredLockOptionForUpdateCallback if a callback is passed without an options object carrying a boolean `lock`
1717
1720
  * @throws BatchingIncompatibleWithLimitOrOffset if the query carries a `limit` or `offset`
1721
+ * @throws CannotSetEncryptedColumnInQueryUpdate if the single-statement `skipHooks` form's attributes name an `@Encrypted` backing column with anything other than `null`
1718
1722
  */
1719
1723
  update(cb: (record: DreamInstance) => UpdateableProperties<DreamInstance> | undefined | Promise<UpdateableProperties<DreamInstance> | undefined>, options: {
1720
1724
  lock: boolean;
@@ -0,0 +1,27 @@
1
+ import Dream from '../Dream.js';
2
+ /**
3
+ * @internal
4
+ *
5
+ * Throws when `attributes` names an @Encrypted backing column with anything other
6
+ * than null. Guards the write paths that never instantiate a model, and so never
7
+ * reach the throwing setter the Encrypted decorator installs for that column.
8
+ *
9
+ * `encryptedAttributes` is class-scoped, but a query rooted at an STI base reaches
10
+ * every one of its children's rows on the shared table, so `dreamClass` and every STI
11
+ * class below it are searched for backing columns.
12
+ *
13
+ * The search deliberately stops there rather than covering the whole hierarchy. A query
14
+ * rooted at an STI child matches only that child's rows, and a backing column only a
15
+ * sibling declares holds nothing meaningful for them: writing plaintext into it corrupts
16
+ * no ciphertext, because there is none to corrupt. Converting such a row to the sibling
17
+ * type is what has to clear that column, and it has to clear it whatever this guard did.
18
+ * Widening the walk to the siblings therefore buys no protection, and this guard runs on
19
+ * every single-statement update, so keep it narrow.
20
+ *
21
+ * Keys are matched in their snakeified form, since Kysely's CamelCasePlugin
22
+ * resolves both the camelCase and the snake_case spelling of a key to the same
23
+ * physical column.
24
+ *
25
+ * null is permitted: it clears the column, and there is no plaintext to mangle.
26
+ */
27
+ export default function assertNoEncryptedColumnWrites(dreamClass: typeof Dream, attributes: Record<string, unknown>): void;
@@ -0,0 +1,8 @@
1
+ import Dream from '../../Dream.js';
2
+ export default class CannotSetEncryptedColumnInQueryUpdate extends Error {
3
+ private dreamClass;
4
+ private encryptedColumnName;
5
+ private encryptedProperty;
6
+ constructor(dreamClass: typeof Dream, encryptedColumnName: string, encryptedProperty: string);
7
+ get message(): string;
8
+ }