mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 00:03:59 +00:00
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:
co-authored by
Claude Opus 4.8
parent
36012f7425
commit
fb9714c649
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
|
||||
|
||||
Reference in New Issue
Block a user