@stacksjs/defaults 0.75.41 → 0.75.42

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.
Files changed (98) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  3. package/ai/skills/stacks-chat/SKILL.md +43 -1
  4. package/ai/skills/stacks-cloud/SKILL.md +36 -0
  5. package/ai/skills/stacks-dashboard/SKILL.md +16 -2
  6. package/ai/skills/stacks-database/SKILL.md +24 -3
  7. package/ai/skills/stacks-deploy/SKILL.md +7 -1
  8. package/ai/skills/stacks-desktop/SKILL.md +10 -0
  9. package/ai/skills/stacks-development/SKILL.md +1 -1
  10. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  11. package/ai/skills/stacks-models/SKILL.md +38 -2
  12. package/ai/skills/stacks-orm/SKILL.md +2 -2
  13. package/ai/skills/stacks-security/SKILL.md +28 -1
  14. package/app/Actions/Auth/DataExportAction.test.ts +84 -0
  15. package/app/Actions/Auth/DataExportAction.ts +40 -0
  16. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
  17. package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
  18. package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
  19. package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
  20. package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
  21. package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
  22. package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
  23. package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
  24. package/app/Jobs/PruneRetainedDataJob.ts +46 -0
  25. package/app/Models/Activity.ts +2 -0
  26. package/app/Models/CampaignSend.ts +2 -0
  27. package/app/Models/Card.ts +1 -0
  28. package/app/Models/CardComment.ts +1 -0
  29. package/app/Models/Comment.ts +5 -0
  30. package/app/Models/CommunicationSuppression.ts +2 -1
  31. package/app/Models/ConsentEvent.ts +4 -3
  32. package/app/Models/Content/Author.ts +5 -0
  33. package/app/Models/EmailListSubscriber.ts +1 -0
  34. package/app/Models/EmailSuppression.ts +2 -0
  35. package/app/Models/Forms/FormSubmission.ts +5 -0
  36. package/app/Models/GdprRequest.ts +77 -0
  37. package/app/Models/MagicLinkToken.ts +2 -0
  38. package/app/Models/Notification.ts +2 -0
  39. package/app/Models/NotificationDelivery.ts +4 -0
  40. package/app/Models/PaymentMethod.ts +5 -0
  41. package/app/Models/PaymentTransaction.ts +1 -0
  42. package/app/Models/PersonalAccessToken.ts +3 -0
  43. package/app/Models/Referral.ts +4 -1
  44. package/app/Models/ReferralCode.ts +4 -1
  45. package/app/Models/Request.ts +3 -0
  46. package/app/Models/SmsOptOut.ts +2 -0
  47. package/app/Models/SocialAccount.ts +3 -0
  48. package/app/Models/SocialPost.ts +2 -0
  49. package/app/Models/StorageItemTask.ts +14 -8
  50. package/app/Models/Subscriber.ts +2 -0
  51. package/app/Models/SubscriberEmail.ts +2 -0
  52. package/app/Models/Subscription.ts +1 -0
  53. package/app/Models/TeamInvitation.ts +2 -0
  54. package/app/Models/TeamMember.ts +1 -0
  55. package/app/Models/User.ts +5 -0
  56. package/app/Models/commerce/Bid.ts +3 -0
  57. package/app/Models/commerce/Cart.ts +2 -0
  58. package/app/Models/commerce/Courier.ts +7 -0
  59. package/app/Models/commerce/CourierPing.ts +3 -0
  60. package/app/Models/commerce/Customer.ts +5 -0
  61. package/app/Models/commerce/DeliveryStop.ts +7 -0
  62. package/app/Models/commerce/GiftCard.ts +4 -0
  63. package/app/Models/commerce/Order.ts +5 -0
  64. package/app/Models/commerce/OrderItem.ts +2 -0
  65. package/app/Models/commerce/Payment.ts +5 -0
  66. package/app/Models/commerce/Pledge.ts +3 -0
  67. package/app/Models/commerce/Transaction.ts +2 -0
  68. package/app/Models/commerce/WaitlistProduct.ts +5 -0
  69. package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
  70. package/ide/vscode/.vscode/extensions.json +1 -1
  71. package/ide/vscode/.vscode/settings.json +8 -149
  72. package/ide/vscode/README.md +48 -23
  73. package/ide/vscode/build.ts +43 -0
  74. package/ide/vscode/package.json +305 -56
  75. package/ide/vscode/scripts/stx-contributes.ts +66 -0
  76. package/ide/vscode/src/env-file.ts +103 -0
  77. package/ide/vscode/src/env.ts +241 -0
  78. package/ide/vscode/src/extension.ts +120 -26
  79. package/ide/vscode/src/pickier-protocol.ts +51 -0
  80. package/ide/vscode/src/pickier-worker.ts +92 -0
  81. package/ide/vscode/src/pickier.ts +433 -0
  82. package/ide/vscode/src/project.ts +14 -4
  83. package/ide/vscode/src/stacks.ts +18 -11
  84. package/ide/vscode/tests/build.test.ts +58 -0
  85. package/ide/vscode/tests/env.test.ts +166 -0
  86. package/ide/vscode/tests/extension.test.ts +8 -0
  87. package/ide/vscode/tests/fake-vscode.ts +228 -0
  88. package/ide/vscode/tests/manifest.test.ts +88 -0
  89. package/ide/vscode/tests/pickier.test.ts +202 -0
  90. package/ide/vscode/tsconfig.json +2 -2
  91. package/package.json +2 -2
  92. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
  93. package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
  94. package/routes/auth.ts +4 -0
  95. package/vcs/github/workflows/buddy-bot.yml +2 -2
  96. package/vcs/github/workflows/ci.yml +3 -3
  97. package/vcs/github/workflows/release.yml +1 -1
  98. package/ide/vscode/src/vscode.d.ts +0 -181
@@ -19,8 +19,10 @@ import {
19
19
  STORAGE_TASK_KINDS,
20
20
  sweepMetadata,
21
21
  tasksForContentType,
22
+ variantsPathFor,
22
23
  } from './file-metadata'
23
24
  import { databaseMetadataStore } from './file-metadata-store'
25
+ import { claimedContentType, hasPreview, previewPathFor, previewPlanFor, previewUrlFor } from './file-preview'
24
26
 
25
27
  const DEFAULT_DISK = 'public'
26
28
  const DEFAULT_MAX_ENTRIES = 1000
@@ -41,6 +43,20 @@ export interface DashboardFileNode {
41
43
  mime_type?: string
42
44
  url?: string
43
45
  thumbnail?: string
46
+ /**
47
+ * The URL of this file's generated preview image (stacksjs/stacks#308), when
48
+ * one was made and the disk can serve it. Absent for a file with no preview,
49
+ * and for every file on a private disk - which has no URL for the original
50
+ * either.
51
+ */
52
+ preview?: string
53
+ /**
54
+ * The first lines of a text file, as plain text, when its preview was made.
55
+ * Text gets a snippet rather than an image: drawing text needs a font, and a
56
+ * card can show twelve lines as text. Read on any disk, private included,
57
+ * because the server reads it rather than the browser.
58
+ */
59
+ previewText?: string
44
60
  /**
45
61
  * Whether somebody starred this, from `storage_items` rather than from the
46
62
  * disk (stacksjs/stacks#2577). It was the literal `false` for as long as
@@ -372,6 +388,7 @@ export async function getDashboardFileSnapshot(
372
388
  items: [],
373
389
  }
374
390
  const folders = new Map<string, DashboardFileNode>([['', root]])
391
+ const previewed: DashboardFileNode[] = []
375
392
  const capacity = await diskCapacity(selected.name, selected.config)
376
393
  const stats: DashboardFileStats = {
377
394
  files: 0,
@@ -444,12 +461,37 @@ export async function getDashboardFileSnapshot(
444
461
  ...pick(entry.path),
445
462
  shared: selected.public,
446
463
  }
464
+ if (hasPreview(file.tasks))
465
+ previewed.push(file)
447
466
  parent.items!.push(file)
448
467
  stats.files++
449
468
  stats.contentBytes += entry.size
450
469
  stats.byType[categoryFor(entry)] += entry.size
451
470
  }
452
471
 
472
+ // Previews resolve after the walk, in the same batches as the stats: an image
473
+ // preview is a URL and costs nothing, but a text preview is a read, and a
474
+ // folder of markdown is a read per file (stacksjs/stacks#308).
475
+ await mapInBatches(previewed, STAT_CONCURRENCY, async (file) => {
476
+ const plan = previewPlanFor(claimedContentType(file.path, file.mime_type))
477
+ if (!plan || !('renderer' in plan))
478
+ return
479
+ if (plan.format === 'png') {
480
+ file.preview = await previewUrlFor(selected.adapter, file.path, {
481
+ public: selected.public,
482
+ servesProjectPublic: selected.name === 'public' && selected.config.driver === 'local',
483
+ })
484
+ return
485
+ }
486
+ try {
487
+ file.previewText = await selected.adapter.readToString(previewPathFor(file.path, 'txt'))
488
+ }
489
+ catch {
490
+ // A snippet that has gone missing is a file without a preview, not a
491
+ // listing that fails. Reprocessing makes it again.
492
+ }
493
+ })
494
+
453
495
  for (const folder of folders.values()) {
454
496
  folder.items!.sort((a, b) => {
455
497
  if ((a.type === 'folder') !== (b.type === 'folder'))
@@ -521,12 +563,14 @@ export async function deleteDashboardFile(
521
563
  if (await selected.adapter.fileExists(path)) {
522
564
  await selected.adapter.deleteFile(path)
523
565
  await followDelete(store, selected.name, path)
566
+ await deleteVariants(selected.adapter, path)
524
567
  return { path, type: 'file' }
525
568
  }
526
569
  if (await selected.adapter.directoryExists(path)) {
527
570
  await selected.adapter.deleteDirectory(path)
528
571
  // The subtree, because deleting a folder deletes everything under it.
529
572
  await followDelete(store, selected.name, path)
573
+ await deleteVariants(selected.adapter, path)
530
574
  return { path, type: 'directory' }
531
575
  }
532
576
 
@@ -573,6 +617,7 @@ export async function renameDashboardFile(
573
617
  // After the move, so a failed move does not leave the metadata describing a
574
618
  // path with no file at it.
575
619
  await followRename(store, selected.name, from, to)
620
+ await moveVariants(selected.adapter, from, to)
576
621
  return { from, to, type: 'file', moved: 1 }
577
622
  }
578
623
 
@@ -603,6 +648,7 @@ export async function renameDashboardFile(
603
648
  // A folder rename moves every file beneath it, so this is a prefix update
604
649
  // rather than one row - the part #2577 flagged as the one that would bite.
605
650
  await followRename(store, selected.name, from, to)
651
+ await moveVariants(selected.adapter, from, to)
606
652
 
607
653
  return { from, to, type: 'directory', moved: files.length }
608
654
  }
@@ -824,6 +870,54 @@ export async function setDashboardFileTags(
824
870
  return { path, favorite: record.favorite, tags: record.tags }
825
871
  }
826
872
 
873
+ /**
874
+ * Move a path's derivatives with it (stacksjs/stacks#308).
875
+ *
876
+ * The task rows already follow a rename, so without this a finished preview
877
+ * would report `done` at the new path while its file sat under the old one.
878
+ * The variants folder mirrors the original's path, so a folder rename is a
879
+ * prefix move here too.
880
+ *
881
+ * Best effort, after the rename has succeeded: the rename is what was asked
882
+ * for, and a derivative that failed to follow is regenerated by reprocessing
883
+ * rather than worth failing the request over.
884
+ */
885
+ async function moveVariants(adapter: StorageAdapter, from: string, to: string): Promise<void> {
886
+ const source = variantsPathFor(from)
887
+ const target = variantsPathFor(to)
888
+
889
+ try {
890
+ if (!(await adapter.directoryExists(source)))
891
+ return
892
+
893
+ const files: string[] = []
894
+ for await (const entry of adapter.list(source, { deep: true })) {
895
+ const listed = normalizeListedPath(String(entry.path))
896
+ if (entry.type === 'file' && listed)
897
+ files.push(listed.startsWith(`${source}/`) ? listed : `${source}/${listed}`)
898
+ }
899
+
900
+ for (const file of files)
901
+ await adapter.moveFile(file, `${target}/${file.slice(source.length + 1)}`)
902
+
903
+ await adapter.deleteDirectory(source)
904
+ }
905
+ catch {
906
+ // See above: the rename succeeded, and that is the answer the caller gets.
907
+ }
908
+ }
909
+
910
+ /** Remove a path's derivatives once the path itself is gone. Best effort, like {@link moveVariants}. */
911
+ async function deleteVariants(adapter: StorageAdapter, path: string): Promise<void> {
912
+ try {
913
+ if (await adapter.directoryExists(variantsPathFor(path)))
914
+ await adapter.deleteDirectory(variantsPathFor(path))
915
+ }
916
+ catch {
917
+ // An orphaned derivative is hidden from the listing and costs only space.
918
+ }
919
+ }
920
+
827
921
  /** 404 unless the path is a file or a folder on the disk. */
828
922
  async function assertExists(adapter: StorageAdapter, path: string): Promise<void> {
829
923
  if (await adapter.fileExists(path) || await adapter.directoryExists(path))
@@ -853,6 +947,7 @@ const TASK_JOBS = {
853
947
  optimize: 'OptimizeStorageImageJob',
854
948
  transcode: 'TranscodeStorageVideoJob',
855
949
  tag: 'TagStorageMediaJob',
950
+ preview: 'GenerateStoragePreviewJob',
856
951
  } as const satisfies Record<StorageTaskKind, JobName>
857
952
 
858
953
  const queueDispatcher: TaskDispatcher = async (job, payload) => {
@@ -872,6 +967,12 @@ const queueDispatcher: TaskDispatcher = async (job, payload) => {
872
967
  * nobody asked for. The dashboard knows them from the upload; a caller that
873
968
  * does not can dispatch the transcode later through
874
969
  * {@link reprocessDashboardFile}.
970
+ *
971
+ * A preview is decided separately, by `file-preview.ts`, and from the type the
972
+ * file CLAIMS - the reported type, or the extension when the disk reports
973
+ * nothing useful - because far more kinds have an answer there (#308). A kind
974
+ * with no renderer here is recorded `skipped` with its reason and never
975
+ * queued.
875
976
  */
876
977
  export async function dispatchDashboardFileTasks(
877
978
  input: { disk?: string, path: unknown, contentType?: string, videoProfile?: Record<string, unknown>, only?: readonly StorageTaskKind[] },
@@ -882,7 +983,11 @@ export async function dispatchDashboardFileTasks(
882
983
  const selected = resolveDisk(manager, input.disk)
883
984
  const path = normalizeDashboardFilePath(input.path)
884
985
 
885
- const applicable = tasksForContentType(input.contentType)
986
+ const preview = previewPlanFor(claimedContentType(path, input.contentType))
987
+ const applicable: StorageTaskKind[] = [
988
+ ...tasksForContentType(input.contentType),
989
+ ...(preview ? ['preview' as const] : []),
990
+ ]
886
991
  const wanted = input.only
887
992
  ? applicable.filter(kind => input.only!.includes(kind))
888
993
  : applicable
@@ -891,13 +996,20 @@ export async function dispatchDashboardFileTasks(
891
996
  if (runnable.length === 0)
892
997
  return []
893
998
 
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
- })
999
+ return await dispatchTasks(
1000
+ store,
1001
+ selected.name,
1002
+ path,
1003
+ runnable,
1004
+ async (kind) => {
1005
+ await dispatch(TASK_JOBS[kind], {
1006
+ disk: selected.name,
1007
+ path,
1008
+ ...(kind === 'transcode' ? { profile: input.videoProfile } : {}),
1009
+ })
1010
+ },
1011
+ kind => kind === 'preview' && preview && 'skip' in preview ? preview.skip : undefined,
1012
+ )
901
1013
  }
902
1014
 
903
1015
  /**
@@ -42,28 +42,79 @@ export interface StorageItemMetadata {
42
42
  }
43
43
 
44
44
  /**
45
- * The three kinds of background work a file can carry (stacksjs/stacks#2578).
45
+ * The kinds of background work a file can carry (stacksjs/stacks#2578, #308).
46
46
  *
47
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.
48
+ * `tag` asks a vision model what is in the file, `preview` renders the small
49
+ * image a file manager shows in place of a generic icon. Independent: a video
50
+ * whose transcode finished and whose tagging failed is a normal state.
50
51
  */
51
- export type StorageTaskKind = 'optimize' | 'transcode' | 'tag'
52
+ export type StorageTaskKind = 'optimize' | 'transcode' | 'tag' | 'preview'
52
53
 
53
- export type StorageTaskState = 'queued' | 'running' | 'done' | 'failed'
54
+ /**
55
+ * Where one task is.
56
+ *
57
+ * `skipped` is not a failure and not a success: the work does not apply to this
58
+ * file HERE - a PDF with no homegrown renderer to draw it, an SVG that is not
59
+ * rasterized because it is markup somebody else wrote. It is recorded rather
60
+ * than left absent so a file manager can say why a file has no preview, and it
61
+ * is terminal, so the queue does not retry something that cannot succeed.
62
+ */
63
+ export type StorageTaskState = 'queued' | 'running' | 'done' | 'failed' | 'skipped'
54
64
 
55
65
  /** One unit of background work, as the dashboard sees it. */
56
66
  export interface StorageItemTask {
57
67
  kind: StorageTaskKind
58
68
  state: StorageTaskState
59
69
  attempts: number
70
+ /** Why a `failed` task failed, or why a `skipped` one did not apply. */
60
71
  error?: string
61
72
  startedAt?: string
62
73
  finishedAt?: string
63
74
  }
64
75
 
65
76
  /** Every task kind, in the order a file would run them. */
66
- export const STORAGE_TASK_KINDS: readonly StorageTaskKind[] = ['optimize', 'transcode', 'tag']
77
+ export const STORAGE_TASK_KINDS: readonly StorageTaskKind[] = ['optimize', 'transcode', 'tag', 'preview']
78
+
79
+ /**
80
+ * Thrown from a task's work to record it as `skipped` rather than `failed`.
81
+ *
82
+ * For the case only the bytes can reveal: a `.png` whose bytes are a PDF, a
83
+ * `.ttf` that turns out to be a compressed WOFF. That is not a fault to retry -
84
+ * a second attempt reads the same bytes - so {@link runTask} records the reason
85
+ * and returns instead of rethrowing.
86
+ */
87
+ export class StorageTaskSkipped extends Error {
88
+ constructor(reason: string) {
89
+ super(reason)
90
+ this.name = 'StorageTaskSkipped'
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Where derivatives live, relative to the disk root (stacksjs/stacks#2578).
96
+ *
97
+ * Hidden, so the file manager's listing skips it. Defined here rather than in
98
+ * a job, because the request path needs it too - a rename has to move a file's
99
+ * derivatives with it - and importing a job would load its encoder.
100
+ */
101
+ export const VARIANT_PREFIX = '.variants'
102
+
103
+ /** The folder holding one file's derivatives. */
104
+ export function variantsPathFor(path: string): string {
105
+ return `${VARIANT_PREFIX}/${path}`
106
+ }
107
+
108
+ /**
109
+ * Where a file's preview is stored (stacksjs/stacks#308).
110
+ *
111
+ * Derived from the path rather than recorded in a column, so it cannot go
112
+ * stale: the task row says whether one was made, and this says where. A rename
113
+ * through the dashboard moves the folder it sits in along with the task row.
114
+ */
115
+ export function previewPathFor(path: string): string {
116
+ return `${variantsPathFor(path)}/preview.png`
117
+ }
67
118
 
68
119
  /** The `taggable_type` these rows use, which keeps them apart from the CMS's. */
69
120
  export const STORAGE_ITEM_TYPE = 'storage_items'
@@ -313,25 +364,40 @@ export async function sweepMetadata(
313
364
  export function aggregateTaskState(tasks: readonly StorageItemTask[]): StorageTaskState | null {
314
365
  if (tasks.length === 0)
315
366
  return null
316
- if (tasks.some(task => task.state === 'failed'))
367
+ // A skipped task says nothing about the work that DID apply, so it does not
368
+ // count towards the word - a photo whose preview was skipped and whose
369
+ // variants finished is done. Only a file where nothing applied reads as
370
+ // skipped, which is still more than `null`: somebody looked.
371
+ const applied = tasks.filter(task => task.state !== 'skipped')
372
+ if (applied.length === 0)
373
+ return 'skipped'
374
+ if (applied.some(task => task.state === 'failed'))
317
375
  return 'failed'
318
- if (tasks.some(task => task.state === 'running'))
376
+ if (applied.some(task => task.state === 'running'))
319
377
  return 'running'
320
- if (tasks.some(task => task.state === 'queued'))
378
+ if (applied.some(task => task.state === 'queued'))
321
379
  return 'queued'
322
380
  return 'done'
323
381
  }
324
382
 
383
+ /** The bare, lower-cased MIME type, without parameters. */
384
+ export function bareMime(contentType: string | undefined): string {
385
+ return (contentType ?? '').toLowerCase().split(';')[0]?.trim() ?? ''
386
+ }
387
+
325
388
  /**
326
- * Which kinds of work a file's content type calls for.
389
+ * Which kinds of media work a file's content type calls for.
327
390
  *
328
391
  * Decided from the MIME type rather than the extension, when one is known: a
329
392
  * `.mp4` that is actually a PDF should not be handed to a transcoder. Tagging
330
393
  * applies to images and video alike, because a vision model can describe a
331
394
  * frame as readily as a photograph.
395
+ *
396
+ * `preview` is not decided here: it applies to far more types than these, and
397
+ * `file-preview.ts` owns which (stacksjs/stacks#308).
332
398
  */
333
399
  export function tasksForContentType(contentType: string | undefined): StorageTaskKind[] {
334
- const mime = (contentType ?? '').toLowerCase().split(';')[0]?.trim() ?? ''
400
+ const mime = bareMime(contentType)
335
401
 
336
402
  if (mime.startsWith('image/')) {
337
403
  // SVG is markup, not a raster: there is nothing to re-encode, and running
@@ -361,10 +427,24 @@ export async function dispatchTasks(
361
427
  path: string,
362
428
  kinds: readonly StorageTaskKind[],
363
429
  dispatch: (kind: StorageTaskKind) => Promise<void>,
430
+ /**
431
+ * The reason a kind does not apply, when that is already known from the
432
+ * content type. Such a kind is recorded `skipped` and never queued: a queue
433
+ * entry that can only find out what the dispatcher already knew is noise.
434
+ */
435
+ skip: (kind: StorageTaskKind) => string | undefined = () => undefined,
364
436
  ): Promise<StorageItemTask[]> {
365
437
  const queued: StorageItemTask[] = []
366
438
 
367
439
  for (const kind of kinds) {
440
+ const reason = skip(kind)
441
+ if (reason !== undefined) {
442
+ const skipped: StorageItemTask = { kind, state: 'skipped', attempts: 0, error: reason, finishedAt: new Date().toISOString() }
443
+ await store.writeTask(disk, path, skipped)
444
+ queued.push(skipped)
445
+ continue
446
+ }
447
+
368
448
  const task: StorageItemTask = { kind, state: 'queued', attempts: 0 }
369
449
  await store.writeTask(disk, path, task)
370
450
 
@@ -400,6 +480,10 @@ export async function dispatchTasks(
400
480
  * and applies its backoff. The row and the queue are answering different
401
481
  * questions: the queue decides whether to try again, the row is what somebody
402
482
  * looking at the file sees.
483
+ *
484
+ * The one exception is {@link StorageTaskSkipped}: recorded as `skipped` with
485
+ * its reason, and NOT rethrown, because a retry would read the same bytes and
486
+ * reach the same answer. The result is `undefined` in that case.
403
487
  */
404
488
  export async function runTask<T>(
405
489
  store: StorageMetadataStore,
@@ -407,7 +491,7 @@ export async function runTask<T>(
407
491
  path: string,
408
492
  kind: StorageTaskKind,
409
493
  work: () => Promise<T>,
410
- ): Promise<T> {
494
+ ): Promise<T | undefined> {
411
495
  const previous = (await store.tasksUnder(disk, path)).get(path)?.find(task => task.kind === kind)
412
496
  const attempts = (previous?.attempts ?? 0) + 1
413
497
  const startedAt = new Date().toISOString()
@@ -428,6 +512,18 @@ export async function runTask<T>(
428
512
  return result
429
513
  }
430
514
  catch (error) {
515
+ if (error instanceof StorageTaskSkipped) {
516
+ await store.writeTask(disk, path, {
517
+ kind,
518
+ state: 'skipped',
519
+ attempts,
520
+ startedAt,
521
+ finishedAt: new Date().toISOString(),
522
+ error: describeError(error),
523
+ })
524
+ return undefined
525
+ }
526
+
431
527
  await store.writeTask(disk, path, {
432
528
  kind,
433
529
  state: 'failed',
@@ -127,8 +127,9 @@ describe('dispatchDashboardFileTasks', () => {
127
127
  dispatch,
128
128
  )
129
129
 
130
- expect(tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
131
- expect(dispatched.map(entry => entry.job)).toEqual(['OptimizeStorageImageJob', 'TagStorageMediaJob'])
130
+ // Plus the preview, which `file-preview.test.ts` covers (stacksjs/stacks#308).
131
+ expect(tasks.map(task => task.kind)).toEqual(['optimize', 'tag', 'preview'])
132
+ expect(dispatched.map(entry => entry.job)).toEqual(['OptimizeStorageImageJob', 'TagStorageMediaJob', 'GenerateStoragePreviewJob'])
132
133
  expect(dispatched[0]?.payload).toEqual({ disk: 'public', path: 'photo.png' })
133
134
 
134
135
  const recorded = (await store.tasksUnder('public', '')).get('photo.png')
@@ -136,9 +137,11 @@ describe('dispatchDashboardFileTasks', () => {
136
137
  })
137
138
 
138
139
  test('dispatches nothing for a content type with no work', async () => {
139
- await manager.disk('public').write('notes.pdf', 'x')
140
+ // An archive rather than a PDF since #308: a PDF now records a skipped
141
+ // preview, which is a row but still not a queue entry.
142
+ await manager.disk('public').write('bundle.zip', 'x')
140
143
 
141
- expect(await dispatchDashboardFileTasks({ path: 'notes.pdf', contentType: 'application/pdf' }, manager, store, dispatch)).toEqual([])
144
+ expect(await dispatchDashboardFileTasks({ path: 'bundle.zip', contentType: 'application/zip' }, manager, store, dispatch)).toEqual([])
142
145
  expect(dispatched).toEqual([])
143
146
  expect(await store.tasksUnder('public', '')).toEqual(new Map())
144
147
  })
@@ -149,7 +152,8 @@ describe('dispatchDashboardFileTasks', () => {
149
152
  await manager.disk('public').write('clip.mp4', 'x')
150
153
 
151
154
  const withoutProfile = await dispatchDashboardFileTasks({ path: 'clip.mp4', contentType: 'video/mp4' }, manager, store, dispatch)
152
- expect(withoutProfile.map(task => task.kind)).toEqual(['tag'])
155
+ // The preview is recorded skipped: nothing here decodes a video frame.
156
+ expect(withoutProfile.map(task => [task.kind, task.state])).toEqual([['tag', 'queued'], ['preview', 'skipped']])
153
157
 
154
158
  dispatched = []
155
159
  const withProfile = await dispatchDashboardFileTasks(
@@ -158,7 +162,7 @@ describe('dispatchDashboardFileTasks', () => {
158
162
  store,
159
163
  dispatch,
160
164
  )
161
- expect(withProfile.map(task => task.kind)).toEqual(['transcode', 'tag'])
165
+ expect(withProfile.map(task => task.kind)).toEqual(['transcode', 'tag', 'preview'])
162
166
  expect(dispatched[0]?.payload.profile).toEqual({ width: 1920, height: 1080 })
163
167
  })
164
168
 
@@ -251,7 +255,7 @@ describe('the snapshot reports processing', () => {
251
255
  const node = nodeAt(snapshot.root, 'photo.png')
252
256
 
253
257
  expect(node?.processing).toBe('queued')
254
- expect(node?.tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
258
+ expect(node?.tasks.map(task => task.kind)).toEqual(['optimize', 'tag', 'preview'])
255
259
  })
256
260
 
257
261
  test('a file nobody processed reports null, not done', async () => {
@@ -309,7 +313,7 @@ describe('reprocessDashboardFile', () => {
309
313
 
310
314
  const result = await reprocessDashboardFile({ path: 'photo.png' }, manager, store, dispatch)
311
315
 
312
- expect(result.tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
316
+ expect(result.tasks.map(task => task.kind)).toEqual(['optimize', 'tag', 'preview'])
313
317
  // The failed task is reset to queued rather than left showing its old
314
318
  // error beside a job that is about to run.
315
319
  const optimize = (await store.tasksUnder('public', '')).get('photo.png')?.find(task => task.kind === 'optimize')
@@ -333,7 +337,7 @@ describe('reprocessDashboardFile', () => {
333
337
  .rejects.toThrow(/was not found/)
334
338
  })
335
339
 
336
- test('rejects a kind that is not one of the three', async () => {
340
+ test('rejects a kind that is not one of the four', async () => {
337
341
  await manager.disk('public').write('photo.png', 'x')
338
342
 
339
343
  expect(reprocessDashboardFile({ path: 'photo.png', kinds: ['reticulate'] }, manager, store, dispatch))