@stacksjs/defaults 0.74.33 → 0.74.34
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/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +60 -0
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-storage/SKILL.md +58 -4
- package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +25 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +31 -0
- package/app/Actions/Dashboard/Content/FileTagsAction.ts +27 -0
- package/app/Actions/Dashboard/Content/file-manager.test.ts +47 -27
- package/app/Actions/Dashboard/Content/file-manager.ts +338 -12
- package/app/Actions/Dashboard/Content/file-metadata-store.ts +432 -0
- package/app/Actions/Dashboard/Content/file-metadata.test.ts +357 -0
- package/app/Actions/Dashboard/Content/file-metadata.ts +550 -0
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +344 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +74 -0
- package/app/Jobs/TagStorageMediaJob.ts +122 -0
- package/app/Jobs/TranscodeStorageVideoJob.ts +88 -0
- package/app/Models/StorageItem.ts +123 -0
- package/app/Models/StorageItemTask.ts +134 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard-api.ts +10 -0
- package/vcs/github/workflows/buddy-bot.yml +109 -0
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- package/vcs/github/renovate.json +0 -5
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import type { StorageItemMetadata, StorageItemTask, StorageMetadataStore, StorageTaskKind, StorageTaskState } from './file-metadata'
|
|
2
|
+
import { db, sqlDateTime } from '@stacksjs/database'
|
|
3
|
+
import { isEmptyMetadata, isUnderPrefix, normalizeTags, repathUnderPrefix, STORAGE_ITEM_TYPE } from './file-metadata'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The database-backed {@link StorageMetadataStore} (stacksjs/stacks#2577).
|
|
7
|
+
*
|
|
8
|
+
* Split from `file-metadata.ts` so the reconciliation rules there can be tested
|
|
9
|
+
* without a database - the file manager makes the same split with its `Manager`
|
|
10
|
+
* parameter, and for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* Two tables. `storage_items` holds the row per `(disk, path)`; tags go through
|
|
13
|
+
* `tags` and `taggable_models`, which is the vocabulary #2577 asks for by name -
|
|
14
|
+
* the one the dashboard's own tag manager writes and reads. `taggable_type` is
|
|
15
|
+
* `storage_items`, so a file's tags and a post's share the vocabulary without
|
|
16
|
+
* their pivot rows colliding.
|
|
17
|
+
*
|
|
18
|
+
* `tags`, not `taggables`, and the distinction is not cosmetic
|
|
19
|
+
* (stacksjs/stacks#2579). `taggables` is a different mechanism: the `taggable`
|
|
20
|
+
* trait writes tag names straight into it with no pivot row. Everything that
|
|
21
|
+
* writes `taggable_models` writes a `tags` id - the dashboard validates against
|
|
22
|
+
* `tags`, writes them, reads them back and counts them - so a join to
|
|
23
|
+
* `taggables` returns an empty set, or worse a row whose id happened to
|
|
24
|
+
* collide.
|
|
25
|
+
*
|
|
26
|
+
* Written against the query builder rather than the `StorageItem` model because
|
|
27
|
+
* every operation here is a set operation - one query for a subtree, one prefix
|
|
28
|
+
* update for a folder rename - and doing those a row at a time through the model
|
|
29
|
+
* would turn a folder of a thousand files into a thousand round trips.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
interface StorageItemRow {
|
|
33
|
+
id: number
|
|
34
|
+
disk: string
|
|
35
|
+
path: string
|
|
36
|
+
favorite: number | boolean | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toBoolean(value: number | boolean | null | undefined): boolean {
|
|
40
|
+
// SQLite has no boolean type, so `favorite` comes back as 0/1 here and as a
|
|
41
|
+
// real boolean on Postgres and MySQL.
|
|
42
|
+
return value === true || value === 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The rows for a subtree, keyed by path.
|
|
47
|
+
*
|
|
48
|
+
* `prefix` matches the path itself as well as everything under it, so a single
|
|
49
|
+
* path can be looked up with the same call the listing uses - which is what lets
|
|
50
|
+
* `setFavorite` read-before-write without a second query shape.
|
|
51
|
+
*/
|
|
52
|
+
async function rowsUnder(disk: string, prefix: string): Promise<StorageItemRow[]> {
|
|
53
|
+
// Cast through `unknown`: the query builder types a `selectAll()` on a table
|
|
54
|
+
// it has no generated interface for as `Record<string, unknown>`, and the two
|
|
55
|
+
// shapes do not overlap enough for a direct assertion. The columns are the
|
|
56
|
+
// ones the migration creates.
|
|
57
|
+
const all = async (build: (q: ReturnType<typeof scoped>) => ReturnType<typeof scoped>): Promise<StorageItemRow[]> =>
|
|
58
|
+
await build(scoped(disk)).execute() as unknown as StorageItemRow[]
|
|
59
|
+
|
|
60
|
+
if (!prefix)
|
|
61
|
+
return await all(query => query)
|
|
62
|
+
|
|
63
|
+
// Two queries rather than one with an OR. The builder's `or` group takes a
|
|
64
|
+
// shape this table has no generated interface for, and an `orWhere` on the
|
|
65
|
+
// fluent chain would bind as `disk = ? AND path = ? OR path LIKE ?` - which
|
|
66
|
+
// reads rows from every other disk. Both of these are indexed, and a subtree
|
|
67
|
+
// read is not a hot path.
|
|
68
|
+
//
|
|
69
|
+
// `LIKE 'prefix/%'` plus the exact match, never `LIKE 'prefix%'`: the looser
|
|
70
|
+
// pattern also matches `reports-archive` when the prefix is `reports`, which
|
|
71
|
+
// is a different folder that happens to share a spelling.
|
|
72
|
+
const [exact, beneath] = await Promise.all([
|
|
73
|
+
all(query => query.where('path', '=', prefix)),
|
|
74
|
+
all(query => query.where('path', 'like', `${escapeLike(prefix)}/%`)),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
return [...exact, ...beneath]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The `storage_items` rows for one disk, before any path filter. */
|
|
81
|
+
function scoped(disk: string) {
|
|
82
|
+
return db.selectFrom('storage_items').selectAll().where('disk', '=', disk)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Escape the wildcards `LIKE` would otherwise interpret.
|
|
87
|
+
*
|
|
88
|
+
* A path may legally contain `%` or `_`, and an unescaped `_` matches any
|
|
89
|
+
* character - so a folder named `q_1` would sweep `q11` along with itself.
|
|
90
|
+
*/
|
|
91
|
+
function escapeLike(value: string): string {
|
|
92
|
+
return value.replace(/[\\%_]/g, character => `\\${character}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function tagsFor(ids: number[]): Promise<Map<number, string[]>> {
|
|
96
|
+
const byItem = new Map<number, string[]>()
|
|
97
|
+
if (ids.length === 0)
|
|
98
|
+
return byItem
|
|
99
|
+
|
|
100
|
+
const rows = await db
|
|
101
|
+
.selectFrom('taggable_models')
|
|
102
|
+
.innerJoin('tags', 'tags.id', '=', 'taggable_models.tag_id')
|
|
103
|
+
.select(['taggable_models.taggable_id as itemId', 'tags.name as name'])
|
|
104
|
+
.where('taggable_models.taggable_type', '=', STORAGE_ITEM_TYPE)
|
|
105
|
+
.where('taggable_models.taggable_id', 'in', ids)
|
|
106
|
+
.execute() as Array<{ itemId: number, name: string }>
|
|
107
|
+
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
const existing = byItem.get(row.itemId)
|
|
110
|
+
if (existing)
|
|
111
|
+
existing.push(row.name)
|
|
112
|
+
else
|
|
113
|
+
byItem.set(row.itemId, [row.name])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const [id, names] of byItem)
|
|
117
|
+
byItem.set(id, normalizeTags(names))
|
|
118
|
+
|
|
119
|
+
return byItem
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The `tags` row for a name, created if this is the first thing to use it.
|
|
124
|
+
*
|
|
125
|
+
* Shared with posts rather than scoped to files, which is the point: a tag is a
|
|
126
|
+
* word somebody chose, and having "invoice" mean one thing on a post and a
|
|
127
|
+
* different thing on a file is how a tag list stops being useful. The pivot's
|
|
128
|
+
* `taggable_type` is what keeps the two sets of ATTACHMENTS apart.
|
|
129
|
+
*/
|
|
130
|
+
async function tagIdFor(name: string): Promise<number> {
|
|
131
|
+
const existing = await db
|
|
132
|
+
.selectFrom('tags')
|
|
133
|
+
.select(['id'])
|
|
134
|
+
.where('name', '=', name)
|
|
135
|
+
.executeTakeFirst() as { id: number } | undefined
|
|
136
|
+
|
|
137
|
+
if (existing)
|
|
138
|
+
return existing.id
|
|
139
|
+
|
|
140
|
+
await db
|
|
141
|
+
.insertInto('tags')
|
|
142
|
+
.values({
|
|
143
|
+
name,
|
|
144
|
+
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
|
145
|
+
created_at: sqlDateTime(),
|
|
146
|
+
updated_at: sqlDateTime(),
|
|
147
|
+
uuid: crypto.randomUUID(),
|
|
148
|
+
})
|
|
149
|
+
.execute()
|
|
150
|
+
|
|
151
|
+
// Re-selected rather than read from the write's return value, which on SQLite
|
|
152
|
+
// is only `{ changes, lastInsertRowid }`.
|
|
153
|
+
const created = await db
|
|
154
|
+
.selectFrom('tags')
|
|
155
|
+
.select(['id'])
|
|
156
|
+
.where('name', '=', name)
|
|
157
|
+
.executeTakeFirst() as { id: number } | undefined
|
|
158
|
+
|
|
159
|
+
if (!created)
|
|
160
|
+
throw new Error(`[file-metadata] failed to create the tag "${name}"`)
|
|
161
|
+
|
|
162
|
+
return created.id
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function itemIdFor(disk: string, path: string): Promise<number | undefined> {
|
|
166
|
+
const row = await db
|
|
167
|
+
.selectFrom('storage_items')
|
|
168
|
+
.select(['id'])
|
|
169
|
+
.where('disk', '=', disk)
|
|
170
|
+
.where('path', '=', path)
|
|
171
|
+
.executeTakeFirst() as { id: number } | undefined
|
|
172
|
+
|
|
173
|
+
return row?.id
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface StorageItemTaskRow {
|
|
177
|
+
id: number
|
|
178
|
+
disk: string
|
|
179
|
+
path: string
|
|
180
|
+
kind: StorageTaskKind
|
|
181
|
+
state: StorageTaskState
|
|
182
|
+
attempts: number | null
|
|
183
|
+
error: string | null
|
|
184
|
+
started_at: string | null
|
|
185
|
+
finished_at: string | null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The `storage_item_tasks` rows for a subtree, same prefix rules as above. */
|
|
189
|
+
async function taskRowsUnder(disk: string, prefix: string): Promise<StorageItemTaskRow[]> {
|
|
190
|
+
const scopedTasks = (): ReturnType<typeof db.selectFrom> =>
|
|
191
|
+
db.selectFrom('storage_item_tasks').selectAll().where('disk', '=', disk)
|
|
192
|
+
|
|
193
|
+
const all = async (build: (q: ReturnType<typeof scopedTasks>) => ReturnType<typeof scopedTasks>): Promise<StorageItemTaskRow[]> =>
|
|
194
|
+
await build(scopedTasks()).execute() as unknown as StorageItemTaskRow[]
|
|
195
|
+
|
|
196
|
+
if (!prefix)
|
|
197
|
+
return await all(query => query)
|
|
198
|
+
|
|
199
|
+
const [exact, beneath] = await Promise.all([
|
|
200
|
+
all(query => query.where('path', '=', prefix)),
|
|
201
|
+
all(query => query.where('path', 'like', `${escapeLike(prefix)}/%`)),
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
return [...exact, ...beneath]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function toTask(row: StorageItemTaskRow): StorageItemTask {
|
|
208
|
+
return {
|
|
209
|
+
kind: row.kind,
|
|
210
|
+
state: row.state,
|
|
211
|
+
attempts: Number(row.attempts ?? 0),
|
|
212
|
+
error: row.error ?? undefined,
|
|
213
|
+
startedAt: row.started_at ?? undefined,
|
|
214
|
+
finishedAt: row.finished_at ?? undefined,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function deleteItems(ids: number[]): Promise<void> {
|
|
219
|
+
if (ids.length === 0)
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
// Pivot rows first: a `storage_items` row removed while its pivot rows remain
|
|
223
|
+
// leaves tags attached to an id that will eventually be reused by a different
|
|
224
|
+
// file, which is how somebody else's tags appear on your upload.
|
|
225
|
+
await db
|
|
226
|
+
.deleteFrom('taggable_models')
|
|
227
|
+
.where('taggable_type', '=', STORAGE_ITEM_TYPE)
|
|
228
|
+
.where('taggable_id', 'in', ids)
|
|
229
|
+
.execute()
|
|
230
|
+
|
|
231
|
+
await db.deleteFrom('storage_items').where('id', 'in', ids).execute()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const databaseMetadataStore: StorageMetadataStore = {
|
|
235
|
+
async under(disk, prefix) {
|
|
236
|
+
const rows = await rowsUnder(disk, prefix)
|
|
237
|
+
const tags = await tagsFor(rows.map(row => row.id))
|
|
238
|
+
|
|
239
|
+
const records = new Map<string, StorageItemMetadata>()
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
records.set(row.path, {
|
|
242
|
+
favorite: toBoolean(row.favorite),
|
|
243
|
+
tags: tags.get(row.id) ?? [],
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return records
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async write(disk, path, metadata) {
|
|
251
|
+
const existingId = await itemIdFor(disk, path)
|
|
252
|
+
|
|
253
|
+
if (isEmptyMetadata(metadata)) {
|
|
254
|
+
// Nothing left to say about this file, so the row goes. A row recording
|
|
255
|
+
// "not starred, no tags" is indistinguishable from no row, and keeping it
|
|
256
|
+
// would grow the table by one for every file anybody ever starred and
|
|
257
|
+
// then unstarred.
|
|
258
|
+
if (existingId !== undefined)
|
|
259
|
+
await deleteItems([existingId])
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let id = existingId
|
|
264
|
+
if (id === undefined) {
|
|
265
|
+
await db
|
|
266
|
+
.insertInto('storage_items')
|
|
267
|
+
.values({
|
|
268
|
+
disk,
|
|
269
|
+
path,
|
|
270
|
+
favorite: metadata.favorite,
|
|
271
|
+
created_at: sqlDateTime(),
|
|
272
|
+
updated_at: sqlDateTime(),
|
|
273
|
+
uuid: crypto.randomUUID(),
|
|
274
|
+
})
|
|
275
|
+
.execute()
|
|
276
|
+
|
|
277
|
+
id = await itemIdFor(disk, path)
|
|
278
|
+
if (id === undefined)
|
|
279
|
+
throw new Error(`[file-metadata] failed to create the record for ${disk}:${path}`)
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
await db
|
|
283
|
+
.updateTable('storage_items')
|
|
284
|
+
.set({ favorite: metadata.favorite, updated_at: sqlDateTime() })
|
|
285
|
+
.where('id', '=', id)
|
|
286
|
+
.execute()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Tags are replaced rather than merged: the caller sends the whole set,
|
|
290
|
+
// because a UI that can add a tag can also remove one and there is no
|
|
291
|
+
// separate signal for that.
|
|
292
|
+
await db
|
|
293
|
+
.deleteFrom('taggable_models')
|
|
294
|
+
.where('taggable_type', '=', STORAGE_ITEM_TYPE)
|
|
295
|
+
.where('taggable_id', '=', id)
|
|
296
|
+
.execute()
|
|
297
|
+
|
|
298
|
+
for (const name of metadata.tags) {
|
|
299
|
+
await db
|
|
300
|
+
.insertInto('taggable_models')
|
|
301
|
+
.values({
|
|
302
|
+
tag_id: await tagIdFor(name),
|
|
303
|
+
taggable_id: id,
|
|
304
|
+
taggable_type: STORAGE_ITEM_TYPE,
|
|
305
|
+
created_at: sqlDateTime(),
|
|
306
|
+
updated_at: sqlDateTime(),
|
|
307
|
+
})
|
|
308
|
+
.execute()
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async move(disk, from, to) {
|
|
313
|
+
const rows = await rowsUnder(disk, from)
|
|
314
|
+
if (rows.length === 0)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
// Read, repath and write each row rather than one `UPDATE ... SET path =
|
|
318
|
+
// REPLACE(path, ...)`: the SQL form differs across the three engines this
|
|
319
|
+
// supports, and a folder rename is a handful of rows, not a hot path.
|
|
320
|
+
//
|
|
321
|
+
// A destination that already has a row is overwritten, matching the storage
|
|
322
|
+
// move it is following - `renameDashboardFile` has already replaced the file
|
|
323
|
+
// at `to`, so leaving its old metadata behind would describe the file that
|
|
324
|
+
// is gone.
|
|
325
|
+
for (const row of rows) {
|
|
326
|
+
const next = repathUnderPrefix(row.path, from, to)
|
|
327
|
+
if (next === row.path)
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
const collision = await itemIdFor(disk, next)
|
|
331
|
+
if (collision !== undefined && collision !== row.id)
|
|
332
|
+
await deleteItems([collision])
|
|
333
|
+
|
|
334
|
+
await db
|
|
335
|
+
.updateTable('storage_items')
|
|
336
|
+
.set({ path: next, updated_at: sqlDateTime() })
|
|
337
|
+
.where('id', '=', row.id)
|
|
338
|
+
.execute()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Tasks follow the file, or a transcode that finished before a rename
|
|
342
|
+
// reports as never having run (stacksjs/stacks#2578).
|
|
343
|
+
for (const task of await taskRowsUnder(disk, from)) {
|
|
344
|
+
const next = repathUnderPrefix(task.path, from, to)
|
|
345
|
+
if (next === task.path)
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
// A task already recorded at the destination is replaced, matching the
|
|
349
|
+
// storage move this follows: the file that was there is gone.
|
|
350
|
+
const collisions = (await taskRowsUnder(disk, next)).filter(row => row.path === next && row.kind === task.kind && row.id !== task.id)
|
|
351
|
+
await deleteTasks(collisions.map(row => row.id))
|
|
352
|
+
|
|
353
|
+
await db
|
|
354
|
+
.updateTable('storage_item_tasks')
|
|
355
|
+
.set({ path: next, updated_at: sqlDateTime() })
|
|
356
|
+
.where('id', '=', task.id)
|
|
357
|
+
.execute()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return rows.length
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
async forget(disk, path) {
|
|
364
|
+
const rows = await rowsUnder(disk, path)
|
|
365
|
+
await deleteItems(rows.map(row => row.id))
|
|
366
|
+
await deleteTasks((await taskRowsUnder(disk, path)).map(row => row.id))
|
|
367
|
+
return rows.length
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async sweep(disk, prefix, keep) {
|
|
371
|
+
const rows = await rowsUnder(disk, prefix)
|
|
372
|
+
const orphans = rows.filter(row => isUnderPrefix(row.path, prefix) && !keep.has(row.path))
|
|
373
|
+
await deleteItems(orphans.map(row => row.id))
|
|
374
|
+
|
|
375
|
+
const orphanTasks = (await taskRowsUnder(disk, prefix)).filter(row => !keep.has(row.path))
|
|
376
|
+
await deleteTasks(orphanTasks.map(row => row.id))
|
|
377
|
+
|
|
378
|
+
return orphans.length
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async tasksUnder(disk, prefix) {
|
|
382
|
+
const byPath = new Map<string, StorageItemTask[]>()
|
|
383
|
+
for (const row of await taskRowsUnder(disk, prefix)) {
|
|
384
|
+
const existing = byPath.get(row.path)
|
|
385
|
+
if (existing)
|
|
386
|
+
existing.push(toTask(row))
|
|
387
|
+
else
|
|
388
|
+
byPath.set(row.path, [toTask(row)])
|
|
389
|
+
}
|
|
390
|
+
return byPath
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
async writeTask(disk, path, task) {
|
|
394
|
+
const existing = await db
|
|
395
|
+
.selectFrom('storage_item_tasks')
|
|
396
|
+
.select(['id'])
|
|
397
|
+
.where('disk', '=', disk)
|
|
398
|
+
.where('path', '=', path)
|
|
399
|
+
.where('kind', '=', task.kind)
|
|
400
|
+
.executeTakeFirst() as { id: number } | undefined
|
|
401
|
+
|
|
402
|
+
const values = {
|
|
403
|
+
state: task.state,
|
|
404
|
+
attempts: task.attempts,
|
|
405
|
+
error: task.error ?? null,
|
|
406
|
+
started_at: task.startedAt ?? null,
|
|
407
|
+
finished_at: task.finishedAt ?? null,
|
|
408
|
+
updated_at: sqlDateTime(),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Replaced rather than appended: re-running a kind should leave one answer
|
|
412
|
+
// to "what happened to the transcode". The history of attempts belongs to
|
|
413
|
+
// the queue, not here.
|
|
414
|
+
if (existing) {
|
|
415
|
+
await db.updateTable('storage_item_tasks').set(values).where('id', '=', existing.id).execute()
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await db
|
|
420
|
+
.insertInto('storage_item_tasks')
|
|
421
|
+
.values({ disk, path, kind: task.kind, ...values, created_at: sqlDateTime(), uuid: crypto.randomUUID() })
|
|
422
|
+
.execute()
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function deleteTasks(ids: number[]): Promise<void> {
|
|
427
|
+
if (ids.length === 0)
|
|
428
|
+
return
|
|
429
|
+
await db.deleteFrom('storage_item_tasks').where('id', 'in', ids).execute()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export type { StorageItemMetadata, StorageMetadataStore }
|