From 1be390aa878e15a58f530f3a878e8f9caeb89c43 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:50:28 +0200 Subject: [PATCH] fix(frontend): no phantom draft when opening a CLI-pushed script (#10997) * fix(frontend): no phantom draft when opening a CLI-pushed script Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QpqVdiTiqVGCvgmpBzL6m3 * fix(frontend): infer the dbt descriptor schema on mount like ScriptEditor Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QpqVdiTiqVGCvgmpBzL6m3 * fix(frontend): retry the baseline schema inference once like the editors do Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QpqVdiTiqVGCvgmpBzL6m3 --------- Co-authored-by: Claude Fable 5.1 --- .../src/lib/components/ScriptEditor.svelte | 51 +---------- .../src/lib/components/dbt/DbtEditor.svelte | 13 ++- frontend/src/lib/scriptEditorSchema.ts | 90 +++++++++++++++++++ .../scripts/edit/[...path]/+page.svelte | 22 ++++- 4 files changed, 122 insertions(+), 54 deletions(-) create mode 100644 frontend/src/lib/scriptEditorSchema.ts diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index fdab141d03..f8028aeb1f 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -15,7 +15,7 @@ import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils' import Editor from './Editor.svelte' import { inferArgs, inferAssets, inferAnsibleExecutionMode } from '$lib/infer' - import { parsePipelineAnnotations } from '$lib/components/assets/AssetGraph/parsePipelineAnnotations' + import { injectPartitionArg } from '$lib/scriptEditorSchema' import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' import WacDiagram from '$lib/components/graph/WacDiagram.svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -1074,55 +1074,6 @@ } } - // A `// partitioned` pipeline script is materialized one slice at a time and - // receives the slice as a runtime `partition` arg (the cascade injects it in - // production). It isn't a code parameter, so schema inference doesn't see it — - // surface it in the test form so a partitioned script can be run manually. - function injectPartitionArg( - s: any, - a: Record | undefined, - l: string | undefined, - c: string - ) { - try { - if (l !== 'duckdb' || !s?.properties) return - const part = parsePipelineAnnotations(c).partition - if (!part) return - // Date-based partition kinds render a date / datetime picker; a dynamic - // key is a free-form string. - const format = - part.kind === 'hourly' - ? 'date-time' - : part.kind === 'daily' || part.kind === 'weekly' || part.kind === 'monthly' - ? 'date' - : undefined - if (!s.properties['partition']) { - s.properties['partition'] = { - type: 'string', - ...(format ? { format } : {}), - // ISO output so partition keys sort lexicographically (the date - // picker defaults to dd-MM-yyyy otherwise). - ...(format === 'date' ? { dateFormat: 'yyyy-MM-dd' } : {}), - description: - part.kind === 'dynamic' - ? 'Partition key value to materialize.' - : `Partition (${part.kind}) to materialize.` - } - if (Array.isArray(s.order) && !s.order.includes('partition')) { - s.order = ['partition', ...s.order] - } - } - // Pre-fill the *test* arg with the current slice for date kinds — a - // convenience default, kept on the args (not baked into the schema, - // where it would persist to the deployed script and go stale). - if (format && a && (a['partition'] == null || a['partition'] === '')) { - const now = new Date() - a['partition'] = - format === 'date' ? now.toISOString().slice(0, 10) : now.toISOString().slice(0, 16) - } - } catch (e) {} - } - async function inferModuleSchema() { if (activeModuleTab === null) return try { diff --git a/frontend/src/lib/components/dbt/DbtEditor.svelte b/frontend/src/lib/components/dbt/DbtEditor.svelte index f12e510c61..1d3de73e58 100644 --- a/frontend/src/lib/components/dbt/DbtEditor.svelte +++ b/frontend/src/lib/components/dbt/DbtEditor.svelte @@ -8,7 +8,7 @@ // single file, so the arguments, the run and the graph are all the project's // whichever file happens to be open. import { untrack } from 'svelte' - import { createEventDispatcher, onDestroy } from 'svelte' + import { createEventDispatcher, onDestroy, onMount } from 'svelte' import type { Schema, SupportedLanguage } from '$lib/common' import type { Preview, ScriptModule } from '$lib/gen' import { workspaceStore } from '$lib/stores' @@ -97,6 +97,17 @@ } }) + // The stored schema can predate the parser (a CLI push, an older version), and + // the autosave baseline is taken as the mounted editor holds it + // (`schemaAsEditorMounts`), so the descriptor is re-inferred on mount rather + // than only on its first edit — otherwise the two only agree once edited. + onMount(async () => { + await inferSchema(code) + // Same single retry as ScriptEditor: the first parse can lose a transient + // wasm init race, and the baseline (`schemaAsEditorMounts`) retries too. + if (!validDescriptor && code) await inferSchema(code) + }) + function flushOpenFile() { if (openFile !== null && modules?.[openFile]) { modules[openFile] = { ...modules[openFile], content: editorCode } diff --git a/frontend/src/lib/scriptEditorSchema.ts b/frontend/src/lib/scriptEditorSchema.ts new file mode 100644 index 0000000000..7bd13b32a7 --- /dev/null +++ b/frontend/src/lib/scriptEditorSchema.ts @@ -0,0 +1,90 @@ +import type { Schema, SupportedLanguage } from '$lib/common' +import type { Script } from '$lib/gen' +import { inferArgs } from '$lib/infer' +import { emptySchema } from '$lib/utils' +import { parsePipelineAnnotations } from '$lib/components/assets/AssetGraph/parsePipelineAnnotations' + +/** + * A `// partitioned` pipeline script is materialized one slice at a time and + * receives the slice as a runtime `partition` arg (the cascade injects it in + * production). It isn't a code parameter, so schema inference doesn't see it — + * surface it in the test form so a partitioned script can be run manually. + */ +export function injectPartitionArg( + s: any, + a: Record | undefined, + l: string | undefined, + c: string +) { + try { + if (l !== 'duckdb' || !s?.properties) return + const part = parsePipelineAnnotations(c).partition + if (!part) return + // Date-based partition kinds render a date / datetime picker; a dynamic + // key is a free-form string. + const format = + part.kind === 'hourly' + ? 'date-time' + : part.kind === 'daily' || part.kind === 'weekly' || part.kind === 'monthly' + ? 'date' + : undefined + if (!s.properties['partition']) { + s.properties['partition'] = { + type: 'string', + ...(format ? { format } : {}), + // ISO output so partition keys sort lexicographically (the date + // picker defaults to dd-MM-yyyy otherwise). + ...(format === 'date' ? { dateFormat: 'yyyy-MM-dd' } : {}), + description: + part.kind === 'dynamic' + ? 'Partition key value to materialize.' + : `Partition (${part.kind}) to materialize.` + } + if (Array.isArray(s.order) && !s.order.includes('partition')) { + s.order = ['partition', ...s.order] + } + } + // Pre-fill the *test* arg with the current slice for date kinds — a + // convenience default, kept on the args (not baked into the schema, + // where it would persist to the deployed script and go stale). + if (format && a && (a['partition'] == null || a['partition'] === '')) { + const now = new Date() + a['partition'] = + format === 'date' ? now.toISOString().slice(0, 10) : now.toISOString().slice(0, 16) + } + } catch (e) {} +} + +/** + * The schema the script editor holds right after mounting on `content`, without + * any user input: `ScriptEditor.inferSchema` re-infers the main signature into + * the stored schema and injects the duckdb partition arg. A schema stored by + * another producer (a CLI push, an older parser) can lack keys the current + * parser emits, so the editor rewrites it on mount; a draft baseline taken from + * the raw stored schema would then never compare equal to the untouched editor. + * Returns a copy; a parse failure leaves it as far as inference got, which is + * also what the editor keeps. + */ +export async function schemaAsEditorMounts( + language: SupportedLanguage | undefined, + content: string, + schema: unknown, + kind: Script['kind'] | undefined +): Promise { + const copy: Schema = schema ? structuredClone(schema as Schema) : emptySchema() + const infer = async () => { + await inferArgs(language, content, copy, kind === 'preprocessor' ? 'preprocessor' : undefined) + injectPartitionArg(copy, undefined, language, content) + } + try { + await infer() + } catch { + // The editors retry a failed mount inference once (transient wasm init); + // the baseline has to land where that retry leaves the editor. + if (!content || !language) return copy + try { + await infer() + } catch {} + } + return copy +} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index f363aa40c0..4ee73f5207 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -22,6 +22,7 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast' import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte' + import { schemaAsEditorMounts } from '$lib/scriptEditorSchema' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte' @@ -301,6 +302,18 @@ getDraft }) if (tok !== loadScriptToken) return + // The editor re-infers the schema as soon as it mounts, so a baseline + // taken from the raw stored schema would flag every unedited open of a + // script whose stored schema differs from what this parser emits. + const baselineSchema = backendScript.no_deployed + ? undefined + : await schemaAsEditorMounts( + backendScript.language, + backendScript.content ?? '', + backendScript.schema, + backendScript.kind + ) + if (tok !== loadScriptToken) return // Backend only computes `other_drafts_users` when `getDraft`. Don't clobber // the known list on a `getDraft:false` reload (e.g. reset-to-deployed, which // discards only OUR draft and must keep other users' drafts visible). @@ -329,11 +342,14 @@ savedScript = structuredClone($state.snapshot(effectiveScript)) const parentHash = topHash ?? backendScript.hash // Baseline for the autosave `discardIf`: the deployed script with the - // same `parent_hash` graft the seed below applies, so the unedited draft - // compares equal. `undefined` when there's no deployed row. + // same `parent_hash` graft the seed below applies and the schema as the + // mounted editor holds it, so the unedited draft compares equal. + // `undefined` when there's no deployed row. deployedBaseline = backendScript.no_deployed ? undefined - : structuredClone($state.snapshot({ ...deployedScript, parent_hash: parentHash })) + : structuredClone( + $state.snapshot({ ...deployedScript, schema: baselineSchema, parent_hash: parentHash }) + ) // "Load another user's draft" handoff: show their value over the // deployed metadata. If WE already have a draft → overlay mode (never // saved until the user confirms overwriting their own draft).