Files
windmill/frontend/src/lib/userDraftDbMigration.ts
T
Ruben Fiszel 1cd2cc5fbd feat: bundle data-pipeline drafts into the DB-backed user draft system
Pipeline drafts were browser-only (localStorage `pipeline-<folder>`), so they
didn't sync across devices, weren't server-visible, and never showed in the
drafts list. Store them instead as one per-user `draft` row of a new
`data_pipeline` kind, keyed at the folder (`f/<folder>/data_pipeline`), holding
the same `{ drafts, activeDraftPath }` bundle.

Stage 1 — backend kind: add `data_pipeline` to DRAFT_KIND (migration) and
`UserDraftItemKind` (deployed_table=None, private). The list/update handlers
and folder-path access check already cover a backing-table-less kind.

Stage 2 — sync: add `GET /drafts/get_own/{kind}/{path}` so an editor with no
deployed-overlay GET can load its own draft. The pipeline page now hydrates
from the DB on mount (one-time localStorage import for in-flight drafts) and
persists via UserDraftDbSyncer (debounce + optimistic-concurrency), keeping a
localStorage crash mirror.

Stage 3 — surface: the drafts review page renders the bundle as a "pipeline"
row that opens `/pipeline/<folder>` (open-only; excluded from bulk deploy).

Verified end-to-end in-browser: DB-seeded draft hydrates to "Edit (1)", edits
persist back, and the row shows with Open pipeline / Discard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:49:23 +02:00

222 lines
7.1 KiB
TypeScript

/**
* One-off migration from the localStorage UserDraft autosave to the
* DB-backed `draft` table. Runs after `migrateLegacyUserDrafts` (which
* produces the `userdraft/w/{workspace}/{kind}/{path}` keys this reads),
* POSTing each to `/drafts/update` and clearing the source key only on
* success — so it's idempotent without a sentinel; failed entries retry next
* mount. Not workspace-gated: keys embed their own workspace and the token
* covers all of them, so gating would orphan other-workspace entries.
* Deliberately self-contained (no `userDraft.svelte.ts` import) so the
* runtime module stays free of legacy decoders.
*/
import { DraftService } from './gen'
import type { UserDraftItemKind } from './gen'
import { sendUserToast } from './toast'
import { getUsernameForNamespace } from './userNamespace'
import { randomUUID } from './utils/uuid'
// Mirror of `USER_DRAFT_ITEM_KINDS`, inlined to avoid importing the reactive
// runtime. The `_Exhaustive` assertion below fails compilation if this list
// drifts from the OpenAPI schema's kinds.
const ITEM_KINDS = [
'script',
'flow',
'app',
'raw_app',
'resource',
'variable',
'trigger_schedule',
'trigger_webhook',
'trigger_default_email',
'trigger_email',
'trigger_http',
'trigger_websocket',
'trigger_postgres',
'trigger_kafka',
'trigger_nats',
'trigger_mqtt',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'trigger_poll',
'trigger_cli',
'trigger_nextcloud',
'trigger_google',
'trigger_github',
'data_pipeline'
] as const satisfies readonly UserDraftItemKind[]
type _Exhaustive =
Exclude<UserDraftItemKind, (typeof ITEM_KINDS)[number]> extends never ? true : never
const _: _Exhaustive = true
void _
const KEY_PREFIX = 'userdraft/w/'
type ParsedKey = {
key: string
workspace: string
itemKind: UserDraftItemKind
path: string
}
/**
* Split a `userdraft/w/{workspace}/{kind}/{path}` key into its parts, or
* `undefined` for keys that don't match or have an unrecognized kind.
* `path` is `''` only for a legacy `/add` autosave (key ended `.../{kind}/`);
* the caller mints a fresh slot for those. An empty `path` here is always that
* case, never garbage, since a non-matching key returns `undefined`.
*/
function parseKey(key: string): ParsedKey | undefined {
if (!key.startsWith(KEY_PREFIX)) return undefined
const rest = key.slice(KEY_PREFIX.length)
const firstSlash = rest.indexOf('/')
if (firstSlash <= 0) return undefined
const workspace = rest.slice(0, firstSlash)
const afterWorkspace = rest.slice(firstSlash + 1)
for (const kind of ITEM_KINDS) {
const kindPrefix = `${kind}/`
if (afterWorkspace.startsWith(kindPrefix)) {
const path = afterWorkspace.slice(kindPrefix.length)
return { key, workspace, itemKind: kind, path }
}
}
return undefined
}
/**
* Mint a fresh `u/{user}/draft_{uuid}` slot for a pathless legacy `/add`
* autosave, matching the editors' `/add` redirect convention (`makeDraftAddLoad`).
* Underscores not dashes (path segments are `[a-zA-Z0-9_]` words); `randomUUID`
* not `crypto.randomUUID` (WebCrypto is absent on non-secure origins).
*/
function mintDraftAddPath(): string {
const username = getUsernameForNamespace()
const uuid = randomUUID().replaceAll('-', '_')
return `u/${username}/draft_${uuid}`
}
/**
* Extract `value` and `lastWrittenAt` from the LS payload (`{ value, lastWrittenAt?, ... }`).
* `lastWrittenAt` rides along as `last_sync` so a fresher server draft wins.
* `undefined` when the slot is empty / unparseable / wrong shape.
*/
function readPayload(key: string): { value: unknown; lastWrittenAt?: number } | undefined {
try {
const raw = localStorage.getItem(key)
if (raw == null || raw === 'undefined') return undefined
const parsed = JSON.parse(raw)
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
const lastWrittenAt = (parsed as { lastWrittenAt?: unknown }).lastWrittenAt
return {
value: (parsed as { value: unknown }).value,
lastWrittenAt: typeof lastWrittenAt === 'number' ? lastWrittenAt : undefined
}
} catch {
return undefined
}
}
function collectKeys(): string[] {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null && k.startsWith(KEY_PREFIX)) keys.push(k)
}
return keys
}
/**
* Push every LS `userdraft/...` entry to `/drafts/update`, clearing each on
* success; failures stay in LS and retry next mount.
*
* `lastWrittenAt` rides as `last_sync` so the server rejects the upload when its
* own draft is fresher (the user may have edited the same path from another
* browser since this LS write). Missing `lastWrittenAt` uses epoch 0 (insert if
* absent, else yield). A `conflict` response means the server won; drop the LS
* copy. Never throws — the caller is fire-and-forget.
*/
export async function migrateUserDraftsToDb(): Promise<void> {
if (typeof localStorage === 'undefined') return
const keys = collectKeys()
if (keys.length === 0) return
// Parse up front so we only announce the migration when there's a real
// `userdraft/...` entry to upload (and can drop unparseable junk first).
const toMigrate: {
key: string
parsed: ParsedKey
path: string
value: unknown
lastWrittenAt?: number
}[] = []
for (const key of keys) {
const parsed = parseKey(key)
if (!parsed) continue
const payload = readPayload(key)
if (payload === undefined) {
// Unparseable or empty — clear so we don't keep retrying it.
try {
localStorage.removeItem(key)
} catch {
// ignore
}
continue
}
// A legacy `/add` autosave has no path — mint a fresh slot so it lands
// as a regular draft-only item instead of being dropped.
const path = parsed.path === '' ? mintDraftAddPath() : parsed.path
toMigrate.push({
key,
parsed,
path,
value: payload.value,
lastWrittenAt: payload.lastWrittenAt
})
}
if (toMigrate.length === 0) return
// Legacy drafts detected — tell the user the one-off upload is running.
sendUserToast('Migrating local storage drafts ...', 'info')
for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) {
try {
const res = await DraftService.updateDraft({
workspace: parsed.workspace,
kind: parsed.itemKind,
path,
requestBody: { value, last_sync: new Date(lastWrittenAt ?? 0).toISOString() }
})
if (res.status === 'conflict') {
console.info(
`UserDraft LS→DB migration: server draft for ${path} is fresher, dropping LS copy`
)
}
try {
localStorage.removeItem(key)
} catch {
// Best-effort. If LS removal fails the next mount retries
// the save (the conflict rule keeps it idempotent).
}
} catch (e) {
// Leave the LS entry in place — the next mount tries again — but
// surface it so the user isn't silently stuck, with an escape
// hatch to drop the un-migratable draft.
console.error('UserDraft LS→DB migration: failed for', key, e)
sendUserToast(`Could not migrate draft ${path} in workspace ${parsed.workspace}`, 'error', [
{
label: 'Delete draft',
callback: () => {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
}
])
}
}
}