feat: validate and make explicit the hub project's resource type export (#10388)

* feat: make hub resource type export explicit and validated

* fix: skip resource type validation when the workspace has no type catalog

* fix: snapshot the export opt-in and exclude s3_object unconditionally

* fix: capture the export opt-in at publish click, not after the draft request

* docs: correct the publish-snapshot rationale
This commit is contained in:
Ruben Fiszel
2026-07-28 18:30:55 +02:00
committed by GitHub
parent faa2aaf214
commit 91b8ce581a
4 changed files with 163 additions and 33 deletions
@@ -287,8 +287,9 @@
{/if}
<Tooltip>
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.
</Tooltip>
</span>
{#if s.dependencyTypes.length === 0}
@@ -303,6 +304,9 @@
: 'bg-surface'}"
>
{r.resource_type}
{#if s.exportedResourceTypes.has(r.resource_type)}
<Badge color="blue" size="xs">exported</Badge>
{/if}
</span>
{/each}
<Button
@@ -311,7 +315,9 @@
wrapperClasses="ml-auto"
onclick={() => resourceDrawer?.openDrawer()}
>
View details
{s.exportedDependencyTypes.length > 0
? `View details (${s.exportedDependencyTypes.length} type definition${s.exportedDependencyTypes.length > 1 ? 's' : ''} exported)`
: 'View details'}
</Button>
{/if}
</div>
@@ -830,12 +836,19 @@
<DrawerContent title="Resource dependencies" on:close={() => resourceDrawer?.closeDrawer()}>
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
Resource types the selected items depend on. Each is synced to the Hub so a fork knows
what credentials it needs to fill. <span class="font-semibold">Input</span> 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.
<span class="font-semibold">Input</span> means the item takes the resource as a
parameter;
<span class="font-semibold">hardcoded path</span> means the item pins a specific resource
path in its code.
</p>
<p class="text-xs text-secondary">
Publishing a type's own <span class="font-semibold">definition</span> (its schema and
description) is a separate, explicit choice: tick
<span class="font-semibold">Export type definition</span> only for custom types the Hub doesn't
already know. Standard types are already defined on the Hub and need no export.
</p>
{#if s.dependencyTypes.length === 0}
<span class="text-xs text-hint">No resource references in the current selection.</span>
{:else}
@@ -852,6 +865,19 @@
<span class="text-[11px] text-hint">
{r.usages.length} usage{r.usages.length > 1 ? 's' : ''}
</span>
<div class="ml-auto shrink-0">
<Toggle
size="xs"
checked={s.exportedResourceTypes.has(r.resource_type)}
disabled={s.deploying}
on:change={() => 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.'
}}
/>
</div>
</div>
<div class="flex flex-col gap-3">
{#each r.usages as u, ui (ui)}
@@ -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<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): 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)
})
})
@@ -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-<type>` arg formats.
*
* A `resource-<x>` 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<string> | undefined): string[] {
const validate = known !== undefined && known.size > 0
const out = new Set<string>()
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
@@ -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<ItemKind, string> = {
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<string>): A
return { assets, runnables, edges, triggers, macro_edges, test_edges }
}
function typesFromSchema(schema: any): string[] {
const out = new Set<string>()
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-<x>` arg format. Stays undefined until the list loads.
resourceTypeNames = $state<Set<string> | 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<Set<string>>(new Set())
bundlePreview = $state<ProjectBundle | undefined>(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<string>) {
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-<type>).
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-<type>). 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/<slug>/<type> stub.
const stubsByPath = new Map<string, { path: string; resource_type: string }>()
@@ -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++
}
}