diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index 12cfb8cd08..cbe0554be8 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -287,8 +287,9 @@ {/if} Resource types the selected items depend on (whether passed as inputs or - referenced by a hardcoded path). Synced to the Hub so a fork knows what - credentials it needs to fill. + referenced by a hardcoded path). A stub resource of each type is synced to the + Hub so a fork knows what credentials it needs to fill. Publishing a type's own + definition is opt-in — tick it in the details drawer. {#if s.dependencyTypes.length === 0} @@ -303,6 +304,9 @@ : 'bg-surface'}" > {r.resource_type} + {#if s.exportedResourceTypes.has(r.resource_type)} + exported + {/if} {/each} {/if} @@ -830,12 +836,19 @@ resourceDrawer?.closeDrawer()}>

- Resource types the selected items depend on. Each is synced to the Hub so a fork knows - what credentials it needs to fill. Input means the - item takes the resource as a parameter; + Resource types the selected items depend on. A stub resource of each type is synced to + the Hub so a fork knows what credentials it needs to fill. + Input means the item takes the resource as a + parameter; hardcoded path means the item pins a specific resource path in its code.

+

+ Publishing a type's own definition (its schema and + description) is a separate, explicit choice: tick + Export type definition only for custom types the Hub doesn't + already know. Standard types are already defined on the Hub and need no export. +

{#if s.dependencyTypes.length === 0} No resource references in the current selection. {:else} @@ -852,6 +865,19 @@ {r.usages.length} usage{r.usages.length > 1 ? 's' : ''} +
+ s.toggleResourceTypeExport(r.resource_type)} + options={{ + right: 'Export type definition', + rightTooltip: + 'Publishes this resource type (name, schema, description) to the Hub project. Leave off if the Hub already defines it.' + }} + /> +
{#each r.usages as u, ui (ui)} diff --git a/frontend/src/lib/components/workspaceSettings/deployToHubItems.test.ts b/frontend/src/lib/components/workspaceSettings/deployToHubItems.test.ts index 07aa21abc3..5ec8eed841 100644 --- a/frontend/src/lib/components/workspaceSettings/deployToHubItems.test.ts +++ b/frontend/src/lib/components/workspaceSettings/deployToHubItems.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { canRecordSession, mergeAppTableOrigin, type DeployItem } from './deployToHubItems' +import { + canRecordSession, + inputResourceTypes, + mergeAppTableOrigin, + type DeployItem +} from './deployToHubItems' function item(over: Partial & Pick): DeployItem { return { rec: 'none', ...over } @@ -34,3 +39,26 @@ describe('mergeAppTableOrigin', () => { ).toBe(orphan) }) }) + +describe('inputResourceTypes', () => { + const schema = { + properties: { + db: { format: 'resource-postgresql' }, + file: { format: 'resource-s3_object' }, + typo: { format: 'resource-postgres' }, + theme: { format: 'resource-app_theme' }, + name: { format: 'email' } + } + } + it('keeps only formats the workspace declares as a resource type', () => { + expect(inputResourceTypes(schema, new Set(['postgresql', 'stripe']))).toEqual(['postgresql']) + }) + // Undefined (still loading) and empty (a workspace that never synced the Hub's + // types) are both "no catalog" — validating would drop every legitimate type. + // `s3_object` is never a resource type, so it stays out even here. + it('falls back to every non-hidden format without a type catalog', () => { + const all = ['postgresql', 'postgres'] + expect(inputResourceTypes(schema, undefined)).toEqual(all) + expect(inputResourceTypes(schema, new Set())).toEqual(all) + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/deployToHubItems.ts b/frontend/src/lib/components/workspaceSettings/deployToHubItems.ts index e4e85da5a1..5c018bcc96 100644 --- a/frontend/src/lib/components/workspaceSettings/deployToHubItems.ts +++ b/frontend/src/lib/components/workspaceSettings/deployToHubItems.ts @@ -18,6 +18,43 @@ export interface DeployItem { export const canRecord = (k: Kind) => k === 'script' || k === 'flow' +// `s3_object` is a built-in file-picker format, never a resource type (it is not +// on the Hub's type list), so it is excluded even when there is no catalog to +// validate against. +export const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache', 's3_object']) + +/** + * Resource types an item takes as an input, read off its schema's + * `resource-` arg formats. + * + * A `resource-` format is not a type declaration: stale and misspelled ones + * pass through unchanged. Publishing those to the Hub would push an empty-schema + * type and a stub resource nothing can ever fill, so an input-derived type only + * counts once the workspace declares it — the same bar `ArgInput` applies before + * rendering an arg as a resource picker. + * + * `known` is only authoritative once it holds something: undefined (still + * loading, or the call failed) and empty (a workspace whose type catalog was + * never synced from the Hub) both mean "no catalog to validate against", and + * filtering on one would drop every legitimate type instead. + */ +export function inputResourceTypes(schema: unknown, known: Set | undefined): string[] { + const validate = known !== undefined && known.size > 0 + const out = new Set() + const props = (schema as any)?.properties + if (props && typeof props === 'object') { + for (const key of Object.keys(props)) { + const fmt = props[key]?.format + if (typeof fmt !== 'string' || !fmt.startsWith('resource-')) continue + const type = fmt.slice('resource-'.length) + if (HIDDEN_RESOURCE_TYPES.has(type)) continue + if (validate && !known.has(type)) continue + out.add(type) + } + } + return [...out] +} + // A raw app has no run to capture: its demo is a recorded session of someone // using it, driven in the record drawer and replayed on the Hub page. Legacy raw // apps live only in the `raw_app` table, and the record surface loads the app diff --git a/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts b/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts index ab1adbfdea..521989262a 100644 --- a/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts +++ b/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts @@ -38,7 +38,9 @@ import type { Kind } from '$lib/utils_deployable' import { canRecord, canRecordSession, + inputResourceTypes, mergeAppTableOrigin, + HIDDEN_RESOURCE_TYPES, type DeployItem } from './deployToHubItems' import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types' @@ -92,8 +94,6 @@ const ITEM_KIND_ROUTE: Record = { raw_app: 'apps_raw/get' } -const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache']) - // Prune a folder's asset graph to a set of scripts so a pipeline recording only // runs, renders and samples the project's included members — a deselected branch // (its nodes, code, logs/results and table samples) never enters the recording. @@ -114,20 +114,6 @@ function pruneGraphToScripts(graph: AssetGraphResponse, scripts: Set): A return { assets, runnables, edges, triggers, macro_edges, test_edges } } -function typesFromSchema(schema: any): string[] { - const out = new Set() - const props = schema?.properties - if (props && typeof props === 'object') { - for (const key of Object.keys(props)) { - const fmt = props[key]?.format - if (typeof fmt === 'string' && fmt.startsWith('resource-')) { - out.add(fmt.slice('resource-'.length)) - } - } - } - return [...out] -} - type DependencyUsage = | { role: 'input'; label: string; kind: ItemKind; itemPath: string } | { role: 'hardcoded'; label: string; kind: ItemKind; path: string; itemPath: string } @@ -219,6 +205,14 @@ export class DeployToHubSession { // they pick up the fresh SQL (Monaco doesn't sync external `code` changes). migrationsGeneration = $state(0) + // Resource type names declared by the workspace, used to tell a real type from + // an arbitrary `resource-` arg format. Stays undefined until the list loads. + resourceTypeNames = $state | undefined>(undefined) + // Resource types the user explicitly opted into publishing. Opt-in, never + // derived from the selection: exporting a type definition to the Hub is a + // deliberate act, so the default is to export none. + exportedResourceTypes = $state>(new Set()) + bundlePreview = $state(undefined) detectingResources = $state(false) // Data tables (→ tables) the current selection reads/writes, detected off the @@ -257,11 +251,26 @@ export class DeployToHubSession { load() { void this.#loadWorkspace() + void this.#loadResourceTypeNames() void this.#loadTriggers() void this.rehydrateFromHub() void this.#loadPipelineGraph() } + // Deliberately not `resourceTypesStore.getResourceTypes()`: its error path + // resolves to a non-empty `['error_fetching_names']`, which here would read as + // a real catalog and filter out every legitimate type. A failure must leave the + // catalog unset so validation stays off. + async #loadResourceTypeNames() { + try { + const names = await ResourceService.listResourceTypeNames({ workspace: this.workspace }) + if (this.#disposed) return + this.resourceTypeNames = new Set(names) + } catch (e: any) { + console.error('failed to load resource type names, resource type validation is off', e) + } + } + filteredWorkspaceItems = $derived( this.workspaceItems.filter((i) => i.path.startsWith(this.selectedFolder + '/')) ) @@ -372,8 +381,7 @@ export class DeployToHubSession { itemPath: it.path }) } - for (const t of typesFromSchema(it.schema)) { - if (HIDDEN_RESOURCE_TYPES.has(t)) continue + for (const t of inputResourceTypes(it.schema, this.resourceTypeNames)) { ensure(t).usages.push({ role: 'input', label, kind: it.kind, itemPath: it.path }) } } @@ -400,6 +408,22 @@ export class DeployToHubSession { return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type)) }) + // Only the types the user ticked, restricted to what the current selection + // actually depends on — deselecting the last item that used a type drops it + // from the export without the user having to untick it. + exportedDependencyTypes = $derived( + this.dependencyTypes + .map((d) => d.resource_type) + .filter((rt) => this.exportedResourceTypes.has(rt)) + ) + + toggleResourceTypeExport = (resource_type: string) => { + const next = new Set(this.exportedResourceTypes) + if (next.has(resource_type)) next.delete(resource_type) + else next.add(resource_type) + this.exportedResourceTypes = next + } + toggleItem = (item: { key: string }) => { const next = new Set(this.manualDeselected) if (next.has(item.key)) next.delete(item.key) @@ -827,9 +851,13 @@ export class DeployToHubSession { if (this.deploying || this.triggersLoading || this.triggerDiscoveryFailed) return this.deploying = true try { + // Captured at click time rather than read in #deployAll, which only runs + // after the draft request resolves: what gets published must be what was + // ticked on confirmation, whatever mutates `exportedResourceTypes` after. + const exportedTypes = new Set(this.exportedResourceTypes) if (!(await this.#createDraft())) return onDraftCreated?.() - await this.#deployAll() + await this.#deployAll(exportedTypes) } finally { this.deploying = false } @@ -1048,7 +1076,7 @@ export class DeployToHubSession { return results.reduce((a: number, b) => a + b, 0) } - async #deployAll() { + async #deployAll(exportedTypes: Set) { const slug = this.hubSlug // Snapshot the selection up-front: `selectedItems`/`relevantTriggers` are // derived from live workspace data and `migrationDrafts` is edited in the @@ -1096,14 +1124,22 @@ export class DeployToHubSession { // was replaced (workspace/folder switch) in the meantime. if (this.#disposed) return - // Types come from $res: stubs AND schema inputs (resource-). - const inputTypes = bundle.items - .flatMap((i) => typesFromSchema(i.schema)) - .filter((t) => !HIDDEN_RESOURCE_TYPES.has(t)) + // Types come from $res: stubs AND schema inputs (resource-). A stub's + // type is declared by an existing resource, so it needs no validation; an + // input's is a free-form format string, so it does. + const inputTypes = bundle.items.flatMap((i) => + inputResourceTypes(i.schema, this.resourceTypeNames) + ) const types = [ ...new Set([...bundle.resourceStubs.map((s) => s.resource_type), ...inputTypes]) ] - const depFailures = await this.#pushResourceTypes(slug, types) + // Only the types the user ticked are published. The others still get their + // stub below, so a fork knows which credential to fill; only the type + // definition itself stays out of the Hub. + const depFailures = await this.#pushResourceTypes( + slug, + types.filter((t) => exportedTypes.has(t)) + ) // Input-type deps with no path get a conventional f// stub. const stubsByPath = new Map() @@ -1185,7 +1221,10 @@ export class DeployToHubSession { this.hubHasRemoteLogo = this.hubLogo !== null this.hubLogo = undefined } catch (e: any) { - sendUserToast(`Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`, true) + sendUserToast( + `Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`, + true + ) failures++ } }