-
-
Notifications
You must be signed in to change notification settings - Fork 20
feat(pg-delta): add statement-level debugging to schema apply #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/pg-delta-next
Are you sure you want to change the base?
Changes from 7 commits
84afc36
1cfcfbc
243cacf
f89204c
43fac76
971d5d6
b42ee07
2ac8de7
292395c
c27dc3a
96c9502
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@supabase/pg-delta": minor | ||
| --- | ||
|
|
||
| Add statement-level debugging for `schema apply`: `--dry-run` prints the exact executable script (segment-split, mirroring execution) to stdout without applying anything or running the fingerprint gate; `--verbose` streams a segment/action progress trace to stderr as the real apply runs, including every non-action statement actually sent on the connection (`BEGIN`, preamble `SET`/`SET LOCAL`, `COMMIT`, `ROLLBACK`, `RESET ALL`) since the applied statements are planner-rendered atomic DDL, not the authored declarative SQL; `--out-plan <plan.json>` archives the plan artifact right after planning. The library `apply()` gains a corresponding optional `onEvent` observer (`ApplyEvent`: `segmentStart`/`actionStart`/`actionEnd`/`segmentEnd`/`control`) that any caller can hook into — purely additive, a throwing observer never changes apply's control flow, action statuses, or report. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,35 @@ export interface ApplyReport { | |
| error?: { actionIndex: number; sql: string; message: string }; | ||
| } | ||
|
|
||
| /** Observability events emitted during `apply()` (statement-level debugging | ||
| * for `schema apply --verbose`). Purely additive — no event ever changes | ||
| * what apply() does or reports; see the `onEvent` doc comment below. | ||
| * | ||
| * The applied statements are planner-rendered atomic DDL, not the authored | ||
| * declarative SQL, so `actionStart`/`actionEnd` alone are not a complete | ||
| * record of the wire: `control` covers every OTHER statement apply() sends | ||
| * on the same connection — `BEGIN`, each preamble `SET`/`SET LOCAL`, `COMMIT`, | ||
| * `ROLLBACK` (including best-effort rollbacks on an error path), and | ||
| * `RESET ALL` — so a `--verbose` trace shows exactly what ran, transaction | ||
| * framing included. */ | ||
| export type ApplyEvent = | ||
| | { | ||
| kind: "segmentStart"; | ||
| segmentIndex: number; | ||
| segmentCount: number; | ||
| start: number; | ||
| end: number; | ||
| transactional: boolean; | ||
| } | ||
| | { kind: "actionStart"; actionIndex: number; sql: string } | ||
| | { kind: "actionEnd"; actionIndex: number; ok: boolean; ms: number } | ||
| | { | ||
| kind: "segmentEnd"; | ||
| segmentIndex: number; | ||
| outcome: "committed" | "rolledBack" | "inDoubt"; | ||
| } | ||
| | { kind: "control"; sql: string }; | ||
|
|
||
| export interface ApplyOptions { | ||
| /** re-extract the target and require its fingerprint to equal the | ||
| * plan's source fingerprint (stage 6 deliverable 3). Defaults to ON; | ||
|
|
@@ -50,6 +79,10 @@ export interface ApplyOptions { | |
| * plan was fingerprinted from; otherwise operationally-managed objects present | ||
| * on the real target read as drift and reject a valid managed plan. */ | ||
| reextract?: (pool: Pool) => Promise<{ factBase: FactBase }>; | ||
| /** observability hook (statement-level debugging): fired at segment/action | ||
| * boundaries as apply() executes. Purely additive — see `emit` below for | ||
| * the isolation guarantee. */ | ||
| onEvent?: (event: ApplyEvent) => void; | ||
| } | ||
|
|
||
| interface Segment { | ||
|
|
@@ -109,6 +142,23 @@ function errorEntry( | |
| }; | ||
| } | ||
|
|
||
| /** Fire an observer event, swallowing anything it throws. `onEvent` is a | ||
| * debugging hook (`schema apply --verbose`), never a semantics participant — | ||
| * a throwing observer must NEVER change apply's control flow, action | ||
| * statuses, or the returned report, so every emission is wrapped here rather | ||
| * than left to each call site. */ | ||
| function emit( | ||
| onEvent: ((event: ApplyEvent) => void) | undefined, | ||
| event: ApplyEvent, | ||
| ): void { | ||
| if (onEvent === undefined) return; | ||
| try { | ||
| onEvent(event); | ||
| } catch { | ||
| // swallowed by design — see doc comment above | ||
| } | ||
| } | ||
|
|
||
| export async function apply( | ||
| thePlan: Plan, | ||
| target: Pool, | ||
|
|
@@ -208,42 +258,112 @@ export async function apply( | |
| ), | ||
| ]; | ||
|
|
||
| for (const segment of segments) { | ||
| const onEvent = options?.onEvent; | ||
| for (let segIdx = 0; segIdx < segments.length; segIdx++) { | ||
| const segment = segments[segIdx]!; | ||
| // segmentStart fires before the preamble/BEGIN for this segment, for | ||
| // BOTH transactional and non-transactional segments. | ||
| emit(onEvent, { | ||
| kind: "segmentStart", | ||
| segmentIndex: segIdx, | ||
| segmentCount: segments.length, | ||
| start: segment.start, | ||
| end: segment.end, | ||
| transactional: segment.transactional, | ||
| }); | ||
|
|
||
| if (!segment.transactional) { | ||
| // a lone non-transactional action; session-level settings, reset after | ||
| const index = segment.start; | ||
| const action = thePlan.actions[index]!; | ||
| // the failure return is deferred past the finally so segmentEnd stays | ||
| // the segment's LAST event — after the RESET ALL control — on every | ||
| // path; a trace must never show wire traffic after the outcome line. | ||
| let failure: NonNullable<ApplyReport["error"]> | undefined; | ||
| try { | ||
| for (const sql of preamble(false)) await client.query(sql); | ||
| await client.query(action.sql); | ||
| // session-level preamble SETs hit the wire BEFORE the action; a | ||
| // preamble failure emits NO action events (the action never ran). | ||
| for (const sql of preamble(false)) { | ||
| emit(onEvent, { kind: "control", sql }); | ||
| await client.query(sql); | ||
| } | ||
| const actionStartedAt = Date.now(); | ||
| emit(onEvent, { | ||
| kind: "actionStart", | ||
| actionIndex: index, | ||
| sql: action.sql, | ||
| }); | ||
| try { | ||
| await client.query(action.sql); | ||
| // actionEnd fires as the action settles, so `ms` measures only the | ||
| // action's round-trip (not the RESET ALL below). | ||
| emit(onEvent, { | ||
| kind: "actionEnd", | ||
| actionIndex: index, | ||
| ok: true, | ||
| ms: Date.now() - actionStartedAt, | ||
| }); | ||
| } catch (error) { | ||
| emit(onEvent, { | ||
| kind: "actionEnd", | ||
| actionIndex: index, | ||
| ok: false, | ||
| ms: Date.now() - actionStartedAt, | ||
| }); | ||
| throw error; | ||
| } | ||
| } catch (error) { | ||
| // a failed non-transactional DDL is NOT safely unapplied — it can | ||
| // leave durable side effects (e.g. an INVALID index from a cancelled | ||
| // CREATE INDEX CONCURRENTLY). Report it inDoubt so the caller knows | ||
| // the database must be re-extracted before retry (review P1). | ||
| statuses[index] = "inDoubt"; | ||
| failure = errorEntry(index, action.sql, error); | ||
| } finally { | ||
| // ALWAYS restore session state before the client returns to the pool, | ||
| // on success or failure — RESET ALL must not be skipped on the | ||
| // failure path, and a reset failure must not flip the action's outcome. | ||
| emit(onEvent, { kind: "control", sql: "RESET ALL" }); | ||
| await client.query("RESET ALL").catch(() => {}); | ||
| } | ||
| if (failure !== undefined) { | ||
| emit(onEvent, { | ||
| kind: "segmentEnd", | ||
| segmentIndex: segIdx, | ||
| outcome: "inDoubt", | ||
| }); | ||
| return { | ||
| status: "failed", | ||
| appliedActions, | ||
| actionStatuses: statuses, | ||
| error: errorEntry(index, action.sql, error), | ||
| error: failure, | ||
| }; | ||
| } finally { | ||
| // ALWAYS restore session state before the client returns to the pool, | ||
| // on success or failure — RESET ALL must not be skipped by the catch's | ||
| // early return, and a reset failure must not flip the action's outcome. | ||
| await client.query("RESET ALL").catch(() => {}); | ||
| } | ||
| statuses[index] = "applied"; | ||
| appliedActions += 1; | ||
| emit(onEvent, { | ||
| kind: "segmentEnd", | ||
| segmentIndex: segIdx, | ||
| outcome: "committed", | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| emit(onEvent, { kind: "control", sql: "BEGIN" }); | ||
| await client.query("BEGIN"); | ||
| for (const sql of preamble(true)) await client.query(sql); | ||
| for (const sql of preamble(true)) { | ||
| emit(onEvent, { kind: "control", sql }); | ||
| await client.query(sql); | ||
| } | ||
| } catch (error) { | ||
| emit(onEvent, { kind: "control", sql: "ROLLBACK" }); | ||
| await client.query("ROLLBACK").catch(() => {}); | ||
| emit(onEvent, { | ||
| kind: "segmentEnd", | ||
| segmentIndex: segIdx, | ||
| outcome: "rolledBack", | ||
| }); | ||
| return { | ||
| status: "failed", | ||
| appliedActions, | ||
|
|
@@ -253,25 +373,52 @@ export async function apply( | |
| } | ||
| for (let i = segment.start; i < segment.end; i++) { | ||
| const action = thePlan.actions[i]!; | ||
| const actionStartedAt = Date.now(); | ||
| emit(onEvent, { kind: "actionStart", actionIndex: i, sql: action.sql }); | ||
|
Comment on lines
+379
to
+380
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an Useful? React with 👍 / 👎. |
||
| try { | ||
| await client.query(action.sql); | ||
| } catch (error) { | ||
| emit(onEvent, { | ||
| kind: "actionEnd", | ||
| actionIndex: i, | ||
| ok: false, | ||
| ms: Date.now() - actionStartedAt, | ||
| }); | ||
| emit(onEvent, { kind: "control", sql: "ROLLBACK" }); | ||
| await client.query("ROLLBACK").catch(() => {}); | ||
| emit(onEvent, { | ||
| kind: "segmentEnd", | ||
| segmentIndex: segIdx, | ||
| outcome: "rolledBack", | ||
| }); | ||
| return { | ||
| status: "failed", | ||
| appliedActions, | ||
| actionStatuses: statuses, | ||
| error: errorEntry(i, action.sql, error), | ||
| }; | ||
| } | ||
| emit(onEvent, { | ||
| kind: "actionEnd", | ||
| actionIndex: i, | ||
| ok: true, | ||
| ms: Date.now() - actionStartedAt, | ||
| }); | ||
| } | ||
| try { | ||
| emit(onEvent, { kind: "control", sql: "COMMIT" }); | ||
| await client.query("COMMIT"); | ||
| } catch (error) { | ||
| // the commit itself failed: the segment's fate is unknown | ||
| for (let i = segment.start; i < segment.end; i++) | ||
| statuses[i] = "inDoubt"; | ||
| emit(onEvent, { kind: "control", sql: "ROLLBACK" }); | ||
| await client.query("ROLLBACK").catch(() => {}); | ||
| emit(onEvent, { | ||
| kind: "segmentEnd", | ||
| segmentIndex: segIdx, | ||
| outcome: "inDoubt", | ||
| }); | ||
| return { | ||
| status: "failed", | ||
| appliedActions, | ||
|
|
@@ -281,6 +428,11 @@ export async function apply( | |
| } | ||
| for (let i = segment.start; i < segment.end; i++) statuses[i] = "applied"; | ||
| appliedActions += segment.end - segment.start; | ||
| emit(onEvent, { | ||
| kind: "segmentEnd", | ||
| segmentIndex: segIdx, | ||
| outcome: "committed", | ||
| }); | ||
| } | ||
| } finally { | ||
| client.release(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.