velocious 1.0.567 → 1.0.568

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 (108) hide show
  1. package/README.md +29 -1
  2. package/build/database/drivers/base-table.js +4 -3
  3. package/build/database/drivers/base.js +28 -0
  4. package/build/database/drivers/index-metadata.js +28 -0
  5. package/build/database/drivers/mssql/column.js +2 -34
  6. package/build/database/drivers/mssql/columns-index.js +37 -0
  7. package/build/database/drivers/mssql/table.js +55 -11
  8. package/build/database/drivers/mysql/sql/alter-table.js +13 -0
  9. package/build/database/drivers/pgsql/column.js +2 -27
  10. package/build/database/drivers/pgsql/columns-index.js +37 -0
  11. package/build/database/drivers/pgsql/table.js +61 -10
  12. package/build/database/drivers/sqlite/sql/alter-table.js +18 -0
  13. package/build/database/migration/index.js +92 -3
  14. package/build/database/query/alter-table-base.js +20 -1
  15. package/build/database/record/attachments/normalize-input.js +38 -10
  16. package/build/database/record/attachments/storage-drivers/filesystem.js +33 -2
  17. package/build/database/record/attachments/storage-drivers/native.js +17 -2
  18. package/build/database/record/attachments/storage-drivers/s3.js +27 -7
  19. package/build/database/record/attachments/store.js +159 -43
  20. package/build/database/table-data/table-foreign-key.js +9 -1
  21. package/build/environment-handlers/base.js +1 -1
  22. package/build/environment-handlers/node/attachment-path-source.js +144 -0
  23. package/build/environment-handlers/node.js +28 -3
  24. package/build/src/database/drivers/base-table.d.ts.map +1 -1
  25. package/build/src/database/drivers/base-table.js +4 -3
  26. package/build/src/database/drivers/base.d.ts +7 -0
  27. package/build/src/database/drivers/base.d.ts.map +1 -1
  28. package/build/src/database/drivers/base.js +24 -1
  29. package/build/src/database/drivers/index-metadata.d.ts +41 -0
  30. package/build/src/database/drivers/index-metadata.d.ts.map +1 -0
  31. package/build/src/database/drivers/index-metadata.js +26 -0
  32. package/build/src/database/drivers/mssql/column.d.ts.map +1 -1
  33. package/build/src/database/drivers/mssql/column.js +3 -31
  34. package/build/src/database/drivers/mssql/columns-index.d.ts +41 -0
  35. package/build/src/database/drivers/mssql/columns-index.d.ts.map +1 -1
  36. package/build/src/database/drivers/mssql/columns-index.js +35 -1
  37. package/build/src/database/drivers/mssql/table.d.ts +42 -0
  38. package/build/src/database/drivers/mssql/table.d.ts.map +1 -1
  39. package/build/src/database/drivers/mssql/table.js +49 -12
  40. package/build/src/database/drivers/mysql/sql/alter-table.d.ts.map +1 -1
  41. package/build/src/database/drivers/mysql/sql/alter-table.js +12 -1
  42. package/build/src/database/drivers/pgsql/column.d.ts.map +1 -1
  43. package/build/src/database/drivers/pgsql/column.js +3 -24
  44. package/build/src/database/drivers/pgsql/columns-index.d.ts +41 -0
  45. package/build/src/database/drivers/pgsql/columns-index.d.ts.map +1 -1
  46. package/build/src/database/drivers/pgsql/columns-index.js +35 -1
  47. package/build/src/database/drivers/pgsql/table.d.ts +42 -0
  48. package/build/src/database/drivers/pgsql/table.d.ts.map +1 -1
  49. package/build/src/database/drivers/pgsql/table.js +54 -11
  50. package/build/src/database/drivers/sqlite/sql/alter-table.d.ts.map +1 -1
  51. package/build/src/database/drivers/sqlite/sql/alter-table.js +15 -1
  52. package/build/src/database/migration/index.d.ts +35 -1
  53. package/build/src/database/migration/index.d.ts.map +1 -1
  54. package/build/src/database/migration/index.js +78 -4
  55. package/build/src/database/query/alter-table-base.d.ts +6 -0
  56. package/build/src/database/query/alter-table-base.d.ts.map +1 -1
  57. package/build/src/database/query/alter-table-base.js +18 -2
  58. package/build/src/database/record/attachments/normalize-input.d.ts +33 -4
  59. package/build/src/database/record/attachments/normalize-input.d.ts.map +1 -1
  60. package/build/src/database/record/attachments/normalize-input.js +39 -11
  61. package/build/src/database/record/attachments/storage-drivers/filesystem.d.ts +2 -5
  62. package/build/src/database/record/attachments/storage-drivers/filesystem.d.ts.map +1 -1
  63. package/build/src/database/record/attachments/storage-drivers/filesystem.js +28 -3
  64. package/build/src/database/record/attachments/storage-drivers/native.d.ts +2 -6
  65. package/build/src/database/record/attachments/storage-drivers/native.d.ts.map +1 -1
  66. package/build/src/database/record/attachments/storage-drivers/native.js +14 -3
  67. package/build/src/database/record/attachments/storage-drivers/s3.d.ts +2 -6
  68. package/build/src/database/record/attachments/storage-drivers/s3.d.ts.map +1 -1
  69. package/build/src/database/record/attachments/storage-drivers/s3.js +29 -8
  70. package/build/src/database/record/attachments/store.d.ts +27 -0
  71. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  72. package/build/src/database/record/attachments/store.js +144 -44
  73. package/build/src/database/table-data/table-foreign-key.d.ts +9 -1
  74. package/build/src/database/table-data/table-foreign-key.d.ts.map +1 -1
  75. package/build/src/database/table-data/table-foreign-key.js +9 -2
  76. package/build/src/environment-handlers/base.d.ts +2 -5
  77. package/build/src/environment-handlers/base.d.ts.map +1 -1
  78. package/build/src/environment-handlers/base.js +2 -2
  79. package/build/src/environment-handlers/node/attachment-path-source.d.ts +64 -0
  80. package/build/src/environment-handlers/node/attachment-path-source.d.ts.map +1 -0
  81. package/build/src/environment-handlers/node/attachment-path-source.js +116 -0
  82. package/build/src/environment-handlers/node.d.ts +12 -0
  83. package/build/src/environment-handlers/node.d.ts.map +1 -1
  84. package/build/src/environment-handlers/node.js +24 -4
  85. package/build/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/database/drivers/base-table.js +4 -3
  88. package/src/database/drivers/base.js +28 -0
  89. package/src/database/drivers/index-metadata.js +28 -0
  90. package/src/database/drivers/mssql/column.js +2 -34
  91. package/src/database/drivers/mssql/columns-index.js +37 -0
  92. package/src/database/drivers/mssql/table.js +55 -11
  93. package/src/database/drivers/mysql/sql/alter-table.js +13 -0
  94. package/src/database/drivers/pgsql/column.js +2 -27
  95. package/src/database/drivers/pgsql/columns-index.js +37 -0
  96. package/src/database/drivers/pgsql/table.js +61 -10
  97. package/src/database/drivers/sqlite/sql/alter-table.js +18 -0
  98. package/src/database/migration/index.js +92 -3
  99. package/src/database/query/alter-table-base.js +20 -1
  100. package/src/database/record/attachments/normalize-input.js +38 -10
  101. package/src/database/record/attachments/storage-drivers/filesystem.js +33 -2
  102. package/src/database/record/attachments/storage-drivers/native.js +17 -2
  103. package/src/database/record/attachments/storage-drivers/s3.js +27 -7
  104. package/src/database/record/attachments/store.js +159 -43
  105. package/src/database/table-data/table-foreign-key.js +9 -1
  106. package/src/environment-handlers/base.js +1 -1
  107. package/src/environment-handlers/node/attachment-path-source.js +144 -0
  108. package/src/environment-handlers/node.js +28 -3
@@ -270,6 +270,57 @@ export default class VelociousDatabaseMigration {
270
270
  )
271
271
  }
272
272
 
273
+ /**
274
+ * RemoveForeignKeyArgsType type.
275
+ * @typedef {object} RemoveForeignKeyArgsType
276
+ * @property {string} [columnName] - Override the derived foreign-key column name.
277
+ */
278
+ /**
279
+ * Runs remove foreign key.
280
+ * @param {string} tableName - Table the foreign key lives on.
281
+ * @param {string} referenceName - Singular reference name used to derive the FK column.
282
+ * @param {RemoveForeignKeyArgsType} [args] - Optional overrides.
283
+ * @returns {Promise<void>} - Resolves when complete.
284
+ */
285
+ async removeForeignKey(tableName, referenceName, args = {}) {
286
+ const {columnName, ...restArgs} = args
287
+
288
+ restArgsError(restArgs)
289
+
290
+ const resolvedColumnName = columnName || `${inflection.underscore(referenceName)}_id`
291
+ const driver = this.getDriver()
292
+ let maximumRemovals = 0
293
+ let previousMatchingCount = 0
294
+
295
+ for (let removalAttempt = 0; ; removalAttempt++) {
296
+ const table = await driver.getTableByName(tableName)
297
+
298
+ if (!table) throw new Error(`Table ${tableName} does not exist`)
299
+
300
+ const foreignKeys = await table.getForeignKeys()
301
+ const matchingForeignKeys = foreignKeys.filter((foreignKey) => foreignKey.getColumnName() == resolvedColumnName)
302
+
303
+ if (matchingForeignKeys.length === 0) {
304
+ if (removalAttempt === 0) throw new Error(`No foreign key on ${tableName}.${resolvedColumnName}`)
305
+
306
+ return
307
+ }
308
+
309
+ if (removalAttempt === 0) {
310
+ maximumRemovals = matchingForeignKeys.length
311
+ } else if (matchingForeignKeys.length >= previousMatchingCount) {
312
+ throw new Error(`Foreign key removal did not reduce matches on ${tableName}.${resolvedColumnName}`)
313
+ }
314
+
315
+ if (removalAttempt >= maximumRemovals) {
316
+ throw new Error(`Foreign key removal exceeded expected matches on ${tableName}.${resolvedColumnName}`)
317
+ }
318
+
319
+ previousMatchingCount = matchingForeignKeys.length
320
+ await driver.removeForeignKey(tableName, matchingForeignKeys[0])
321
+ }
322
+ }
323
+
273
324
  /**
274
325
  * Runs add reference.
275
326
  * @param {string} tableName - Table name.
@@ -298,16 +349,54 @@ export default class VelociousDatabaseMigration {
298
349
  }
299
350
  }
300
351
 
352
+ /**
353
+ * RemoveReferenceArgsType type.
354
+ * @typedef {object} RemoveReferenceArgsType
355
+ * @property {string} [columnName] - Override the derived reference column name.
356
+ * @property {string} [indexName] - Explicit generated index name to remove.
357
+ */
301
358
  /**
302
359
  * Runs remove reference.
303
360
  * @param {string} tableName - Table name.
304
361
  * @param {string} referenceName - Reference name.
362
+ * @param {RemoveReferenceArgsType} [args] - Optional overrides.
305
363
  * @returns {Promise<void>} - Resolves when complete.
306
364
  */
307
- async removeReference(tableName, referenceName) {
308
- const columnName = `${inflection.underscore(referenceName)}_id`
365
+ async removeReference(tableName, referenceName, args = {}) {
366
+ const {columnName, indexName, ...restArgs} = args
367
+
368
+ restArgsError(restArgs)
369
+
370
+ const resolvedColumnName = columnName || `${inflection.underscore(referenceName)}_id`
371
+ const driver = this.getDriver()
372
+ const table = await driver.getTableByName(tableName)
373
+
374
+ if (!table) throw new Error(`Table ${tableName} does not exist`)
375
+
376
+ const foreignKeys = await table.getForeignKeys()
377
+
378
+ for (const foreignKey of foreignKeys) {
379
+ if (foreignKey.getColumnName() != resolvedColumnName) continue
380
+
381
+ await driver.removeForeignKey(tableName, foreignKey)
382
+ }
383
+
384
+ const expectedIndexName = indexName || this._removeIndexName(tableName, [resolvedColumnName])
385
+ const indexes = await table.getIndexes()
386
+ const generatedIndex = indexes.find((index) => {
387
+ const indexColumnNames = index.getColumnNames()
388
+
389
+ return !index.isPrimaryKey() &&
390
+ index.getName() == expectedIndexName &&
391
+ indexColumnNames.length == 1 &&
392
+ indexColumnNames[0] == resolvedColumnName
393
+ })
394
+
395
+ if (generatedIndex) {
396
+ await this.removeIndex(tableName, generatedIndex.getName())
397
+ }
309
398
 
310
- this.removeColumn(tableName, columnName)
399
+ await this.removeColumn(tableName, resolvedColumnName)
311
400
  }
312
401
 
313
402
  /**
@@ -64,10 +64,16 @@ export default class VelociousDatabaseQueryAlterTableBase extends QueryBase {
64
64
  // SQLite's ALTER TABLE does not support adding constraints; the SQLite driver overrides
65
65
  // alterTableSQLs entirely (via TableRebuilder) so this base path is never invoked there.
66
66
  for (const foreignKey of tableData.getForeignKeys()) {
67
- if (!foreignKey.getIsNewForeignKey()) continue
67
+ if (!foreignKey.getIsNewForeignKey() && !foreignKey.getDropForeignKey()) continue
68
68
 
69
69
  if (actionCount > 0) sql += ", "
70
70
 
71
+ if (foreignKey.getDropForeignKey()) {
72
+ sql += this.getDropForeignKeySQL(foreignKey)
73
+ actionCount++
74
+ continue
75
+ }
76
+
71
77
  sql += "ADD"
72
78
 
73
79
  if (foreignKey.getName()) {
@@ -101,4 +107,17 @@ export default class VelociousDatabaseQueryAlterTableBase extends QueryBase {
101
107
 
102
108
  return sqls
103
109
  }
110
+
111
+ /**
112
+ * Runs get drop foreign key sql.
113
+ * @param {import("../table-data/table-foreign-key.js").default} foreignKey - Foreign key to drop.
114
+ * @returns {string} - SQL fragment that removes the foreign key.
115
+ */
116
+ getDropForeignKeySQL(foreignKey) {
117
+ const name = foreignKey.getName()
118
+
119
+ if (!name) throw new Error(`Cannot remove unnamed foreign key on ${foreignKey.getTableName()}.${foreignKey.getColumnName()}`)
120
+
121
+ return `DROP CONSTRAINT ${this.getOptions().quoteIndexName(name)}`
122
+ }
104
123
  }
@@ -2,14 +2,24 @@
2
2
 
3
3
  import UploadedFile from "../../../http-server/client/uploaded-file/uploaded-file.js"
4
4
 
5
+ /**
6
+ * AttachmentPathSource type.
7
+ * @typedef {object} AttachmentPathSource
8
+ * @property {number} byteSize - Opened file snapshot size.
9
+ * @property {string} filePath - Validated source path for metadata only.
10
+ * @property {() => Promise<import("node:stream").Readable>} createReadStream - Creates a bounded snapshot stream.
11
+ * @property {() => Promise<Buffer>} readBuffer - Reads snapshot bytes for compatibility callers.
12
+ * @property {() => Promise<void>} close - Closes the owned source.
13
+ */
5
14
  /**
6
15
  * NormalizedAttachmentInput type.
7
16
  * @typedef {object} NormalizedAttachmentInput
8
17
  * @property {number} byteSize - File size in bytes.
9
- * @property {Buffer} contentBuffer - Raw content bytes.
10
- * @property {string} contentBase64 - Base64 encoded content.
18
+ * @property {Buffer | null} contentBuffer - Raw in-memory content bytes.
19
+ * @property {string | null} contentBase64 - Base64 encoded in-memory content.
11
20
  * @property {string | null} contentType - Content type.
12
21
  * @property {string} filename - Filename.
22
+ * @property {AttachmentPathSource | null} pathSource - Environment-owned opened path source.
13
23
  */
14
24
  /**
15
25
  * Runs base name.
@@ -120,8 +130,16 @@ export default async function normalizeRecordAttachmentInput(input, args = {}) {
120
130
  const environmentHandler = args.environmentHandler
121
131
  /**
122
132
  * Defines buffer.
123
- * @type {Buffer} */
124
- let buffer
133
+ * @type {Buffer | null} */
134
+ let buffer = null
135
+ /**
136
+ * Defines byte size.
137
+ * @type {number | null} */
138
+ let byteSize = null
139
+ /**
140
+ * Defines path source.
141
+ * @type {AttachmentPathSource | null} */
142
+ let pathSource = null
125
143
  /**
126
144
  * Content type.
127
145
  * @type {string | null} */
@@ -147,13 +165,15 @@ export default async function normalizeRecordAttachmentInput(input, args = {}) {
147
165
  const allowedPathPrefixes = Array.isArray(args.allowedPathPrefixes)
148
166
  ? args.allowedPathPrefixes.filter((entry) => typeof entry === "string" && entry.length > 0)
149
167
  : []
150
- const {buffer: fileBuffer, filePath} = await environmentHandler.resolveAttachmentInputPath({
168
+ pathSource = await environmentHandler.resolveAttachmentInputPath({
151
169
  allowedPathPrefixes,
152
170
  inputPath: input.path
153
171
  })
154
172
 
155
- buffer = fileBuffer
156
- filename = typeof input.filename === "string" && input.filename.length > 0 ? input.filename : baseName(filePath)
173
+ byteSize = pathSource.byteSize
174
+ filename = typeof input.filename === "string" && input.filename.length > 0
175
+ ? input.filename
176
+ : baseName(pathSource.filePath)
157
177
  contentType = typeof input.contentType === "string" && input.contentType.length > 0 ? input.contentType : null
158
178
  } else if (isPlainObject(input) && typeof input.contentBase64 === "string") {
159
179
  buffer = Buffer.from(input.contentBase64, "base64")
@@ -184,11 +204,19 @@ export default async function normalizeRecordAttachmentInput(input, args = {}) {
184
204
  ? baseName(filename)
185
205
  : ""
186
206
 
207
+ if (!buffer && !pathSource) {
208
+ throw new Error("Attachment input normalization produced no content")
209
+ }
210
+
211
+ if (buffer) byteSize = buffer.length
212
+ if (byteSize === null) throw new Error("Attachment input normalization produced no byte size")
213
+
187
214
  return {
188
- byteSize: buffer.length,
215
+ byteSize,
189
216
  contentBuffer: buffer,
190
- contentBase64: buffer.toString("base64"),
217
+ contentBase64: buffer ? buffer.toString("base64") : null,
191
218
  contentType,
192
- filename: normalizedFilename || defaultFilename
219
+ filename: normalizedFilename || defaultFilename,
220
+ pathSource
193
221
  }
194
222
  }
@@ -1,7 +1,9 @@
1
1
  // @ts-check
2
2
 
3
+ import { createWriteStream } from "node:fs"
3
4
  import fs from "fs/promises"
4
5
  import path from "path"
6
+ import { pipeline } from "node:stream/promises"
5
7
 
6
8
  /**
7
9
  * Runs normalize base url.
@@ -52,16 +54,45 @@ export default class FilesystemAttachmentStorageDriver {
52
54
  * Runs write.
53
55
  * @param {object} args - Options.
54
56
  * @param {string} args.attachmentId - Attachment id.
55
- * @param {{contentBuffer: Buffer, filename: string}} args.input - Normalized attachment input.
57
+ * @param {import("../normalize-input.js").NormalizedAttachmentInput} args.input - Normalized attachment input.
56
58
  * @returns {Promise<{storageKey: string}>} - Storage key result.
57
59
  */
58
60
  async write({attachmentId, input}) {
59
61
  const normalizedFilename = path.basename(input.filename || "attachment.bin")
60
62
  const storageKey = `${attachmentId}-${normalizedFilename}`
61
63
  const filePath = path.resolve(this.directory(), storageKey)
64
+ const temporaryFilePath = `${filePath}.tmp`
62
65
 
63
66
  await fs.mkdir(path.dirname(filePath), {recursive: true})
64
- await fs.writeFile(filePath, input.contentBuffer)
67
+
68
+ try {
69
+ if (input.pathSource) {
70
+ await pipeline(
71
+ await input.pathSource.createReadStream(),
72
+ createWriteStream(temporaryFilePath)
73
+ )
74
+ } else if (input.contentBuffer) {
75
+ await fs.writeFile(temporaryFilePath, input.contentBuffer)
76
+ } else {
77
+ throw new Error("Filesystem attachment input has no content")
78
+ }
79
+
80
+ await fs.rename(temporaryFilePath, filePath)
81
+ } catch (error) {
82
+ try {
83
+ await fs.unlink(temporaryFilePath)
84
+ } catch (cleanupError) {
85
+ if (!(cleanupError instanceof Error) || !("code" in cleanupError) || cleanupError.code !== "ENOENT") {
86
+ throw new AggregateError(
87
+ [error, cleanupError],
88
+ `Filesystem attachment write and partial-file cleanup both failed for ${storageKey}`,
89
+ {cause: cleanupError}
90
+ )
91
+ }
92
+ }
93
+
94
+ throw error
95
+ }
65
96
 
66
97
  return {storageKey}
67
98
  }
@@ -47,7 +47,7 @@ export default class NativeAttachmentStorageDriver {
47
47
  * Runs write.
48
48
  * @param {object} args - Write args.
49
49
  * @param {string} args.attachmentId - Attachment id.
50
- * @param {{contentBase64: string, contentType: string | null, filename: string}} args.input - Normalized attachment input.
50
+ * @param {import("../normalize-input.js").NormalizedAttachmentInput} args.input - Normalized attachment input.
51
51
  * @param {import("../../index.js").default} args.model - Model instance.
52
52
  * @param {string} args.name - Attachment name.
53
53
  * @returns {Promise<{storageKey: string}>} - Storage key result.
@@ -57,10 +57,25 @@ export default class NativeAttachmentStorageDriver {
57
57
  throwMissingMethod({driverName: this.name, methodName: "write"})
58
58
  }
59
59
 
60
+ let contentBase64 = input.contentBase64
61
+
62
+ if (input.pathSource) {
63
+ // Native write callbacks have always received Base64. Path input is
64
+ // intentionally buffered here, after driver selection, to preserve that
65
+ // public callback contract without forcing filesystem/S3 to buffer.
66
+ const contentBuffer = await input.pathSource.readBuffer()
67
+
68
+ contentBase64 = contentBuffer.toString("base64")
69
+ }
70
+
71
+ if (contentBase64 === null) {
72
+ throw new Error(`Attachment storage driver "${this.name}" input has no content`)
73
+ }
74
+
60
75
  const result = await this.options.write({
61
76
  attachmentId,
62
77
  attachmentName: name,
63
- contentBase64: input.contentBase64,
78
+ contentBase64,
64
79
  contentType: input.contentType,
65
80
  filename: input.filename,
66
81
  model
@@ -167,20 +167,40 @@ export default class S3AttachmentStorageDriver {
167
167
  * Runs write.
168
168
  * @param {object} args - Write args.
169
169
  * @param {string} args.attachmentId - Attachment id.
170
- * @param {{contentBuffer: Buffer, contentType: string | null, filename: string}} args.input - Normalized attachment input.
170
+ * @param {import("../normalize-input.js").NormalizedAttachmentInput} args.input - Normalized attachment input.
171
171
  * @returns {Promise<{storageKey: string}>} - Storage key.
172
172
  */
173
173
  async write({attachmentId, input}) {
174
174
  const {PutObjectCommand} = await this.s3Runtime()
175
175
  const client = await this.client()
176
176
  const storageKey = `${attachmentId}-${input.filename}`
177
+ /**
178
+ * S3 request body.
179
+ * @type {Buffer | import("node:stream").Readable | null} */
180
+ let body = input.contentBuffer
181
+ /**
182
+ * Path input stream.
183
+ * @type {import("node:stream").Readable | null} */
184
+ let pathInputStream = null
185
+
186
+ if (input.pathSource) {
187
+ pathInputStream = await input.pathSource.createReadStream()
188
+ body = pathInputStream
189
+ }
177
190
 
178
- await client.send(new PutObjectCommand({
179
- Body: input.contentBuffer,
180
- Bucket: this.bucket(),
181
- ContentType: input.contentType || undefined,
182
- Key: storageKey
183
- }))
191
+ if (!body) throw new Error("S3 attachment input has no content")
192
+
193
+ try {
194
+ await client.send(new PutObjectCommand({
195
+ Body: body,
196
+ Bucket: this.bucket(),
197
+ ContentLength: input.byteSize,
198
+ ContentType: input.contentType || undefined,
199
+ Key: storageKey
200
+ }))
201
+ } finally {
202
+ if (pathInputStream) pathInputStream.destroy()
203
+ }
184
204
 
185
205
  return {storageKey}
186
206
  }
@@ -156,65 +156,181 @@ export default class RecordAttachmentsStore {
156
156
  allowedPathPrefixes,
157
157
  environmentHandler: this.configuration.getEnvironmentHandler()
158
158
  })
159
+ /**
160
+ * Attachment persistence error.
161
+ * This stays opaque so any JavaScript thrown value is preserved exactly.
162
+ * @type {unknown} */
163
+ let persistenceError = null
164
+ let persistenceFailed = false
165
+
166
+ try {
167
+ const persistenceInput = await this.persistenceInputFor(normalizedInput)
168
+
169
+ await this.persistNormalizedAttachment({
170
+ model,
171
+ name,
172
+ normalizedInput: persistenceInput,
173
+ replace
174
+ })
175
+ } catch (error) {
176
+ persistenceFailed = true
177
+ persistenceError = error
178
+ }
179
+
180
+ if (normalizedInput.pathSource) {
181
+ try {
182
+ await normalizedInput.pathSource.close()
183
+ } catch (closeError) {
184
+ if (persistenceFailed) {
185
+ throw new AggregateError(
186
+ [persistenceError, closeError],
187
+ `Attachment persistence and path-source close both failed for ${model.getModelClass().getModelName()}#${String(model.id())} (${name})`,
188
+ {cause: closeError}
189
+ )
190
+ }
191
+
192
+ throw closeError
193
+ }
194
+ }
195
+
196
+ if (persistenceFailed) throw persistenceError
197
+ }
198
+
199
+ /**
200
+ * Materializes path content once when a legacy schema requires Base64.
201
+ * @param {import("./normalize-input.js").NormalizedAttachmentInput} normalizedInput - Normalized attachment input.
202
+ * @returns {Promise<import("./normalize-input.js").NormalizedAttachmentInput>} - Input used by the driver and database.
203
+ */
204
+ async persistenceInputFor(normalizedInput) {
205
+ if (this._contentBase64Nullable || !normalizedInput.pathSource) return normalizedInput
206
+
207
+ const contentBuffer = await normalizedInput.pathSource.readBuffer()
208
+
209
+ return {
210
+ ...normalizedInput,
211
+ contentBase64: contentBuffer.toString("base64"),
212
+ contentBuffer,
213
+ pathSource: null
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Persists one normalized attachment while its path source remains open.
219
+ * @param {object} args - Options.
220
+ * @param {import("../index.js").default} args.model - Model instance.
221
+ * @param {string} args.name - Attachment name.
222
+ * @param {import("./normalize-input.js").NormalizedAttachmentInput} args.normalizedInput - Normalized attachment.
223
+ * @param {boolean} args.replace - Whether to replace existing attachments.
224
+ * @returns {Promise<void>} - Resolves after persistence.
225
+ */
226
+ async persistNormalizedAttachment({model, name, normalizedInput, replace}) {
159
227
  const attachmentDriver = await this.resolveAttachmentDriver({model, name})
160
228
  const attachmentDriverName = this._attachmentDriverNameFor({model, name})
161
229
  const now = Date.now()
162
230
  const recordType = model.getModelClass().getModelName()
163
231
  const recordId = String(model.id())
164
232
  const attachmentId = generateUUID()
165
- const {storageKey} = await attachmentDriver.write({
166
- attachmentId,
167
- input: normalizedInput,
168
- model,
169
- name
170
- })
233
+ /**
234
+ * Written storage key.
235
+ * @type {string | null} */
236
+ let storageKey = null
237
+ let rowPersisted = false
238
+
239
+ try {
240
+ const writeResult = await attachmentDriver.write({
241
+ attachmentId,
242
+ input: normalizedInput,
243
+ model,
244
+ name
245
+ })
246
+
247
+ storageKey = writeResult.storageKey
248
+
249
+ // Current schemas keep content_base64 nullable and avoid duplicating
250
+ // driver-backed content. Legacy path input was materialized once before
251
+ // the driver write so this value describes those exact persisted bytes.
252
+ const databaseContentBase64 = await this.databaseContentBase64For(normalizedInput)
253
+
254
+ await this._withDb(async (db) => {
255
+ if (replace) {
256
+ const existingRows = await db
257
+ .newQuery()
258
+ .from(ATTACHMENTS_TABLE)
259
+ .where({name, record_id: recordId, record_type: recordType})
260
+ .results()
261
+
262
+ for (const existingRow of existingRows) {
263
+ await this.deleteAttachmentRowStorage({model, name, row: existingRow})
264
+ }
265
+
266
+ await db.delete({
267
+ conditions: {name, record_id: recordId, record_type: recordType},
268
+ tableName: ATTACHMENTS_TABLE
269
+ })
270
+ }
171
271
 
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()
272
+ const position = replace ? 0 : await this._nextPosition({db, name, recordId, recordType})
273
+ /**
274
+ * Insert data.
275
+ * @type {Record<string, ?>} */
276
+ const insertData = {
277
+ byte_size: normalizedInput.byteSize,
278
+ content_base64: databaseContentBase64,
279
+ content_type: normalizedInput.contentType,
280
+ created_at_ms: now,
281
+ filename: normalizedInput.filename,
282
+ id: attachmentId,
283
+ name,
284
+ position,
285
+ record_id: recordId,
286
+ record_type: recordType,
287
+ updated_at_ms: now
288
+ }
179
289
 
180
- for (const existingRow of existingRows) {
181
- await this.deleteAttachmentRowStorage({model, name, row: existingRow})
290
+ if (this._driverColumnsAvailable) {
291
+ insertData.driver = attachmentDriverName
292
+ insertData.storage_key = storageKey
182
293
  }
183
294
 
184
- await db.delete({
185
- conditions: {name, record_id: recordId, record_type: recordType},
295
+ await db.insert({
296
+ data: insertData,
186
297
  tableName: ATTACHMENTS_TABLE
187
298
  })
188
- }
189
299
 
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
300
+ rowPersisted = true
301
+ })
302
+ } catch (error) {
303
+ if (!rowPersisted && storageKey && typeof attachmentDriver.delete === "function") {
304
+ try {
305
+ await attachmentDriver.delete({
306
+ model,
307
+ name,
308
+ row: {id: attachmentId, storage_key: storageKey},
309
+ storageKey
310
+ })
311
+ } catch (cleanupError) {
312
+ throw new AggregateError(
313
+ [error, cleanupError],
314
+ `Attachment write finalization and new-storage cleanup both failed for ${recordType}#${recordId} (${name})`,
315
+ {cause: cleanupError}
316
+ )
317
+ }
206
318
  }
207
319
 
208
- if (this._driverColumnsAvailable) {
209
- insertData.driver = attachmentDriverName
210
- insertData.storage_key = storageKey
211
- }
320
+ throw error
321
+ }
322
+ }
212
323
 
213
- await db.insert({
214
- data: insertData,
215
- tableName: ATTACHMENTS_TABLE
216
- })
217
- })
324
+ /**
325
+ * Resolves the database content_base64 value for current and legacy schemas.
326
+ * @param {import("./normalize-input.js").NormalizedAttachmentInput} normalizedInput - Normalized attachment input.
327
+ * @returns {Promise<string | null>} - Nullable or legacy Base64 database value.
328
+ */
329
+ async databaseContentBase64For(normalizedInput) {
330
+ if (this._contentBase64Nullable) return null
331
+ if (normalizedInput.contentBase64 !== null) return normalizedInput.contentBase64
332
+
333
+ throw new Error("Legacy attachment schema requires materialized content bytes")
218
334
  }
219
335
 
220
336
  /**
@@ -7,16 +7,18 @@ export default class TableForeignKey {
7
7
  * Runs constructor.
8
8
  * @param {object} args - Options object.
9
9
  * @param {string} args.columnName - Column name.
10
+ * @param {boolean} [args.dropForeignKey] - Whether to drop this foreign key.
10
11
  * @param {boolean} [args.isNewForeignKey] - Whether is new foreign key.
11
12
  * @param {string} [args.name] - Name.
12
13
  * @param {string} args.tableName - Table name.
13
14
  * @param {string} args.referencedColumnName - Referenced column name.
14
15
  * @param {string} args.referencedTableName - Referenced table name.
15
16
  */
16
- constructor({columnName, isNewForeignKey, name, tableName, referencedColumnName, referencedTableName, ...restArgs}) {
17
+ constructor({columnName, dropForeignKey, isNewForeignKey, name, tableName, referencedColumnName, referencedTableName, ...restArgs}) {
17
18
  restArgsError(restArgs)
18
19
 
19
20
  this._columnName = columnName
21
+ this._dropForeignKey = dropForeignKey
20
22
  this._isNewForeignKey = isNewForeignKey
21
23
  this._name = name
22
24
  this._tableName = tableName
@@ -30,6 +32,12 @@ export default class TableForeignKey {
30
32
  */
31
33
  getColumnName() { return this._columnName }
32
34
 
35
+ /**
36
+ * Runs get drop foreign key.
37
+ * @returns {boolean} - Whether this foreign key should be dropped.
38
+ */
39
+ getDropForeignKey() { return this._dropForeignKey || false }
40
+
33
41
  /**
34
42
  * Runs get is new foreign key.
35
43
  * @returns {boolean} - Whether is new foreign key.
@@ -559,7 +559,7 @@ export default class VelociousEnvironmentHandlerBase {
559
559
  * @param {object} _args - Args.
560
560
  * @param {string[]} _args.allowedPathPrefixes - Allowed path prefixes.
561
561
  * @param {string} _args.inputPath - Input path.
562
- * @returns {Promise<{buffer: Buffer, filePath: string}>} - Resolved path and file bytes.
562
+ * @returns {Promise<import("../database/record/attachments/normalize-input.js").AttachmentPathSource>} - Opened path source.
563
563
  */
564
564
  async resolveAttachmentInputPath(_args) {
565
565
  throw new Error("Attachment path input is not supported in this environment")