- 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++
}
}