@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,550 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The dashboard file manager's metadata layer (stacksjs/stacks#2577).
|
|
5
|
+
*
|
|
6
|
+
* Everything the file manager could already do maps to a `StorageAdapter`
|
|
7
|
+
* method. Favourites and tags do not: a disk knows a path, some bytes, a size
|
|
8
|
+
* and an ACL, and there is nowhere on it to record that somebody starred a
|
|
9
|
+
* file. `app/Models/StorageItem.ts` is where that lives, and this is the layer
|
|
10
|
+
* between it and the file manager.
|
|
11
|
+
*
|
|
12
|
+
* ## The disk is authoritative, these rows are advisory
|
|
13
|
+
*
|
|
14
|
+
* The question #2577 asks to settle before anything else, because the answer
|
|
15
|
+
* decides the rest. A bucket several systems write to changes without the
|
|
16
|
+
* dashboard's knowledge, so a table claiming to describe its contents would be
|
|
17
|
+
* wrong within a day of being right. So:
|
|
18
|
+
*
|
|
19
|
+
* - The listing comes from the disk. Rows are joined onto it, and a path with
|
|
20
|
+
* no row is a file with nothing recorded about it - which is most files, and
|
|
21
|
+
* is why unstarring deletes the row rather than storing `false`.
|
|
22
|
+
* - A row whose path no longer exists is an orphan, and is never rendered
|
|
23
|
+
* because nothing renders rows. {@link sweepMetadata} removes the orphans a
|
|
24
|
+
* listing has just PROVED are orphans - the ones under a prefix it walked to
|
|
25
|
+
* completion - which costs nothing extra because the walk already happened.
|
|
26
|
+
* Nothing scans a whole disk to garbage collect.
|
|
27
|
+
* - Renames and deletes made through the dashboard reconcile eagerly, so a
|
|
28
|
+
* starred file keeps its star as it moves. A folder is a prefix update,
|
|
29
|
+
* because moving a folder moves everything beneath it.
|
|
30
|
+
*
|
|
31
|
+
* The consequence, stated rather than left to be discovered: a file renamed
|
|
32
|
+
* OUTSIDE the dashboard loses its metadata. Nothing connects the old path to
|
|
33
|
+
* the new one - to a bucket listing a rename and a copy-then-delete are the
|
|
34
|
+
* same two events - so any reconciliation there would be a guess, and a guess
|
|
35
|
+
* that moves somebody's tags onto the wrong file is worse than losing them.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** What the dashboard records about one file. */
|
|
39
|
+
export interface StorageItemMetadata {
|
|
40
|
+
favorite: boolean
|
|
41
|
+
tags: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The three kinds of background work a file can carry (stacksjs/stacks#2578).
|
|
46
|
+
*
|
|
47
|
+
* `optimize` builds the image variants, `transcode` the mp4 and HLS renditions,
|
|
48
|
+
* `tag` asks a vision model what is in the file. Independent: a video whose
|
|
49
|
+
* transcode finished and whose tagging failed is a normal state.
|
|
50
|
+
*/
|
|
51
|
+
export type StorageTaskKind = 'optimize' | 'transcode' | 'tag'
|
|
52
|
+
|
|
53
|
+
export type StorageTaskState = 'queued' | 'running' | 'done' | 'failed'
|
|
54
|
+
|
|
55
|
+
/** One unit of background work, as the dashboard sees it. */
|
|
56
|
+
export interface StorageItemTask {
|
|
57
|
+
kind: StorageTaskKind
|
|
58
|
+
state: StorageTaskState
|
|
59
|
+
attempts: number
|
|
60
|
+
error?: string
|
|
61
|
+
startedAt?: string
|
|
62
|
+
finishedAt?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every task kind, in the order a file would run them. */
|
|
66
|
+
export const STORAGE_TASK_KINDS: readonly StorageTaskKind[] = ['optimize', 'transcode', 'tag']
|
|
67
|
+
|
|
68
|
+
/** The `taggable_type` these rows use, which keeps them apart from the CMS's. */
|
|
69
|
+
export const STORAGE_ITEM_TYPE = 'storage_items'
|
|
70
|
+
|
|
71
|
+
const EMPTY: StorageItemMetadata = { favorite: false, tags: [] }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Storage for the metadata, behind an interface.
|
|
75
|
+
*
|
|
76
|
+
* The file manager already takes its `Storage` manager as a parameter so its
|
|
77
|
+
* tests do not need a disk; this is the same move for the same reason. The
|
|
78
|
+
* default implementation is in `file-metadata-store.ts`, which reaches the
|
|
79
|
+
* database and the CMS taggables module - neither of which a unit test of the
|
|
80
|
+
* reconciliation rules should have to stand up.
|
|
81
|
+
*
|
|
82
|
+
* Paths are disk-relative and carry no leading slash, exactly as the file
|
|
83
|
+
* manager reports them, so every method here can match on equality rather than
|
|
84
|
+
* normalizing at each call site.
|
|
85
|
+
*/
|
|
86
|
+
export interface StorageMetadataStore {
|
|
87
|
+
/** Every record on `disk` whose path is `prefix` or sits beneath it. */
|
|
88
|
+
under: (disk: string, prefix: string) => Promise<Map<string, StorageItemMetadata>>
|
|
89
|
+
/** Record `metadata` for one path, or drop the row when it has nothing to say. */
|
|
90
|
+
write: (disk: string, path: string, metadata: StorageItemMetadata) => Promise<void>
|
|
91
|
+
/** Move `from` (and everything beneath it) to `to`. Returns rows moved. */
|
|
92
|
+
move: (disk: string, from: string, to: string) => Promise<number>
|
|
93
|
+
/** Forget `path` and everything beneath it. Returns rows removed. */
|
|
94
|
+
forget: (disk: string, path: string) => Promise<number>
|
|
95
|
+
/** Remove rows under `prefix` whose path is not in `keep`. Returns rows removed. */
|
|
96
|
+
sweep: (disk: string, prefix: string, keep: ReadonlySet<string>) => Promise<number>
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every task on `disk` whose path is `prefix` or beneath it.
|
|
100
|
+
*
|
|
101
|
+
* Kept on the same interface as the metadata rather than a second one,
|
|
102
|
+
* because `move`, `forget` and `sweep` have to reconcile both tables and a
|
|
103
|
+
* caller that could reconcile one without the other would eventually do so.
|
|
104
|
+
*/
|
|
105
|
+
tasksUnder: (disk: string, prefix: string) => Promise<Map<string, StorageItemTask[]>>
|
|
106
|
+
|
|
107
|
+
/** Record one task, replacing whatever was recorded for that kind. */
|
|
108
|
+
writeTask: (disk: string, path: string, task: StorageItemTask) => Promise<void>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether a record still says anything.
|
|
113
|
+
*
|
|
114
|
+
* A row recording `favorite: false` and no tags is indistinguishable from no
|
|
115
|
+
* row at all, and keeping it would mean the table grows by one row for every
|
|
116
|
+
* file anybody ever starred and unstarred. {@link StorageMetadataStore.write}
|
|
117
|
+
* deletes instead.
|
|
118
|
+
*/
|
|
119
|
+
export function isEmptyMetadata(metadata: StorageItemMetadata): boolean {
|
|
120
|
+
return !metadata.favorite && metadata.tags.length === 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Normalize the tags a caller supplied.
|
|
125
|
+
*
|
|
126
|
+
* Trimmed, emptied entries dropped, case-folded for comparison but stored as
|
|
127
|
+
* first written, and deduplicated. Sorting is deliberate: two callers sending
|
|
128
|
+
* the same tags in a different order should produce the same record, or a
|
|
129
|
+
* "did this change" comparison anywhere upstream is a coin flip.
|
|
130
|
+
*/
|
|
131
|
+
export function normalizeTags(tags: readonly unknown[]): string[] {
|
|
132
|
+
const seen = new Map<string, string>()
|
|
133
|
+
|
|
134
|
+
for (const raw of tags) {
|
|
135
|
+
if (typeof raw !== 'string')
|
|
136
|
+
continue
|
|
137
|
+
const tag = raw.trim()
|
|
138
|
+
if (!tag)
|
|
139
|
+
continue
|
|
140
|
+
const key = tag.toLowerCase()
|
|
141
|
+
if (!seen.has(key))
|
|
142
|
+
seen.set(key, tag)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [...seen.values()].sort((a, b) => a.localeCompare(b))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether `path` is `prefix` itself or sits beneath it.
|
|
150
|
+
*
|
|
151
|
+
* The `/` matters. Without it, renaming `reports` would also rewrite
|
|
152
|
+
* `reports-archive`, which is a different folder that happens to share a
|
|
153
|
+
* spelling - the kind of bug that only appears once somebody has both.
|
|
154
|
+
*/
|
|
155
|
+
export function isUnderPrefix(path: string, prefix: string): boolean {
|
|
156
|
+
if (!prefix)
|
|
157
|
+
return true
|
|
158
|
+
return path === prefix || path.startsWith(`${prefix}/`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `path` with a `from` prefix replaced by `to`.
|
|
163
|
+
*
|
|
164
|
+
* Returns `path` unchanged when it is not under `from`, so a caller can map a
|
|
165
|
+
* whole table through this without filtering first.
|
|
166
|
+
*/
|
|
167
|
+
export function repathUnderPrefix(path: string, from: string, to: string): string {
|
|
168
|
+
if (path === from)
|
|
169
|
+
return to
|
|
170
|
+
if (!isUnderPrefix(path, from))
|
|
171
|
+
return path
|
|
172
|
+
return to ? `${to}/${path.slice(from.length + 1)}` : path.slice(from.length + 1)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read the metadata for a subtree, as a map the listing can look paths up in.
|
|
177
|
+
*
|
|
178
|
+
* One query per listing rather than one per file: a folder of 1,000 files would
|
|
179
|
+
* otherwise be 1,000 round trips to answer a question about the handful of them
|
|
180
|
+
* that are starred.
|
|
181
|
+
*/
|
|
182
|
+
export async function metadataUnder(
|
|
183
|
+
store: StorageMetadataStore,
|
|
184
|
+
disk: string,
|
|
185
|
+
prefix = '',
|
|
186
|
+
): Promise<Map<string, StorageItemMetadata>> {
|
|
187
|
+
return await store.under(disk, prefix)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The metadata for one path, or the empty record when there is no row. */
|
|
191
|
+
export function metadataFor(
|
|
192
|
+
records: ReadonlyMap<string, StorageItemMetadata>,
|
|
193
|
+
path: string,
|
|
194
|
+
): StorageItemMetadata {
|
|
195
|
+
return records.get(path) ?? EMPTY
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Star or unstar a file, leaving its tags alone.
|
|
200
|
+
*
|
|
201
|
+
* Reads before writing so the two fields do not clobber each other: the
|
|
202
|
+
* dashboard sends them from separate controls, and a star that silently
|
|
203
|
+
* cleared somebody's tags would be found long after the fact.
|
|
204
|
+
*/
|
|
205
|
+
export async function setFavorite(
|
|
206
|
+
store: StorageMetadataStore,
|
|
207
|
+
disk: string,
|
|
208
|
+
path: string,
|
|
209
|
+
favorite: boolean,
|
|
210
|
+
): Promise<StorageItemMetadata> {
|
|
211
|
+
const current = metadataFor(await store.under(disk, path), path)
|
|
212
|
+
const next: StorageItemMetadata = { favorite, tags: current.tags }
|
|
213
|
+
await store.write(disk, path, next)
|
|
214
|
+
return next
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Replace a file's tags, leaving its star alone. */
|
|
218
|
+
export async function setTags(
|
|
219
|
+
store: StorageMetadataStore,
|
|
220
|
+
disk: string,
|
|
221
|
+
path: string,
|
|
222
|
+
tags: readonly unknown[],
|
|
223
|
+
): Promise<StorageItemMetadata> {
|
|
224
|
+
const current = metadataFor(await store.under(disk, path), path)
|
|
225
|
+
const next: StorageItemMetadata = { favorite: current.favorite, tags: normalizeTags(tags) }
|
|
226
|
+
await store.write(disk, path, next)
|
|
227
|
+
return next
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Follow a rename.
|
|
232
|
+
*
|
|
233
|
+
* Called by `renameDashboardFile` after the storage move succeeds, never
|
|
234
|
+
* before: a failed move that had already rewritten the rows would leave the
|
|
235
|
+
* metadata describing a file that does not exist, which is worse than the
|
|
236
|
+
* metadata being briefly stale.
|
|
237
|
+
*
|
|
238
|
+
* A folder rename moves every row beneath it, which is why this is a prefix
|
|
239
|
+
* operation rather than an update by id.
|
|
240
|
+
*/
|
|
241
|
+
export async function followRename(
|
|
242
|
+
store: StorageMetadataStore,
|
|
243
|
+
disk: string,
|
|
244
|
+
from: string,
|
|
245
|
+
to: string,
|
|
246
|
+
): Promise<number> {
|
|
247
|
+
if (from === to)
|
|
248
|
+
return 0
|
|
249
|
+
return await store.move(disk, from, to)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Follow a delete.
|
|
254
|
+
*
|
|
255
|
+
* Deleting a folder deletes everything under it, so this forgets the subtree.
|
|
256
|
+
*/
|
|
257
|
+
export async function followDelete(
|
|
258
|
+
store: StorageMetadataStore,
|
|
259
|
+
disk: string,
|
|
260
|
+
path: string,
|
|
261
|
+
): Promise<number> {
|
|
262
|
+
return await store.forget(disk, path)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Follow a copy.
|
|
267
|
+
*
|
|
268
|
+
* The star and tags come with the copy, because the alternative - a duplicate
|
|
269
|
+
* that silently loses them - reads as the copy having failed. The source keeps
|
|
270
|
+
* its own; `duplicateDashboardFile` is a copy, not a move.
|
|
271
|
+
*/
|
|
272
|
+
export async function followCopy(
|
|
273
|
+
store: StorageMetadataStore,
|
|
274
|
+
disk: string,
|
|
275
|
+
from: string,
|
|
276
|
+
to: string,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
const source = metadataFor(await store.under(disk, from), from)
|
|
279
|
+
if (isEmptyMetadata(source))
|
|
280
|
+
return
|
|
281
|
+
await store.write(disk, to, source)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Drop rows for paths a completed listing did not see.
|
|
286
|
+
*
|
|
287
|
+
* Only safe to call with a listing that ran to completion: a truncated one has
|
|
288
|
+
* not proved a path is absent, only that it did not get that far, and sweeping
|
|
289
|
+
* on it would delete the metadata of every file past the limit. The caller
|
|
290
|
+
* passes `truncated` so the decision is made where that is known.
|
|
291
|
+
*/
|
|
292
|
+
export async function sweepMetadata(
|
|
293
|
+
store: StorageMetadataStore,
|
|
294
|
+
disk: string,
|
|
295
|
+
prefix: string,
|
|
296
|
+
seen: ReadonlySet<string>,
|
|
297
|
+
options: { truncated: boolean },
|
|
298
|
+
): Promise<number> {
|
|
299
|
+
if (options.truncated)
|
|
300
|
+
return 0
|
|
301
|
+
return await store.sweep(disk, prefix, seen)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The one word the file manager shows for a file's processing.
|
|
306
|
+
*
|
|
307
|
+
* Reduces the independent tasks to a status, worst-first, because that is what
|
|
308
|
+
* a caller acts on: a failure is the thing to surface even when two other kinds
|
|
309
|
+
* finished. `null` means no work was ever dispatched for this file, which is
|
|
310
|
+
* not the same as work that finished - an image uploaded before optimization
|
|
311
|
+
* existed should not claim to have been optimized.
|
|
312
|
+
*/
|
|
313
|
+
export function aggregateTaskState(tasks: readonly StorageItemTask[]): StorageTaskState | null {
|
|
314
|
+
if (tasks.length === 0)
|
|
315
|
+
return null
|
|
316
|
+
if (tasks.some(task => task.state === 'failed'))
|
|
317
|
+
return 'failed'
|
|
318
|
+
if (tasks.some(task => task.state === 'running'))
|
|
319
|
+
return 'running'
|
|
320
|
+
if (tasks.some(task => task.state === 'queued'))
|
|
321
|
+
return 'queued'
|
|
322
|
+
return 'done'
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Which kinds of work a file's content type calls for.
|
|
327
|
+
*
|
|
328
|
+
* Decided from the MIME type rather than the extension, when one is known: a
|
|
329
|
+
* `.mp4` that is actually a PDF should not be handed to a transcoder. Tagging
|
|
330
|
+
* applies to images and video alike, because a vision model can describe a
|
|
331
|
+
* frame as readily as a photograph.
|
|
332
|
+
*/
|
|
333
|
+
export function tasksForContentType(contentType: string | undefined): StorageTaskKind[] {
|
|
334
|
+
const mime = (contentType ?? '').toLowerCase().split(';')[0]?.trim() ?? ''
|
|
335
|
+
|
|
336
|
+
if (mime.startsWith('image/')) {
|
|
337
|
+
// SVG is markup, not a raster: there is nothing to re-encode, and running
|
|
338
|
+
// it through a decoder is a parser attack surface for no benefit.
|
|
339
|
+
if (mime === 'image/svg+xml')
|
|
340
|
+
return []
|
|
341
|
+
return ['optimize', 'tag']
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (mime.startsWith('video/'))
|
|
345
|
+
return ['transcode', 'tag']
|
|
346
|
+
|
|
347
|
+
return []
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Queue the work a file calls for, and record it.
|
|
352
|
+
*
|
|
353
|
+
* Recording happens BEFORE the dispatch and the row is marked failed if the
|
|
354
|
+
* dispatch throws, so a queue that is down leaves a visible failure rather than
|
|
355
|
+
* a file that silently never gets processed. That is the whole reason the state
|
|
356
|
+
* is in a table the dashboard reads rather than only in the queue.
|
|
357
|
+
*/
|
|
358
|
+
export async function dispatchTasks(
|
|
359
|
+
store: StorageMetadataStore,
|
|
360
|
+
disk: string,
|
|
361
|
+
path: string,
|
|
362
|
+
kinds: readonly StorageTaskKind[],
|
|
363
|
+
dispatch: (kind: StorageTaskKind) => Promise<void>,
|
|
364
|
+
): Promise<StorageItemTask[]> {
|
|
365
|
+
const queued: StorageItemTask[] = []
|
|
366
|
+
|
|
367
|
+
for (const kind of kinds) {
|
|
368
|
+
const task: StorageItemTask = { kind, state: 'queued', attempts: 0 }
|
|
369
|
+
await store.writeTask(disk, path, task)
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
await dispatch(kind)
|
|
373
|
+
queued.push(task)
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
const failed: StorageItemTask = {
|
|
377
|
+
kind,
|
|
378
|
+
state: 'failed',
|
|
379
|
+
attempts: 0,
|
|
380
|
+
error: describeError(error),
|
|
381
|
+
finishedAt: new Date().toISOString(),
|
|
382
|
+
}
|
|
383
|
+
await store.writeTask(disk, path, failed)
|
|
384
|
+
queued.push(failed)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return queued
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Run one task, recording what happened to it either way.
|
|
393
|
+
*
|
|
394
|
+
* The three jobs share this rather than each writing their own transitions,
|
|
395
|
+
* because the transitions are the part a dashboard depends on and three
|
|
396
|
+
* hand-written copies would eventually disagree - one forgetting to clear the
|
|
397
|
+
* error on a successful retry, say, leaving a green file with a red message.
|
|
398
|
+
*
|
|
399
|
+
* Rethrows on failure after recording, so the queue still counts the attempt
|
|
400
|
+
* and applies its backoff. The row and the queue are answering different
|
|
401
|
+
* questions: the queue decides whether to try again, the row is what somebody
|
|
402
|
+
* looking at the file sees.
|
|
403
|
+
*/
|
|
404
|
+
export async function runTask<T>(
|
|
405
|
+
store: StorageMetadataStore,
|
|
406
|
+
disk: string,
|
|
407
|
+
path: string,
|
|
408
|
+
kind: StorageTaskKind,
|
|
409
|
+
work: () => Promise<T>,
|
|
410
|
+
): Promise<T> {
|
|
411
|
+
const previous = (await store.tasksUnder(disk, path)).get(path)?.find(task => task.kind === kind)
|
|
412
|
+
const attempts = (previous?.attempts ?? 0) + 1
|
|
413
|
+
const startedAt = new Date().toISOString()
|
|
414
|
+
|
|
415
|
+
await store.writeTask(disk, path, { kind, state: 'running', attempts, startedAt })
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const result = await work()
|
|
419
|
+
await store.writeTask(disk, path, {
|
|
420
|
+
kind,
|
|
421
|
+
state: 'done',
|
|
422
|
+
attempts,
|
|
423
|
+
startedAt,
|
|
424
|
+
finishedAt: new Date().toISOString(),
|
|
425
|
+
// No `error`, so a retry that succeeds clears the previous failure rather
|
|
426
|
+
// than leaving a done task carrying a stale message.
|
|
427
|
+
})
|
|
428
|
+
return result
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
await store.writeTask(disk, path, {
|
|
432
|
+
kind,
|
|
433
|
+
state: 'failed',
|
|
434
|
+
attempts,
|
|
435
|
+
startedAt,
|
|
436
|
+
finishedAt: new Date().toISOString(),
|
|
437
|
+
error: describeError(error),
|
|
438
|
+
})
|
|
439
|
+
throw error
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** A failure as a string a dashboard can show, bounded so a stack trace cannot fill the column. */
|
|
444
|
+
export function describeError(error: unknown): string {
|
|
445
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
446
|
+
return message.length > 2000 ? `${message.slice(0, 1997)}...` : message
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A {@link StorageMetadataStore} held in memory.
|
|
451
|
+
*
|
|
452
|
+
* The counterpart to `InMemoryStorageAdapter` next door, and there for the same
|
|
453
|
+
* reason: the file manager's own tests build a real disk in a temp directory
|
|
454
|
+
* and should not also have to stand up a database to assert that a rename
|
|
455
|
+
* carries a star with it. It is a real implementation of the contract, not a
|
|
456
|
+
* stub - which is what makes it worth testing the reconciliation rules against.
|
|
457
|
+
*
|
|
458
|
+
* Not exported from an entry point; production reads
|
|
459
|
+
* `databaseMetadataStore` from `file-metadata-store.ts`.
|
|
460
|
+
*/
|
|
461
|
+
export function createMemoryMetadataStore(): StorageMetadataStore {
|
|
462
|
+
const rows = new Map<string, StorageItemMetadata>()
|
|
463
|
+
const tasks = new Map<string, StorageItemTask[]>()
|
|
464
|
+
const key = (disk: string, path: string): string => `${disk}\u0000${path}`
|
|
465
|
+
|
|
466
|
+
function scan<T>(source: Map<string, T>, disk: string, prefix: string): Array<[string, T]> {
|
|
467
|
+
const found: Array<[string, T]> = []
|
|
468
|
+
for (const [composite, value] of source) {
|
|
469
|
+
const separator = composite.indexOf('\u0000')
|
|
470
|
+
if (composite.slice(0, separator) !== disk)
|
|
471
|
+
continue
|
|
472
|
+
const path = composite.slice(separator + 1)
|
|
473
|
+
if (isUnderPrefix(path, prefix))
|
|
474
|
+
found.push([path, value])
|
|
475
|
+
}
|
|
476
|
+
return found
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function entries(disk: string, prefix: string): Array<[string, StorageItemMetadata]> {
|
|
480
|
+
return scan(rows, disk, prefix)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
async under(disk, prefix) {
|
|
485
|
+
return new Map(entries(disk, prefix).map(([path, metadata]) => [path, { ...metadata, tags: [...metadata.tags] }]))
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
async write(disk, path, metadata) {
|
|
489
|
+
if (isEmptyMetadata(metadata))
|
|
490
|
+
rows.delete(key(disk, path))
|
|
491
|
+
else
|
|
492
|
+
rows.set(key(disk, path), { favorite: metadata.favorite, tags: [...metadata.tags] })
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
async move(disk, from, to) {
|
|
496
|
+
const moving = entries(disk, from)
|
|
497
|
+
for (const [path, metadata] of moving) {
|
|
498
|
+
rows.delete(key(disk, path))
|
|
499
|
+
rows.set(key(disk, repathUnderPrefix(path, from, to)), metadata)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Tasks follow the file too, or a transcode that finished before a rename
|
|
503
|
+
// reports as never having run.
|
|
504
|
+
for (const [path, list] of scan(tasks, disk, from)) {
|
|
505
|
+
tasks.delete(key(disk, path))
|
|
506
|
+
tasks.set(key(disk, repathUnderPrefix(path, from, to)), list)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return moving.length
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
async forget(disk, path) {
|
|
513
|
+
const going = entries(disk, path)
|
|
514
|
+
for (const [found] of going)
|
|
515
|
+
rows.delete(key(disk, found))
|
|
516
|
+
for (const [found] of scan(tasks, disk, path))
|
|
517
|
+
tasks.delete(key(disk, found))
|
|
518
|
+
return going.length
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
async sweep(disk, prefix, keep) {
|
|
522
|
+
const orphans = entries(disk, prefix).filter(([path]) => !keep.has(path))
|
|
523
|
+
for (const [path] of orphans)
|
|
524
|
+
rows.delete(key(disk, path))
|
|
525
|
+
for (const [path] of scan(tasks, disk, prefix).filter(([path]) => !keep.has(path)))
|
|
526
|
+
tasks.delete(key(disk, path))
|
|
527
|
+
return orphans.length
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
async tasksUnder(disk, prefix) {
|
|
531
|
+
return new Map(scan(tasks, disk, prefix).map(([path, list]) => [path, list.map(task => ({ ...task }))]))
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
async writeTask(disk, path, task) {
|
|
535
|
+
const existing = tasks.get(key(disk, path)) ?? []
|
|
536
|
+
tasks.set(key(disk, path), [...existing.filter(entry => entry.kind !== task.kind), { ...task }])
|
|
537
|
+
},
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The parent directories a path implies, nearest first. Used by the store's prefix queries. */
|
|
542
|
+
export function ancestorsOf(path: string): string[] {
|
|
543
|
+
const ancestors: string[] = []
|
|
544
|
+
let current = posix.dirname(path)
|
|
545
|
+
while (current && current !== '.' && current !== '/') {
|
|
546
|
+
ancestors.push(current)
|
|
547
|
+
current = posix.dirname(current)
|
|
548
|
+
}
|
|
549
|
+
return ancestors
|
|
550
|
+
}
|