@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.
@@ -1,14 +1,35 @@
1
1
  import type { ResponseStatus } from '@stacksjs/bun-router'
2
2
  import { statfs } from 'node:fs/promises'
3
3
  import { posix } from 'node:path'
4
+ import type { JobName } from '@stacksjs/queue'
4
5
  import type { StorageAdapter, StorageManager, UploadedFileLike, Visibility } from '@stacksjs/storage'
5
6
  import { Storage } from '@stacksjs/storage'
7
+ import type { StorageItemTask, StorageMetadataStore, StorageTaskKind, StorageTaskState } from './file-metadata'
8
+ import {
9
+ aggregateTaskState,
10
+ dispatchTasks,
11
+ followCopy,
12
+ followDelete,
13
+ followRename,
14
+ metadataFor as recordFor,
15
+ metadataUnder,
16
+ normalizeTags,
17
+ setFavorite as writeFavorite,
18
+ setTags as writeTags,
19
+ STORAGE_TASK_KINDS,
20
+ sweepMetadata,
21
+ tasksForContentType,
22
+ } from './file-metadata'
23
+ import { databaseMetadataStore } from './file-metadata-store'
6
24
 
7
25
  const DEFAULT_DISK = 'public'
8
26
  const DEFAULT_MAX_ENTRIES = 1000
9
27
  const MAX_PATH_LENGTH = 2048
10
28
  const MAX_COMPONENT_LENGTH = 255
11
29
  const STAT_CONCURRENCY = 24
30
+ /** Bounds on what a caller may attach, so one file cannot fill the tag table. */
31
+ const MAX_TAGS = 32
32
+ const MAX_TAG_LENGTH = 60
12
33
 
13
34
  export interface DashboardFileNode {
14
35
  id: string
@@ -20,7 +41,23 @@ export interface DashboardFileNode {
20
41
  mime_type?: string
21
42
  url?: string
22
43
  thumbnail?: string
23
- starred: false
44
+ /**
45
+ * Whether somebody starred this, from `storage_items` rather than from the
46
+ * disk (stacksjs/stacks#2577). It was the literal `false` for as long as
47
+ * there was nowhere to record the answer.
48
+ */
49
+ starred: boolean
50
+ /** Tags from the `taggable` vocabulary, empty for a file nobody has tagged. */
51
+ tags: string[]
52
+ /**
53
+ * The one word for this file's background work (stacksjs/stacks#2578), or
54
+ * `null` when none was ever dispatched - which is not the same as work that
55
+ * finished. Worst-first across the kinds, because a failure is what a viewer
56
+ * needs to see even when two other kinds succeeded.
57
+ */
58
+ processing: StorageTaskState | null
59
+ /** Each kind separately, for a UI that wants to show which half failed. */
60
+ tasks: StorageItemTask[]
24
61
  shared: boolean
25
62
  items?: DashboardFileNode[]
26
63
  }
@@ -280,6 +317,7 @@ export function normalizeDashboardFileLimit(value: unknown): number {
280
317
  export async function getDashboardFileSnapshot(
281
318
  options: { disk?: string, maxEntries?: number } = {},
282
319
  manager: Manager = Storage,
320
+ store: StorageMetadataStore = databaseMetadataStore,
283
321
  ): Promise<DashboardFileSnapshot> {
284
322
  const selected = resolveDisk(manager, options.disk)
285
323
  const maxEntries = normalizeDashboardFileLimit(options.maxEntries)
@@ -297,12 +335,18 @@ export async function getDashboardFileSnapshot(
297
335
  entries.push({ path, type: rawEntry.type })
298
336
  }
299
337
 
300
- const metadata = await mapInBatches(
338
+ // One query for the whole subtree, not one per file: a folder of a thousand
339
+ // files would otherwise be a thousand round trips to answer a question about
340
+ // the handful of them anybody starred (stacksjs/stacks#2577).
341
+ const records = await metadataUnder(store, selected.name)
342
+ const tasks = await store.tasksUnder(selected.name, '')
343
+
344
+ const stated = await mapInBatches(
301
345
  entries,
302
346
  STAT_CONCURRENCY,
303
347
  entry => metadataFor(selected.adapter, entry, selected.public, selected.name === 'public' && selected.config.driver === 'local'),
304
348
  )
305
- metadata.sort((a, b) => {
349
+ stated.sort((a, b) => {
306
350
  const depth = a.path.split('/').length - b.path.split('/').length
307
351
  if (depth)
308
352
  return depth
@@ -318,7 +362,12 @@ export async function getDashboardFileSnapshot(
318
362
  size: 0,
319
363
  path: '',
320
364
  lastModified: null,
365
+ // The disk root is not a file and has no row; a star on "everything" would
366
+ // not mean anything, and nothing processes a disk.
321
367
  starred: false,
368
+ tags: [],
369
+ processing: null,
370
+ tasks: [],
322
371
  shared: selected.public,
323
372
  items: [],
324
373
  }
@@ -332,6 +381,23 @@ export async function getDashboardFileSnapshot(
332
381
  disk: capacity.stats,
333
382
  }
334
383
 
384
+ /**
385
+ * The recorded metadata for a path, as the fields a node carries.
386
+ *
387
+ * A path with no row is a file nobody starred or tagged, which is most of
388
+ * them - so the absence of a row is the answer rather than a missing one.
389
+ */
390
+ function pick(path: string): Pick<DashboardFileNode, 'starred' | 'tags' | 'processing' | 'tasks'> {
391
+ const record = recordFor(records, path)
392
+ const running = tasks.get(path) ?? []
393
+ return {
394
+ starred: record.favorite,
395
+ tags: record.tags,
396
+ processing: aggregateTaskState(running),
397
+ tasks: running,
398
+ }
399
+ }
400
+
335
401
  function ensureFolder(path: string): DashboardFileNode {
336
402
  const normalized = normalizeListedPath(path)
337
403
  const existing = folders.get(normalized)
@@ -346,7 +412,7 @@ export async function getDashboardFileSnapshot(
346
412
  size: 0,
347
413
  path: normalized,
348
414
  lastModified: null,
349
- starred: false,
415
+ ...pick(normalized),
350
416
  shared: selected.public,
351
417
  items: [],
352
418
  }
@@ -356,7 +422,7 @@ export async function getDashboardFileSnapshot(
356
422
  return folder
357
423
  }
358
424
 
359
- for (const entry of metadata) {
425
+ for (const entry of stated) {
360
426
  if (entry.type === 'directory') {
361
427
  const folder = ensureFolder(entry.path)
362
428
  folder.lastModified = entry.lastModified
@@ -375,7 +441,7 @@ export async function getDashboardFileSnapshot(
375
441
  mime_type: entry.mimeType,
376
442
  url: entry.url,
377
443
  thumbnail: entry.thumbnail,
378
- starred: false,
444
+ ...pick(entry.path),
379
445
  shared: selected.public,
380
446
  }
381
447
  parent.items!.push(file)
@@ -392,6 +458,23 @@ export async function getDashboardFileSnapshot(
392
458
  })
393
459
  }
394
460
 
461
+ // Rows for paths this walk did not see are orphans - a file deleted by
462
+ // something other than the dashboard, which is the normal case for a bucket
463
+ // several systems write to. The walk already enumerated every path, so
464
+ // removing them costs one delete rather than a second listing pass.
465
+ //
466
+ // Skipped when the listing was TRUNCATED, and that guard is the whole reason
467
+ // this takes the flag: a truncated walk has not shown that a path is absent,
468
+ // only that it stopped before reaching it, and sweeping on that would delete
469
+ // the metadata of every file past the limit (stacksjs/stacks#2577).
470
+ await sweepMetadata(
471
+ store,
472
+ selected.name,
473
+ '',
474
+ new Set(stated.map(entry => entry.path)),
475
+ { truncated },
476
+ )
477
+
395
478
  return {
396
479
  disk: selected.name,
397
480
  disks: manager.getConfiguredDisks().map((name) => {
@@ -403,7 +486,7 @@ export async function getDashboardFileSnapshot(
403
486
  truncated,
404
487
  warnings: [
405
488
  ...(capacity.warning ? [capacity.warning] : []),
406
- ...metadata.flatMap(entry => entry.warnings),
489
+ ...stated.flatMap(entry => entry.warnings),
407
490
  ],
408
491
  }
409
492
  }
@@ -427,16 +510,23 @@ export async function createDashboardDirectory(
427
510
  export async function deleteDashboardFile(
428
511
  input: { disk?: string, path: unknown },
429
512
  manager: Manager = Storage,
513
+ store: StorageMetadataStore = databaseMetadataStore,
430
514
  ): Promise<{ path: string, type: 'file' | 'directory' }> {
431
515
  const selected = resolveDisk(manager, input.disk)
432
516
  const path = normalizeDashboardFilePath(input.path)
433
517
 
518
+ // Metadata is forgotten AFTER the storage delete in both branches, never
519
+ // before: a delete that fails having already dropped the rows would leave a
520
+ // file that still exists with its stars and tags gone (stacksjs/stacks#2577).
434
521
  if (await selected.adapter.fileExists(path)) {
435
522
  await selected.adapter.deleteFile(path)
523
+ await followDelete(store, selected.name, path)
436
524
  return { path, type: 'file' }
437
525
  }
438
526
  if (await selected.adapter.directoryExists(path)) {
439
527
  await selected.adapter.deleteDirectory(path)
528
+ // The subtree, because deleting a folder deletes everything under it.
529
+ await followDelete(store, selected.name, path)
440
530
  return { path, type: 'directory' }
441
531
  }
442
532
 
@@ -462,6 +552,7 @@ export async function deleteDashboardFile(
462
552
  export async function renameDashboardFile(
463
553
  input: { disk?: string, path: unknown, name: unknown },
464
554
  manager: Manager = Storage,
555
+ store: StorageMetadataStore = databaseMetadataStore,
465
556
  ): Promise<{ from: string, to: string, type: 'file' | 'directory', moved: number }> {
466
557
  const selected = resolveDisk(manager, input.disk)
467
558
  const from = normalizeDashboardFilePath(input.path)
@@ -479,6 +570,9 @@ export async function renameDashboardFile(
479
570
 
480
571
  if (await selected.adapter.fileExists(from)) {
481
572
  await selected.adapter.moveFile(from, to)
573
+ // After the move, so a failed move does not leave the metadata describing a
574
+ // path with no file at it.
575
+ await followRename(store, selected.name, from, to)
482
576
  return { from, to, type: 'file', moved: 1 }
483
577
  }
484
578
 
@@ -506,6 +600,10 @@ export async function renameDashboardFile(
506
600
  // no-op there rather than a failure.
507
601
  await selected.adapter.deleteDirectory(from)
508
602
 
603
+ // A folder rename moves every file beneath it, so this is a prefix update
604
+ // rather than one row - the part #2577 flagged as the one that would bite.
605
+ await followRename(store, selected.name, from, to)
606
+
509
607
  return { from, to, type: 'directory', moved: files.length }
510
608
  }
511
609
 
@@ -607,6 +705,7 @@ async function availableCopyName(path: string, exists: (candidate: string) => Pr
607
705
  export async function duplicateDashboardFile(
608
706
  input: { disk?: string, path: unknown, name?: unknown },
609
707
  manager: Manager = Storage,
708
+ store: StorageMetadataStore = databaseMetadataStore,
610
709
  ): Promise<{ from: string, to: string, type: 'file' | 'directory', copied: number }> {
611
710
  const selected = resolveDisk(manager, input.disk)
612
711
  const from = normalizeDashboardFilePath(input.path)
@@ -628,6 +727,9 @@ export async function duplicateDashboardFile(
628
727
 
629
728
  if (await selected.adapter.fileExists(from)) {
630
729
  await selected.adapter.copyFile(from, to)
730
+ // The star and tags come with the copy. A duplicate that silently lost them
731
+ // reads as the copy having half-failed.
732
+ await followCopy(store, selected.name, from, to)
631
733
  return { from, to, type: 'file', copied: 1 }
632
734
  }
633
735
 
@@ -644,19 +746,223 @@ export async function duplicateDashboardFile(
644
746
  files.push(listed.startsWith(`${from}/`) ? listed : `${from}/${listed}`)
645
747
  }
646
748
 
647
- for (const file of files)
648
- await selected.adapter.copyFile(file, `${to}/${file.slice(from.length + 1)}`)
749
+ for (const file of files) {
750
+ const destination = `${to}/${file.slice(from.length + 1)}`
751
+ await selected.adapter.copyFile(file, destination)
752
+ await followCopy(store, selected.name, file, destination)
753
+ }
754
+
755
+ // The folder's own record, separately: it is not among the files copied.
756
+ await followCopy(store, selected.name, from, to)
649
757
 
650
758
  return { from, to, type: 'directory', copied: files.length }
651
759
  }
652
760
 
761
+ /**
762
+ * Star or unstar a file or a folder (stacksjs/stacks#2577).
763
+ *
764
+ * Existence is checked against the DISK, not against the table: a star on a
765
+ * path that is not there would be an orphan the moment it was written, and the
766
+ * caller would get a 200 for it. Folders can be starred too - they are a path
767
+ * like any other to this table, even where the disk has no such thing.
768
+ */
769
+ export async function setDashboardFileFavorite(
770
+ input: { disk?: string, path: unknown, favorite: unknown },
771
+ manager: Manager = Storage,
772
+ store: StorageMetadataStore = databaseMetadataStore,
773
+ ): Promise<{ path: string, favorite: boolean, tags: string[] }> {
774
+ const selected = resolveDisk(manager, input.disk)
775
+ const path = normalizeDashboardFilePath(input.path)
776
+
777
+ if (typeof input.favorite !== 'boolean') {
778
+ throw new DashboardFileError('Favorite must be true or false.', 422, {
779
+ favorite: 'Send a boolean.',
780
+ })
781
+ }
782
+
783
+ await assertExists(selected.adapter, path)
784
+
785
+ const record = await writeFavorite(store, selected.name, path, input.favorite)
786
+ return { path, favorite: record.favorite, tags: record.tags }
787
+ }
788
+
789
+ /**
790
+ * Replace a file's tags (stacksjs/stacks#2577).
791
+ *
792
+ * The whole set, not a delta: a UI that can add a tag can also remove one, and
793
+ * there is no separate signal for a removal. Names are trimmed, deduplicated
794
+ * case-insensitively and sorted, so sending the same tags in a different order
795
+ * produces the same record.
796
+ *
797
+ * They go into the `taggable` vocabulary the CMS already uses, scoped by
798
+ * `taggable_type`, rather than a second tag table - which #2577 asks for
799
+ * explicitly, and which matters because a tag is a word somebody chose and
800
+ * having it mean two things in two places is how a tag list stops being useful.
801
+ */
802
+ export async function setDashboardFileTags(
803
+ input: { disk?: string, path: unknown, tags: unknown },
804
+ manager: Manager = Storage,
805
+ store: StorageMetadataStore = databaseMetadataStore,
806
+ ): Promise<{ path: string, favorite: boolean, tags: string[] }> {
807
+ const selected = resolveDisk(manager, input.disk)
808
+ const path = normalizeDashboardFilePath(input.path)
809
+
810
+ if (!Array.isArray(input.tags))
811
+ throw new DashboardFileError('Tags must be an array.', 422, { tags: 'Send an array of tag names.' })
812
+ if (input.tags.length > MAX_TAGS)
813
+ throw new DashboardFileError(`A file may carry at most ${MAX_TAGS} tags.`, 422, { tags: 'Too many tags.' })
814
+ for (const tag of input.tags) {
815
+ if (typeof tag !== 'string')
816
+ throw new DashboardFileError('Tags must be strings.', 422, { tags: 'Every tag must be a string.' })
817
+ if (tag.trim().length > MAX_TAG_LENGTH)
818
+ throw new DashboardFileError(`A tag must not exceed ${MAX_TAG_LENGTH} characters.`, 422, { tags: 'A tag is too long.' })
819
+ }
820
+
821
+ await assertExists(selected.adapter, path)
822
+
823
+ const record = await writeTags(store, selected.name, path, normalizeTags(input.tags))
824
+ return { path, favorite: record.favorite, tags: record.tags }
825
+ }
826
+
827
+ /** 404 unless the path is a file or a folder on the disk. */
828
+ async function assertExists(adapter: StorageAdapter, path: string): Promise<void> {
829
+ if (await adapter.fileExists(path) || await adapter.directoryExists(path))
830
+ return
831
+ throw new DashboardFileError(`Storage item "${path}" was not found.`, 404)
832
+ }
833
+
834
+ /**
835
+ * How a dispatched job reaches the queue.
836
+ *
837
+ * A parameter so the upload path can be tested without a queue, and so an app
838
+ * that wants a different dispatcher - a synchronous one in a test, a
839
+ * rate-limited one in front of a paid vision API - can supply it. The default
840
+ * reads `@stacksjs/queue` lazily: `uploadDashboardFiles` is on the request path
841
+ * and most uploads are not media.
842
+ */
843
+ export type TaskDispatcher = (job: JobName, payload: Record<string, unknown>) => Promise<void>
844
+
845
+ /**
846
+ * The job each kind of work runs.
847
+ *
848
+ * Typed as the generated `JobName` union rather than `string`, so a job renamed
849
+ * or removed under this map is a compile error instead of a dispatch that fails
850
+ * at runtime for a file nobody is watching.
851
+ */
852
+ const TASK_JOBS = {
853
+ optimize: 'OptimizeStorageImageJob',
854
+ transcode: 'TranscodeStorageVideoJob',
855
+ tag: 'TagStorageMediaJob',
856
+ } as const satisfies Record<StorageTaskKind, JobName>
857
+
858
+ const queueDispatcher: TaskDispatcher = async (job, payload) => {
859
+ const { Jobs } = await import('@stacksjs/queue')
860
+ await Jobs.dispatch(job as JobName, payload)
861
+ }
862
+
863
+ /**
864
+ * Queue the processing an uploaded file calls for (stacksjs/stacks#2578).
865
+ *
866
+ * Only ever dispatches for a content type that has work to do - most uploads
867
+ * are documents, and a queue entry per PDF that immediately finds nothing to do
868
+ * is noise in the one place somebody looks when a transcode is stuck.
869
+ *
870
+ * A video is dispatched only when the caller supplies a profile: the ladder is
871
+ * derived from the source dimensions, and a job that guesses builds renditions
872
+ * nobody asked for. The dashboard knows them from the upload; a caller that
873
+ * does not can dispatch the transcode later through
874
+ * {@link reprocessDashboardFile}.
875
+ */
876
+ export async function dispatchDashboardFileTasks(
877
+ input: { disk?: string, path: unknown, contentType?: string, videoProfile?: Record<string, unknown>, only?: readonly StorageTaskKind[] },
878
+ manager: Manager = Storage,
879
+ store: StorageMetadataStore = databaseMetadataStore,
880
+ dispatch: TaskDispatcher = queueDispatcher,
881
+ ): Promise<StorageItemTask[]> {
882
+ const selected = resolveDisk(manager, input.disk)
883
+ const path = normalizeDashboardFilePath(input.path)
884
+
885
+ const applicable = tasksForContentType(input.contentType)
886
+ const wanted = input.only
887
+ ? applicable.filter(kind => input.only!.includes(kind))
888
+ : applicable
889
+
890
+ const runnable = wanted.filter(kind => kind !== 'transcode' || input.videoProfile !== undefined)
891
+ if (runnable.length === 0)
892
+ return []
893
+
894
+ return await dispatchTasks(store, selected.name, path, runnable, async (kind) => {
895
+ await dispatch(TASK_JOBS[kind], {
896
+ disk: selected.name,
897
+ path,
898
+ ...(kind === 'transcode' ? { profile: input.videoProfile } : {}),
899
+ })
900
+ })
901
+ }
902
+
903
+ /**
904
+ * Run a file's processing again (stacksjs/stacks#2578).
905
+ *
906
+ * The re-run path the issue asked for, and it is not optional: the first
907
+ * version of any of these produces output somebody wants regenerated - a
908
+ * better ladder, a model that has since improved, an optimization that ran
909
+ * before a preset changed.
910
+ *
911
+ * Existence is checked against the DISK, so re-running a file that has since
912
+ * been deleted is a 404 rather than a job that fails minutes later in a worker
913
+ * log nobody reads.
914
+ */
915
+ export async function reprocessDashboardFile(
916
+ input: { disk?: string, path: unknown, kinds?: unknown, videoProfile?: Record<string, unknown> },
917
+ manager: Manager = Storage,
918
+ store: StorageMetadataStore = databaseMetadataStore,
919
+ dispatch: TaskDispatcher = queueDispatcher,
920
+ ): Promise<{ path: string, tasks: StorageItemTask[] }> {
921
+ const selected = resolveDisk(manager, input.disk)
922
+ const path = normalizeDashboardFilePath(input.path)
923
+
924
+ let only: StorageTaskKind[] | undefined
925
+ if (input.kinds !== undefined && input.kinds !== null) {
926
+ if (!Array.isArray(input.kinds))
927
+ throw new DashboardFileError('Kinds must be an array.', 422, { kinds: 'Send an array of task kinds.' })
928
+ for (const kind of input.kinds) {
929
+ if (!STORAGE_TASK_KINDS.includes(kind as StorageTaskKind)) {
930
+ throw new DashboardFileError(`Unknown task kind "${String(kind)}".`, 422, {
931
+ kinds: `Choose from ${STORAGE_TASK_KINDS.join(', ')}.`,
932
+ })
933
+ }
934
+ }
935
+ only = input.kinds as StorageTaskKind[]
936
+ }
937
+
938
+ if (!(await selected.adapter.fileExists(path)))
939
+ throw new DashboardFileError(`Storage item "${path}" was not found.`, 404)
940
+
941
+ const tasks = await dispatchDashboardFileTasks(
942
+ {
943
+ disk: selected.name,
944
+ path,
945
+ contentType: await selected.adapter.mimeType(path),
946
+ videoProfile: input.videoProfile,
947
+ only,
948
+ },
949
+ manager,
950
+ store,
951
+ dispatch,
952
+ )
953
+
954
+ return { path, tasks }
955
+ }
956
+
653
957
  export async function uploadDashboardFiles(
654
958
  input: { disk?: string, path?: string, files: UploadedFileLike[] },
655
959
  manager: Manager = Storage,
656
- ): Promise<Array<{ path: string, url: string, size: number }>> {
960
+ store: StorageMetadataStore = databaseMetadataStore,
961
+ dispatch: TaskDispatcher = queueDispatcher,
962
+ ): Promise<Array<{ path: string, url: string, size: number, tasks: StorageItemTask[] }>> {
657
963
  const selected = resolveDisk(manager, input.disk)
658
964
  const directory = normalizeDashboardFilePath(input.path ?? '', { allowEmpty: true })
659
- const uploaded: Array<{ path: string, url: string, size: number }> = []
965
+ const uploaded: Array<{ path: string, url: string, size: number, tasks: StorageItemTask[] }> = []
660
966
 
661
967
  try {
662
968
  for (const file of input.files) {
@@ -666,7 +972,7 @@ export async function uploadDashboardFiles(
666
972
  filename: 'original',
667
973
  overwrite: false,
668
974
  })
669
- uploaded.push({ path: result.path, url: result.url, size: result.size })
975
+ uploaded.push({ path: result.path, url: result.url, size: result.size, tasks: [] })
670
976
  }
671
977
  }
672
978
  catch (error) {
@@ -691,5 +997,25 @@ export async function uploadDashboardFiles(
691
997
  throw error
692
998
  }
693
999
 
1000
+ // Dispatched after every file is safely written, not inside the loop above:
1001
+ // the rollback path deletes what was uploaded, and a job already queued for a
1002
+ // file that is about to be deleted would run against nothing.
1003
+ //
1004
+ // A dispatch failure is recorded on the task row rather than thrown, so a
1005
+ // queue that is down does not fail an upload that succeeded - the file is
1006
+ // there, and the dashboard shows the processing as failed.
1007
+ for (const file of uploaded) {
1008
+ file.tasks = await dispatchDashboardFileTasks(
1009
+ {
1010
+ disk: selected.name,
1011
+ path: file.path,
1012
+ contentType: await selected.adapter.mimeType(file.path).catch(() => undefined),
1013
+ },
1014
+ manager,
1015
+ store,
1016
+ dispatch,
1017
+ )
1018
+ }
1019
+
694
1020
  return uploaded
695
1021
  }