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.
- package/README.md +29 -1
- package/build/database/drivers/base-table.js +4 -3
- package/build/database/drivers/base.js +28 -0
- package/build/database/drivers/index-metadata.js +28 -0
- package/build/database/drivers/mssql/column.js +2 -34
- package/build/database/drivers/mssql/columns-index.js +37 -0
- package/build/database/drivers/mssql/table.js +55 -11
- package/build/database/drivers/mysql/sql/alter-table.js +13 -0
- package/build/database/drivers/pgsql/column.js +2 -27
- package/build/database/drivers/pgsql/columns-index.js +37 -0
- package/build/database/drivers/pgsql/table.js +61 -10
- package/build/database/drivers/sqlite/sql/alter-table.js +18 -0
- package/build/database/migration/index.js +92 -3
- package/build/database/query/alter-table-base.js +20 -1
- package/build/database/record/attachments/normalize-input.js +38 -10
- package/build/database/record/attachments/storage-drivers/filesystem.js +33 -2
- package/build/database/record/attachments/storage-drivers/native.js +17 -2
- package/build/database/record/attachments/storage-drivers/s3.js +27 -7
- package/build/database/record/attachments/store.js +159 -43
- package/build/database/table-data/table-foreign-key.js +9 -1
- package/build/environment-handlers/base.js +1 -1
- package/build/environment-handlers/node/attachment-path-source.js +144 -0
- package/build/environment-handlers/node.js +28 -3
- package/build/src/database/drivers/base-table.d.ts.map +1 -1
- package/build/src/database/drivers/base-table.js +4 -3
- package/build/src/database/drivers/base.d.ts +7 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +24 -1
- package/build/src/database/drivers/index-metadata.d.ts +41 -0
- package/build/src/database/drivers/index-metadata.d.ts.map +1 -0
- package/build/src/database/drivers/index-metadata.js +26 -0
- package/build/src/database/drivers/mssql/column.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/column.js +3 -31
- package/build/src/database/drivers/mssql/columns-index.d.ts +41 -0
- package/build/src/database/drivers/mssql/columns-index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/columns-index.js +35 -1
- package/build/src/database/drivers/mssql/table.d.ts +42 -0
- package/build/src/database/drivers/mssql/table.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/table.js +49 -12
- package/build/src/database/drivers/mysql/sql/alter-table.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/sql/alter-table.js +12 -1
- package/build/src/database/drivers/pgsql/column.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/column.js +3 -24
- package/build/src/database/drivers/pgsql/columns-index.d.ts +41 -0
- package/build/src/database/drivers/pgsql/columns-index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/columns-index.js +35 -1
- package/build/src/database/drivers/pgsql/table.d.ts +42 -0
- package/build/src/database/drivers/pgsql/table.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/table.js +54 -11
- package/build/src/database/drivers/sqlite/sql/alter-table.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/sql/alter-table.js +15 -1
- package/build/src/database/migration/index.d.ts +35 -1
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +78 -4
- package/build/src/database/query/alter-table-base.d.ts +6 -0
- package/build/src/database/query/alter-table-base.d.ts.map +1 -1
- package/build/src/database/query/alter-table-base.js +18 -2
- package/build/src/database/record/attachments/normalize-input.d.ts +33 -4
- package/build/src/database/record/attachments/normalize-input.d.ts.map +1 -1
- package/build/src/database/record/attachments/normalize-input.js +39 -11
- package/build/src/database/record/attachments/storage-drivers/filesystem.d.ts +2 -5
- package/build/src/database/record/attachments/storage-drivers/filesystem.d.ts.map +1 -1
- package/build/src/database/record/attachments/storage-drivers/filesystem.js +28 -3
- package/build/src/database/record/attachments/storage-drivers/native.d.ts +2 -6
- package/build/src/database/record/attachments/storage-drivers/native.d.ts.map +1 -1
- package/build/src/database/record/attachments/storage-drivers/native.js +14 -3
- package/build/src/database/record/attachments/storage-drivers/s3.d.ts +2 -6
- package/build/src/database/record/attachments/storage-drivers/s3.d.ts.map +1 -1
- package/build/src/database/record/attachments/storage-drivers/s3.js +29 -8
- package/build/src/database/record/attachments/store.d.ts +27 -0
- package/build/src/database/record/attachments/store.d.ts.map +1 -1
- package/build/src/database/record/attachments/store.js +144 -44
- package/build/src/database/table-data/table-foreign-key.d.ts +9 -1
- package/build/src/database/table-data/table-foreign-key.d.ts.map +1 -1
- package/build/src/database/table-data/table-foreign-key.js +9 -2
- package/build/src/environment-handlers/base.d.ts +2 -5
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +2 -2
- package/build/src/environment-handlers/node/attachment-path-source.d.ts +64 -0
- package/build/src/environment-handlers/node/attachment-path-source.d.ts.map +1 -0
- package/build/src/environment-handlers/node/attachment-path-source.js +116 -0
- package/build/src/environment-handlers/node.d.ts +12 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +24 -4
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/database/drivers/base-table.js +4 -3
- package/src/database/drivers/base.js +28 -0
- package/src/database/drivers/index-metadata.js +28 -0
- package/src/database/drivers/mssql/column.js +2 -34
- package/src/database/drivers/mssql/columns-index.js +37 -0
- package/src/database/drivers/mssql/table.js +55 -11
- package/src/database/drivers/mysql/sql/alter-table.js +13 -0
- package/src/database/drivers/pgsql/column.js +2 -27
- package/src/database/drivers/pgsql/columns-index.js +37 -0
- package/src/database/drivers/pgsql/table.js +61 -10
- package/src/database/drivers/sqlite/sql/alter-table.js +18 -0
- package/src/database/migration/index.js +92 -3
- package/src/database/query/alter-table-base.js +20 -1
- package/src/database/record/attachments/normalize-input.js +38 -10
- package/src/database/record/attachments/storage-drivers/filesystem.js +33 -2
- package/src/database/record/attachments/storage-drivers/native.js +17 -2
- package/src/database/record/attachments/storage-drivers/s3.js +27 -7
- package/src/database/record/attachments/store.js +159 -43
- package/src/database/table-data/table-foreign-key.js +9 -1
- package/src/environment-handlers/base.js +1 -1
- package/src/environment-handlers/node/attachment-path-source.js +144 -0
- 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 =
|
|
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,
|
|
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
|
-
|
|
168
|
+
pathSource = await environmentHandler.resolveAttachmentInputPath({
|
|
151
169
|
allowedPathPrefixes,
|
|
152
170
|
inputPath: input.path
|
|
153
171
|
})
|
|
154
172
|
|
|
155
|
-
|
|
156
|
-
filename = typeof input.filename === "string" && input.filename.length > 0
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
290
|
+
if (this._driverColumnsAvailable) {
|
|
291
|
+
insertData.driver = attachmentDriverName
|
|
292
|
+
insertData.storage_key = storageKey
|
|
182
293
|
}
|
|
183
294
|
|
|
184
|
-
await db.
|
|
185
|
-
|
|
295
|
+
await db.insert({
|
|
296
|
+
data: insertData,
|
|
186
297
|
tableName: ATTACHMENTS_TABLE
|
|
187
298
|
})
|
|
188
|
-
}
|
|
189
299
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
320
|
+
throw error
|
|
321
|
+
}
|
|
322
|
+
}
|
|
212
323
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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<
|
|
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")
|