velocious 1.0.567 → 1.0.569

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 (247) hide show
  1. package/README.md +49 -1
  2. package/build/configuration.js +36 -0
  3. package/build/database/drivers/base-table.js +4 -3
  4. package/build/database/drivers/base.js +195 -54
  5. package/build/database/drivers/index-metadata.js +28 -0
  6. package/build/database/drivers/mssql/column.js +2 -34
  7. package/build/database/drivers/mssql/columns-index.js +37 -0
  8. package/build/database/drivers/mssql/index.js +10 -7
  9. package/build/database/drivers/mssql/table.js +55 -11
  10. package/build/database/drivers/mysql/index.js +10 -5
  11. package/build/database/drivers/mysql/sql/alter-table.js +13 -0
  12. package/build/database/drivers/pgsql/column.js +2 -27
  13. package/build/database/drivers/pgsql/columns-index.js +37 -0
  14. package/build/database/drivers/pgsql/index.js +4 -4
  15. package/build/database/drivers/pgsql/table.js +61 -10
  16. package/build/database/drivers/sqlite/base.js +2 -2
  17. package/build/database/drivers/sqlite/connection-sql-js.js +62 -2
  18. package/build/database/drivers/sqlite/index.web.js +74 -0
  19. package/build/database/drivers/sqlite/sql/alter-table.js +18 -0
  20. package/build/database/migration/index.js +92 -3
  21. package/build/database/operation-connection.js +110 -0
  22. package/build/database/operation-lease.js +47 -0
  23. package/build/database/operation.js +140 -0
  24. package/build/database/pool/base.js +26 -1
  25. package/build/database/pool/single-multi-use.js +49 -0
  26. package/build/database/query/alter-table-base.js +20 -1
  27. package/build/database/query/model-class-query.js +38 -7
  28. package/build/database/query/preloader/belongs-to.js +3 -2
  29. package/build/database/query/preloader/has-many.js +4 -3
  30. package/build/database/query/preloader/has-one.js +2 -1
  31. package/build/database/query/preloader/query-for-model.js +17 -0
  32. package/build/database/query/query-data.js +12 -3
  33. package/build/database/query/with-count.js +5 -3
  34. package/build/database/record/acts-as-list.js +11 -8
  35. package/build/database/record/attachments/normalize-input.js +38 -10
  36. package/build/database/record/attachments/storage-drivers/filesystem.js +33 -2
  37. package/build/database/record/attachments/storage-drivers/native.js +17 -2
  38. package/build/database/record/attachments/storage-drivers/s3.js +27 -7
  39. package/build/database/record/attachments/store.js +173 -53
  40. package/build/database/record/auditing.js +12 -9
  41. package/build/database/record/counter-cache-magnitude.js +5 -3
  42. package/build/database/record/index.js +95 -14
  43. package/build/database/record/instance-relationships/belongs-to.js +4 -2
  44. package/build/database/record/instance-relationships/has-many.js +7 -5
  45. package/build/database/record/instance-relationships/has-one.js +4 -2
  46. package/build/database/record/validators/uniqueness.js +3 -2
  47. package/build/database/table-data/table-foreign-key.js +9 -1
  48. package/build/environment-handlers/base.js +1 -1
  49. package/build/environment-handlers/node/attachment-path-source.js +144 -0
  50. package/build/environment-handlers/node.js +28 -3
  51. package/build/frontend-models/websocket-publishers.js +2 -2
  52. package/build/src/configuration.d.ts +12 -0
  53. package/build/src/configuration.d.ts.map +1 -1
  54. package/build/src/configuration.js +35 -1
  55. package/build/src/database/drivers/base-table.d.ts.map +1 -1
  56. package/build/src/database/drivers/base-table.js +4 -3
  57. package/build/src/database/drivers/base.d.ts +73 -20
  58. package/build/src/database/drivers/base.d.ts.map +1 -1
  59. package/build/src/database/drivers/base.js +181 -54
  60. package/build/src/database/drivers/index-metadata.d.ts +41 -0
  61. package/build/src/database/drivers/index-metadata.d.ts.map +1 -0
  62. package/build/src/database/drivers/index-metadata.js +26 -0
  63. package/build/src/database/drivers/mssql/column.d.ts.map +1 -1
  64. package/build/src/database/drivers/mssql/column.js +3 -31
  65. package/build/src/database/drivers/mssql/columns-index.d.ts +41 -0
  66. package/build/src/database/drivers/mssql/columns-index.d.ts.map +1 -1
  67. package/build/src/database/drivers/mssql/columns-index.js +35 -1
  68. package/build/src/database/drivers/mssql/index.d.ts +4 -1
  69. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  70. package/build/src/database/drivers/mssql/index.js +11 -8
  71. package/build/src/database/drivers/mssql/table.d.ts +42 -0
  72. package/build/src/database/drivers/mssql/table.d.ts.map +1 -1
  73. package/build/src/database/drivers/mssql/table.js +49 -12
  74. package/build/src/database/drivers/mysql/index.d.ts +2 -1
  75. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  76. package/build/src/database/drivers/mysql/index.js +10 -6
  77. package/build/src/database/drivers/mysql/sql/alter-table.d.ts.map +1 -1
  78. package/build/src/database/drivers/mysql/sql/alter-table.js +12 -1
  79. package/build/src/database/drivers/pgsql/column.d.ts.map +1 -1
  80. package/build/src/database/drivers/pgsql/column.js +3 -24
  81. package/build/src/database/drivers/pgsql/columns-index.d.ts +41 -0
  82. package/build/src/database/drivers/pgsql/columns-index.d.ts.map +1 -1
  83. package/build/src/database/drivers/pgsql/columns-index.js +35 -1
  84. package/build/src/database/drivers/pgsql/index.d.ts +2 -1
  85. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  86. package/build/src/database/drivers/pgsql/index.js +5 -5
  87. package/build/src/database/drivers/pgsql/table.d.ts +42 -0
  88. package/build/src/database/drivers/pgsql/table.d.ts.map +1 -1
  89. package/build/src/database/drivers/pgsql/table.js +54 -11
  90. package/build/src/database/drivers/sqlite/base.d.ts +1 -1
  91. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  92. package/build/src/database/drivers/sqlite/base.js +3 -3
  93. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +20 -0
  94. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  95. package/build/src/database/drivers/sqlite/connection-sql-js.js +56 -3
  96. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  97. package/build/src/database/drivers/sqlite/index.web.js +64 -1
  98. package/build/src/database/drivers/sqlite/sql/alter-table.d.ts.map +1 -1
  99. package/build/src/database/drivers/sqlite/sql/alter-table.js +15 -1
  100. package/build/src/database/migration/index.d.ts +35 -1
  101. package/build/src/database/migration/index.d.ts.map +1 -1
  102. package/build/src/database/migration/index.js +78 -4
  103. package/build/src/database/operation-connection.d.ts +61 -0
  104. package/build/src/database/operation-connection.d.ts.map +1 -0
  105. package/build/src/database/operation-connection.js +93 -0
  106. package/build/src/database/operation-lease.d.ts +27 -0
  107. package/build/src/database/operation-lease.d.ts.map +1 -0
  108. package/build/src/database/operation-lease.js +44 -0
  109. package/build/src/database/operation.d.ts +77 -0
  110. package/build/src/database/operation.d.ts.map +1 -0
  111. package/build/src/database/operation.js +123 -0
  112. package/build/src/database/pool/base.d.ts +14 -0
  113. package/build/src/database/pool/base.d.ts.map +1 -1
  114. package/build/src/database/pool/base.js +24 -2
  115. package/build/src/database/pool/single-multi-use.d.ts +1 -0
  116. package/build/src/database/pool/single-multi-use.d.ts.map +1 -1
  117. package/build/src/database/pool/single-multi-use.js +49 -1
  118. package/build/src/database/query/alter-table-base.d.ts +6 -0
  119. package/build/src/database/query/alter-table-base.d.ts.map +1 -1
  120. package/build/src/database/query/alter-table-base.js +18 -2
  121. package/build/src/database/query/model-class-query.d.ts +14 -0
  122. package/build/src/database/query/model-class-query.d.ts.map +1 -1
  123. package/build/src/database/query/model-class-query.js +32 -8
  124. package/build/src/database/query/preloader/belongs-to.d.ts.map +1 -1
  125. package/build/src/database/query/preloader/belongs-to.js +4 -3
  126. package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
  127. package/build/src/database/query/preloader/has-many.js +5 -4
  128. package/build/src/database/query/preloader/has-one.d.ts.map +1 -1
  129. package/build/src/database/query/preloader/has-one.js +3 -2
  130. package/build/src/database/query/preloader/query-for-model.d.ts +10 -0
  131. package/build/src/database/query/preloader/query-for-model.d.ts.map +1 -0
  132. package/build/src/database/query/preloader/query-for-model.js +16 -0
  133. package/build/src/database/query/query-data.d.ts.map +1 -1
  134. package/build/src/database/query/query-data.js +13 -4
  135. package/build/src/database/query/with-count.d.ts.map +1 -1
  136. package/build/src/database/query/with-count.js +6 -4
  137. package/build/src/database/record/acts-as-list.js +12 -9
  138. package/build/src/database/record/attachments/attachment-record.d.ts +9 -0
  139. package/build/src/database/record/attachments/attachment-record.d.ts.map +1 -1
  140. package/build/src/database/record/attachments/normalize-input.d.ts +33 -4
  141. package/build/src/database/record/attachments/normalize-input.d.ts.map +1 -1
  142. package/build/src/database/record/attachments/normalize-input.js +39 -11
  143. package/build/src/database/record/attachments/storage-drivers/filesystem.d.ts +2 -5
  144. package/build/src/database/record/attachments/storage-drivers/filesystem.d.ts.map +1 -1
  145. package/build/src/database/record/attachments/storage-drivers/filesystem.js +28 -3
  146. package/build/src/database/record/attachments/storage-drivers/native.d.ts +2 -6
  147. package/build/src/database/record/attachments/storage-drivers/native.d.ts.map +1 -1
  148. package/build/src/database/record/attachments/storage-drivers/native.js +14 -3
  149. package/build/src/database/record/attachments/storage-drivers/s3.d.ts +2 -6
  150. package/build/src/database/record/attachments/storage-drivers/s3.d.ts.map +1 -1
  151. package/build/src/database/record/attachments/storage-drivers/s3.js +29 -8
  152. package/build/src/database/record/attachments/store.d.ts +31 -2
  153. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  154. package/build/src/database/record/attachments/store.js +158 -54
  155. package/build/src/database/record/auditing.d.ts.map +1 -1
  156. package/build/src/database/record/auditing.js +13 -10
  157. package/build/src/database/record/counter-cache-magnitude.js +6 -4
  158. package/build/src/database/record/index.d.ts +42 -2
  159. package/build/src/database/record/index.d.ts.map +1 -1
  160. package/build/src/database/record/index.js +87 -15
  161. package/build/src/database/record/instance-relationships/belongs-to.d.ts.map +1 -1
  162. package/build/src/database/record/instance-relationships/belongs-to.js +4 -3
  163. package/build/src/database/record/instance-relationships/has-many.d.ts.map +1 -1
  164. package/build/src/database/record/instance-relationships/has-many.js +7 -5
  165. package/build/src/database/record/instance-relationships/has-one.d.ts.map +1 -1
  166. package/build/src/database/record/instance-relationships/has-one.js +4 -3
  167. package/build/src/database/record/validators/uniqueness.d.ts.map +1 -1
  168. package/build/src/database/record/validators/uniqueness.js +4 -3
  169. package/build/src/database/table-data/table-foreign-key.d.ts +9 -1
  170. package/build/src/database/table-data/table-foreign-key.d.ts.map +1 -1
  171. package/build/src/database/table-data/table-foreign-key.js +9 -2
  172. package/build/src/environment-handlers/base.d.ts +2 -5
  173. package/build/src/environment-handlers/base.d.ts.map +1 -1
  174. package/build/src/environment-handlers/base.js +2 -2
  175. package/build/src/environment-handlers/node/attachment-path-source.d.ts +64 -0
  176. package/build/src/environment-handlers/node/attachment-path-source.d.ts.map +1 -0
  177. package/build/src/environment-handlers/node/attachment-path-source.js +116 -0
  178. package/build/src/environment-handlers/node.d.ts +12 -0
  179. package/build/src/environment-handlers/node.d.ts.map +1 -1
  180. package/build/src/environment-handlers/node.js +24 -4
  181. package/build/src/frontend-models/websocket-publishers.js +3 -3
  182. package/build/src/sync/server-sequence-allocator.d.ts +16 -4
  183. package/build/src/sync/server-sequence-allocator.d.ts.map +1 -1
  184. package/build/src/sync/server-sequence-allocator.js +37 -11
  185. package/build/src/sync/sync-client.d.ts.map +1 -1
  186. package/build/src/sync/sync-client.js +7 -3
  187. package/build/src/sync/sync-publisher.d.ts +2 -1
  188. package/build/src/sync/sync-publisher.d.ts.map +1 -1
  189. package/build/src/sync/sync-publisher.js +11 -6
  190. package/build/sync/server-sequence-allocator.js +43 -10
  191. package/build/sync/sync-client.js +6 -2
  192. package/build/sync/sync-publisher.js +10 -5
  193. package/build/tsconfig.tsbuildinfo +1 -1
  194. package/package.json +1 -1
  195. package/src/configuration.js +36 -0
  196. package/src/database/drivers/base-table.js +4 -3
  197. package/src/database/drivers/base.js +195 -54
  198. package/src/database/drivers/index-metadata.js +28 -0
  199. package/src/database/drivers/mssql/column.js +2 -34
  200. package/src/database/drivers/mssql/columns-index.js +37 -0
  201. package/src/database/drivers/mssql/index.js +10 -7
  202. package/src/database/drivers/mssql/table.js +55 -11
  203. package/src/database/drivers/mysql/index.js +10 -5
  204. package/src/database/drivers/mysql/sql/alter-table.js +13 -0
  205. package/src/database/drivers/pgsql/column.js +2 -27
  206. package/src/database/drivers/pgsql/columns-index.js +37 -0
  207. package/src/database/drivers/pgsql/index.js +4 -4
  208. package/src/database/drivers/pgsql/table.js +61 -10
  209. package/src/database/drivers/sqlite/base.js +2 -2
  210. package/src/database/drivers/sqlite/connection-sql-js.js +62 -2
  211. package/src/database/drivers/sqlite/index.web.js +74 -0
  212. package/src/database/drivers/sqlite/sql/alter-table.js +18 -0
  213. package/src/database/migration/index.js +92 -3
  214. package/src/database/operation-connection.js +110 -0
  215. package/src/database/operation-lease.js +47 -0
  216. package/src/database/operation.js +140 -0
  217. package/src/database/pool/base.js +26 -1
  218. package/src/database/pool/single-multi-use.js +49 -0
  219. package/src/database/query/alter-table-base.js +20 -1
  220. package/src/database/query/model-class-query.js +38 -7
  221. package/src/database/query/preloader/belongs-to.js +3 -2
  222. package/src/database/query/preloader/has-many.js +4 -3
  223. package/src/database/query/preloader/has-one.js +2 -1
  224. package/src/database/query/preloader/query-for-model.js +17 -0
  225. package/src/database/query/query-data.js +12 -3
  226. package/src/database/query/with-count.js +5 -3
  227. package/src/database/record/acts-as-list.js +11 -8
  228. package/src/database/record/attachments/normalize-input.js +38 -10
  229. package/src/database/record/attachments/storage-drivers/filesystem.js +33 -2
  230. package/src/database/record/attachments/storage-drivers/native.js +17 -2
  231. package/src/database/record/attachments/storage-drivers/s3.js +27 -7
  232. package/src/database/record/attachments/store.js +173 -53
  233. package/src/database/record/auditing.js +12 -9
  234. package/src/database/record/counter-cache-magnitude.js +5 -3
  235. package/src/database/record/index.js +95 -14
  236. package/src/database/record/instance-relationships/belongs-to.js +4 -2
  237. package/src/database/record/instance-relationships/has-many.js +7 -5
  238. package/src/database/record/instance-relationships/has-one.js +4 -2
  239. package/src/database/record/validators/uniqueness.js +3 -2
  240. package/src/database/table-data/table-foreign-key.js +9 -1
  241. package/src/environment-handlers/base.js +1 -1
  242. package/src/environment-handlers/node/attachment-path-source.js +144 -0
  243. package/src/environment-handlers/node.js +28 -3
  244. package/src/frontend-models/websocket-publishers.js +2 -2
  245. package/src/sync/server-sequence-allocator.js +43 -10
  246. package/src/sync/sync-client.js +6 -2
  247. package/src/sync/sync-publisher.js +10 -5
@@ -88,9 +88,10 @@ export default class RecordAttachmentsStore {
88
88
 
89
89
  /**
90
90
  * Runs ensure ready.
91
+ * @param {import("../index.js").default} [model] - Operation-owning model.
91
92
  * @returns {Promise<void>} - Resolves when schema is ready.
92
93
  */
93
- async ensureReady() {
94
+ async ensureReady(model) {
94
95
  if (this._readyPromise) {
95
96
  await this._readyPromise
96
97
  return
@@ -124,7 +125,7 @@ export default class RecordAttachmentsStore {
124
125
  await db.createTable(table)
125
126
  this._driverColumnsAvailable = true
126
127
  this._contentBase64Nullable = true
127
- })
128
+ }, model)
128
129
  })()
129
130
 
130
131
  try {
@@ -144,7 +145,7 @@ export default class RecordAttachmentsStore {
144
145
  * @returns {Promise<void>} - Resolves when complete.
145
146
  */
146
147
  async attach({input, model, name, replace}) {
147
- await this.ensureReady()
148
+ await this.ensureReady(model)
148
149
  const attachmentsConfiguration = this.configuration.getAttachmentsConfiguration?.() || {}
149
150
  const allowPathInput = attachmentsConfiguration.allowPathInput === true
150
151
  const allowedPathPrefixes = Array.isArray(attachmentsConfiguration.allowedPathPrefixes)
@@ -156,65 +157,181 @@ export default class RecordAttachmentsStore {
156
157
  allowedPathPrefixes,
157
158
  environmentHandler: this.configuration.getEnvironmentHandler()
158
159
  })
160
+ /**
161
+ * Attachment persistence error.
162
+ * This stays opaque so any JavaScript thrown value is preserved exactly.
163
+ * @type {unknown} */
164
+ let persistenceError = null
165
+ let persistenceFailed = false
166
+
167
+ try {
168
+ const persistenceInput = await this.persistenceInputFor(normalizedInput)
169
+
170
+ await this.persistNormalizedAttachment({
171
+ model,
172
+ name,
173
+ normalizedInput: persistenceInput,
174
+ replace
175
+ })
176
+ } catch (error) {
177
+ persistenceFailed = true
178
+ persistenceError = error
179
+ }
180
+
181
+ if (normalizedInput.pathSource) {
182
+ try {
183
+ await normalizedInput.pathSource.close()
184
+ } catch (closeError) {
185
+ if (persistenceFailed) {
186
+ throw new AggregateError(
187
+ [persistenceError, closeError],
188
+ `Attachment persistence and path-source close both failed for ${model.getModelClass().getModelName()}#${String(model.id())} (${name})`,
189
+ {cause: closeError}
190
+ )
191
+ }
192
+
193
+ throw closeError
194
+ }
195
+ }
196
+
197
+ if (persistenceFailed) throw persistenceError
198
+ }
199
+
200
+ /**
201
+ * Materializes path content once when a legacy schema requires Base64.
202
+ * @param {import("./normalize-input.js").NormalizedAttachmentInput} normalizedInput - Normalized attachment input.
203
+ * @returns {Promise<import("./normalize-input.js").NormalizedAttachmentInput>} - Input used by the driver and database.
204
+ */
205
+ async persistenceInputFor(normalizedInput) {
206
+ if (this._contentBase64Nullable || !normalizedInput.pathSource) return normalizedInput
207
+
208
+ const contentBuffer = await normalizedInput.pathSource.readBuffer()
209
+
210
+ return {
211
+ ...normalizedInput,
212
+ contentBase64: contentBuffer.toString("base64"),
213
+ contentBuffer,
214
+ pathSource: null
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Persists one normalized attachment while its path source remains open.
220
+ * @param {object} args - Options.
221
+ * @param {import("../index.js").default} args.model - Model instance.
222
+ * @param {string} args.name - Attachment name.
223
+ * @param {import("./normalize-input.js").NormalizedAttachmentInput} args.normalizedInput - Normalized attachment.
224
+ * @param {boolean} args.replace - Whether to replace existing attachments.
225
+ * @returns {Promise<void>} - Resolves after persistence.
226
+ */
227
+ async persistNormalizedAttachment({model, name, normalizedInput, replace}) {
159
228
  const attachmentDriver = await this.resolveAttachmentDriver({model, name})
160
229
  const attachmentDriverName = this._attachmentDriverNameFor({model, name})
161
230
  const now = Date.now()
162
231
  const recordType = model.getModelClass().getModelName()
163
232
  const recordId = String(model.id())
164
233
  const attachmentId = generateUUID()
165
- const {storageKey} = await attachmentDriver.write({
166
- attachmentId,
167
- input: normalizedInput,
168
- model,
169
- name
170
- })
234
+ /**
235
+ * Written storage key.
236
+ * @type {string | null} */
237
+ let storageKey = null
238
+ let rowPersisted = false
239
+
240
+ try {
241
+ const writeResult = await attachmentDriver.write({
242
+ attachmentId,
243
+ input: normalizedInput,
244
+ model,
245
+ name
246
+ })
171
247
 
172
- await this._withDb(async (db) => {
173
- if (replace) {
174
- const existingRows = await db
175
- .newQuery()
176
- .from(ATTACHMENTS_TABLE)
177
- .where({name, record_id: recordId, record_type: recordType})
178
- .results()
248
+ storageKey = writeResult.storageKey
179
249
 
180
- for (const existingRow of existingRows) {
181
- await this.deleteAttachmentRowStorage({model, name, row: existingRow})
250
+ // Current schemas keep content_base64 nullable and avoid duplicating
251
+ // driver-backed content. Legacy path input was materialized once before
252
+ // the driver write so this value describes those exact persisted bytes.
253
+ const databaseContentBase64 = await this.databaseContentBase64For(normalizedInput)
254
+
255
+ await this._withDb(async (db) => {
256
+ if (replace) {
257
+ const existingRows = await db
258
+ .newQuery()
259
+ .from(ATTACHMENTS_TABLE)
260
+ .where({name, record_id: recordId, record_type: recordType})
261
+ .results()
262
+
263
+ for (const existingRow of existingRows) {
264
+ await this.deleteAttachmentRowStorage({model, name, row: existingRow})
265
+ }
266
+
267
+ await db.delete({
268
+ conditions: {name, record_id: recordId, record_type: recordType},
269
+ tableName: ATTACHMENTS_TABLE
270
+ })
271
+ }
272
+
273
+ const position = replace ? 0 : await this._nextPosition({db, name, recordId, recordType})
274
+ /**
275
+ * Insert data.
276
+ * @type {Record<string, ?>} */
277
+ const insertData = {
278
+ byte_size: normalizedInput.byteSize,
279
+ content_base64: databaseContentBase64,
280
+ content_type: normalizedInput.contentType,
281
+ created_at_ms: now,
282
+ filename: normalizedInput.filename,
283
+ id: attachmentId,
284
+ name,
285
+ position,
286
+ record_id: recordId,
287
+ record_type: recordType,
288
+ updated_at_ms: now
182
289
  }
183
290
 
184
- await db.delete({
185
- conditions: {name, record_id: recordId, record_type: recordType},
291
+ if (this._driverColumnsAvailable) {
292
+ insertData.driver = attachmentDriverName
293
+ insertData.storage_key = storageKey
294
+ }
295
+
296
+ await db.insert({
297
+ data: insertData,
186
298
  tableName: ATTACHMENTS_TABLE
187
299
  })
188
- }
189
300
 
190
- const position = replace ? 0 : await this._nextPosition({db, name, recordId, recordType})
191
- /**
192
- * Insert data.
193
- * @type {Record<string, ?>} */
194
- const insertData = {
195
- byte_size: normalizedInput.byteSize,
196
- content_base64: this._contentBase64Nullable ? null : normalizedInput.contentBase64,
197
- content_type: normalizedInput.contentType,
198
- created_at_ms: now,
199
- filename: normalizedInput.filename,
200
- id: attachmentId,
201
- name,
202
- position,
203
- record_id: recordId,
204
- record_type: recordType,
205
- updated_at_ms: now
301
+ rowPersisted = true
302
+ }, model)
303
+ } catch (error) {
304
+ if (!rowPersisted && storageKey && typeof attachmentDriver.delete === "function") {
305
+ try {
306
+ await attachmentDriver.delete({
307
+ model,
308
+ name,
309
+ row: {id: attachmentId, storage_key: storageKey},
310
+ storageKey
311
+ })
312
+ } catch (cleanupError) {
313
+ throw new AggregateError(
314
+ [error, cleanupError],
315
+ `Attachment write finalization and new-storage cleanup both failed for ${recordType}#${recordId} (${name})`,
316
+ {cause: cleanupError}
317
+ )
318
+ }
206
319
  }
207
320
 
208
- if (this._driverColumnsAvailable) {
209
- insertData.driver = attachmentDriverName
210
- insertData.storage_key = storageKey
211
- }
321
+ throw error
322
+ }
323
+ }
212
324
 
213
- await db.insert({
214
- data: insertData,
215
- tableName: ATTACHMENTS_TABLE
216
- })
217
- })
325
+ /**
326
+ * Resolves the database content_base64 value for current and legacy schemas.
327
+ * @param {import("./normalize-input.js").NormalizedAttachmentInput} normalizedInput - Normalized attachment input.
328
+ * @returns {Promise<string | null>} - Nullable or legacy Base64 database value.
329
+ */
330
+ async databaseContentBase64For(normalizedInput) {
331
+ if (this._contentBase64Nullable) return null
332
+ if (normalizedInput.contentBase64 !== null) return normalizedInput.contentBase64
333
+
334
+ throw new Error("Legacy attachment schema requires materialized content bytes")
218
335
  }
219
336
 
220
337
  /**
@@ -319,7 +436,7 @@ export default class RecordAttachmentsStore {
319
436
  * @returns {Promise<Record<string, ?> | null>} - Attachment row.
320
437
  */
321
438
  async findOne({id, model, name}) {
322
- await this.ensureReady()
439
+ await this.ensureReady(model)
323
440
 
324
441
  return await this._withDb(async (db) => {
325
442
  const recordType = model.getModelClass().getModelName()
@@ -339,7 +456,7 @@ export default class RecordAttachmentsStore {
339
456
  const rows = await query.results()
340
457
 
341
458
  return rows[0] || null
342
- })
459
+ }, model)
343
460
  }
344
461
 
345
462
  /**
@@ -350,7 +467,7 @@ export default class RecordAttachmentsStore {
350
467
  * @returns {Promise<Array<Record<string, ?>>>} - Attachment rows.
351
468
  */
352
469
  async findMany({model, name}) {
353
- await this.ensureReady()
470
+ await this.ensureReady(model)
354
471
 
355
472
  return await this._withDb(async (db) => {
356
473
  const recordType = model.getModelClass().getModelName()
@@ -363,7 +480,7 @@ export default class RecordAttachmentsStore {
363
480
  .order("created_at_ms ASC")
364
481
 
365
482
  return await query.results()
366
- })
483
+ }, model)
367
484
  }
368
485
 
369
486
  /**
@@ -401,7 +518,7 @@ export default class RecordAttachmentsStore {
401
518
  * @returns {Promise<number>} - Number of attachments purged.
402
519
  */
403
520
  async purgeAll({model, name}) {
404
- await this.ensureReady()
521
+ await this.ensureReady(model)
405
522
 
406
523
  return await this._withDb(async (db) => {
407
524
  const recordType = model.getModelClass().getModelName()
@@ -433,7 +550,7 @@ export default class RecordAttachmentsStore {
433
550
  }
434
551
 
435
552
  return rows.length
436
- })
553
+ }, model)
437
554
  }
438
555
 
439
556
  /**
@@ -616,9 +733,12 @@ export default class RecordAttachmentsStore {
616
733
  * Runs with db.
617
734
  * @template T
618
735
  * @param {(db: import("../../../database/drivers/base.js").default) => Promise<T>} callback - Callback.
736
+ * @param {import("../index.js").default} [model] - Operation-owning model.
619
737
  * @returns {Promise<T>} - Callback result.
620
738
  */
621
- async _withDb(callback) {
739
+ async _withDb(callback, model) {
740
+ if (model && model.databaseOperation()) return await callback(model.connection())
741
+
622
742
  const pool = this.configuration.getDatabasePool(this.databaseIdentifier)
623
743
  /**
624
744
  * Defines result.
@@ -148,18 +148,20 @@ function dedicatedAuditTableName(modelClass) {
148
148
  * Resolves audit table data for a model class. Cached per model.
149
149
  * Called lazily on first createAudit / withoutAudit / relationship usage.
150
150
  * @param {AuditedModelClass} modelClass - Audited model class.
151
+ * @param {import("../drivers/base.js").default} [connection] - Explicit record-owned connection.
151
152
  * @returns {Promise<AuditTableData>} Resolved audit table metadata.
152
153
  */
153
- async function resolveAuditTableData(modelClass) {
154
+ async function resolveAuditTableData(modelClass, connection) {
154
155
  if (modelClass._auditTableResolved && modelClass._auditTableData) {
155
156
  return modelClass._auditTableData
156
157
  }
157
158
 
158
- const tableData = await buildAuditTableData(modelClass)
159
+ const resolvedConnection = connection || modelClass.connection()
160
+ const tableData = await buildAuditTableData(modelClass, resolvedConnection)
159
161
  const configuration = modelClass._getConfiguration()
160
162
 
161
163
  tableData.auditClass.registerRecordClass({configuration})
162
- await tableData.auditClass.initializeRecord({configuration})
164
+ await tableData.auditClass.initializeRecord({configuration, connection: resolvedConnection})
163
165
 
164
166
  modelClass._auditTableData = tableData
165
167
  modelClass._auditTableResolved = true
@@ -174,11 +176,12 @@ async function resolveAuditTableData(modelClass) {
174
176
  * registered Audit model for shared tables; falls back to a framework-owned
175
177
  * dynamic class.
176
178
  * @param {AuditedModelClass} modelClass - Audited model class.
179
+ * @param {import("../drivers/base.js").default} connection - Explicit record-owned connection.
177
180
  * @returns {Promise<AuditTableData>} Audit table metadata.
178
181
  */
179
- async function buildAuditTableData(modelClass) {
182
+ async function buildAuditTableData(modelClass, connection) {
180
183
  const dedicatedTable = dedicatedAuditTableName(modelClass)
181
- const dedicatedExists = await dedicatedTableExistsForConnection(modelClass, dedicatedTable)
184
+ const dedicatedExists = await dedicatedTableExistsForConnection(modelClass, dedicatedTable, connection)
182
185
 
183
186
  if (dedicatedExists) {
184
187
  const auditClass = dedicatedAuditClass(modelClass, dedicatedTable)
@@ -217,9 +220,10 @@ async function buildAuditTableData(modelClass) {
217
220
  * Checks whether a dedicated audit table exists for a model's connection.
218
221
  * @param {AuditedModelClass} modelClass - Audited model class.
219
222
  * @param {string} tableName - Dedicated audit table name to check.
223
+ * @param {import("../drivers/base.js").default} connection - Explicit record-owned connection.
220
224
  * @returns {Promise<boolean>} Whether the table exists.
221
225
  */
222
- async function dedicatedTableExistsForConnection(modelClass, tableName) {
226
+ async function dedicatedTableExistsForConnection(modelClass, tableName, connection) {
223
227
  const databaseIdentifier = modelClass.getDatabaseIdentifier()
224
228
  const cacheKey = `${databaseIdentifier}:${tableName}`
225
229
  const cached = dedicatedTableCache.get(cacheKey)
@@ -228,7 +232,6 @@ async function dedicatedTableExistsForConnection(modelClass, tableName) {
228
232
  return cached
229
233
  }
230
234
 
231
- const connection = modelClass.connection()
232
235
  const table = await connection.getTableByName(tableName, {throwError: false})
233
236
  const exists = Boolean(table)
234
237
 
@@ -546,11 +549,11 @@ async function createAudit(record, args) {
546
549
  async function createAuditWithCurrentConnection(record, args, modelClass) {
547
550
  if (!record.isPersisted()) throw new Error(`Cannot audit unpersisted ${modelClass.getModelName()} record`)
548
551
 
549
- const tableData = await resolveAuditTableData(modelClass)
552
+ const db = record.connection()
553
+ const tableData = await resolveAuditTableData(modelClass, db)
550
554
  const action = normalizeAction(args.action)
551
555
  const auditedChanges = args.auditedChanges === undefined ? null : args.auditedChanges
552
556
  const params = args.params === undefined ? null : args.params
553
- const db = modelClass.connection()
554
557
  const currentDate = new Date()
555
558
 
556
559
  const auditActionId = await findOrCreateLookupId({
@@ -204,9 +204,11 @@ async function incrementParentCounter(record, definition, parentId, amount) {
204
204
  throw new Error(`magnitudeCounterCache on ${modelClass.getModelName()} could not resolve the "${definition.belongsTo}" parent model class`)
205
205
  }
206
206
 
207
- // Update through the PARENT model's connection: the row being modified belongs
208
- // to the parent, which may live on a different database/tenant than the child.
209
- const db = parentModelClass.connection()
207
+ // Resolve through the record so an operation both owns the parent write and
208
+ // rejects a relationship whose parent routes to another database.
209
+ const db = record
210
+ .queryForModel(parentModelClass)
211
+ .driver
210
212
  const counterColumnSql = db.quoteColumn(definition.counterColumn)
211
213
  const truncatedAmount = Math.trunc(amount)
212
214
 
@@ -444,6 +444,11 @@ class VelociousDatabaseRecord {
444
444
  * @type {import("../drivers/base.js").default | undefined} */
445
445
  __connection = undefined
446
446
 
447
+ /**
448
+ * Explicit operation owning this record's database work.
449
+ * @type {import("../operation.js").default | undefined} */
450
+ _databaseOperation = undefined
451
+
447
452
  /**
448
453
  * Instance relationships.
449
454
  * @type {Record<string, import("./instance-relationships/base.js").default>} */
@@ -863,9 +868,10 @@ class VelociousDatabaseRecord {
863
868
  * single UPDATE ... SET col = (SELECT COUNT(*)) so concurrent
864
869
  * creates/destroys cannot race into a stale count.
865
870
  * @param {number | string | null} parentId - Parent primary-key value.
871
+ * @param {VelociousDatabaseRecord} record - Child record owning the connection.
866
872
  * @returns {Promise<void>} - Resolves when the counter cache has been synced.
867
873
  */
868
- async function syncCounter(parentId) {
874
+ async function syncCounter(parentId, record) {
869
875
  if (!parentId) return
870
876
 
871
877
  const relationship = ChildModel.getRelationshipByName(relationshipName)
@@ -880,7 +886,9 @@ class VelociousDatabaseRecord {
880
886
  const parentTable = ParentModel.tableName()
881
887
  const childTable = ChildModel.tableName()
882
888
  const pkColumn = inflection.underscore(primaryKey)
883
- const connection = ParentModel.connection()
889
+ const connection = record
890
+ .queryForModel(ParentModel)
891
+ .driver
884
892
  const quoted = connection.quote(parentId)
885
893
 
886
894
  const sql = `UPDATE ${connection.quoteTable(parentTable)} SET ${connection.quoteColumn(counterColumn)} = (SELECT COUNT(*) FROM ${connection.quoteTable(childTable)} WHERE ${connection.quoteColumn(fk)} = ${quoted}) WHERE ${connection.quoteColumn(pkColumn)} = ${quoted}`
@@ -901,11 +909,11 @@ class VelociousDatabaseRecord {
901
909
  }
902
910
 
903
911
  ChildModel.afterCreate(async (record) => {
904
- await syncCounter(readFkAttribute(record))
912
+ await syncCounter(readFkAttribute(record), record)
905
913
  })
906
914
 
907
915
  ChildModel.afterDestroy(async (record) => {
908
- await syncCounter(readFkAttribute(record))
916
+ await syncCounter(readFkAttribute(record), record)
909
917
  })
910
918
 
911
919
  ChildModel.beforeSave(async (record) => {
@@ -932,8 +940,8 @@ class VelociousDatabaseRecord {
932
940
 
933
941
  if (previousParentId !== undefined) {
934
942
  delete model[prevKey]
935
- await syncCounter(previousParentId)
936
- await syncCounter(readFkAttribute(model))
943
+ await syncCounter(previousParentId, record)
944
+ await syncCounter(readFkAttribute(model), record)
937
945
  }
938
946
  })
939
947
  }
@@ -1452,15 +1460,16 @@ class VelociousDatabaseRecord {
1452
1460
  * Runs initialize record.
1453
1461
  * @param {object} args - Options object.
1454
1462
  * @param {import("../../configuration.js").default} args.configuration - Configuration instance.
1463
+ * @param {import("../drivers/base.js").default} [args.connection] - Explicit metadata connection.
1455
1464
  * @returns {Promise<void>} - Resolves when complete.
1456
1465
  */
1457
- static async initializeRecord({configuration, ...restArgs}) {
1466
+ static async initializeRecord({configuration, connection: explicitConnection, ...restArgs}) {
1458
1467
  restArgsError(restArgs)
1459
1468
 
1460
1469
  if (!configuration) throw new Error(`No configuration given for ${this.name}`)
1461
1470
 
1462
1471
  this.registerRecordClass({configuration})
1463
- const connection = this.connection({enforceTenantDatabaseScope: false})
1472
+ const connection = explicitConnection || this.connection({enforceTenantDatabaseScope: false})
1464
1473
 
1465
1474
  this._databaseType = connection.getType()
1466
1475
 
@@ -2427,7 +2436,7 @@ class VelociousDatabaseRecord {
2427
2436
  await this._runLifecycleCallbacks("beforeValidation")
2428
2437
  await this._runValidations()
2429
2438
 
2430
- await this.getModelClass().transaction(async () => {
2439
+ const saveInTransaction = async () => {
2431
2440
  await this._runLifecycleCallbacks("beforeSave")
2432
2441
 
2433
2442
  // If any belongs-to-relationships was saved, then updated-at should still be set on this record.
@@ -2454,7 +2463,13 @@ class VelociousDatabaseRecord {
2454
2463
  await this._autoSaveAttachments()
2455
2464
  await this._runLifecycleCallbacks("afterSave")
2456
2465
  await this._emitRecordChangeAfterCommit(isNewRecord ? "create" : "update")
2457
- })
2466
+ }
2467
+
2468
+ if (this._databaseOperation) {
2469
+ await this._databaseOperation.transaction(saveInTransaction)
2470
+ } else {
2471
+ await this.getModelClass().transaction(saveInTransaction)
2472
+ }
2458
2473
  })
2459
2474
 
2460
2475
  this._assignedAttributeNames = undefined
@@ -2481,6 +2496,7 @@ class VelociousDatabaseRecord {
2481
2496
  if (model) {
2482
2497
  if (model instanceof VelociousDatabaseRecord) {
2483
2498
  if (model.isChanged()) {
2499
+ this.bindRelatedRecord(model)
2484
2500
  await model.save()
2485
2501
 
2486
2502
  const foreignKey = this._relationshipForeignKeyAttribute(instanceRelationship)
@@ -2539,6 +2555,7 @@ class VelociousDatabaseRecord {
2539
2555
 
2540
2556
  if (loaded) {
2541
2557
  for (const model of loaded) {
2558
+ this.bindRelatedRecord(model)
2542
2559
  const foreignKey = model._relationshipForeignKeyAttribute(instanceRelationship)
2543
2560
 
2544
2561
  model.setAttribute(foreignKey, this.id())
@@ -2592,6 +2609,7 @@ class VelociousDatabaseRecord {
2592
2609
  }
2593
2610
 
2594
2611
  for (const model of loaded) {
2612
+ this.bindRelatedRecord(model)
2595
2613
  const foreignKey = model._relationshipForeignKeyAttribute(instanceRelationship)
2596
2614
 
2597
2615
  model.setAttribute(foreignKey, this.id())
@@ -3021,15 +3039,18 @@ class VelociousDatabaseRecord {
3021
3039
  * Runs new query.
3022
3040
  * @template {typeof VelociousDatabaseRecord} MC
3023
3041
  * @this {MC}
3042
+ * @param {{driver?: import("../drivers/base.js").default | (() => import("../drivers/base.js").default), operation?: import("../operation.js").default}} [args] - Explicit query ownership.
3024
3043
  * @returns {ModelClassQuery<MC>} - The new query.
3025
3044
  */
3026
- static _newQuery() {
3045
+ static _newQuery({driver = () => this.connection(), operation, ...restArgs} = {}) {
3046
+ restArgsError(restArgs)
3027
3047
  this._assertHasBeenInitialized()
3028
3048
  const handler = new Handler()
3029
3049
  const query = new ModelClassQuery({
3030
- driver: () => this.connection(),
3050
+ driver,
3031
3051
  handler,
3032
- modelClass: this
3052
+ modelClass: this,
3053
+ operation
3033
3054
  })
3034
3055
 
3035
3056
  return query.from(new FromTable(this.tableName()))
@@ -3416,6 +3437,53 @@ class VelociousDatabaseRecord {
3416
3437
  }
3417
3438
  }
3418
3439
 
3440
+ /**
3441
+ * Binds future query, lifecycle, relationship, and persistence work to an operation.
3442
+ * @param {import("../operation.js").default} operation - Owning operation.
3443
+ * @returns {this} - Bound record.
3444
+ */
3445
+ bindDatabaseOperation(operation) {
3446
+ if (this._databaseOperation && this._databaseOperation !== operation) {
3447
+ throw new Error("Record is already bound to another database operation")
3448
+ }
3449
+
3450
+ this._databaseOperation = operation
3451
+
3452
+ return this
3453
+ }
3454
+
3455
+ /**
3456
+ * Returns the explicit operation owning this record, if any.
3457
+ * @returns {import("../operation.js").default | undefined} - Owning operation.
3458
+ */
3459
+ databaseOperation() {
3460
+ return this._databaseOperation
3461
+ }
3462
+
3463
+ /**
3464
+ * Binds a related record to the same operation as this record.
3465
+ * @template {VelociousDatabaseRecord} Model
3466
+ * @param {Model} record - Related record.
3467
+ * @returns {Model} - Related record.
3468
+ */
3469
+ bindRelatedRecord(record) {
3470
+ if (this._databaseOperation) this._databaseOperation.bindRecord(record)
3471
+
3472
+ return record
3473
+ }
3474
+
3475
+ /**
3476
+ * Builds a model query preserving this record's operation ownership.
3477
+ * @template {typeof VelociousDatabaseRecord} MC
3478
+ * @param {MC} ModelClass - Target model class.
3479
+ * @returns {ModelClassQuery<MC>} - Target query.
3480
+ */
3481
+ queryForModel(ModelClass) {
3482
+ if (this._databaseOperation) return this._databaseOperation.forModel(ModelClass)
3483
+
3484
+ return ModelClass._newQuery()
3485
+ }
3486
+
3419
3487
  /**
3420
3488
  * Runs load existing record.
3421
3489
  * @param {object} attributes - Attributes.
@@ -3473,11 +3541,20 @@ class VelociousDatabaseRecord {
3473
3541
  * @returns {import("../drivers/base.js").default} - The connection.
3474
3542
  */
3475
3543
  _connection() {
3544
+ if (this._databaseOperation) return this._databaseOperation.connection()
3476
3545
  if (this.__connection) return this.__connection
3477
3546
 
3478
3547
  return this.getModelClass().connection()
3479
3548
  }
3480
3549
 
3550
+ /**
3551
+ * Returns the connection that owns this record's database work.
3552
+ * @returns {import("../drivers/base.js").default} - Connection.
3553
+ */
3554
+ connection() {
3555
+ return this._connection()
3556
+ }
3557
+
3481
3558
  /**
3482
3559
  * Counts dependent records for a `dependent: "restrict"` relationship.
3483
3560
  * @param {RestrictInstanceRelationship} instanceRelationship - Relationship instance to count.
@@ -4374,7 +4451,11 @@ class VelociousDatabaseRecord {
4374
4451
 
4375
4452
  whereObject[primaryKey] = id
4376
4453
 
4377
- const query = /** @type {import("../query/model-class-query.js").default<MC>} */ (this.getModelClass().where(whereObject))
4454
+ const query = /** @type {import("../query/model-class-query.js").default<MC>} */ (
4455
+ this
4456
+ .queryForModel(this.getModelClass())
4457
+ .where(whereObject)
4458
+ )
4378
4459
  const reloadedModel = await query.first()
4379
4460
 
4380
4461
  if (!reloadedModel) throw new Error(`${this.constructor.name}#${id} couldn't be reloaded - record didn't exist`)
@@ -27,7 +27,9 @@ export default class VelociousDatabaseRecordBelongsToInstanceRelationship extend
27
27
 
28
28
  if (!TargetModelClass) throw new Error("Can't build a new record without a target model")
29
29
 
30
- const newInstance = /** @type {InstanceType<TMC>} */ (new TargetModelClass(data))
30
+ const newInstance = this.getModel().bindRelatedRecord(
31
+ /** @type {InstanceType<TMC>} */ (new TargetModelClass(data))
32
+ )
31
33
 
32
34
  this._loaded = newInstance
33
35
 
@@ -85,7 +87,7 @@ export default class VelociousDatabaseRecordBelongsToInstanceRelationship extend
85
87
 
86
88
  whereArgs[primaryKey] = foreignModelID
87
89
 
88
- const query = this.applyScope(TargetModelClass.where(whereArgs))
90
+ const query = this.applyScope(this.getModel().queryForModel(TargetModelClass).where(whereArgs))
89
91
 
90
92
  const foreignModel = await query.first()
91
93