From 6efda8b6236b6a8806dde2dbf65ba068e9571b83 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 17 Oct 2026 21:08:21 +0200 Subject: [PATCH 1/2] Use published indexed media queries in the wedding gallery --- .yarnrc.yml | 2 +- CONTRIBUTING.md | 6 ++- example/app/Gallery.tsx | 7 ++- example/app/StorageGallery.test.tsx | 29 ++++++++++- example/app/StorageGallery.tsx | 14 ++++-- example/convex/media.test.ts | 74 +++++++++++++++++++++++++++-- example/convex/media.ts | 52 +++++--------------- example/package.json | 2 +- yarn.lock | 10 ++-- 9 files changed, 136 insertions(+), 60 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 56e39f0..2fd64a8 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -9,4 +9,4 @@ npmPreapprovedPackages: - "@transloadit/viewer@0.0.8" - "@transloadit/zod@5.0.1" - "@transloadit/node@5.0.1" - - "@transloadit/convex@0.5.0" + - "@transloadit/convex@0.6.0" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b2b8c3..c83c1b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,14 +46,16 @@ Changing the code invalidates existing album access. Without a code, entering a Cloud tests accept `E2E_WEDDING_UPLOAD_CODE` for protected previews. Search indexing is disabled separately; it is not access control. -The gallery hides items after `NEXT_PUBLIC_GALLERY_RETENTION_HOURS` (default 24). +The gallery displays the last `NEXT_PUBLIC_GALLERY_RETENTION_HOURS` (default 24), measured when +the album opens. Its fixed `createdAfter` bound is applied before pagination, so loading another +page does not spend it on hidden history or move the display cutoff. Reloading starts a new visit. This is a display policy, not deletion or permission revocation. The Storage cleanup job below performs deletion. Existing public R2 objects and their lifecycle configuration are untouched by this code removal; old URLs cannot be made private retroactively. ## Demo deployment (Vercel + stable Convex) -The hosted wedding app is a Yarn workspace with an exact `@transloadit/convex@0.5.0` registry +The hosted wedding app is a Yarn workspace with an exact `@transloadit/convex@0.6.0` registry dependency. It does not alias package imports to this repository's `src/`. Both `example:build` and the production backend deploy check the resolved package before proceeding. Publish a component release first, then update `example/package.json` and the lockfile to dogfood it on the live demo. diff --git a/example/app/Gallery.tsx b/example/app/Gallery.tsx index 078df15..2ac9e33 100644 --- a/example/app/Gallery.tsx +++ b/example/app/Gallery.tsx @@ -255,6 +255,7 @@ function GalleryViewer({ } export function Gallery({ + displayTime, storageAssets = [], linkedAsset, linkLoading = false, @@ -263,6 +264,7 @@ export function Gallery({ loading = false, loadingMore = false, }: { + displayTime?: number storageAssets?: StorageGalleryAsset[] linkedAsset?: StorageGalleryAsset | null linkLoading?: boolean @@ -274,11 +276,12 @@ export function Gallery({ const t = useTranslations('album') const selectedId = useGalleryLocation() const [direction, setDirection] = useState<'next' | 'previous'>('next') - const items = loadFailed ? [] : buildStorageGalleryItems(storageAssets) + const items = loadFailed ? [] : buildStorageGalleryItems(storageAssets, { now: displayTime }) const position = items.findIndex((item) => item.id === selectedId) // A deep-linked item outside loaded pages has no known neighbors or ordinal. Keep its live // authorized subscription, but do not append it to the grid or invent a pagination position. - const linked = linkedAsset && !loadFailed ? buildStorageGalleryItems([linkedAsset]) : [] + const linked = + linkedAsset && !loadFailed ? buildStorageGalleryItems([linkedAsset], { now: displayTime }) : [] const selected = items[position] ?? linked.find((item) => item.id === selectedId) const navigate = (value: 'next' | 'previous') => { // Read the current URL, not a render closure, so rapid clicks never collapse into one move. diff --git a/example/app/StorageGallery.test.tsx b/example/app/StorageGallery.test.tsx index 642b76e..857f610 100644 --- a/example/app/StorageGallery.test.tsx +++ b/example/app/StorageGallery.test.tsx @@ -5,7 +5,7 @@ import type { PaginationOptions } from 'convex/server' import { ConvexError } from 'convex/values' import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { AlbumIntlProvider } from '../i18n/AlbumIntlProvider' -import type { StorageGalleryAsset } from '../lib/gallery' +import { galleryRetentionMs, type StorageGalleryAsset } from '../lib/gallery' import { StorageGallery } from './StorageGallery' // A client whose subscriptions answer from `respond`; `update` pushes a new answer to all of them. @@ -110,6 +110,33 @@ test('keeps the next-page button disabled and announces loading while a page is expect(shown()).toBe(1) }) +test('keeps one display cutoff for query pages and rendered memories throughout the visit', () => { + const openedAt = Date.now() + const now = vi.spyOn(Date, 'now').mockReturnValue(openedAt) + const watch = vi.spyOn(client, 'watchQuery') + const first = { ...photo(1), createdAt: openedAt - galleryRetentionMs + 2000 } + const second = { ...photo(2), createdAt: openedAt - galleryRetentionMs + 1000 } + respond = ({ cursor }) => + cursor === null + ? { page: [first], isDone: false, continueCursor: 'next' } + : { page: [second], isDone: true, continueCursor: 'end' } + render(album) + expect(shown()).toBe(1) + expect(watch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ createdAfter: openedAt - galleryRetentionMs }), + expect.anything(), + ) + + now.mockReturnValue(openedAt + 5000) + fireEvent.click(screen.getByRole('button', { name: 'Show more memories' })) + expect(shown()).toBe(2) + expect(screen.queryByRole('button', { name: 'Show more memories' })).toBeNull() + expect( + watch.mock.calls.every(([, args]) => args?.createdAfter === openedAt - galleryRetentionMs), + ).toBe(true) +}) + test('refused cursors restart the album, other failures replace its photos with an alert', () => { respond = ({ cursor }) => cursor === null diff --git a/example/app/StorageGallery.tsx b/example/app/StorageGallery.tsx index 09d33bc..683f063 100644 --- a/example/app/StorageGallery.tsx +++ b/example/app/StorageGallery.tsx @@ -3,14 +3,14 @@ import { useQuery } from 'convex/react' import { makeFunctionReference, type PaginationOptions, type PaginationResult } from 'convex/server' import { usePaginatedQuery } from 'convex-helpers/react' -import { Component, type ReactNode } from 'react' -import type { StorageGalleryAsset } from '../lib/gallery' +import { Component, type ReactNode, useState } from 'react' +import { galleryRetentionMs, type StorageGalleryAsset } from '../lib/gallery' import { parseGalleryMedia, useGalleryLocation } from '../lib/gallery-location' import { Gallery } from './Gallery' const listMediaRef = makeFunctionReference< 'query', - { paginationOpts: PaginationOptions }, + { paginationOpts: PaginationOptions; createdAfter?: number }, PaginationResult >('media:list') const getMediaRef = makeFunctionReference< @@ -22,7 +22,12 @@ const pageSize = 24 // Canonical private receipts only; the media route authorizes every request. const StoragePages = () => { - const media = usePaginatedQuery(listMediaRef, {}, { initialNumItems: pageSize }) + // Paging and rendering share one visit's clock: loading another page must not move its cutoff. + const [displayTime] = useState(Date.now) + const createdAfter = Number.isFinite(galleryRetentionMs) + ? displayTime - galleryRetentionMs + : undefined + const media = usePaginatedQuery(listMediaRef, { createdAfter }, { initialNumItems: pageSize }) const selected = useGalleryLocation() const identity = parseGalleryMedia(selected) const listed = media.results.some( @@ -31,6 +36,7 @@ const StoragePages = () => { const linked = useQuery(getMediaRef, identity && !listed ? identity : 'skip') return ( { vi.stubEnv('CONVEX_CLOUD_URL', 'https://happy-otter-123.convex.cloud') }) -afterEach(() => vi.unstubAllEnvs()) +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) describe('private Storage uploads', () => { test.each([0, -1, 1.5, 13])( @@ -241,6 +244,43 @@ describe('private Storage uploads', () => { }) describe('gallery and delivery authorization', () => { + test('excludes hidden history before paging without revoking retained exact versions', async () => { + const startedAt = Date.now() + const now = vi.spyOn(Date, 'now').mockReturnValue(startedAt) + const t = setup() + const guest = await admit(t, 'Alex') + const { asset: old } = await upload(guest, t, 'old') + now.mockReturnValue(startedAt + 100) + await upload(guest, t, 'boundary') + now.mockReturnValue(startedAt + 200) + const { asset: recent } = await upload(guest, t, 'recent') + now.mockReturnValue(startedAt + 300) + const { asset: newest } = await upload(guest, t, 'newest') + const createdAfter = startedAt + 100 + + const first = await guest.query(api.media.list, { + createdAfter, + paginationOpts: { numItems: 1, cursor: null }, + }) + expect(first.page.map((row: { asset: { asset_id: string } }) => row.asset.asset_id)).toEqual([ + newest.asset_id, + ]) + const rest = await guest.query(api.media.list, { + createdAfter, + paginationOpts: { numItems: 24, cursor: first.continueCursor }, + }) + expect(rest.page.map((row: { asset: { asset_id: string } }) => row.asset.asset_id)).toEqual([ + recent.asset_id, + ]) + expect(rest.isDone).toBe(true) + expect(await guest.query(api.media.get, link(old))).toMatchObject({ + asset: { asset_id: old.asset_id, version_id: old.version_id }, + }) + expect(await guest.query(api.media.forDelivery, request(old))).toMatchObject({ + asset_id: old.asset_id, + }) + }) + test('a shared exact-version link resolves outside the loaded page, without listing all pages', async () => { const t = setup() const alex = await admit(t, 'Alex') @@ -272,7 +312,7 @@ describe('gallery and delivery authorization', () => { ]) }) - test('joins private video renditions by source identity across pages, not by filename', async () => { + test('joins private video renditions by source identity even in an Assembly with over 500 results', async () => { const t = setup() const guest = await admit(t, 'Alex') const { assemblyOptions } = await guest.action(api.wedding.createWeddingAssemblyOptions, { @@ -306,7 +346,13 @@ describe('gallery and delivery authorization', () => { fields: params.fields, results: { ':original': [original], - video_encode: [video, other], + video_encode: [ + video, + other, + ...Array.from({ length: 500 }, (_, i) => + result(`unrelated-${i}`, 'video/mp4', `unrelated-${i}`), + ), + ], video_poster: [poster], }, }, @@ -342,6 +388,13 @@ describe('gallery and delivery authorization', () => { await t.mutation(components.transloadit.lib.requestStoredAssetDeletion, { album: 'wedding-gallery', createdBefore: Date.now() + 1, + limit: 500, + }) + // The large Assembly spans two bounded deletion batches; no receipt is revoked until selected. + await t.mutation(components.transloadit.lib.requestStoredAssetDeletion, { + album: 'wedding-gallery', + createdBefore: Date.now() + 1, + limit: 500, }) expect(await guest.query(api.media.forDelivery, request(video, 'original'))).toBeNull() expect(await guest.query(api.media.forDelivery, request(poster, 'original'))).toBeNull() @@ -449,11 +502,24 @@ describe('gallery and delivery authorization', () => { for (const denied of [ { ...asset, version_id: damId('otherversion') }, { ...asset, asset_id: damId('otherasset') }, - ]) + ]) { expect(await alex.query(api.media.forDelivery, request(denied))).toBeNull() + expect(await alex.query(api.media.get, link(denied))).toBeNull() + } await expect(alex.query(api.media.forDelivery, request(asset, 'thumbnail'))).rejects.toThrow() }) + test('rejects non-finite display cutoffs with a fixed component error', async () => { + const t = setup() + const guest = await admit(t, 'Alex') + await expect( + guest.query(api.media.list, { + createdAfter: Number.POSITIVE_INFINITY, + paginationOpts: { numItems: 10, cursor: null }, + }), + ).rejects.toThrow('Invalid Storage media cutoff') + }) + test('ignores receipts that are not bound to a matching server-created upload', async () => { const t = setup() const alex = await admit(t, 'Alex') diff --git a/example/convex/media.ts b/example/convex/media.ts index 18b9232..3dbff68 100644 --- a/example/convex/media.ts +++ b/example/convex/media.ts @@ -10,6 +10,12 @@ import { findGuest, requireGuest } from './guests' type StoredAssetRow = Infer const vAction = v.union(v.literal('preview'), v.literal('original'), v.literal('download')) +const mediaProducers = { + album, + // Keep old private photos readable alongside direct originals, without a data migration. + originalSteps: [':original', 'images_filtered'], + renditions: { playback: 'video_encode', poster: 'video_poster' }, +} const vGalleryAsset = v.object({ id: v.string(), @@ -93,15 +99,13 @@ const galleryAsset = async ( * convex-helpers uses to keep loaded pages stable in a live album. */ export const list = query({ - args: { paginationOpts: paginationOptsValidator }, + args: { paginationOpts: paginationOptsValidator, createdAfter: v.optional(v.number()) }, returns: paginationResultValidator(vGalleryAsset), handler: async (ctx, args) => { await requireGuest(ctx) const result = await ctx.runQuery(components.transloadit.lib.listStoredMedia, { - album, - // Keep old private photos readable alongside direct originals, without a data migration. - originalSteps: [':original', 'images_filtered'], - renditions: { playback: 'video_encode', poster: 'video_poster' }, + ...mediaProducers, + createdAfter: args.createdAfter, paginationOpts: args.paginationOpts, }) const page = [] @@ -142,43 +146,11 @@ export const get = query({ handler: async (ctx, args) => { const workspace = getStorageWorkspace() if (!workspace || !(await findGuest(ctx))) return null - const row = await ctx.runQuery(components.transloadit.lib.getStoredAsset, { + const media = await ctx.runQuery(components.transloadit.lib.getStoredMedia, { + ...mediaProducers, workspace, ...args, }) - if ( - !row || - ![':original', 'images_filtered'].includes(row.stepName) || - !(await findBoundUpload(ctx, row)) - ) - return null - if (!row.asset.mime?.startsWith('video/')) return galleryAsset(ctx, row, {}) - // This app signs batches of at most 12 uploads with three stored outputs per video. Reuse - // the component's bounded Assembly lookup; custom/ambiguous excess results fail closed. - const siblings = await ctx.runQuery(components.transloadit.lib.listStoredAssetsForAssembly, { - assemblyId: row.assemblyId, - limit: 500, - }) - if (siblings.length === 500) return null - const sourceId = (id: StoredAssetRow['originalId']) => - typeof id === 'string' ? id : id?.length === 1 ? id[0] : undefined - const original = sourceId(row.originalId) - const rendition = (step: string) => { - if (!original) return undefined - const matches = siblings.filter( - (item) => - item.stepName === step && - sourceId(item.originalId) === original && - item.album === row.album && - item.userId === row.userId && - item.uploadId === row.uploadId && - item.asset.workspace === workspace, - ) - return matches.length === 1 ? matches[0] : undefined - } - return galleryAsset(ctx, row, { - playback: rendition('video_encode'), - poster: rendition('video_poster'), - }) + return media ? galleryAsset(ctx, media.original, media.renditions) : null }, }) diff --git a/example/package.json b/example/package.json index f86e260..7a0b2a6 100644 --- a/example/package.json +++ b/example/package.json @@ -10,7 +10,7 @@ "dependencies": { "@auth/core": "^0.41.3", "@convex-dev/auth": "^0.0.95", - "@transloadit/convex": "0.5.0", + "@transloadit/convex": "0.6.0", "@transloadit/node": "5.0.1", "@transloadit/viewer": "0.0.8", "@transloadit/zod": "5.0.1", diff --git a/yarn.lock b/yarn.lock index a62c688..390415b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1890,9 +1890,9 @@ __metadata: languageName: node linkType: hard -"@transloadit/convex@npm:0.5.0": - version: 0.5.0 - resolution: "@transloadit/convex@npm:0.5.0" +"@transloadit/convex@npm:0.6.0": + version: 0.6.0 + resolution: "@transloadit/convex@npm:0.6.0" dependencies: "@transloadit/utils": "npm:^4.8.1" "@transloadit/zod": "npm:5.0.1" @@ -1902,7 +1902,7 @@ __metadata: dependenciesMeta: esbuild: built: true - checksum: 10c0/cff302af49cd9cf17e7e0c74c40a073d72732e2e33d970159efbbb50885664ec70742c0449b95385dac8b7bff2c71c7ce9b4beca03ec8c16f9f120cb43ae84de + checksum: 10c0/46ca74924ff03003f019d422cca856cf7fb708b8aed8a384a899a8bf06f876c30833f178b3b6aa0f11a9ad9cb627a6abbe97a36a5346d2e4b4884fa837bccc11 languageName: node linkType: hard @@ -4847,7 +4847,7 @@ __metadata: dependencies: "@auth/core": "npm:^0.41.3" "@convex-dev/auth": "npm:^0.0.95" - "@transloadit/convex": "npm:0.5.0" + "@transloadit/convex": "npm:0.6.0" "@transloadit/node": "npm:5.0.1" "@transloadit/viewer": "npm:0.0.8" "@transloadit/zod": "npm:5.0.1" From ed67d897b3c1fd34e5b51e23cb25c0ecd1bf7b43 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 17 Oct 2026 21:11:01 +0200 Subject: [PATCH 2/2] Connect gallery preview to its Convex backend