@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.
@@ -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
+ }