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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpqVdiTiqVGCvgmpBzL6m3

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-07 17:50:28 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 8f553eab35
commit 1be390aa87
4 changed files with 122 additions and 54 deletions
@@ -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<string, any> | 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 {
@@ -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 }
+90
View File
@@ -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<string, any> | 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<Schema> {
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
}
@@ -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).