From 7da90061c40a305736e25014fe2cca0eab28ea6e Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Mon, 30 Sep 2024 01:50:15 -0400 Subject: [PATCH] Update Clips APIs --- app/browser.tsx | 7 +- app/features/Developer/Console/Console.tsx | 6 +- app/features/Series/Series.tsx | 10 +- .../Series/graphql/SeriesQuery.graphql | 6 +- app/features/WatchThrough/WatchThrough.tsx | 22 +- .../WatchThroughDetailsFragment.graphql | 6 +- app/server/app.tsx | 4 +- app/server/graphql/resolvers/apps/App.ts | 7 + .../graphql/resolvers/apps/ClipsExtension.ts | 483 +++++++++----- .../graphql/resolvers/shared/resolvers.ts | 8 +- app/shared/clips.ts | 2 +- app/shared/clips/Clip/Clip.tsx | 60 +- app/shared/clips/Clip/ClipSettings.tsx | 30 +- .../InstalledClipExtensionFragment.graphql | 26 +- app/shared/clips/components/Button.tsx | 4 +- app/shared/clips/context.ts | 2 +- app/shared/clips/extension.ts | 28 +- .../graphql/ClipsExtensionFragment.graphql | 27 - .../ClipsExtensionPointFragment.graphql | 36 + app/shared/clips/manager.ts | 619 +++++++++++++----- app/shared/clips/react.tsx | 100 +-- graphql/main/apps.schema.graphql | 105 ++- ...atestClipsExtensionVersionMutation.graphql | 12 +- .../PushClipsExtensionMutation.graphql | 4 +- packages/cli/source/commands/push/push.ts | 95 ++- packages/thread-render/source/renderer.ts | 4 +- packages/thread-render/source/types.ts | 4 +- prisma/schema.prisma | 1 + 28 files changed, 1098 insertions(+), 620 deletions(-) delete mode 100644 app/shared/clips/graphql/ClipsExtensionFragment.graphql create mode 100644 app/shared/clips/graphql/ClipsExtensionPointFragment.graphql diff --git a/app/browser.tsx b/app/browser.tsx index 05775c9e..9501648f 100644 --- a/app/browser.tsx +++ b/app/browser.tsx @@ -15,7 +15,7 @@ import Env from 'quilt:module/env'; import type {AppContext} from '~/shared/context.ts'; import {SearchParam} from '~/global/auth.ts'; -import {createClipsManager} from '~/shared/clips.ts'; +import {ClipsManager} from '~/shared/clips.ts'; import App from './App.tsx'; import {EXTENSION_POINTS} from './clips.ts'; @@ -79,10 +79,7 @@ const context = { graphql: {cache: new GraphQLCache(), fetch: fetchGraphQL}, performance, clipsManager: user - ? createClipsManager( - {user, graphql: fetchGraphQL, router}, - EXTENSION_POINTS, - ) + ? new ClipsManager({user, graphql: fetchGraphQL, router}, EXTENSION_POINTS) : undefined, } satisfies AppContext; diff --git a/app/features/Developer/Console/Console.tsx b/app/features/Developer/Console/Console.tsx index 1c08ca7e..325caa41 100644 --- a/app/features/Developer/Console/Console.tsx +++ b/app/features/Developer/Console/Console.tsx @@ -16,7 +16,7 @@ import {Page} from '~/shared/page.ts'; import { useClipsManager, useLocalDevelopmentServerQuery, - type ClipsLocalDevelopmentServer, + type ClipsLocalDevelopmentServerType, } from '~/shared/clips.ts'; import developerConsoleQuery, { @@ -37,7 +37,7 @@ export default function Console() { ); } -function ConnectedConsole({server}: {server: ClipsLocalDevelopmentServer}) { +function ConnectedConsole({server}: {server: ClipsLocalDevelopmentServerType}) { const {data, loading} = useLocalDevelopmentServerQuery(developerConsoleQuery); usePerformanceNavigation({state: loading ? 'loading' : 'complete'}); @@ -119,7 +119,7 @@ function ExtensionBuildResult({ } } -function ConnectToConsole({server}: {server: ClipsLocalDevelopmentServer}) { +function ConnectToConsole({server}: {server: ClipsLocalDevelopmentServerType}) { usePerformanceNavigation({state: 'complete'}); const localUrl = useSignal(''); diff --git a/app/features/Series/Series.tsx b/app/features/Series/Series.tsx index 8428cb43..ee90dcbe 100644 --- a/app/features/Series/Series.tsx +++ b/app/features/Series/Series.tsx @@ -193,7 +193,7 @@ function SeriesWithData({ @@ -212,13 +212,13 @@ function SeriesWithData({ function AccessoryClips({ id, name, - installations, + clips, }: { id: string; name: string; - installations: SeriesQueryData.Series['clipsInstallations']; + clips: SeriesQueryData.Series['clipsToRender']; }) { - const accessoryClips = useClips('series.details.accessory', installations, { + const accessoryClips = useClips('series.details.accessory', clips, { id, name, }); @@ -228,7 +228,7 @@ function AccessoryClips({ return ( {accessoryClips.map((clip) => ( - + ))} ); diff --git a/app/features/Series/graphql/SeriesQuery.graphql b/app/features/Series/graphql/SeriesQuery.graphql index 32834a59..a1a0a306 100644 --- a/app/features/Series/graphql/SeriesQuery.graphql +++ b/app/features/Series/graphql/SeriesQuery.graphql @@ -1,4 +1,4 @@ -#import "../../../shared/clips/graphql/ClipsExtensionFragment.graphql" +#import "../../../shared/clips/graphql/ClipsExtensionPointFragment.graphql" query Series($id: ID, $handle: String) { series(id: $id, handle: $handle) { @@ -55,8 +55,8 @@ query Series($id: ID, $handle: String) { episode } } - clipsInstallations(target: "series.details.accessory") { - ...ClipsExtension + clipsToRender(target: "series.details.accessory") { + ...ClipsExtensionPoint } } } diff --git a/app/features/WatchThrough/WatchThrough.tsx b/app/features/WatchThrough/WatchThrough.tsx index fb655d85..eb26c175 100644 --- a/app/features/WatchThrough/WatchThrough.tsx +++ b/app/features/WatchThrough/WatchThrough.tsx @@ -127,7 +127,7 @@ function WatchThroughWithData({ settings, from, to, - clipsInstallations, + clipsToRender, } = watchThrough; const watchingSingleSeason = from.season === to.season; @@ -234,7 +234,7 @@ function WatchThroughWithData({ id={id} url={url} series={series} - installations={clipsInstallations} + clips={clipsToRender} currentWatch={nextEpisodeForm} /> @@ -1016,24 +1016,26 @@ function AccessoryClips({ id, url, series, - installations, + clips, currentWatch, }: Pick & { - installations: WatchThroughQueryData.WatchThrough['clipsInstallations']; + clips: WatchThroughQueryData.WatchThrough['clipsToRender']; currentWatch: Signal; }) { - const accessoryClips = useClips( - 'watch-through.details.accessory', - installations, - {id, url, seriesId: series.id, seriesName: series.name, currentWatch}, - ); + const accessoryClips = useClips('watch-through.details.accessory', clips, { + id, + url, + seriesId: series.id, + seriesName: series.name, + currentWatch, + }); if (accessoryClips.length === 0) return null; return ( {accessoryClips.map((clip) => ( - + ))} ); diff --git a/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql b/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql index b307a674..d8fa0b53 100644 --- a/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql +++ b/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql @@ -1,4 +1,4 @@ -#import "../../../shared/clips/graphql/ClipsExtensionFragment.graphql" +#import "../../../shared/clips/graphql/ClipsExtensionPointFragment.graphql" fragment WatchThroughDetails on WatchThrough { id @@ -129,7 +129,7 @@ fragment WatchThroughDetails on WatchThrough { settings { spoilerAvoidance } - clipsInstallations(target: "watch-through.details.accessory") { - ...ClipsExtension + clipsToRender(target: "watch-through.details.accessory") { + ...ClipsExtensionPoint } } diff --git a/app/server/app.tsx b/app/server/app.tsx index 17edf88f..4c1ecc3e 100644 --- a/app/server/app.tsx +++ b/app/server/app.tsx @@ -5,7 +5,7 @@ import {Router} from '@quilted/quilt/navigation'; import {BrowserAssets} from 'quilt:module/assets'; import type {AppContext} from '~/shared/context.ts'; -import {createClipsManager} from '~/shared/clips.ts'; +import {ClipsManager} from '~/shared/clips.ts'; import App from '../App.tsx'; import {EXTENSION_POINTS} from '../clips.ts'; @@ -62,7 +62,7 @@ export const handleApp: RequestHandler = async function handleApp(request) { router, graphql: {cache: new GraphQLCache(), fetch: fetchGraphQL}, clipsManager: user - ? createClipsManager( + ? new ClipsManager( {user, graphql: fetchGraphQL, router}, EXTENSION_POINTS, ) diff --git a/app/server/graphql/resolvers/apps/App.ts b/app/server/graphql/resolvers/apps/App.ts index d1ae208d..583b296a 100644 --- a/app/server/graphql/resolvers/apps/App.ts +++ b/app/server/graphql/resolvers/apps/App.ts @@ -250,6 +250,13 @@ export const AppInstallation = createResolverWithGid('AppInstallation', { async extensions({id}, _, {prisma}) { const installations = await prisma.clipsExtensionInstallation.findMany({ where: {appInstallationId: id}, + include: { + extension: { + include: { + activeVersion: true, + }, + }, + }, take: 50, // orderBy: {createAt, 'desc'}, }); diff --git a/app/server/graphql/resolvers/apps/ClipsExtension.ts b/app/server/graphql/resolvers/apps/ClipsExtension.ts index 075de144..a6d4fa7c 100644 --- a/app/server/graphql/resolvers/apps/ClipsExtension.ts +++ b/app/server/graphql/resolvers/apps/ClipsExtension.ts @@ -6,9 +6,11 @@ import type { ClipsExtensionInstallation as DatabaseClipsExtensionInstallation, } from '@prisma/client'; import Env from 'quilt:module/env'; +import type {ExtensionPoint} from '@watching/clips'; import {z} from 'zod'; import type { + ClipsExtensionBuildModuleInput, ClipsExtensionPointSupportInput, ClipsExtensionPointSupportConditionInput, CreateClipsInitialVersion, @@ -33,11 +35,22 @@ declare module '@quilted/quilt/env' { } } +type DatabaseClipsExtensionInstallationWithActiveVersion = + DatabaseClipsExtensionInstallation & { + extension: DatabaseClipsExtension & { + activeVersion: DatabaseClipsExtensionVersion | null; + }; + }; + declare module '../types' { export interface GraphQLValues { ClipsExtension: DatabaseClipsExtension; ClipsExtensionVersion: DatabaseClipsExtensionVersion; - ClipsExtensionInstallation: DatabaseClipsExtensionInstallation; + ClipsExtensionInstallation: DatabaseClipsExtensionInstallationWithActiveVersion; + ClipsExtensionPointInstallation: { + target: ExtensionPoint; + installation: DatabaseClipsExtensionInstallationWithActiveVersion; + }; } } @@ -51,7 +64,7 @@ export const Query = createQueryResolver({ }, include: { extension: { - select: {activeVersion: {select: {status: true, extends: true}}}, + include: {activeVersion: true}, }, }, take: 50, @@ -64,12 +77,20 @@ export const Query = createQueryResolver({ return filterInstallations(installations, { target, conditions: resolvedConditions, - }); + }).map(({installation}) => installation); }, - clipsInstallation(_, {id}, {prisma}) { - return prisma.clipsExtensionInstallation.findUniqueOrThrow({ - where: {id: fromGid(id).id}, - }); + async clipsInstallation(_, {id}, {prisma}) { + const installation = + await prisma.clipsExtensionInstallation.findUniqueOrThrow({ + where: {id: fromGid(id).id}, + include: { + extension: { + include: {activeVersion: true}, + }, + }, + }); + + return installation; }, }); @@ -133,7 +154,7 @@ export const Mutation = createMutationResolver({ }, async pushClipsExtension( _, - {id: extensionId, code, name, translations, extends: supports, settings}, + {id: extensionId, build, name, translations, extends: supports, settings}, {prisma, request}, ) { const id = fromGid(extensionId).id; @@ -148,8 +169,8 @@ export const Mutation = createMutationResolver({ }); const {version: versionInput} = await createStagedClipsVersion({ - code, id, + build, appId: extension.appId, extensionName: name ?? extension.name, translations, @@ -211,7 +232,7 @@ export const Mutation = createMutationResolver({ const extension = await prisma.clipsExtension.findUniqueOrThrow({ where: {id: fromGid(id).id}, include: { - activeVersion: {select: {extends: true}}, + activeVersion: true, }, }); @@ -248,6 +269,11 @@ export const Mutation = createMutationResolver({ settings: settings == null ? undefined : JSON.parse(settings), appInstallationId: appInstallation.id, }, + include: { + extension: { + include: {activeVersion: true}, + }, + }, }); return { @@ -281,20 +307,23 @@ export const Mutation = createMutationResolver({ select: {id: true, userId: true}, }); - const {extension, ...installation} = - await prisma.clipsExtensionInstallation.update({ - where: {id: installationDetails.id}, - include: {extension: true}, - data: - settings == null - ? {} - : { - settings: JSON.parse(settings), - }, - }); + const installation = await prisma.clipsExtensionInstallation.update({ + where: {id: installationDetails.id}, + include: { + extension: { + include: {activeVersion: true}, + }, + }, + data: + settings == null + ? {} + : { + settings: JSON.parse(settings), + }, + }); return { - extension, + extension: installation.extension, installation, }; }, @@ -335,7 +364,6 @@ export const ClipsExtensionVersion = createResolverWithGid( prisma.clipsExtension.findUniqueOrThrow({ where: {id: extensionId}, }), - assets: ({scriptUrl}) => (scriptUrl ? [{source: scriptUrl}] : []), translations: ({translations}) => translations ? JSON.stringify(translations) : null, // Database needs to store the exact right shapes in its JSON fields @@ -395,63 +423,125 @@ export const ClipsExtensionInstallation = createResolverWithGid( prisma.appInstallation.findUniqueOrThrow({ where: {id: appInstallationId}, }), - async version({extensionId}, _, {prisma}) { - const extension = await prisma.clipsExtension.findUniqueOrThrow({ - where: {id: extensionId}, - select: {activeVersion: true}, - }); - + async version({extension}) { return extension.activeVersion!; }, - async translations({extensionId}, _, {prisma}) { - const extension = await prisma.clipsExtension.findUniqueOrThrow({ - where: {id: extensionId}, - select: {activeVersion: true}, - }); - + async translations({extension}) { const translations = extension.activeVersion?.translations; return translations ? JSON.stringify(translations) : null; }, - async liveQuery({target, extensionId}, _, {prisma}) { - const extension = await prisma.clipsExtension.findUniqueOrThrow({ - where: {id: extensionId}, - select: {activeVersion: true}, - }); - - const extend = (extension.activeVersion?.extends ?? []) as any[]; + settings: ({settings}) => (settings ? JSON.stringify(settings) : null), + }, +); - return extend.find((extend) => extend.target === target)?.liveQuery; +export const ClipsExtensionPointInstallation = createResolverWithGid( + 'ClipsExtensionPointInstallation', + { + id: ({target, installation}) => + toGid(`${installation.id}/${target}`, 'ClipsExtensionPointInstallation'), + target: ({target}) => target, + apiVersion: ({installation}) => + installation.extension.activeVersion!.apiVersion, + entry({target, installation}) { + const {entry} = getExtensionPoint(target, installation); + const module = getExtensionBuildModule(entry.module, installation); + + switch (module.contentType) { + case 'JAVASCRIPT': + return { + asHTML: null, + asJavaScript: { + src: module.src!, + }, + }; + case 'HTML': + return { + asHTML: { + content: module.content, + }, + asJavaScript: null, + }; + default: + throw new Error(`Unsupported content type: ${module.contentType}`); + } }, - async loading({target, extensionId}, _, {prisma}) { - const extension = await prisma.clipsExtension.findUniqueOrThrow({ - where: {id: extensionId}, - select: {activeVersion: true}, - }); + liveQuery({target, installation}) { + const {liveQuery} = getExtensionPoint(target, installation); - const extend = (extension.activeVersion?.extends ?? []) as any[]; + if (liveQuery == null) return null; - const loading = extend.find((extend) => extend.target === target) - ?.loading; + const module = getExtensionBuildModule(liveQuery.module, installation); + + return { + content: module.content, + }; + }, + loading({target, installation}) { + const {loading} = getExtensionPoint(target, installation); if (loading == null) return null; + const module = getExtensionBuildModule(loading.module, installation); + return { - ui: loading?.ui - ? { - html: loading.ui, - } - : null, + content: module.content, }; }, - settings: ({settings}) => (settings ? JSON.stringify(settings) : null), + settings: ({installation}) => + installation.extension.activeVersion?.settings + ? JSON.stringify(installation.extension.activeVersion.settings) + : null, + translations: ({installation}) => + installation.extension.activeVersion?.translations + ? JSON.stringify(installation.extension.activeVersion.translations) + : null, + extension: ({installation}) => installation.extension, + extensionInstallation: ({installation}) => installation, + appInstallation: ({installation}, _, {prisma}) => + prisma.appInstallation.findUniqueOrThrow({ + where: {id: installation.appInstallationId}, + }), }, ); +function getExtensionPoint( + target: string, + installation: DatabaseClipsExtensionInstallationWithActiveVersion, +) { + const extend = installation.extension.activeVersion + ?.extends as any as ClipsExtensionPointSupportDatabaseJSON[]; + const extensionPoint = extend?.find((extend) => extend.target === target); + + if (extensionPoint == null) { + throw new Error(`Extension point not found: ${target}`); + } + + return extensionPoint; +} + +function getExtensionBuildModule( + name: string, + installation: DatabaseClipsExtensionInstallationWithActiveVersion, +) { + const modules = ( + installation.extension.activeVersion + ?.build as any as ClipsExtensionBuildDatabaseJSON + )?.modules; + + const module = modules?.find((module) => module.name === name); + + if (module == null) { + throw new Error(`Unknown module: ${name}`); + } + + return module; +} + const SeriesExtensionPoint = z.enum(['series.details.accessory']); export const Series = createResolver('Series', { - async clipsInstallations(series, {target}, {user, prisma}) { + async clipsToRender(series, {target}, {user, prisma}) { if (!SeriesExtensionPoint.safeParse(target).success) { throw new Error(`Invalid target: ${target}`); } @@ -464,7 +554,7 @@ export const Series = createResolver('Series', { }, include: { extension: { - select: {activeVersion: {select: {status: true, extends: true}}}, + include: {activeVersion: true}, }, }, take: 50, @@ -487,7 +577,7 @@ export const Series = createResolver('Series', { const WatchThroughExtensionPoint = z.enum(['watch-through.details.accessory']); export const WatchThrough = createResolver('WatchThrough', { - async clipsInstallations({seriesId}, {target}, {user, prisma}) { + async clipsToRender({seriesId}, {target}, {user, prisma}) { if (!WatchThroughExtensionPoint.safeParse(target).success) { throw new Error(`Invalid target: ${target}`); } @@ -505,7 +595,7 @@ export const WatchThrough = createResolver('WatchThrough', { }, include: { extension: { - select: {activeVersion: {select: {status: true, extends: true}}}, + include: {activeVersion: true}, }, }, take: 50, @@ -550,8 +640,64 @@ async function parseLoadingUI(loadingUI: string) { return validateAndNormalizeLoadingUI(loadingUI); } +interface ClipsExtensionBuildDatabaseJSON { + modules: ClipsExtensionBuildModuleDatabaseJSON[]; +} + +interface ClipsExtensionBuildModuleDatabaseJSON + extends ClipsExtensionBuildModuleInput { + src?: string; +} + +interface ClipsExtensionPointSupportDatabaseJSON + extends ClipsExtensionPointSupportInput { + target: ExtensionPoint; +} + +// const ClipsExtensionBuildModuleInputSchema = z.object({ +// name: z.string(), +// content: z.string(), +// contentType: z.enum(['JAVASCRIPT', 'HTML', 'GRAPHQL']), +// }); + +// const ClipsExtensionBuildModuleDatabaseSchema = +// ClipsExtensionBuildModuleInputSchema.extend({ +// src: z.string().optional(), +// }); + +// const ClipsExtensionBuildDatabaseSchema = z.object({ +// modules: z.array(ClipsExtensionBuildModuleDatabaseSchema), +// }); + +// const ClipsExtensionPointSupportConditionInputSchema = z.object({ +// series: z +// .object({ +// handle: z.string().optional(), +// }) +// .optional(), +// }); + +// const ClipsExtensionPointModuleDatabaseSchema = z.object({ +// module: z.string(), +// }); + +// const ClipsExtensionPointSchema = z.enum([ +// 'series.details.accessory', +// 'watch-through.details.accessory', +// ]); + +// const ClipsExtensionPointSupportDatabaseSchema = z.object({ +// target: ClipsExtensionPointSchema, +// conditions: z +// .array(ClipsExtensionPointSupportConditionInputSchema) +// .optional(), +// entry: ClipsExtensionPointModuleDatabaseSchema, +// liveQuery: ClipsExtensionPointModuleDatabaseSchema.optional(), +// loading: ClipsExtensionPointModuleDatabaseSchema.optional(), +// }); + async function createStagedClipsVersion({ - code, + build, appId, extensionName, translations, @@ -560,97 +706,128 @@ async function createStagedClipsVersion({ settings, }: { id: string; - code: string; appId: string; extensionName: string; } & Pick & CreateClipsInitialVersion) { - const hash = createHash('sha256').update(code).digest('hex'); - const path = `assets/clips/${appId}/${toParam(extensionName)}.${hash}.js`; - - const token = await createSignedToken( - {path, code}, - { - secret: Env.UPLOAD_CLIPS_JWT_SECRET, - expiresIn: '5m', - }, - ); + const modules = new Map(); + + for (const buildModule of build.modules) { + if (buildModule.contentType === 'JAVASCRIPT') { + if (buildModule.name !== '_extension.js') continue; + modules.set(buildModule.name, buildModule); + } + + if (buildModule.content.length > 10_000) { + throw new Error( + `Clip module content for ${buildModule.name} is too long`, + ); + } + + modules.set(buildModule.name, buildModule); + } + + const javascriptModule = modules.get('_extension.js'); - const putResult = await fetch( - new URL('/internal/upload/clips', request.url), - { - method: 'PUT', - body: token, - headers: { - 'Content-Type': 'application/json', + if (javascriptModule) { + const code = javascriptModule.content; + + const hash = createHash('sha256').update(code).digest('hex'); + const path = `assets/clips/${appId}/${toParam(extensionName)}.${hash.slice( + 0, + 8, + )}.js`; + + const token = await createSignedToken( + {path, code}, + { + secret: Env.UPLOAD_CLIPS_JWT_SECRET, + expiresIn: '5m', }, - // @see https://github.com/nodejs/node/issues/46221 - ...{duplex: 'half'}, - }, - ); + ); + + const putResult = await fetch( + new URL('/internal/upload/clips', request.url), + { + method: 'PUT', + body: token, + headers: { + 'Content-Type': 'application/json', + }, + // @see https://github.com/nodejs/node/issues/46221 + ...{duplex: 'half'}, + }, + ); + + if (!putResult.ok) { + throw new Error(`Could not upload clips: '${await putResult.text()}'`); + } - if (!putResult.ok) { - throw new Error(`Could not upload clips: '${await putResult.text()}'`); + javascriptModule.content = ''; + javascriptModule.src = `https://watch.lemon.tools/${path}`; } + const extensionPoints = await Promise.all( + (supports ?? []).map( + async ({target, entry, liveQuery, loading, conditions}) => { + if (!modules.has(entry.module)) { + throw new Error(`Unknown entry module: ${entry.module}`); + } + + if (liveQuery) { + const module = modules.get(liveQuery.module); + + if (module == null) { + throw new Error(`Unknown live query module: ${liveQuery.module}`); + } + + if (module.contentType !== 'GRAPHQL') { + throw new Error( + `Unsupported content type for live query: ${module.contentType}`, + ); + } + + // TODO: what if multiple targets use the same live query? + module.content = await parseLiveQuery(module.content); + } + + if (loading) { + const module = modules.get(loading.module); + + if (module == null) { + throw new Error(`Unknown loading module: ${loading.module}`); + } + + module.content = await parseLoadingUI(module.content); + } + + return { + target: await parseExtensionPointTarget(target), + entry, + liveQuery, + loading, + conditions: conditions?.map((condition) => { + if (condition?.series?.handle == null) { + throw new Error(`Unknown condition: ${condition}`); + } + + return condition; + }), + } satisfies ClipsExtensionPointSupportDatabaseJSON; + }, + ), + ); + const version: Omit< import('@prisma/client').Prisma.ClipsExtensionVersionCreateWithoutExtensionInput, 'status' > = { - scriptUrl: `https://watch.lemon.tools/${path}`, apiVersion: 'UNSTABLE', translations: translations && JSON.parse(translations), - extends: supports - ? await Promise.all( - supports.map( - async ({target, modules, liveQuery, loading, conditions}) => { - const [ - parsedTarget, - parsedModules, - parsedLiveQuery, - parsedLoadingUI, - ] = await Promise.all([ - parseExtensionPointTarget(target), - Promise.all( - (modules ?? []).map( - async ({content, contentType = 'HTML'}) => { - if (content.length > 10_000) { - throw new Error( - `Clip module content for ${target} is too long`, - ); - } - - // TODO: validate HTML content - - return {content, contentType}; - }, - ), - ), - liveQuery ? parseLiveQuery(liveQuery) : undefined, - loading?.ui ? parseLoadingUI(loading.ui) : undefined, - ]); - - return { - target: parsedTarget, - modules: parsedModules, - liveQuery: parsedLiveQuery, - loading: parsedLoadingUI - ? { - ui: parsedLoadingUI, - } - : undefined, - conditions: conditions?.map((condition) => { - if (condition?.series?.handle == null) { - throw new Error(`Unknown condition: ${condition}`); - } - - return condition; - }), - }; - }, - ) as any, - ) - : [], + build: { + modules: Array.from(modules.values()), + } satisfies ClipsExtensionBuildDatabaseJSON as any, + extends: extensionPoints as any, settings: settings?.fields ? { fields: settings.fields?.map( @@ -768,14 +945,7 @@ async function resolveConditions( } function filterInstallations( - installations: (DatabaseClipsExtensionInstallation & { - extension: { - activeVersion: Pick< - DatabaseClipsExtensionVersion, - 'status' | 'extends' - > | null; - }; - })[], + installations: DatabaseClipsExtensionInstallationWithActiveVersion[], { target, conditions, @@ -784,20 +954,19 @@ function filterInstallations( conditions: ResolvedCondition[]; }, ) { - return installations.filter((installation) => { + return installations.flatMap<{ + target: ExtensionPoint; + installation: DatabaseClipsExtensionInstallationWithActiveVersion; + }>((installation) => { const version = installation.extension.activeVersion; if (version == null || version.status !== 'PUBLISHED') { - return false; + return []; } - const extensionPoints = - (version.extends as { - target: string; - conditions?: {series?: {handle?: string}}[]; - }[]) ?? []; - - return extensionPoints.some((extensionPoint) => { + const matches = ( + version.extends as unknown as ClipsExtensionPointSupportDatabaseJSON[] + ).some((extensionPoint) => { if (target != null && target !== extensionPoint.target) { return false; } @@ -817,5 +986,9 @@ function filterInstallations( ); }); }); + + return matches + ? {target: installation.target as ExtensionPoint, installation} + : []; }); } diff --git a/app/server/graphql/resolvers/shared/resolvers.ts b/app/server/graphql/resolvers/shared/resolvers.ts index 297ba330..72ca016e 100644 --- a/app/server/graphql/resolvers/shared/resolvers.ts +++ b/app/server/graphql/resolvers/shared/resolvers.ts @@ -32,8 +32,8 @@ export {createResolver, createQueryResolver, createMutationResolver}; export function createResolverWithGid< Type extends keyof Resolvers, - Fields extends keyof Resolvers[Type], ->(type: Type, resolver: Required>) { + Resolver extends Partial, +>(type: Type, resolver: Resolver): Resolver & {id: (arg: any) => string} { return {id: ({id}: {id: string}) => toGid(id, type), ...resolver}; } @@ -51,10 +51,10 @@ export function createUnionResolver(): UnionResolver { const RESOLVED_TYPE = Symbol.for('watch.resolved-type'); -export function addResolvedType(type: string, object: T) { +export function addResolvedType(type: string, object: T): T { return {...object, [RESOLVED_TYPE]: type}; } -function resolveType(obj: {[RESOLVED_TYPE]?: string; id: string}) { +function resolveType(obj: any) { return obj[RESOLVED_TYPE] ?? fromGid(obj.id).type; } diff --git a/app/shared/clips.ts b/app/shared/clips.ts index 9c00c511..0aefd46f 100644 --- a/app/shared/clips.ts +++ b/app/shared/clips.ts @@ -8,4 +8,4 @@ export { export * from './clips/manager.ts'; export * from './clips/local-development.tsx'; export * from './clips/extension-points'; -export {type ClipsExtensionFragmentData as InstalledClipsExtensionPointFragment} from './clips/graphql/ClipsExtensionFragment.graphql'; +export {type ClipsExtensionPointFragmentData as InstalledClipsExtensionPointFragment} from './clips/graphql/ClipsExtensionPointFragment.graphql'; diff --git a/app/shared/clips/Clip/Clip.tsx b/app/shared/clips/Clip/Clip.tsx index 3937394e..c552c9e7 100644 --- a/app/shared/clips/Clip/Clip.tsx +++ b/app/shared/clips/Clip/Clip.tsx @@ -20,11 +20,13 @@ import type {ThreadRendererInstance} from '@watching/thread-render'; import {useGraphQLMutation} from '~/shared/graphql.ts'; -import {ClipsExtensionPointBeingRenderedContext} from '../context.ts'; -import {useClipsManager} from '../react.tsx'; import { + ClipsExtensionPointBeingRenderedContext, + useClipsExtensionPointBeingRendered, +} from '../context.ts'; +import { + ClipsExtensionPointRenderer, type ClipsExtensionPoint, - type ClipsExtensionPointInstance, type ClipsExtensionPointInstanceContext, } from '../extension.ts'; @@ -35,43 +37,33 @@ import styles from './Clip.module.css'; import uninstallClipsExtensionFromClipMutation from './graphql/UninstallClipsExtensionFromClipMutation.graphql'; export interface ClipProps { - extension: ClipsExtensionPoint; + extensionPoint: ClipsExtensionPoint; } export function Clip({ - extension, + extensionPoint, }: ClipProps) { - const {name, app} = extension.extension; - - const manager = useClipsManager(); - const installed = - extension.installed && manager.fetchInstance(extension.installed); - const local = extension.local && manager.fetchInstance(extension.local); - - const renderer = local ?? installed; + const {name, app} = extensionPoint.extension; + const renderer = + extensionPoint.installed?.renderer ?? extensionPoint.local?.renderer; return ( - +
- {installed?.instance.value && ( + {extensionPoint.installed && (
- +
)} - {renderer && } - {extension.installed && ( - - )} - {extension.installed && } + {renderer && } + {extensionPoint.installed && } + {extensionPoint.installed && } } @@ -118,27 +110,25 @@ function ViewAppAction() { } function RestartClipButton({ - instance, + renderer, }: { - instance: ClipsExtensionPointInstance; + renderer: ClipsExtensionPointRenderer; }) { return ( - ); } -function UninstallClipButton({ - extension, -}: { - extension: ClipsExtensionPoint; -}) { +function UninstallClipButton() { + const extensionPoint = useClipsExtensionPointBeingRendered(); + const uninstallClipsExtensionFromClip = useGraphQLMutation( uninstallClipsExtensionFromClipMutation, ); - const {id} = extension.extension; + const {id} = extensionPoint.installation; return (