refactor: persist ai chat drafts as db workspace drafts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-03 19:09:36 +02:00
co-authored by Claude Opus 4.8
parent 36012f7425
commit fb9714c649
17 changed files with 3256 additions and 2186 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,506 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// dbDraft.ts only touches these services. Mock them as plain spies so the
// unit is exercised in isolation (no transitive $lib/gen imports).
vi.mock('$lib/gen', () => ({
ScriptService: {
createScript: vi.fn(async () => 'new-hash'),
getScriptByPathWithDraft: vi.fn(async () => {
throw new Error('getScriptByPathWithDraft mock not configured')
})
},
FlowService: {
createFlow: vi.fn(async () => 'created'),
getFlowByPathWithDraft: vi.fn(async () => {
throw new Error('getFlowByPathWithDraft mock not configured')
}),
getFlowLatestVersion: vi.fn(async () => {
throw new Error('getFlowLatestVersion mock not configured')
})
},
AppService: {
createAppRaw: vi.fn(async () => 'created'),
getAppByPathWithDraft: vi.fn(async () => {
throw new Error('getAppByPathWithDraft mock not configured')
})
},
DraftService: { createDraft: vi.fn(async () => 'created') }
}))
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
import {
saveScriptDbDraft,
saveFlowDbDraft,
saveAppDbDraft,
readScriptDbDraft,
readFlowDbDraft,
readAppDbDraft
} from './dbDraft'
const ws = 'test-ws'
const scriptCreate = ScriptService.createScript as unknown as ReturnType<typeof vi.fn>
const flowCreate = FlowService.createFlow as unknown as ReturnType<typeof vi.fn>
const appCreate = AppService.createAppRaw as unknown as ReturnType<typeof vi.fn>
const draftCreate = DraftService.createDraft as unknown as ReturnType<typeof vi.fn>
const scriptWithDraft = ScriptService.getScriptByPathWithDraft as unknown as ReturnType<typeof vi.fn>
const flowWithDraft = FlowService.getFlowByPathWithDraft as unknown as ReturnType<typeof vi.fn>
const flowLatestVersion = FlowService.getFlowLatestVersion as unknown as ReturnType<typeof vi.fn>
const appWithDraft = AppService.getAppByPathWithDraft as unknown as ReturnType<typeof vi.fn>
// A 404 from the generated client surfaces as a thrown ApiError-shaped object
// with `.status === 404`. The read path catches *only* this case.
function notFound() {
return Object.assign(new Error('not found'), { status: 404 })
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('saveScriptDbDraft', () => {
const baseScript = {
path: 'u/test/foo',
summary: 'Foo',
description: '',
content: 'def main(): pass',
language: 'python3',
schema: { type: 'object', properties: {} },
kind: 'script',
is_template: false
} as any
it('creates a draft_only anchor for a brand-new item, then upserts the draft row', async () => {
await saveScriptDbDraft(ws, baseScript.path, baseScript, { itemExists: false })
expect(scriptCreate).toHaveBeenCalledTimes(1)
const anchor = scriptCreate.mock.calls[0][0]
expect(anchor.workspace).toBe(ws)
expect(anchor.requestBody.draft_only).toBe(true)
expect(anchor.requestBody.path).toBe(baseScript.path)
expect(anchor.requestBody.content).toBe(baseScript.content)
expect(anchor.requestBody.language).toBe(baseScript.language)
expect(draftCreate).toHaveBeenCalledTimes(1)
const draftCall = draftCreate.mock.calls[0][0]
expect(draftCall.workspace).toBe(ws)
expect(draftCall.requestBody.typ).toBe('script')
expect(draftCall.requestBody.path).toBe(baseScript.path)
expect(draftCall.requestBody.value.content).toBe(baseScript.content)
expect(draftCall.requestBody.value.draft_triggers).toEqual([])
})
it('skips the anchor when the item already exists (deployed or draft_only)', async () => {
await saveScriptDbDraft(ws, baseScript.path, baseScript, { itemExists: true })
expect(scriptCreate).not.toHaveBeenCalled()
expect(draftCreate).toHaveBeenCalledTimes(1)
})
it('keeps draft_only/no-triggers/no-parent_hash on the anchor while preserving triggers in the draft row', async () => {
await saveScriptDbDraft(
ws,
baseScript.path,
{ ...baseScript, parent_hash: 'abc', draft_triggers: [{ x: 1 }] },
{ itemExists: false }
)
const anchor = scriptCreate.mock.calls[0][0].requestBody
expect(anchor.draft_only).toBe(true)
expect(anchor.parent_hash).toBeUndefined()
expect('draft_triggers' in anchor).toBe(false)
const draftValue = draftCreate.mock.calls[0][0].requestBody.value
expect(draftValue.draft_triggers).toEqual([{ x: 1 }])
})
})
describe('saveFlowDbDraft', () => {
const baseFlow = {
path: 'u/test/flow',
summary: 'My flow',
description: 'desc',
value: { modules: [] },
schema: { type: 'object', properties: {} }
} as any
it('creates a draft_only anchor for a new flow, then upserts the draft row with path + empty triggers', async () => {
await saveFlowDbDraft(ws, baseFlow.path, baseFlow, { itemExists: false })
expect(flowCreate).toHaveBeenCalledTimes(1)
const anchor = flowCreate.mock.calls[0][0].requestBody
expect(anchor.draft_only).toBe(true)
expect(anchor.path).toBe(baseFlow.path)
expect(anchor.summary).toBe(baseFlow.summary)
expect(anchor.value).toEqual(baseFlow.value)
const draftCall = draftCreate.mock.calls[0][0].requestBody
expect(draftCall.typ).toBe('flow')
expect(draftCall.path).toBe(baseFlow.path)
expect(draftCall.value.path).toBe(baseFlow.path)
expect(draftCall.value.value).toEqual(baseFlow.value)
expect(draftCall.value.draft_triggers).toEqual([])
})
it('skips the anchor when the flow already exists', async () => {
await saveFlowDbDraft(ws, baseFlow.path, baseFlow, { itemExists: true })
expect(flowCreate).not.toHaveBeenCalled()
expect(draftCreate).toHaveBeenCalledTimes(1)
})
})
describe('saveAppDbDraft', () => {
const baseApp = {
summary: 'My app',
files: { '/main.tsx': 'export default 1' },
runnables: { a: { type: 'inline' } },
data: { tables: [] },
policy: { execution_mode: 'publisher' },
custom_path: 'my-app'
} as any
const path = 'u/test/app'
it('bundles + creates a draft_only raw-app anchor for a new app, then upserts the nested draft value', async () => {
const bundle = vi.fn(async () => ({ js: 'JS', css: 'CSS' }))
await saveAppDbDraft(ws, path, baseApp, { itemExists: false, bundle })
expect(bundle).toHaveBeenCalledTimes(1)
expect(appCreate).toHaveBeenCalledTimes(1)
const fd = appCreate.mock.calls[0][0].formData
expect(fd.js).toBe('JS')
expect(fd.css).toBe('CSS')
expect(fd.app.draft_only).toBe(true)
expect(fd.app.path).toBe(path)
expect(fd.app.policy).toEqual(baseApp.policy)
// anchor app value is the raw { files, runnables, data } — not the flattened draft value
expect(fd.app.value).toEqual({
files: baseApp.files,
runnables: baseApp.runnables,
data: baseApp.data
})
const draftCall = draftCreate.mock.calls[0][0].requestBody
expect(draftCall.typ).toBe('app')
expect(draftCall.path).toBe(path)
// DB draft value nests the raw app under `value`, like the raw-app editor stores it
expect(draftCall.value.value).toEqual({
files: baseApp.files,
runnables: baseApp.runnables,
data: baseApp.data
})
expect(draftCall.value.path).toBe(path)
expect(draftCall.value.summary).toBe(baseApp.summary)
expect(draftCall.value.policy).toEqual(baseApp.policy)
expect(draftCall.value.custom_path).toBe(baseApp.custom_path)
})
it('skips the anchor (and the bundle) when the app already exists', async () => {
const bundle = vi.fn(async () => ({ js: 'JS', css: 'CSS' }))
await saveAppDbDraft(ws, path, baseApp, { itemExists: true, bundle })
expect(bundle).not.toHaveBeenCalled()
expect(appCreate).not.toHaveBeenCalled()
expect(draftCreate).toHaveBeenCalledTimes(1)
})
it('requires a bundle to create a new anchor', async () => {
await expect(saveAppDbDraft(ws, path, baseApp, { itemExists: false })).rejects.toThrow(
/bundle/i
)
})
it('requires a policy to create a new anchor', async () => {
const bundle = vi.fn(async () => ({ js: 'JS', css: 'CSS' }))
await expect(
saveAppDbDraft(ws, path, { ...baseApp, policy: undefined }, { itemExists: false, bundle })
).rejects.toThrow(/policy/i)
})
})
describe('readScriptDbDraft', () => {
const path = 'f/scripts/existing'
function deployed(extra: Record<string, any> = {}) {
return {
path,
hash: 'deployed-hash',
summary: 'deployed summary',
description: 'deployed description',
content: 'deployed content',
language: 'bun',
kind: 'script',
...extra
}
}
it('returns the not-found result when no row exists at the path', async () => {
scriptWithDraft.mockRejectedValueOnce(notFound())
const res = await readScriptDbDraft(ws, path)
expect(res).toEqual({
itemExists: false,
deployedExists: false,
draftOnly: false,
hasDbDraft: false,
value: undefined,
meta: {}
})
})
it('rethrows non-404 errors instead of swallowing them', async () => {
scriptWithDraft.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 }))
await expect(readScriptDbDraft(ws, path)).rejects.toThrow('boom')
})
it('returns the deployed value when the row has no draft', async () => {
scriptWithDraft.mockResolvedValueOnce(deployed())
const res = await readScriptDbDraft(ws, path)
expect(res.itemExists).toBe(true)
expect(res.deployedExists).toBe(true)
expect(res.draftOnly).toBe(false)
expect(res.hasDbDraft).toBe(false)
expect(res.value?.content).toBe('deployed content')
expect(res.meta).toEqual({ remoteRev: 'deployed-hash', remoteDraftRev: undefined })
})
it('returns the draft value (not the deployed one) when a draft row exists', async () => {
scriptWithDraft.mockResolvedValueOnce(
deployed({
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path,
summary: 'db draft summary',
description: 'db draft description',
content: 'draft content',
language: 'bun',
kind: 'script'
}
})
)
const res = await readScriptDbDraft(ws, path)
expect(res.itemExists).toBe(true)
expect(res.deployedExists).toBe(true)
expect(res.draftOnly).toBe(false)
expect(res.hasDbDraft).toBe(true)
expect(res.value?.content).toBe('draft content')
expect(res.value?.summary).toBe('db draft summary')
expect(res.meta).toEqual({
remoteRev: 'deployed-hash',
remoteDraftRev: '2026-05-22T10:00:00Z'
})
})
it('marks a draft_only row as not-deployed', async () => {
scriptWithDraft.mockResolvedValueOnce(
deployed({
draft_only: true,
draft_created_at: '2026-05-22T10:00:00Z',
draft: { path, summary: 's', content: 'draft content', language: 'bun', kind: 'script' }
})
)
const res = await readScriptDbDraft(ws, path)
expect(res.itemExists).toBe(true)
expect(res.deployedExists).toBe(false)
expect(res.draftOnly).toBe(true)
expect(res.hasDbDraft).toBe(true)
expect(res.value?.content).toBe('draft content')
})
})
describe('readFlowDbDraft', () => {
const path = 'f/flows/existing'
function deployed(extra: Record<string, any> = {}) {
return {
path,
summary: 'deployed summary',
description: 'deployed description',
value: { modules: [] },
schema: { properties: { deployed: { type: 'boolean' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
archived: false,
extra_perms: {},
...extra
}
}
it('returns the not-found result when no row exists at the path', async () => {
flowWithDraft.mockRejectedValueOnce(notFound())
const res = await readFlowDbDraft(ws, path)
expect(res).toEqual({
itemExists: false,
deployedExists: false,
draftOnly: false,
hasDbDraft: false,
value: undefined,
meta: {}
})
// No remoteRev lookup when the item doesn't exist.
expect(flowLatestVersion).not.toHaveBeenCalled()
})
it('rethrows non-404 errors instead of swallowing them', async () => {
flowWithDraft.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 }))
await expect(readFlowDbDraft(ws, path)).rejects.toThrow('boom')
})
it('returns the deployed value with remoteRev from getFlowLatestVersion', async () => {
flowWithDraft.mockResolvedValueOnce(deployed())
flowLatestVersion.mockResolvedValueOnce({ id: 42 })
const res = await readFlowDbDraft(ws, path)
expect(res.itemExists).toBe(true)
expect(res.deployedExists).toBe(true)
expect(res.draftOnly).toBe(false)
expect(res.hasDbDraft).toBe(false)
expect(res.value?.value).toEqual({ modules: [] })
expect(res.meta).toEqual({ remoteRev: 42, remoteDraftRev: undefined })
})
it('returns the draft value when a draft row exists', async () => {
flowWithDraft.mockResolvedValueOnce(
deployed({
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path,
summary: 'db draft summary',
value: { modules: [{ id: 'a' }] },
schema: { properties: { draft: { type: 'string' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:30:00Z',
archived: false,
extra_perms: {}
}
})
)
flowLatestVersion.mockResolvedValueOnce({ id: 42 })
const res = await readFlowDbDraft(ws, path)
expect(res.hasDbDraft).toBe(true)
expect(res.value?.value).toEqual({ modules: [{ id: 'a' }] })
expect(res.value?.summary).toBe('db draft summary')
expect(res.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-05-22T10:00:00Z' })
})
it('leaves remoteRev undefined for a draft_only flow with no deployed version', async () => {
flowWithDraft.mockResolvedValueOnce(
deployed({
draft_only: true,
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path,
summary: 's',
value: { modules: [] },
edited_by: 'admin',
edited_at: '2026-05-22T09:30:00Z',
archived: false,
extra_perms: {}
}
})
)
const res = await readFlowDbDraft(ws, path)
expect(res.draftOnly).toBe(true)
expect(res.deployedExists).toBe(false)
expect(res.hasDbDraft).toBe(true)
expect(res.meta.remoteRev).toBeUndefined()
expect(res.meta.remoteDraftRev).toBe('2026-05-22T10:00:00Z')
// A draft_only flow has no deployed version to look up.
expect(flowLatestVersion).not.toHaveBeenCalled()
})
it('tolerates a 404 from getFlowLatestVersion (remoteRev undefined)', async () => {
flowWithDraft.mockResolvedValueOnce(deployed())
flowLatestVersion.mockRejectedValueOnce(notFound())
const res = await readFlowDbDraft(ws, path)
expect(res.deployedExists).toBe(true)
expect(res.meta.remoteRev).toBeUndefined()
})
})
describe('readAppDbDraft', () => {
const path = 'f/apps/report'
function deployed(extra: Record<string, any> = {}) {
return {
path,
summary: 'deployed app',
versions: [3, 4],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
policy: { execution_mode: 'publisher' },
custom_path: 'report',
...extra
}
}
it('returns the not-found result when no row exists at the path', async () => {
appWithDraft.mockRejectedValueOnce(notFound())
const res = await readAppDbDraft(ws, path)
expect(res).toEqual({
itemExists: false,
deployedExists: false,
draftOnly: false,
hasDbDraft: false,
value: undefined,
meta: {}
})
})
it('rethrows non-404 errors instead of swallowing them', async () => {
appWithDraft.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 }))
await expect(readAppDbDraft(ws, path)).rejects.toThrow('boom')
})
it('returns the deployed value (shaped via appSourceToDraftValue) when no draft', async () => {
appWithDraft.mockResolvedValueOnce(deployed())
const res = await readAppDbDraft(ws, path)
expect(res.itemExists).toBe(true)
expect(res.deployedExists).toBe(true)
expect(res.hasDbDraft).toBe(false)
expect(res.value?.files).toEqual({ '/src/App.tsx': 'deployed content' })
expect(res.value?.policy).toEqual({ execution_mode: 'publisher' })
expect(res.value?.custom_path).toBe('report')
expect(res.meta).toEqual({ remoteRev: 4, remoteDraftRev: undefined })
})
it('returns the draft value when a draft row exists', async () => {
appWithDraft.mockResolvedValueOnce(
deployed({
draft_created_at: '2026-05-22T10:30:00Z',
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: { main: { type: 'inline' } },
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'anonymous' },
custom_path: 'report'
}
})
)
const res = await readAppDbDraft(ws, path)
expect(res.hasDbDraft).toBe(true)
expect(res.value?.files).toEqual({ '/src/App.tsx': 'draft content' })
expect(res.value?.summary).toBe('saved app draft')
expect(res.value?.policy).toEqual({ execution_mode: 'anonymous' })
expect(res.value?.data).toEqual({ tables: ['orders'], datatable: 'db', schema: 'public' })
expect(res.meta).toEqual({ remoteRev: 4, remoteDraftRev: '2026-05-22T10:30:00Z' })
})
it('marks a draft_only app as not-deployed', async () => {
appWithDraft.mockResolvedValueOnce(
deployed({
draft_only: true,
draft_created_at: '2026-05-22T10:30:00Z',
draft: {
summary: 's',
value: { files: {}, runnables: {}, data: { tables: [] } },
policy: { execution_mode: 'publisher' }
}
})
)
const res = await readAppDbDraft(ws, path)
expect(res.draftOnly).toBe(true)
expect(res.deployedExists).toBe(false)
expect(res.hasDbDraft).toBe(true)
})
})
@@ -0,0 +1,328 @@
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
import type { Flow, NewScript } from '$lib/gen'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import type { UserDraftMeta } from '$lib/userDraft.svelte'
import type { AppDraftValue } from './workspaceItems'
/**
* Persists AI-chat drafts of `script` / `flow` / `app` items exactly the way the
* editors' "Save draft" buttons do, against the backend `draft` table.
*
* The DB draft is the source of truth for AI-chat drafts. localStorage mirroring
* for an open live editor is layered on top in `draftStore.ts` — this module
* never touches localStorage.
*
* Two pieces per save:
* 1. The draft row itself, via `DraftService.createDraft` — an upsert keyed by
* (workspace, path, typ). Its `value` mirrors the shape the editors store.
* 2. For a brand-new item with no row yet in the script/flow/app table, a
* `draft_only` anchor row. This is required because `get*ByPathWithDraft`
* is a `LEFT JOIN` *from* the item table — without the anchor the draft is
* unreadable. Existing items (deployed or already draft_only) skip this.
*
* `opts.itemExists` is "a row already exists at this path in the item table
* (deployed or draft_only)". The caller knows this from the read it already did,
* so we never fetch it again here (keeps this module pure and avoids the
* redundant-fetch problem).
*/
// The editors store the full item plus a `draft_triggers` array in the draft
// `value`. AI chat manages triggers as separate (localStorage-only) drafts, so
// we preserve any triggers already carried on the draft and otherwise default
// to none — never dropping the field, so the round-trip matches the editors'.
type ScriptDbDraft = NewScript & { draft_triggers?: unknown[] }
type FlowDbDraft = Flow & { draft_triggers?: unknown[] }
function scriptDraftValue(draft: ScriptDbDraft): ScriptDbDraft {
return { ...draft, draft_triggers: draft.draft_triggers ?? [] }
}
// The anchor carries every NewScript field so that `listScripts({includeDraftOnly})`
// and a first-time deploy observe the same metadata the draft was written with.
// `draft_triggers` isn't a NewScript field, and a fresh draft_only row has no
// parent — strip both.
function scriptAnchorBody(draft: ScriptDbDraft): NewScript {
const { draft_triggers: _triggers, parent_hash: _parent, ...rest } = draft
return { ...rest, draft_only: true }
}
export async function saveScriptDbDraft(
workspace: string,
path: string,
draft: ScriptDbDraft,
opts: { itemExists: boolean }
): Promise<void> {
if (!opts.itemExists) {
await ScriptService.createScript({ workspace, requestBody: scriptAnchorBody(draft) })
}
await DraftService.createDraft({
workspace,
requestBody: { path, typ: 'script', value: scriptDraftValue(draft) }
})
}
function flowDraftValue(draft: FlowDbDraft, path: string): FlowDbDraft {
return { ...draft, path, draft_triggers: draft.draft_triggers ?? [] }
}
function flowAnchorBody(draft: Flow, path: string) {
return {
path,
summary: draft.summary ?? '',
description: draft.description ?? '',
value: draft.value,
schema: draft.schema,
draft_only: true
}
}
export async function saveFlowDbDraft(
workspace: string,
path: string,
draft: FlowDbDraft,
opts: { itemExists: boolean }
): Promise<void> {
if (!opts.itemExists) {
await FlowService.createFlow({ workspace, requestBody: flowAnchorBody(draft, path) })
}
await DraftService.createDraft({
workspace,
requestBody: { path, typ: 'flow', value: flowDraftValue(draft, path) }
})
}
// AI chat works with raw apps. The raw-app editor stores the runtime
// `{ files, runnables, data }` nested under `value`, alongside path/summary/
// policy/custom_path — mirror that exactly so a draft round-trips between chat
// and an open raw-app editor.
function rawAppValue(value: AppDraftValue) {
return { files: value.files, runnables: value.runnables, data: value.data }
}
function appDraftValue(value: AppDraftValue, path: string) {
return {
value: rawAppValue(value),
path,
summary: value.summary ?? '',
policy: value.policy,
custom_path: value.custom_path
}
}
/**
* Unlike scripts/flows, a raw-app anchor row carries the compiled bundle, so the
* caller threads a `bundle` callback (used only when an anchor must be created).
* `value.policy` must already be computed by the caller — `createAppRaw` requires it.
*/
export async function saveAppDbDraft(
workspace: string,
path: string,
value: AppDraftValue,
opts: { itemExists: boolean; bundle?: () => Promise<{ js: string; css: string }> }
): Promise<void> {
if (!opts.itemExists) {
if (!opts.bundle) {
throw new Error('A bundle is required to create a new app draft anchor.')
}
const policy = value.policy
if (!policy) {
throw new Error('A policy is required to create a new app draft anchor.')
}
const { js, css } = await opts.bundle()
await AppService.createAppRaw({
workspace,
formData: {
app: {
value: rawAppValue(value),
path,
summary: value.summary ?? '',
policy,
draft_only: true,
custom_path: value.custom_path
},
js,
css
}
})
}
await DraftService.createDraft({
workspace,
requestBody: { path, typ: 'app', value: appDraftValue(value, path) }
})
}
/**
* The read counterpart of the save functions above. Fetches the *latest DB
* draft falling back to the deployed version* for a single item, surfacing the
* flags the orchestrator needs:
*
* - `itemExists` — a row exists at this path (deployed OR draft_only). This
* is exactly the `opts.itemExists` the matching `save*`
* function consumes, closing the read -> write loop.
* - `deployedExists` — a non-`draft_only` (i.e. deployed) version exists.
* - `draftOnly` — the item row is a `draft_only` anchor.
* - `hasDbDraft` — a draft row exists (the `draft` field is present).
* - `value` — the draft value when `hasDbDraft`, else the deployed
* value, else `undefined`. Shaped exactly like what the
* save path writes / the editors store.
* - `meta` — `{ remoteRev, remoteDraftRev }`: the deployed version's
* id/hash and the DB draft's `created_at`.
*
* This module never touches localStorage; read precedence (live editor ->
* localStorage) is layered on top by the orchestrator.
*/
export type DbDraftRead<V> = {
itemExists: boolean
deployedExists: boolean
draftOnly: boolean
hasDbDraft: boolean
value: V | undefined
meta: UserDraftMeta
}
const NOT_FOUND: DbDraftRead<never> = {
itemExists: false,
deployedExists: false,
draftOnly: false,
hasDbDraft: false,
value: undefined,
meta: {}
}
// A missing item surfaces from the generated client as a thrown ApiError with
// `.status === 404` (the `get*ByPathWithDraft` handlers `not_found_if_none`).
// Catch *only* that case so genuine failures still propagate.
function isNotFound(e: unknown): boolean {
return typeof e === 'object' && e !== null && (e as { status?: unknown }).status === 404
}
export async function readScriptDbDraft(
workspace: string,
path: string
): Promise<DbDraftRead<NewScript>> {
let existing: Awaited<ReturnType<typeof ScriptService.getScriptByPathWithDraft>>
try {
existing = await ScriptService.getScriptByPathWithDraft({ workspace, path })
} catch (e) {
if (isNotFound(e)) return NOT_FOUND
throw e
}
const draftOnly = !!existing.draft_only
const hasDbDraft = existing.draft != null
// Scripts/flows store the draft `value` as the raw item (no wrapper).
const value = (hasDbDraft ? existing.draft : existing) as NewScript
return {
itemExists: true,
deployedExists: !draftOnly,
draftOnly,
hasDbDraft,
value,
meta: { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at }
}
}
export async function readFlowDbDraft(workspace: string, path: string): Promise<DbDraftRead<Flow>> {
let existing: Awaited<ReturnType<typeof FlowService.getFlowByPathWithDraft>>
try {
existing = await FlowService.getFlowByPathWithDraft({ workspace, path })
} catch (e) {
if (isNotFound(e)) return NOT_FOUND
throw e
}
const draftOnly = !!existing.draft_only
const hasDbDraft = existing.draft != null
const value = (hasDbDraft ? existing.draft : existing) as Flow
// `remoteRev` for a flow is the latest deployed version id — a separate
// call. A `draft_only` flow has no deployed version, so skip the lookup;
// otherwise tolerate a 404 (deployed version raced away) by leaving
// `remoteRev` undefined rather than failing the whole read.
let remoteRev: number | undefined
if (!draftOnly) {
try {
const latestVersion = await FlowService.getFlowLatestVersion({ workspace, path })
remoteRev = latestVersion.id
} catch (e) {
if (!isNotFound(e)) throw e
}
}
return {
itemExists: true,
deployedExists: !draftOnly,
draftOnly,
hasDbDraft,
value,
meta: { remoteRev, remoteDraftRev: existing.draft_created_at }
}
}
// The canonical app-source codecs. `core.ts` imports these (the `core -> dbDraft`
// direction is safe; `dbDraft` never imports `core`) so the read path here, the
// chat read/write sites, and the deploy path all interpret the stored app JSON
// identically to the editors.
export function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
if (value.data?.creation) {
return {
tables: value.data.tables ?? [],
datatable: value.data.creation.datatable,
schema: value.data.creation.schema
}
}
if (value.data) {
return value.data
}
if (value.datatables) {
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
}
if (value.dataTableRefs) {
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
}
return { ...DEFAULT_RAW_APP_DATA }
}
export function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
const value = (app.value ?? {}) as Record<string, any>
return {
summary: app.summary ?? '',
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: normalizeRawAppData(value),
policy: app.policy ?? fallback?.policy,
custom_path: app.custom_path ?? fallback?.custom_path
}
}
export function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
return {
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
remoteDraftRev: app.draft_created_at
}
}
export async function readAppDbDraft(
workspace: string,
path: string
): Promise<DbDraftRead<AppDraftValue>> {
let app: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
try {
app = await AppService.getAppByPathWithDraft({ workspace, path })
} catch (e) {
if (isNotFound(e)) return NOT_FOUND
throw e
}
const draftOnly = !!app.draft_only
const hasDbDraft = app.draft != null
// `app.draft` nests the raw app under `.value`; deployed apps expose it the
// same way. `appSourceToDraftValue` reads `.value`/`.summary`/`.policy`/
// `.custom_path`, falling back to the deployed app for policy/custom_path.
const value = appSourceToDraftValue(hasDbDraft ? app.draft : app, app)
return {
itemExists: true,
deployedExists: !draftOnly,
draftOnly,
hasDbDraft,
value,
meta: appDraftMeta(app)
}
}
@@ -1,11 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
import {
buildFlowDeployRequestBody,
buildScriptDeployRequestBody,
type FlowDeployMetadata,
type ScriptDeployMetadata
} from './deployRequests'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import type { WorkspaceItem } from './workspaceItems'
describe('global AI deploy request builders', () => {
@@ -98,93 +93,6 @@ describe('global AI deploy request builders', () => {
expect(requestBody.lock).toBeUndefined()
})
it('uses script draft metadata over existing deployed metadata', () => {
const existing = {
hash: 'parent-hash',
path: 'f/demo/script',
summary: 'existing summary',
description: 'existing description',
content: 'old content',
schema: { properties: { existing: { type: 'string' } } },
is_template: true,
language: 'bun',
kind: 'script',
tag: 'old-tag',
envs: ['OLD_ENV'],
concurrent_limit: 5,
cache_ttl: 300,
cache_ignore_s3_path: false,
dedicated_worker: true,
ws_error_handler_muted: true,
priority: 10,
timeout: 120,
visible_to_runner_only: true,
on_behalf_of_email: 'existing@example.com',
assets: [{ path: 's3://old/key', kind: 's3object' }],
modules: { 'old.ts': { content: 'export const old = 1', language: 'bun' } },
labels: ['old']
} as unknown as Script & Partial<NewScript>
const draft: WorkspaceItem = {
type: 'script',
path: 'f/demo/script',
summary: 'draft summary',
language: 'bun',
value: 'new content',
isDraft: true
}
const draftMetadata: ScriptDeployMetadata = {
description: 'draft description',
schema: { properties: { draft: { type: 'boolean' } } },
tag: 'draft-tag',
envs: [],
concurrent_limit: 0,
cache_ttl: 0,
cache_ignore_s3_path: true,
dedicated_worker: false,
ws_error_handler_muted: false,
priority: 0,
timeout: 0,
visible_to_runner_only: false,
on_behalf_of_email: 'draft@example.com',
assets: [{ path: 's3://draft/key', kind: 's3object' }],
modules: null,
labels: []
}
const requestBody = buildScriptDeployRequestBody(
'f/demo/script',
draft,
existing,
'ai deploy',
draftMetadata
)
expect(requestBody).toMatchObject({
path: 'f/demo/script',
parent_hash: 'parent-hash',
summary: 'draft summary',
description: 'draft description',
content: 'new content',
schema: draftMetadata.schema,
tag: 'draft-tag',
envs: [],
concurrent_limit: 0,
cache_ttl: 0,
cache_ignore_s3_path: true,
dedicated_worker: false,
ws_error_handler_muted: false,
priority: 0,
timeout: 0,
visible_to_runner_only: false,
on_behalf_of_email: 'draft@example.com',
preserve_on_behalf_of: true,
assets: draftMetadata.assets,
modules: null,
labels: [],
deployment_message: 'ai deploy'
})
})
it('preserves existing flow metadata and uses draft value/schema overrides', () => {
const existing = {
path: 'f/demo/flow',
@@ -235,67 +143,6 @@ describe('global AI deploy request builders', () => {
expect(requestBody.value.groups).toEqual(draftValue.groups)
})
it('uses flow draft metadata over existing deployed metadata', () => {
const existing = {
path: 'f/demo/flow',
summary: 'existing summary',
description: 'existing description',
value: { modules: [] },
schema: { required: ['existing'] },
tag: 'old-tag',
ws_error_handler_muted: true,
priority: 7,
dedicated_worker: true,
timeout: 60,
visible_to_runner_only: true,
on_behalf_of_email: 'existing@example.com',
labels: ['old']
} as unknown as Flow
const draftValue = {
value: { modules: [{ id: 'step', value: { type: 'identity' } }] },
schema: { properties: { draft: { type: 'string' } } },
groups: null
}
const draftMetadata: FlowDeployMetadata = {
description: 'draft description',
tag: 'draft-tag',
ws_error_handler_muted: false,
priority: 0,
dedicated_worker: false,
timeout: 0,
visible_to_runner_only: false,
on_behalf_of_email: undefined,
labels: []
}
const requestBody = buildFlowDeployRequestBody(
'f/demo/flow',
'draft summary',
draftValue as any,
existing,
'ai deploy',
draftMetadata
)
expect(requestBody).toMatchObject({
path: 'f/demo/flow',
summary: 'draft summary',
description: 'draft description',
schema: draftValue.schema,
tag: 'draft-tag',
ws_error_handler_muted: false,
priority: 0,
dedicated_worker: false,
timeout: 0,
visible_to_runner_only: false,
labels: [],
deployment_message: 'ai deploy'
})
expect(requestBody.on_behalf_of_email).toBeUndefined()
expect(requestBody.preserve_on_behalf_of).toBeUndefined()
expect(requestBody.value.groups).toBeUndefined()
})
it('falls back to existing flow schema when the draft has no schema', () => {
const existing = {
path: 'f/demo/flow',
@@ -1,8 +1,7 @@
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
export type ScriptDeployMetadata = Partial<Script> & Partial<NewScript>
export type FlowDeployMetadata = Partial<Flow>
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
export type FlowDeployRequestBody = OpenFlowWPath & {
deployment_message?: string
@@ -12,87 +11,56 @@ function preserveOnBehalfOf(email: string | undefined): true | undefined {
return email ? true : undefined
}
const SCRIPT_DEPLOY_METADATA_FIELDS = [
'schema',
'is_template',
'kind',
'tag',
'envs',
'concurrent_limit',
'concurrency_time_window_s',
'debounce_key',
'debounce_delay_s',
'debounce_args_to_accumulate',
'max_total_debouncing_time',
'max_total_debounces_amount',
'cache_ttl',
'cache_ignore_s3_path',
'dedicated_worker',
'ws_error_handler_muted',
'priority',
'restart_unless_cancelled',
'timeout',
'delete_after_secs',
'concurrency_key',
'visible_to_runner_only',
'auto_kind',
'codebase',
'has_preprocessor',
'on_behalf_of_email',
'assets',
'modules',
'labels'
] as const satisfies readonly (keyof ScriptDeployMetadata)[]
const FLOW_DEPLOY_METADATA_FIELDS = [
'tag',
'ws_error_handler_muted',
'priority',
'dedicated_worker',
'timeout',
'visible_to_runner_only',
'on_behalf_of_email',
'labels'
] as const satisfies readonly (keyof FlowDeployMetadata)[]
function mergeDeployMetadata<T extends object>(
existing: T | undefined,
draftMetadata: T | undefined
): T {
return { ...existing, ...draftMetadata } as T
}
function pickDeployMetadata<T extends object, K extends keyof T>(
source: T,
fields: readonly K[]
): Pick<T, K> {
return Object.fromEntries(fields.map((field) => [field, source[field]])) as Pick<T, K>
}
export function buildScriptDeployRequestBody(
path: string,
draft: WorkspaceItem,
existing: ScriptDeployMetadata | undefined,
deploymentMessage: string | undefined,
draftMetadata?: ScriptDeployMetadata
existing: Script | undefined,
deploymentMessage: string | undefined
): NewScript {
if (typeof draft.value !== 'string' || !draft.language) {
throw new Error(`Draft script "${path}" is missing content or language.`)
}
const metadata = mergeDeployMetadata(existing, draftMetadata)
const onBehalfOfEmail = metadata.on_behalf_of_email
const existingWithMetadata = existing as ScriptWithDeployMetadata | undefined
return {
path,
summary: draft.summary ?? metadata.summary ?? '',
description: metadata.description ?? '',
summary: draft.summary ?? existing?.summary ?? '',
description: existing?.description ?? '',
content: draft.value,
parent_hash: existing?.hash,
...pickDeployMetadata(metadata, SCRIPT_DEPLOY_METADATA_FIELDS),
schema: existing?.schema,
is_template: existing?.is_template,
language: draft.language,
kind: existing?.kind,
tag: existing?.tag,
envs: existing?.envs,
concurrent_limit: existing?.concurrent_limit,
concurrency_time_window_s: existing?.concurrency_time_window_s,
debounce_key: existing?.debounce_key,
debounce_delay_s: existing?.debounce_delay_s,
debounce_args_to_accumulate: existing?.debounce_args_to_accumulate,
max_total_debouncing_time: existing?.max_total_debouncing_time,
max_total_debounces_amount: existing?.max_total_debounces_amount,
cache_ttl: existing?.cache_ttl,
cache_ignore_s3_path: existingWithMetadata?.cache_ignore_s3_path,
dedicated_worker: existing?.dedicated_worker,
ws_error_handler_muted: existing?.ws_error_handler_muted,
priority: existing?.priority,
restart_unless_cancelled: existing?.restart_unless_cancelled,
timeout: existing?.timeout,
delete_after_secs: existing?.delete_after_secs,
deployment_message: deploymentMessage,
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail)
concurrency_key: existing?.concurrency_key,
visible_to_runner_only: existing?.visible_to_runner_only,
auto_kind: existing?.auto_kind,
codebase: existing?.codebase,
has_preprocessor: existing?.has_preprocessor,
on_behalf_of_email: existing?.on_behalf_of_email,
preserve_on_behalf_of: preserveOnBehalfOf(existing?.on_behalf_of_email),
assets: existingWithMetadata?.assets,
modules: existing?.modules,
labels: existing?.labels
}
}
@@ -110,21 +78,24 @@ export function buildFlowDeployRequestBody(
path: string,
draftSummary: string | undefined,
flowDraft: FlowDraftValue,
existing: FlowDeployMetadata | undefined,
deploymentMessage: string | undefined,
draftMetadata?: FlowDeployMetadata
existing: Flow | undefined,
deploymentMessage: string | undefined
): FlowDeployRequestBody {
const metadata = mergeDeployMetadata(existing, draftMetadata)
const onBehalfOfEmail = metadata.on_behalf_of_email
return {
path,
summary: draftSummary ?? metadata.summary ?? '',
description: metadata.description ?? '',
summary: draftSummary ?? existing?.summary ?? '',
description: existing?.description ?? '',
value: flowValueWithDraftGroups(flowDraft),
schema: flowDraft.schema ?? metadata.schema ?? {},
...pickDeployMetadata(metadata, FLOW_DEPLOY_METADATA_FIELDS),
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
schema: flowDraft.schema ?? existing?.schema ?? {},
tag: existing?.tag,
ws_error_handler_muted: existing?.ws_error_handler_muted,
priority: existing?.priority,
dedicated_worker: existing?.dedicated_worker,
timeout: existing?.timeout,
visible_to_runner_only: existing?.visible_to_runner_only,
on_behalf_of_email: existing?.on_behalf_of_email,
preserve_on_behalf_of: preserveOnBehalfOf(existing?.on_behalf_of_email),
labels: existing?.labels,
deployment_message: deploymentMessage
}
}
@@ -0,0 +1,364 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// draftStore.ts is the single precedence point over the DB layer (`./dbDraft`)
// and the localStorage layer (`./userDraftAdapter` + `$lib/userDraft.svelte`).
// Mock all three so the orchestration is exercised in isolation.
vi.mock('./dbDraft', () => ({
saveScriptDbDraft: vi.fn(async () => {}),
saveFlowDbDraft: vi.fn(async () => {}),
saveAppDbDraft: vi.fn(async () => {}),
readScriptDbDraft: vi.fn(async () => {
throw new Error('readScriptDbDraft mock not configured')
}),
readFlowDbDraft: vi.fn(async () => {
throw new Error('readFlowDbDraft mock not configured')
}),
readAppDbDraft: vi.fn(async () => {
throw new Error('readAppDbDraft mock not configured')
})
}))
vi.mock('./userDraftAdapter', () => ({
getGlobalDraftStoragePath: vi.fn((_ws: string, _type: string, path: string) => path),
saveGlobalAppDraft: vi.fn(),
saveGlobalScriptDraft: vi.fn(),
saveGlobalFlowDraft: vi.fn(),
userDraftKindFor: vi.fn((type: string) =>
type === 'app' ? 'raw_app' : type === 'script' || type === 'flow' ? type : undefined
)
}))
vi.mock('$lib/userDraft.svelte', () => ({
UserDraft: {
getLiveEditorDraft: vi.fn(() => undefined),
get: vi.fn(() => undefined),
save: vi.fn()
}
}))
import {
saveScriptDbDraft,
saveFlowDbDraft,
saveAppDbDraft,
readScriptDbDraft,
readFlowDbDraft,
readAppDbDraft
} from './dbDraft'
import {
getGlobalDraftStoragePath,
saveGlobalAppDraft,
saveGlobalScriptDraft,
saveGlobalFlowDraft
} from './userDraftAdapter'
import { UserDraft } from '$lib/userDraft.svelte'
import { hasLiveEditor, loadDraft, persistDraft } from './draftStore'
const ws = 'test-ws'
const readScript = readScriptDbDraft as unknown as ReturnType<typeof vi.fn>
const readFlow = readFlowDbDraft as unknown as ReturnType<typeof vi.fn>
const readApp = readAppDbDraft as unknown as ReturnType<typeof vi.fn>
const saveScript = saveScriptDbDraft as unknown as ReturnType<typeof vi.fn>
const saveFlow = saveFlowDbDraft as unknown as ReturnType<typeof vi.fn>
const saveApp = saveAppDbDraft as unknown as ReturnType<typeof vi.fn>
const storagePathMock = getGlobalDraftStoragePath as unknown as ReturnType<typeof vi.fn>
const saveAppMirror = saveGlobalAppDraft as unknown as ReturnType<typeof vi.fn>
const saveScriptMirror = saveGlobalScriptDraft as unknown as ReturnType<typeof vi.fn>
const saveFlowMirror = saveGlobalFlowDraft as unknown as ReturnType<typeof vi.fn>
const getLiveEditorDraft = UserDraft.getLiveEditorDraft as unknown as ReturnType<typeof vi.fn>
const userDraftGet = UserDraft.get as unknown as ReturnType<typeof vi.fn>
// Helpers to drive the "is a live editor open for this path" flag.
function openLiveEditor(storagePath: string, effectivePath?: string) {
getLiveEditorDraft.mockImplementation(() => ({ storagePath, effectivePath }))
}
function noLiveEditor() {
getLiveEditorDraft.mockImplementation(() => undefined)
}
beforeEach(() => {
vi.clearAllMocks()
storagePathMock.mockImplementation((_ws: string, _type: string, path: string) => path)
noLiveEditor()
userDraftGet.mockImplementation(() => undefined)
})
describe('hasLiveEditor', () => {
it('false when no live editor is open', () => {
noLiveEditor()
expect(hasLiveEditor(ws, 'script', 'u/me/foo')).toBe(false)
})
it('true when a live editor is open for this (type, path)', () => {
openLiveEditor('u/me/foo')
expect(hasLiveEditor(ws, 'script', 'u/me/foo')).toBe(true)
})
it('matches on the effectivePath too', () => {
openLiveEditor('draft-storage-path', 'u/me/foo')
// getGlobalDraftStoragePath would resolve u/me/foo -> the live storagePath.
storagePathMock.mockImplementation(() => 'draft-storage-path')
expect(hasLiveEditor(ws, 'script', 'u/me/foo')).toBe(true)
})
it('false when a live editor is open for a different path', () => {
openLiveEditor('u/me/other')
expect(hasLiveEditor(ws, 'script', 'u/me/foo')).toBe(false)
})
it('false for non Group-A types', () => {
openLiveEditor('u/me/foo')
expect(hasLiveEditor(ws, 'variable' as any, 'u/me/foo')).toBe(false)
})
})
describe('loadDraft - no live editor (DB is source of truth)', () => {
it('script: returns the DB draft value + flags when a DB draft exists', async () => {
noLiveEditor()
readScript.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { path: 'u/me/foo', content: 'draft content', language: 'bun', kind: 'script' },
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
})
const res = await loadDraft('script', 'u/me/foo', ws)
expect(res.source).toBe('db')
expect((res.value as any)?.content).toBe('draft content')
expect(res.itemExists).toBe(true)
expect(res.hasDbDraft).toBe(true)
expect(res.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' })
// No localStorage read on the DB branch.
expect(userDraftGet).not.toHaveBeenCalled()
})
it('script: returns the deployed value when no DB draft exists', async () => {
noLiveEditor()
readScript.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: false,
value: { path: 'u/me/foo', content: 'deployed', language: 'bun', kind: 'script' },
meta: { remoteRev: 'h1' }
})
const res = await loadDraft('script', 'u/me/foo', ws)
expect(res.source).toBe('deployed')
expect((res.value as any)?.content).toBe('deployed')
})
it('script: returns none when the item is not found', async () => {
noLiveEditor()
readScript.mockResolvedValueOnce({
itemExists: false,
deployedExists: false,
draftOnly: false,
hasDbDraft: false,
value: undefined,
meta: {}
})
const res = await loadDraft('script', 'u/me/missing', ws)
expect(res.source).toBe('none')
expect(res.value).toBeUndefined()
expect(res.itemExists).toBe(false)
})
it('flow: reads from the DB layer', async () => {
noLiveEditor()
readFlow.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { path: 'u/me/flow', value: { modules: [{ id: 'a' }] } },
meta: { remoteRev: 7, remoteDraftRev: 'd2' }
})
const res = await loadDraft('flow', 'u/me/flow', ws)
expect(res.source).toBe('db')
expect((res.value as any)?.value).toEqual({ modules: [{ id: 'a' }] })
expect(readFlow).toHaveBeenCalledWith(ws, 'u/me/flow')
})
it('app: reads from the DB layer (AppDraftValue shape)', async () => {
noLiveEditor()
readApp.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { summary: 's', files: { '/a.tsx': 'x' }, runnables: {}, data: { tables: [] } },
meta: { remoteRev: 4, remoteDraftRev: 'd3' }
})
const res = await loadDraft('app', 'u/me/app', ws)
expect(res.source).toBe('db')
expect((res.value as any)?.files).toEqual({ '/a.tsx': 'x' })
})
})
describe('loadDraft - live editor open (localStorage mirror wins)', () => {
it('script: returns the raw localStorage NewScript when a live editor + localStorage draft exist', async () => {
openLiveEditor('u/me/foo')
const localValue = { path: 'u/me/foo', content: 'LIVE EDIT', language: 'bun', kind: 'script' }
userDraftGet.mockImplementation(() => localValue)
const res = await loadDraft('script', 'u/me/foo', ws)
expect(res.source).toBe('live')
expect((res.value as any)?.content).toBe('LIVE EDIT')
// The live read must use the per-type itemKind + the live storage path.
expect(userDraftGet).toHaveBeenCalledWith('script', 'u/me/foo', { workspace: ws })
// A pure live read does NOT need the DB call.
expect(readScript).not.toHaveBeenCalled()
})
it('app: returns the raw localStorage AppDraftValue on the live branch', async () => {
openLiveEditor('u/me/app')
const localValue = {
summary: 'live',
files: { '/a.tsx': 'LIVE' },
runnables: {},
data: { tables: [] }
}
userDraftGet.mockImplementation(() => localValue)
const res = await loadDraft('app', 'u/me/app', ws)
expect(res.source).toBe('live')
expect((res.value as any)?.files).toEqual({ '/a.tsx': 'LIVE' })
expect(userDraftGet).toHaveBeenCalledWith('raw_app', 'u/me/app', { workspace: ws })
expect(readApp).not.toHaveBeenCalled()
})
it('falls back to the DB draft when a live editor is open but has no localStorage entry', async () => {
openLiveEditor('u/me/foo')
userDraftGet.mockImplementation(() => undefined)
readScript.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { path: 'u/me/foo', content: 'db draft', language: 'bun', kind: 'script' },
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
})
const res = await loadDraft('script', 'u/me/foo', ws)
expect(res.source).toBe('db')
expect((res.value as any)?.content).toBe('db draft')
})
it('ignores a stale localStorage entry when NO live editor is open (DB is truth)', async () => {
noLiveEditor()
userDraftGet.mockImplementation(() => ({ content: 'STALE LOCAL', language: 'bun' }))
readScript.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { path: 'u/me/foo', content: 'db draft', language: 'bun', kind: 'script' },
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
})
const res = await loadDraft('script', 'u/me/foo', ws)
expect(res.source).toBe('db')
expect((res.value as any)?.content).toBe('db draft')
})
})
describe('persistDraft - no live editor (DB only)', () => {
it('script: writes the DB draft and does NOT mirror to localStorage', async () => {
noLiveEditor()
// itemExists provided -> no extra read.
await persistDraft('script', 'u/me/foo', ws, {
path: 'u/me/foo',
content: 'c',
language: 'bun',
kind: 'script'
} as any, { itemExists: true })
expect(saveScript).toHaveBeenCalledTimes(1)
expect(saveScript.mock.calls[0][3]).toEqual({ itemExists: true })
expect(saveScriptMirror).not.toHaveBeenCalled()
expect(UserDraft.save).not.toHaveBeenCalled()
// itemExists was provided -> no read.
expect(readScript).not.toHaveBeenCalled()
})
it('reads itemExists once when not provided, then threads it into save*', async () => {
noLiveEditor()
readScript.mockResolvedValueOnce({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: false,
value: { path: 'u/me/foo', content: 'deployed', language: 'bun', kind: 'script' },
meta: { remoteRev: 'h1' }
})
await persistDraft('script', 'u/me/foo', ws, {
path: 'u/me/foo',
content: 'c',
language: 'bun',
kind: 'script'
} as any)
expect(readScript).toHaveBeenCalledTimes(1)
expect(saveScript).toHaveBeenCalledTimes(1)
expect(saveScript.mock.calls[0][3]).toEqual({ itemExists: true })
})
})
describe('persistDraft - live editor open (DB write + localStorage mirror)', () => {
it('script: writes the DB draft AND mirrors to localStorage with fresh meta', async () => {
openLiveEditor('u/me/foo')
// The post-write read provides the fresh remoteDraftRev for the mirror (option a).
readScript.mockResolvedValue({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { path: 'u/me/foo', content: 'c', language: 'bun', kind: 'script' },
meta: { remoteRev: 'h1', remoteDraftRev: 'fresh-draft-rev' }
})
const draft = { path: 'u/me/foo', content: 'c', language: 'bun', kind: 'script' } as any
await persistDraft('script', 'u/me/foo', ws, draft, { itemExists: true })
expect(saveScript).toHaveBeenCalledTimes(1)
expect(saveScriptMirror).toHaveBeenCalledTimes(1)
const [mws, mpath, mvalue, mmeta] = saveScriptMirror.mock.calls[0]
expect(mws).toBe(ws)
expect(mpath).toBe('u/me/foo')
expect((mvalue as any).content).toBe('c')
// Mirror stamped with the fresh DB-draft rev so the editor banner isn't stale.
expect(mmeta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'fresh-draft-rev' })
})
it('app: mirrors via saveGlobalAppDraft', async () => {
openLiveEditor('u/me/app')
readApp.mockResolvedValue({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { summary: 's', files: {}, runnables: {}, data: { tables: [] } },
meta: { remoteRev: 4, remoteDraftRev: 'fresh-app-rev' }
})
const value = { summary: 's', files: { '/a.tsx': 'x' }, runnables: {}, data: { tables: [] } } as any
await persistDraft('app', 'u/me/app', ws, value, { itemExists: true })
expect(saveApp).toHaveBeenCalledTimes(1)
expect(saveAppMirror).toHaveBeenCalledTimes(1)
const [, , mvalue, mmeta] = saveAppMirror.mock.calls[0]
expect((mvalue as any).files).toEqual({ '/a.tsx': 'x' })
expect(mmeta).toEqual({ remoteRev: 4, remoteDraftRev: 'fresh-app-rev' })
})
it('flow: writes the DB draft AND mirrors', async () => {
openLiveEditor('u/me/flow')
readFlow.mockResolvedValue({
itemExists: true,
deployedExists: true,
draftOnly: false,
hasDbDraft: true,
value: { path: 'u/me/flow', value: { modules: [] } },
meta: { remoteRev: 9, remoteDraftRev: 'fresh-flow-rev' }
})
const draft = { path: 'u/me/flow', value: { modules: [] } } as any
await persistDraft('flow', 'u/me/flow', ws, draft, { itemExists: true })
expect(saveFlow).toHaveBeenCalledTimes(1)
expect(saveFlowMirror).toHaveBeenCalledTimes(1)
const [, , , mmeta] = saveFlowMirror.mock.calls[0]
expect(mmeta).toEqual({ remoteRev: 9, remoteDraftRev: 'fresh-flow-rev' })
})
})
@@ -0,0 +1,272 @@
import type { Flow, NewScript } from '$lib/gen'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import {
readScriptDbDraft,
readFlowDbDraft,
readAppDbDraft,
saveScriptDbDraft,
saveFlowDbDraft,
saveAppDbDraft,
type DbDraftRead
} from './dbDraft'
import {
getGlobalDraftStoragePath,
saveGlobalAppDraft,
saveGlobalScriptDraft,
saveGlobalFlowDraft,
userDraftKindFor
} from './userDraftAdapter'
import type { AppDraftValue, WorkspaceItemType } from './workspaceItems'
/**
* The single precedence point for AI-chat drafts of `script` / `flow` / `app`
* (Group A). It sits ON TOP of two layers and decides, per (type, path),
* whether to read/write localStorage vs the DB `draft` table.
*
* Architecture rule:
* The DB `draft` table is the source of truth for AI-chat drafts.
* localStorage (`UserDraft`) is only a *live-editor mirror*.
*
* Read precedence (`loadDraft`):
* 1. a live editor is open for this (kind, path) AND has a localStorage draft
* -> use the localStorage value (the editor's in-flight edits)
* 2. else the DB draft (`.draft`)
* 3. else the deployed version
* 4. else none
*
* Write fan-out (`persistDraft`):
* - ALWAYS write the DB draft (`saveXDbDraft`).
* - IFF a live editor is open for this (kind, path), ALSO mirror the value to
* localStorage so the open editor updates live.
*
* The value shapes are kept identical to the DB layer's per-type shapes:
* script -> NewScript, flow -> Flow, app -> AppDraftValue.
* The localStorage layer stores those same shapes (script -> NewScript,
* flow -> Flow, raw_app -> a flattened AppDraftValue), so reads/writes round
* trip between chat and an open editor without lossy conversions.
*/
export type DraftType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
type DraftValueOf = {
script: NewScript
flow: Flow
app: AppDraftValue
}
/**
* Where the loaded value came from:
* - 'live' — an open live editor's localStorage mirror (in-flight edits)
* - 'db' — the DB draft row (`.draft`)
* - 'deployed' — the deployed version (no draft exists)
* - 'none' — nothing exists at this path
*/
export type LoadedDraftSource = 'live' | 'db' | 'deployed' | 'none'
export type LoadedDraft<T extends DraftType = DraftType> = {
source: LoadedDraftSource
value: DraftValueOf[T] | undefined
/**
* Rev metadata of the resolved value. For DB/deployed branches this is the
* `DbDraftRead.meta`. For the live branch it is empty (a pure read of an
* open editor's mirror doesn't need DB flags).
*/
meta: UserDraftMeta
itemExists: boolean
deployedExists: boolean
draftOnly: boolean
hasDbDraft: boolean
}
function readDbDraft<T extends DraftType>(
type: T,
path: string,
workspace: string
): Promise<DbDraftRead<DraftValueOf[T]>> {
switch (type) {
case 'script':
return readScriptDbDraft(workspace, path) as Promise<DbDraftRead<DraftValueOf[T]>>
case 'flow':
return readFlowDbDraft(workspace, path) as Promise<DbDraftRead<DraftValueOf[T]>>
default:
return readAppDbDraft(workspace, path) as Promise<DbDraftRead<DraftValueOf[T]>>
}
}
/**
* Is a live editor currently open for this Group-A (type, path)?
*
* True when a live editor handle is registered for the item kind AND its
* storage path resolves to the same path we're asking about (so an editor open
* for a *different* path doesn't count). `getGlobalDraftStoragePath` resolves
* `path` against the live editor (returning the live storage path when `path`
* matches its storagePath or effectivePath), so a match means "this path".
*/
export function hasLiveEditor(
workspace: string,
type: WorkspaceItemType,
path: string
): boolean {
const itemKind = userDraftKindFor(type)
if (!itemKind) return false
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (!liveDraft) return false
return getGlobalDraftStoragePath(workspace, type, path) === liveDraft.storagePath
}
/**
* Read the raw localStorage mirror value for an OPEN live editor, in the
* per-type shape the DB layer uses (script -> NewScript, flow -> Flow,
* app -> AppDraftValue). Returns `undefined` when there is no localStorage
* entry (e.g. the editor was opened but never edited) — the caller then falls
* back to the DB.
*
* Reads the raw value via `UserDraft.get` rather than `getGlobalDraft`'s
* `WorkspaceItem` (whose `.value` is lossy/variable per type).
*/
function readLiveValue<T extends DraftType>(
type: T,
path: string,
workspace: string
): DraftValueOf[T] | undefined {
const itemKind = userDraftKindFor(type)
if (!itemKind) return undefined
const storagePath = getGlobalDraftStoragePath(workspace, type, path)
return UserDraft.get<DraftValueOf[T]>(itemKind, storagePath, { workspace })
}
/**
* THE read precedence. See the module header for the rules.
*
* For the 'live' branch it skips the DB call entirely — a pure read of an open
* editor's mirror doesn't need the DB flags. For 'db'/'deployed'/'none' it
* returns the `DbDraftRead` flags so callers (and the write path) can reuse
* `itemExists` etc. without re-fetching.
*/
export async function loadDraft<T extends DraftType>(
type: T,
path: string,
workspace: string
): Promise<LoadedDraft<T>> {
if (hasLiveEditor(workspace, type, path)) {
const liveValue = readLiveValue(type, path, workspace)
if (liveValue !== undefined) {
return {
source: 'live',
value: liveValue,
meta: {},
// A live editor implies the item participates; precise DB flags
// aren't needed for a pure read, so report the optimistic shape.
itemExists: true,
deployedExists: false,
draftOnly: false,
hasDbDraft: false
}
}
}
const db = await readDbDraft(type, path, workspace)
const source: LoadedDraftSource = !db.itemExists ? 'none' : db.hasDbDraft ? 'db' : 'deployed'
return {
source,
value: db.value,
meta: db.meta,
itemExists: db.itemExists,
deployedExists: db.deployedExists,
draftOnly: db.draftOnly,
hasDbDraft: db.hasDbDraft
}
}
async function saveDbDraft<T extends DraftType>(
type: T,
path: string,
workspace: string,
value: DraftValueOf[T],
itemExists: boolean,
bundle?: () => Promise<{ js: string; css: string }>
): Promise<void> {
switch (type) {
case 'script':
await saveScriptDbDraft(workspace, path, value as NewScript, { itemExists })
return
case 'flow':
await saveFlowDbDraft(workspace, path, value as Flow, { itemExists })
return
default:
await saveAppDbDraft(workspace, path, value as AppDraftValue, { itemExists, bundle })
return
}
}
function mirrorToLocalStorage<T extends DraftType>(
type: T,
path: string,
workspace: string,
value: DraftValueOf[T],
meta: UserDraftMeta
): void {
switch (type) {
case 'script':
saveGlobalScriptDraft(workspace, path, value as NewScript, meta)
return
case 'flow':
saveGlobalFlowDraft(workspace, path, value as Flow, meta)
return
default:
saveGlobalAppDraft(workspace, path, value as AppDraftValue, meta)
return
}
}
export type PersistDraftOptions = {
/**
* Whether a row already exists at this path in the item table (deployed or
* draft_only). Pass it when the caller already read it (e.g. from a prior
* `loadDraft`) to avoid the redundant fetch. Omit it and this function reads
* it once.
*/
itemExists?: boolean
/**
* App-only: compiled bundle, used solely when a new draft_only anchor must
* be created (see `saveAppDbDraft`).
*/
bundle?: () => Promise<{ js: string; css: string }>
}
/**
* THE write. Always persists the DB draft, then mirrors to localStorage IFF a
* live editor is open for this (type, path).
*
* Staleness (chosen option A — see module note below): after the DB write, when
* a live editor is open, do one `readXDbDraft` to fetch the fresh
* `meta.remoteDraftRev` (the new draft row's `created_at`) and stamp it on the
* mirror. `DraftService.createDraft` doesn't return the new `created_at`, and
* the mirror's content now EQUALS the new DB draft — so without the fresh rev
* the editor's `checkStaleness` banner would false-positive. The extra read
* happens on writes only (rare) and on the live branch only, so the cost is
* negligible relative to keeping the editor banner correct.
*/
export async function persistDraft<T extends DraftType>(
type: T,
path: string,
workspace: string,
value: DraftValueOf[T],
opts: PersistDraftOptions = {}
): Promise<void> {
let itemExists = opts.itemExists
if (itemExists === undefined) {
const db = await readDbDraft(type, path, workspace)
itemExists = db.itemExists
}
await saveDbDraft(type, path, workspace, value, itemExists, opts.bundle)
// Mirror to localStorage only when a live editor is open for this path.
if (!hasLiveEditor(workspace, type, path)) return
// Re-read the DB draft for the fresh `remoteDraftRev` so the mirror's
// staleness check sees it as in-sync with the DB draft we just wrote.
const fresh = await readDbDraft(type, path, workspace)
mirrorToLocalStorage(type, path, workspace, value, fresh.meta)
}
@@ -129,6 +129,19 @@ export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
}
/**
* Maps a workspace item type to the localStorage `UserDraftItemKind` slot it is
* stored under. Exposed so the draft orchestrator (`draftStore.ts`) can drive
* `UserDraft.get`/`UserDraft.save` with the same kind mapping the adapter uses
* internally (script -> 'script', flow -> 'flow', app -> 'raw_app').
*/
export function userDraftKindFor(
type: WorkspaceItemType,
triggerKind?: TriggerKind
): UserDraftItemKind | undefined {
return itemKindFor(type, triggerKind)
}
function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem {
return {
type: 'script',
@@ -346,6 +359,45 @@ export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
return Array.from(drafts.values())
}
/**
* Mirror a `script` draft into localStorage so an open live editor updates
* immediately. Resolves the live editor's storage path and writes the raw
* `NewScript` under the `script` kind, optionally stamping rev metadata so the
* editor's staleness banner stays correct.
*/
export function saveGlobalScriptDraft(
workspace: string,
path: string,
draft: NewScript,
meta?: UserDraftMeta
): void {
const storagePath = resolveDraftStoragePath(workspace, 'script', path)
if (meta) {
UserDraft.setDraftAndMeta('script', storagePath, draft, meta, { workspace })
} else {
UserDraft.save('script', storagePath, draft, { workspace })
}
}
/**
* Mirror a `flow` draft into localStorage so an open live editor updates
* immediately. Resolves the live editor's storage path and writes the raw
* `Flow` under the `flow` kind, optionally stamping rev metadata.
*/
export function saveGlobalFlowDraft(
workspace: string,
path: string,
draft: Flow,
meta?: UserDraftMeta
): void {
const storagePath = resolveDraftStoragePath(workspace, 'flow', path)
if (meta) {
UserDraft.setDraftAndMeta('flow', storagePath, draft, meta, { workspace })
} else {
UserDraft.save('flow', storagePath, draft, { workspace })
}
}
export function saveGlobalAppDraft(
workspace: string,
path: string,
@@ -364,32 +416,6 @@ export function saveGlobalAppDraft(
return stored
}
export function syncLiveGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
value: unknown,
meta?: UserDraftMeta,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (liveDraft?.storagePath !== storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
return undefined
}
const normalized = type === 'app' ? normalizeAppDraftValue(value as AppDraftValue) : value
if (meta) {
UserDraft.setDraftAndMeta(itemKind, storagePath, normalized, meta, { workspace })
} else {
UserDraft.save(itemKind, storagePath, normalized, { workspace })
}
return getGlobalDraft(workspace, type, path, triggerKind)
}
type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
@@ -1,610 +0,0 @@
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
import { inferArgs } from '$lib/infer'
import { emptySchema } from '$lib/utils'
import type { AppWithLastVersion, Flow, ListableApp, NewScript, Script } from '$lib/gen/types.gen'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import type {
AppDraftValue,
FlowDraftValue,
TriggerKind,
WorkspaceItem,
WorkspaceItemType
} from './workspaceItems'
import {
buildFlowDeployRequestBody,
buildScriptDeployRequestBody,
type FlowDeployMetadata,
type ScriptDeployMetadata
} from './deployRequests'
import { getGlobalDraft, getGlobalDraftStoragePath, syncLiveGlobalDraft } from './userDraftAdapter'
export type DraftFlagged = {
has_draft?: boolean
draft_only?: boolean
}
export type ReadableScript = Pick<Script, 'path' | 'summary' | 'language' | 'content'> &
DraftFlagged
export type DbDraftWorkspaceItemType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
export type DeployableDraft = {
item: WorkspaceItem
existingScript?: ScriptDeployMetadata
scriptMetadata?: ScriptDeployMetadata
existingFlow?: FlowDeployMetadata
flowMetadata?: FlowDeployMetadata
appExists?: boolean
}
type DeployStatusHandler = (content: string) => void
type LoadedAppDraftValue = {
value: AppDraftValue
meta?: UserDraftMeta
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
}
export function scriptToItem(
script: ReadableScript,
includeValue: boolean,
isDraft = !!script.has_draft || !!script.draft_only
): WorkspaceItem {
return {
type: 'script',
path: script.path,
summary: script.summary,
language: script.language,
value: includeValue ? script.content : undefined,
isDraft
}
}
export function flowToItem(
flow: Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged,
includeValue: boolean,
isDraft = !!flow.has_draft || !!flow.draft_only
): WorkspaceItem {
return {
type: 'flow',
path: flow.path,
summary: flow.summary,
value: includeValue
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
: undefined,
isDraft
}
}
export function appToItem(
app: (ListableApp | AppWithLastVersion) & DraftFlagged,
includeValue: boolean
): WorkspaceItem {
return {
type: 'app',
path: app.path,
summary: app.summary,
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
isDraft: !!app.has_draft || !!app.draft_only
}
}
export async function loadScriptWithDbDraft(
path: string,
workspace: string
): Promise<ReadableScript> {
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
return script.draft ? { ...script, ...script.draft, path: script.path } : script
}
export async function loadFlowWithDbDraft(
path: string,
workspace: string
): Promise<Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged> {
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
return flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
}
export function isDbDraftWorkspaceItemType(
type: WorkspaceItemType
): type is DbDraftWorkspaceItemType {
return type === 'script' || type === 'flow' || type === 'app'
}
async function createDbDraft(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string,
value: unknown
): Promise<void> {
await DraftService.createDraft({
workspace,
requestBody: {
path,
typ,
value
}
})
}
async function deleteDbDraft(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string
): Promise<void> {
await DraftService.deleteDraft({ workspace, kind: typ, path })
}
export async function deleteDbDraftAndDraftOnlyAnchor(
workspace: string,
typ: DbDraftWorkspaceItemType,
path: string
): Promise<void> {
switch (typ) {
case 'script': {
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPathWithDraft({ workspace, path })
: undefined
if (existing?.draft_only) {
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
}
await deleteDbDraft(workspace, typ, path)
break
}
case 'flow': {
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPathWithDraft({ workspace, path })
: undefined
if (existing?.draft_only) {
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
}
await deleteDbDraft(workspace, typ, path)
break
}
case 'app': {
const existing = (await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPathWithDraft({ workspace, path })
: undefined
if (existing?.draft_only) {
await AppService.deleteApp({ workspace, path })
}
await deleteDbDraft(workspace, typ, path)
break
}
}
}
export async function loadDbDraftForDeploy(
workspace: string,
type: DbDraftWorkspaceItemType,
path: string
): Promise<DeployableDraft | undefined> {
switch (type) {
case 'script': {
if (!(await ScriptService.existsScriptByPath({ workspace, path }))) return undefined
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
if (!script.draft && !script.draft_only) return undefined
const draftScript = script.draft
? { ...script, ...script.draft, path: script.draft.path ?? script.path }
: script
return {
item: scriptToItem(draftScript, true, true),
existingScript: script,
scriptMetadata: draftScript
}
}
case 'flow': {
if (!(await FlowService.existsFlowByPath({ workspace, path }))) return undefined
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
if (!flow.draft && !flow.draft_only) return undefined
const draftFlow = flow.draft
? { ...flow, ...flow.draft, path: flow.draft.path ?? flow.path }
: flow
return {
item: flowToItem(draftFlow, true, true),
existingFlow: flow,
flowMetadata: draftFlow
}
}
case 'app': {
if (!(await AppService.existsApp({ workspace, path }))) return undefined
const app = await AppService.getAppByPathWithDraft({ workspace, path })
if (!app.draft && !app.draft_only) return undefined
const draftPath = (app.draft as { path?: string } | undefined)?.path ?? app.path
const value = appSourceToDraftValue(app.draft ?? app, app)
return {
item: {
type: 'app',
path: draftPath,
summary: value.summary,
value,
isDraft: true
},
appExists: true
}
}
}
}
export async function loadDbDraftItem(
workspace: string,
type: DbDraftWorkspaceItemType,
path: string
): Promise<WorkspaceItem | undefined> {
return (await loadDbDraftForDeploy(workspace, type, path))?.item
}
export async function loadWorkspaceDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<WorkspaceItem | undefined> {
const draft = getGlobalDraft(workspace, type, path, triggerKind)
if (draft) return draft
if (!isDbDraftWorkspaceItemType(type)) return undefined
return loadDbDraftItem(workspace, type, path)
}
export async function loadWorkspaceDraftForDeploy(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<DeployableDraft | undefined> {
const item = getGlobalDraft(workspace, type, path, triggerKind)
if (item) {
const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
switch (type) {
case 'script':
return {
item,
existingScript: (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPath({ workspace, path })
: undefined,
scriptMetadata: UserDraft.get<NewScript>('script', storagePath, { workspace })
}
case 'flow':
return {
item,
existingFlow: (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPath({ workspace, path })
: undefined,
flowMetadata: UserDraft.get<Flow>('flow', storagePath, { workspace })
}
case 'app':
return { item, appExists: await AppService.existsApp({ workspace, path }) }
default:
return { item }
}
}
if (!isDbDraftWorkspaceItemType(type)) return undefined
return loadDbDraftForDeploy(workspace, type, path)
}
export async function deployScriptDraft(args: {
workspace: string
path: string
draft: WorkspaceItem
existing?: ScriptDeployMetadata
deploymentMessage?: string
draftMetadata?: ScriptDeployMetadata
}): Promise<void> {
const { workspace, path, draft, existing, deploymentMessage, draftMetadata } = args
const requestBody = buildScriptDeployRequestBody(
path,
draft,
existing,
deploymentMessage,
draftMetadata
)
try {
const schema = emptySchema()
await inferArgs(requestBody.language, requestBody.content, schema)
requestBody.schema = schema
} catch (e) {
console.error('Failed to infer script schema before deploy', e)
}
await ScriptService.createScript({ workspace, requestBody })
}
export async function deployFlowDraft(args: {
workspace: string
path: string
targetPath?: string
draft: WorkspaceItem
existing?: FlowDeployMetadata
deploymentMessage?: string
draftMetadata?: FlowDeployMetadata
}): Promise<void> {
const {
workspace,
path,
targetPath = path,
draft,
existing,
deploymentMessage,
draftMetadata
} = args
const flowDraft = draft.value as FlowDraftValue
const requestBody = buildFlowDeployRequestBody(
targetPath,
draft.summary,
flowDraft,
existing,
deploymentMessage,
draftMetadata
)
if (existing) {
await FlowService.updateFlow({ workspace, path, requestBody })
} else {
await FlowService.createFlow({ workspace, requestBody })
}
}
export async function deployAppDraft(args: {
workspace: string
path: string
targetPath?: string
draft: WorkspaceItem
appExists?: boolean
deploymentMessage?: string
onStatus?: DeployStatusHandler
}): Promise<void> {
const { workspace, path, targetPath = path, draft, appExists, deploymentMessage, onStatus } = args
const appDraft = draft.value as AppDraftValue
const appValue: AppDraftValue = {
...appDraft,
files: { ...(appDraft.files ?? {}) },
runnables: { ...(appDraft.runnables ?? {}) },
data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA }
}
await recomputeAppPolicy(appValue)
const policy = appValue.policy
if (!policy) {
throw new Error(`Draft app "${targetPath}" has no policy to deploy.`)
}
onStatus?.(`Bundling app "${targetPath}"...`)
const bundle = await bundleRawAppDraft({
workspace,
files: appValue.files,
onLog: (delta) => {
const lines = delta
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const latest = lines[lines.length - 1]
if (latest) {
onStatus?.(`Bundling app "${targetPath}"... ${latest}`)
}
}
})
onStatus?.(`Deploying app "${targetPath}"...`)
const rawAppValue = {
files: appValue.files,
runnables: appValue.runnables,
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
}
const summary = appValue.summary ?? draft.summary ?? ''
if (appExists ?? (await AppService.existsApp({ workspace, path }))) {
// Omit custom_path on update for now. The backend preserves it when absent, while
// sending it requires admin privileges; this chat deploy path does not yet mirror
// the raw app editor's user/admin-specific custom_path handling.
await AppService.updateAppRaw({
workspace,
path,
formData: {
app: {
path: targetPath,
value: rawAppValue,
summary,
policy,
deployment_message: deploymentMessage
},
js: bundle.js,
css: bundle.css
}
})
} else {
await AppService.createAppRaw({
workspace,
formData: {
app: {
path: targetPath,
value: rawAppValue,
summary,
policy,
deployment_message: deploymentMessage,
custom_path: appValue.custom_path
},
js: bundle.js,
css: bundle.css
}
})
}
}
export async function saveScriptDraftToDb(
workspace: string,
path: string,
draft: NewScript,
existing: Awaited<ReturnType<typeof ScriptService.getScriptByPathWithDraft>> | undefined
): Promise<void> {
if (!existing) {
await ScriptService.createScript({
workspace,
requestBody: {
...draft,
path,
parent_hash: undefined,
draft_only: true
}
})
}
await createDbDraft(workspace, 'script', path, draft)
}
export async function saveFlowDraftToDb(
workspace: string,
path: string,
draft: Flow,
existing: Awaited<ReturnType<typeof FlowService.getFlowByPathWithDraft>> | undefined
): Promise<void> {
if (!existing) {
await FlowService.createFlow({
workspace,
requestBody: {
path,
summary: draft.summary ?? '',
description: (draft as any).description ?? '',
value: draft.value,
schema: draft.schema,
tag: draft.tag,
draft_only: true,
ws_error_handler_muted: draft.ws_error_handler_muted,
visible_to_runner_only: draft.visible_to_runner_only,
on_behalf_of_email: draft.on_behalf_of_email,
labels: draft.labels
}
})
}
await createDbDraft(workspace, 'flow', path, draft)
}
function appDraftToDbValue(path: string, value: AppDraftValue): Record<string, unknown> {
return {
value: {
files: value.files,
runnables: value.runnables,
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
},
summary: value.summary ?? '',
policy: value.policy,
path,
custom_path: value.custom_path
}
}
async function createRawAppDraftOnlyAnchor(
workspace: string,
path: string,
value: AppDraftValue
): Promise<void> {
await recomputeAppPolicy(value)
const policy = value.policy
if (!policy) {
throw new Error(`Draft app "${path}" has no policy to save.`)
}
const bundle = await bundleRawAppDraft({
workspace,
files: value.files,
onLog: () => {}
})
await AppService.createAppRaw({
workspace,
formData: {
app: {
path,
value: {
files: value.files,
runnables: value.runnables,
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
},
summary: value.summary ?? '',
policy,
draft_only: true,
custom_path: value.custom_path
},
js: bundle.js,
css: bundle.css
}
})
}
async function saveAppDraftToDb(
workspace: string,
path: string,
value: AppDraftValue,
existing: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>> | undefined
): Promise<void> {
const current =
existing ??
((await AppService.existsApp({ workspace, path }))
? await AppService.getAppByPathWithDraft({ workspace, path })
: undefined)
if (!current) {
await createRawAppDraftOnlyAnchor(workspace, path, value)
}
await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value))
}
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
return appSourceToRawAppDraft(app, fallback)
}
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
return {
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
remoteDraftRev: app.draft_created_at
}
}
export async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
const draft = getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return draft.value as AppDraftValue
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
return appSourceToDraftValue(app.draft ?? app, app)
}
export async function loadAppDraftValue(
path: string,
workspace: string
): Promise<LoadedAppDraftValue> {
const draft = getGlobalDraft(workspace, 'app', path)
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
return { value: draft.value as AppDraftValue }
}
const app = await AppService.getAppByPathWithDraft({ workspace, path })
const value = appSourceToDraftValue(app.draft ?? app, app)
return { value, meta: appDraftMeta(app), existing: app }
}
export async function saveAppDraft(
workspace: string,
path: string,
value: AppDraftValue,
meta?: UserDraftMeta,
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
): Promise<WorkspaceItem> {
await saveAppDraftToDb(workspace, path, value, existing)
const mirrored = syncLiveGlobalDraft(workspace, 'app', path, value, meta)
if (mirrored) return mirrored
return {
type: 'app',
path,
summary: value.summary,
value,
isDraft: true
}
}
export async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
const policy = (await updateRawAppPolicy(
value.runnables as any,
value.policy as any
)) as NonNullable<AppDraftValue['policy']>
if (!policy.execution_mode) {
policy.execution_mode = 'publisher'
}
value.policy = policy
}
@@ -1,43 +0,0 @@
import { DEFAULT_DATA, type RawAppData } from './dataTableRefUtils'
// The raw-app draft shape stored under `UserDraft<RawAppDraft>`.
export type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
function normalizeRawAppData(value: Record<string, any>): RawAppData {
if (value.data) {
if (value.data.creation) {
return {
tables: value.data.tables ?? [],
datatable: value.data.creation.datatable,
schema: value.data.creation.schema
}
}
return value.data
}
if (value.datatables) {
return { ...DEFAULT_DATA, tables: value.datatables }
}
if (value.dataTableRefs) {
return { ...DEFAULT_DATA, tables: value.dataTableRefs }
}
return { ...DEFAULT_DATA }
}
export function appSourceToRawAppDraft(app: any, fallback?: any): RawAppDraft {
const value = (app.value ?? {}) as Record<string, any>
return {
summary: app.summary ?? '',
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: normalizeRawAppData(value),
policy: app.policy ?? fallback?.policy,
custom_path: app.custom_path ?? fallback?.custom_path
}
}
@@ -50,7 +50,7 @@
}
// Mark this editor as the "live editor" for the session's workspace so
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
// the chat's `isLiveDraft` hint and `discard_draft` tool resolve to
// this path. Same registration the regular /flows/edit page does on
// mount, scoped to the session's (forked) workspace.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
@@ -44,7 +44,7 @@
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// so the chat's `isLiveDraft` hint / `discard_draft` tool resolve
// to this path — same registration the regular /apps_raw/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
@@ -80,7 +80,7 @@
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// so the chat's `isLiveDraft` hint / `discard_draft` tool resolve
// to this path — same registration the regular /scripts/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
@@ -5,7 +5,6 @@ import {
type RuntimeRawApp,
type RawAppDraft
} from './appDraftCodec'
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
function runtime(over: Partial<RuntimeRawApp> = {}): RuntimeRawApp {
return {
@@ -60,49 +59,3 @@ describe('appDraftCodec — custom_path round-trip', () => {
expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('existing')
})
})
describe('appSourceToRawAppDraft', () => {
it('unwraps DB draft wrappers instead of treating the wrapper as app files', () => {
const draft = appSourceToRawAppDraft(
{
summary: 'draft app',
value: {
files: { '/src/App.tsx': 'export default function App() { return "draft" }' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'anonymous' },
custom_path: 'draft-url'
},
{
summary: 'deployed app',
value: {
files: { '/src/App.tsx': 'deployed' },
runnables: {},
data: { tables: [] }
},
policy: { execution_mode: 'publisher' },
custom_path: 'deployed-url'
}
)
expect(draft).toEqual({
summary: 'draft app',
files: { '/src/App.tsx': 'export default function App() { return "draft" }' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
policy: { execution_mode: 'anonymous' },
custom_path: 'draft-url'
})
})
})
@@ -1,7 +1,17 @@
import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils'
import type { RawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
export type { RawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
// The raw-app draft shape stored under `UserDraft<RawAppDraft>` — matches the
// regular `/apps_raw/edit` route's UserDraft handle exactly. The chat's
// `userDraftAdapter.saveGlobalAppDraft` writes through the same shape, so
// session previews and the chat round-trip identically.
export type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
@@ -14,7 +14,6 @@ import {
} from '$lib/gen'
import type { HiddenRunnable } from '$lib/components/apps/types'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
import { workspaceStore } from '$lib/stores'
import { emptySchema, type StateStore } from '$lib/utils'
import {
@@ -461,15 +460,30 @@ function createRuntime(session: Session): SessionRuntime {
draft: result.draft,
custom_path: result.custom_path
}
const sourceDraft = appSourceToRawAppDraft(result.draft ?? result, result)
const sourceValue: any = result.draft ?? result.value
let data: RawAppData = { ...DEFAULT_DATA }
if (sourceValue?.data) {
const d = sourceValue.data
if (d.creation) {
data = {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (sourceValue?.datatables) {
data = { ...DEFAULT_DATA, tables: sourceValue.datatables }
}
const runtimeValue = {
files: sourceDraft.files,
runnables: sourceDraft.runnables,
data: sourceDraft.data,
policy: sourceDraft.policy,
summary: sourceDraft.summary,
files: (sourceValue?.files ?? {}) as Record<string, string>,
runnables: (sourceValue?.runnables ?? {}) as Record<string, any>,
data,
policy: result.policy,
summary: result.summary ?? '',
path: result.path,
custom_path: sourceDraft.custom_path
custom_path: result.custom_path
}
UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace })
rawApp.val = runtimeValue