feat(frontend): load and list never-deployed drafts from the draft table

With draft_only stub rows gone, never-deployed items live only in the draft
table. Make them reachable again:
- Editor pages (scripts/flows/apps/raw-apps): when getXByPathWithDraft 404s,
  fall back to DraftService.getDraft and seed the editor from the draft value
  (no deployed version → savedX has no hash, so deploy creates).
- Home ItemsList: fetch DraftService.listDrafts and surface drafts that have no
  deployed counterpart as draft-only rows linking to the editor.
- Add a userDraft / UserDraftDbService unit test (in-memory store, debounced
  persistence, kind→typ mapping, delete-on-undefined, empty-path skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-03 17:09:23 +02:00
co-authored by Claude Opus 4.8
parent 71db1caa23
commit 3e1250cbe3
9 changed files with 433 additions and 57 deletions
@@ -36,7 +36,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
app: ListableApp & { has_draft?: boolean; canWrite: boolean }
app: ListableApp & { has_draft?: boolean; canWrite: boolean; draft_only?: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -79,7 +79,9 @@
{/if}
<Row
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
href={app.draft_only
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
kind="app"
{marked}
path={app.path}
@@ -39,7 +39,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
flow: Flow & { has_draft?: boolean; canWrite: boolean }
flow: Flow & { has_draft?: boolean; canWrite: boolean; draft_only?: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -103,7 +103,9 @@
<Row
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
href={`${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
href={flow.draft_only
? `${base}/flows/edit/${flow.path}`
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{marked}
@@ -51,7 +51,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
script: Script & { canWrite: boolean; use_codebase: boolean }
script: Script & { canWrite: boolean; use_codebase: boolean; draft_only?: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -121,7 +121,7 @@
<Row
aiId={`script-run-button-${script.path}`}
aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`}
href={script.auto_kind === 'lib' && script.kind !== 'preprocessor'
href={script.draft_only || (script.auto_kind === 'lib' && script.kind !== 'preprocessor')
? `${base}/scripts/edit/${script.path}`
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
@@ -9,7 +9,8 @@
type Script,
ScriptService,
type Flow,
type ListableRawApp
type ListableRawApp,
DraftService
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import type uFuzzy from '@leeoniya/ufuzzy'
@@ -63,6 +64,9 @@
starred?: boolean
has_draft?: boolean
hash?: string
/** Set on synthetic items that exist only in the draft table (never
* deployed). Their rows link to the editor instead of a get/ page. */
draft_only?: boolean
}
type TableScript = TableItem<Script, 'script'>
@@ -74,6 +78,9 @@
let flows: TableFlow[] | undefined = $state()
let apps: TableApp[] | undefined = $state()
let raw_apps: TableRawApp[] | undefined = $state()
/** Never-deployed items that live only in the draft table, keyed for merge
* into `combinedItems`. */
let draftOnlyItems: (TableScript | TableFlow | TableApp)[] = $state([])
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
@@ -139,6 +146,76 @@
loading = false
}
/** Surface never-deployed items (rows that exist only in the draft table, no
* deployed script/flow/app) so they're discoverable in the home list.
* Deduped against the deployed items already loaded for the same kind. */
async function loadDraftOnlyItems(): Promise<void> {
let listed: Awaited<ReturnType<typeof DraftService.listDrafts>>
try {
listed = await DraftService.listDrafts({ workspace: $workspaceStore! })
} catch (e) {
console.error('Failed to load drafts', e)
return
}
// Paths of deployed items per kind, to skip drafts that already have a
// deployed counterpart (those are surfaced via the deployed item's
// has_draft badge instead).
const deployedScriptPaths = new Set((scripts ?? []).map((x) => x.path))
const deployedFlowPaths = new Set((flows ?? []).map((x) => x.path))
const deployedAppPaths = new Set([...(apps ?? []), ...(raw_apps ?? [])].map((x) => x.path))
const result: (TableScript | TableFlow | TableApp)[] = []
for (const draft of listed) {
const value = (draft.value ?? {}) as any
const path = draft.path ?? value.path
if (!path) continue
const summary = value.summary ?? ''
const time = draft.created_at ? new Date(draft.created_at).getTime() : 0
const canWriteItem =
canWrite(path, value.extra_perms ?? {}, $userStore) && !$userStore?.operator
if (draft.typ === 'script') {
if (deployedScriptPaths.has(path)) continue
result.push({
path,
summary,
extra_perms: {},
canWrite: canWriteItem,
type: 'script',
time,
has_draft: true,
draft_only: true
} as unknown as TableScript)
} else if (draft.typ === 'flow') {
if (deployedFlowPaths.has(path)) continue
result.push({
path,
summary,
extra_perms: {},
edited_at: draft.created_at,
canWrite: canWriteItem,
type: 'flow',
time,
has_draft: true,
draft_only: true
} as unknown as TableFlow)
} else if (draft.typ === 'app') {
if (deployedAppPaths.has(path)) continue
result.push({
path,
summary,
extra_perms: {},
edited_at: draft.created_at,
canWrite: canWriteItem,
type: 'app',
time,
has_draft: true,
draft_only: true
} as unknown as TableApp)
}
}
draftOnlyItems = result
}
function filterItemsPathsBaseOnUserFilters(
item: TableScript | TableFlow | TableApp | TableRawApp,
filterUserFolders: boolean,
@@ -253,15 +330,19 @@
if ($userStore && $workspaceStore) {
;[archived, includeWithoutMain]
untrack(() => {
loadScripts(includeWithoutMain)
loadFlows()
draftOnlyItems = []
const loads = [loadScripts(includeWithoutMain), loadFlows()]
if (!archived) {
loadApps()
loadRawApps()
loads.push(loadApps(), loadRawApps())
} else {
apps = []
raw_apps = []
}
// Load draft-only (never-deployed) items after the deployed lists
// resolve so we can dedupe against them. Archived view skips them.
if (!archived) {
Promise.all(loads).then(() => loadDraftOnlyItems())
}
})
}
})
@@ -289,7 +370,10 @@
...x,
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime()
}))
})),
// Never-deployed items (draft table only); already carry `type` and
// `time` and link to the editor via `draft_only`.
...draftOnlyItems.map((x) => ({ ...x, time: x.time ?? 0 }))
].sort((a, b) =>
a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1
)
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
// DraftService is hit (debounced) by UserDraftDbService; stub it so tests never
// touch the network and we can assert the calls.
const createDraft = vi.fn(async () => 'ok')
const deleteDraft = vi.fn(async () => 'ok')
vi.mock('./gen', () => ({
DraftService: {
createDraft: (...args: any[]) => createDraft(...args),
deleteDraft: (...args: any[]) => deleteDraft(...args)
}
}))
import {
UserDraft,
normalizeForCompare,
localDraftDiffers,
__resetUserDraftForTesting
} from './userDraft.svelte'
import { UserDraftDbService } from './userDraftDbService'
const WS = 'test-ws'
const opts = { workspace: WS }
beforeEach(() => {
__resetUserDraftForTesting()
createDraft.mockClear()
deleteDraft.mockClear()
})
describe('normalizeForCompare', () => {
it('drops undefined-valued keys via JSON round-trip', () => {
expect(normalizeForCompare({ a: 1, b: undefined })).toEqual({ a: 1 })
})
it('returns undefined for undefined', () => {
expect(normalizeForCompare(undefined)).toBeUndefined()
})
})
describe('localDraftDiffers', () => {
it('is false when there is no local draft', () => {
expect(localDraftDiffers(undefined, { a: 1 })).toBe(false)
expect(localDraftDiffers(null, { a: 1 })).toBe(false)
})
it('treats {a: undefined} and {} as equal (normalized)', () => {
expect(localDraftDiffers({ a: undefined } as any, {} as any)).toBe(false)
})
it('is true when the values meaningfully differ', () => {
expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true)
})
})
describe('UserDraft in-memory store (no live handle)', () => {
it('save then get returns the value without a mounted handle', () => {
UserDraft.save('resource', 'f/r/db', { host: 'x' }, opts)
expect(UserDraft.get('resource', 'f/r/db', opts)).toEqual({ host: 'x' })
expect(UserDraft.has('resource', 'f/r/db', opts)).toBe(true)
})
it('remove clears the value', () => {
UserDraft.save('resource', 'f/r/db', { host: 'x' }, opts)
UserDraft.remove('resource', 'f/r/db', opts)
expect(UserDraft.get('resource', 'f/r/db', opts)).toBeUndefined()
expect(UserDraft.has('resource', 'f/r/db', opts)).toBe(false)
})
it('discard resets the value to the fallback', () => {
UserDraft.save('resource', 'f/r/db', { host: 'edited' }, opts)
UserDraft.discard('resource', 'f/r/db', { host: 'deployed' }, opts)
expect(UserDraft.get('resource', 'f/r/db', opts)).toEqual({ host: 'deployed' })
})
it('list reflects in-memory drafts', () => {
UserDraft.save('resource', 'f/r/a', { v: 1 }, opts)
UserDraft.save('variable', 'f/v/b', { v: 2 }, opts)
const list = UserDraft.list(opts)
expect(list.map((d) => d.path).sort()).toEqual(['f/r/a', 'f/v/b'])
})
})
describe('UserDraftDbService (debounced persistence)', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('persists DB-backed kinds via createDraft after debounce', async () => {
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/x', content: { a: 1 } })
expect(createDraft).not.toHaveBeenCalled() // debounced
await vi.advanceTimersByTimeAsync(700)
expect(createDraft).toHaveBeenCalledTimes(1)
expect(createDraft.mock.calls[0][0]).toMatchObject({
workspace: WS,
requestBody: { path: 'f/s/x', typ: 'script', value: { a: 1 } }
})
})
it('maps raw_app to the app draft typ', async () => {
UserDraftDbService.save({
workspace: WS,
itemKind: 'raw_app',
path: 'f/a/x',
content: { a: 1 }
})
await vi.advanceTimersByTimeAsync(700)
expect(createDraft.mock.calls[0][0].requestBody.typ).toBe('app')
})
it('deletes the draft when content is undefined', async () => {
UserDraftDbService.save({ workspace: WS, itemKind: 'flow', path: 'f/f/x', content: undefined })
await vi.advanceTimersByTimeAsync(700)
expect(deleteDraft).toHaveBeenCalledTimes(1)
expect(deleteDraft.mock.calls[0][0]).toMatchObject({
workspace: WS,
kind: 'flow',
path: 'f/f/x'
})
})
it('is a no-op for kinds without a DB draft', async () => {
UserDraftDbService.save({
workspace: WS,
itemKind: 'resource',
path: 'f/r/x',
content: { a: 1 }
})
UserDraftDbService.save({
workspace: WS,
itemKind: 'trigger_http',
path: 'f/t/x',
content: { a: 1 }
})
await vi.advanceTimersByTimeAsync(700)
expect(createDraft).not.toHaveBeenCalled()
expect(deleteDraft).not.toHaveBeenCalled()
})
it('skips brand-new items at the empty path', async () => {
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: '', content: { a: 1 } })
await vi.advanceTimersByTimeAsync(700)
expect(createDraft).not.toHaveBeenCalled()
})
it('coalesces a burst of edits into the latest write', async () => {
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/y', content: { v: 1 } })
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/y', content: { v: 2 } })
UserDraftDbService.save({ workspace: WS, itemKind: 'script', path: 'f/s/y', content: { v: 3 } })
await vi.advanceTimersByTimeAsync(700)
expect(createDraft).toHaveBeenCalledTimes(1)
expect(createDraft.mock.calls[0][0].requestBody.value).toEqual({ v: 3 })
})
})
@@ -38,10 +38,43 @@
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
const app_w_draft = await AppService.getAppByPathWithDraft({
path: page.params.path ?? '',
workspace: $workspaceStore!
})
let app_w_draft: AppWithLastVersionWDraft
try {
app_w_draft = await AppService.getAppByPathWithDraft({
path: page.params.path ?? '',
workspace: $workspaceStore!
})
} catch (e) {
// No deployed app at this path: it may be a never-deployed item that
// lives only in the draft table. Load it from there.
const draft = await DraftService.getDraft({
workspace: $workspaceStore!,
kind: 'app',
path: page.params.path ?? ''
}).catch(() => undefined)
if (tok !== loadAppToken) return
if (!draft?.value) throw e
// The app draft value has shape { value, path, summary, policy, custom_path }.
const dv = draft.value as {
value: any
path: string
summary: string
policy: any
custom_path?: string
}
// No deployed version → savedApp mirrors the draft, so the builder
// treats a deploy as "create".
savedApp = {
summary: dv.summary,
value: dv.value as App,
path: dv.path,
policy: dv.policy,
draft: dv,
custom_path: dv.custom_path
}
app = { ...dv } as AppWithLastVersion & { value: any }
return
}
if (tok !== loadAppToken) return
const app_w_draft_: AppWithLastVersionWDraft = structuredClone(stateSnapshot(app_w_draft))
savedApp = {
@@ -126,10 +126,59 @@
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
const app_w_draft = await AppService.getAppByPathWithDraft({
path: page.params.path ?? '',
workspace: $workspaceStore!
})
let app_w_draft: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
try {
app_w_draft = await AppService.getAppByPathWithDraft({
path: page.params.path ?? '',
workspace: $workspaceStore!
})
} catch (e) {
// No deployed app at this path: it may be a never-deployed item that
// lives only in the draft table. Raw apps use draft typ 'app'.
const draft = await DraftService.getDraft({
workspace: $workspaceStore!,
kind: 'app',
path: page.params.path ?? ''
}).catch(() => undefined)
if (tok !== loadAppToken) return
if (!draft?.value) throw e
// The raw-app draft value shape is { value: { files, runnables, data },
// summary, policy, custom_path, path } OR the older { files, runnables, ... }.
// Mirror how the deployed path derives backendSource/backendBundle, but
// source it from the fetched draft value instead.
const backendSource: any = draft.value
const value = backendSource.value ?? backendSource
const backendBundle: RawAppDraft = {
files: value?.files ?? {},
runnables: value?.runnables ?? {},
data:
value?.data ??
(value?.datatables ? { ...DEFAULT_DATA, tables: value.datatables } : { ...DEFAULT_DATA }),
summary: backendSource.summary ?? '',
policy: backendSource.policy,
custom_path: backendSource.custom_path
}
// No deployed version → savedApp mirrors the draft, so the builder
// treats a deploy as "create".
savedApp = {
summary: backendSource.summary,
value: value as any,
path: backendSource.path ?? page.params.path ?? '',
policy: backendSource.policy,
draft: backendSource,
custom_path: backendSource.custom_path
}
// Seed the handle (no DB write-back) before populating the form so the
// persist effect's first write matches and is skipped.
draftHandle.setInitial(backendBundle)
extractRawApp({
value,
summary: backendSource.summary,
policy: backendSource.policy,
path: backendSource.path ?? page.params.path ?? ''
})
return
}
if (tok !== loadAppToken) return
const app_w_draft_ = structuredClone(stateSnapshot(app_w_draft))
savedApp = {
@@ -117,45 +117,73 @@
seeding = true
let draftTriggersToApply: Trigger[] | undefined = undefined
let applyPrimarySchedule = false
// Currently there is no way to get version of flow with flow.
const v = (
await FlowService.getFlowLatestVersion({
let flow: Flow
try {
// Currently there is no way to get version of flow with flow.
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
).id
if (tok !== loadFlowToken) return
version = v
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
).id
if (tok !== loadFlowToken) return
version = v
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
}
// The editor works off the backend DB draft when present, otherwise the
// deployed version. Seeding (not assigning) avoids writing the
// freshly-loaded value straight back to the DB.
const flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
flowHandle.setInitial(flow)
// The editor works off the backend DB draft when present, otherwise the
// deployed version. Seeding (not assigning) avoids writing the
// freshly-loaded value straight back to the DB.
flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
flowHandle.setInitial(flow)
if (flowWithDraft.draft != undefined) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
if (flowWithDraft.draft != undefined) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
applyPrimarySchedule = true
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
}
} catch (e) {
// No deployed flow at this path (both getFlowLatestVersion and
// getFlowByPathWithDraft 404): it may be a never-deployed item that
// lives only in the draft table. Load it from there.
const draft = await DraftService.getDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: page.params.path ?? ''
}).catch(() => undefined)
if (tok !== loadFlowToken) return
if (!draft?.value) throw e
// No deployed version → savedFlow mirrors the draft (no version), so
// the builder treats a deploy as "create".
savedFlow = { ...(draft.value as Flow), draft: draft.value as Flow } as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
version = undefined
flow = draft.value as Flow
flowHandle.setInitial(flow)
savedPrimarySchedule = (draft.value as any)?.['primary_schedule']
applyPrimarySchedule = true
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
draftTriggersToApply = (draft.value as any)?.['draft_triggers']
}
await initFlow(flow, flowStore, flowStateStore)
@@ -1,5 +1,5 @@
<script lang="ts">
import { ScriptService, type NewScript, type NewScriptWithDraft } from '$lib/gen'
import { ScriptService, type NewScript, type NewScriptWithDraft, DraftService } from '$lib/gen'
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
@@ -118,10 +118,33 @@
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
scriptHandle.setInitial({ ...scriptByHash, parent_hash: hash, lock: undefined })
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
let scriptWithDraft: NewScriptWithDraft
try {
scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
} catch (e) {
// No deployed script at this path: it may be a never-deployed item
// that lives only in the draft table. Load it from there.
const draft = await DraftService.getDraft({
workspace: $workspaceStore!,
kind: 'script',
path: page.params.path ?? ''
}).catch(() => undefined)
if (tok !== loadScriptToken) return
if (!draft?.value) throw e
const draftVal = { ...(draft.value as EditableScript) }
// No deployed version → savedScript mirrors the draft (no hash), so
// the builder treats a deploy as "create".
savedScript = { ...draftVal, draft: draftVal } as NewScriptWithDraft
scriptHandle.setInitial(draftVal)
if (scriptHandle.draft) applyBaseline(scriptHandle.draft)
urlScriptSeed = undefined
fullyLoaded = true
renderEditor = true
return
}
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptWithDraft))