Skip to content

Commit 46dfe69

Browse files
youhaoweiclaude
andauthored
feat(server): persistent draft controller + ctx.db draft seam [YW-260] (#156)
The DashFrame draft controller — the heart of the assistant drive loop. CONDUCTS the wystack draft mechanism (`withDraft` write-path + `compactLog` + `applyCommands`) over DashFrame's durable YW-125 tables; does NOT reimplement YW-121. ## What it does - `createDraftController(app, db)`: `openDraft` / `appendToDraft` / `publishDraft` / `discardDraft` / `getDraftLog`. - **Persistent**: a draft survives process restart with the draftId as the durable handle (log materialized into `draft_command_log`). - **publish = atomic command-log replay** onto canonical via `applyCommands(commit)` — cold-safe (reads only the durable log, never the shadow), idempotent retry (drops log before sweep). - **ctx.db draft seam** (`withDraftSeam` in app.ts): when a `draftId` is in context, substitutes `ctx.db` with the draft-scoped overlay so command handlers write into `<table>__draft` UNMODIFIED. - **Security boundary**: credentials never drafted — closed 6-table shadow set, no `secret_mappings`/`project_meta` shadow. ## Foundation Builds on the now-merged wystack draft foundation (`5b02c0c`: withDraft + lifecycle + jsonb encode/decode codecs + PK-filtered reads). Verified end-to-end against the FIXED primitive: jsonb round-trips, PK-pinned draft reads, cold-publish, discard sweep. (Supersedes #153, which auto-closed when its bump base branch merged. Rebased onto main, identical content + the read-decode foundation.) Tracked internally as YW-260. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR introduces the persistent draft controller (`createDraftController`) and the `ctx.db` draft seam (`withDraftSeam` / `createFallThroughDraftDb`) for DashFrame's agent drive loop, building on the wystack draft foundation. The seam is dormant in this slice — no host yet injects a `draftId` into request context — so no existing behavior changes. - **`createDraftController`** (new file): implements `openDraft` / `appendToDraft` / `publishDraft` / `discardDraft` / `getDraftLog` over real PGlite tables. The durable log (`draft_command_log`) is a materialized compacted projection (replace-all on each append); publish reads only the log, never the shadow, making warm and cold publish identical. The known durability window between the canonical commit and `deleteLog` is correctly documented and tracked as GitHub issue #157. - **Draft seam** (`app.ts`): `createFallThroughDraftDb` routes draftable artifact tables to `<table>__draft` and falls through to canonical for non-draftable tables (`project_meta`, `secret_mappings`). `buildDashframeApp` now unconditionally wraps `rawApp` to insert the seam; the no-draft path is byte-identical to the original `rawApp.call` decomposition. - **`drizzle-orm` promotion**: moved from `devDependencies` to `dependencies`, correct since `getTableName` / `eq` are used in production runtime code. </details> <details><summary><h3>Confidence Score: 5/5</h3></summary> Safe to merge — the draft seam is dormant (no host injects a draftId), so canonical read/write paths are unchanged. The new controller adds durable state on top of existing shadow tables with no schema-breaking changes. All load-bearing invariants are correct and thoroughly tested: publish strips draftId before replay, writeLog is atomic (transactional delete+insert), the fall-through seam correctly routes non-draftable tables to canonical, and appendToDraft avoids the raw withDraft handle that would bypass the seam. The publish crash window is documented and tracked as GitHub issue #157. The cmdId column addition is nullable and handled by the existing syncSchema bootstrapper. No ticket IDs in source, no security regressions. No files require special attention. The two static constant sets (DRAFTABLE_TABLE_NAMES in app.ts and DRAFT_SHADOW_TABLES in draft-controller.ts) must stay in sync when a new artifact table is added — both are annotated to that effect. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | apps/server/src/draft-controller.ts | New persistent draft lifecycle controller: open/append/publish/discard over real PGlite tables. Correctly routes through fall-through seam, strips draftId before publish replay, and uses transactional writeLog for atomic log replacement. Known durability window now tracked as GitHub issue #157. | | apps/server/src/app.ts | Adds draft seam: withDraftSeam, createFallThroughDraftDb, and draftIdFromContext. The buildDashframeApp wrapper now unconditionally wraps rawApp, decomposing rawApp.call into createTracked + runHandler to allow draft handle injection. No-draft path is byte-identical. | | apps/server/src/draft-controller.test.ts | Comprehensive integration test suite (13 cases) covering isolation, persistence/cold-reload, atomic publish, discard, no-draft canonical path, log re-seq, jsonb round-trip, sparse overlay, non-draftable fall-through, appendToDraft seam routing, command correlation IDs, and mutation snapshot safety. | | packages/server-core/src/schema.ts | Adds nullable cmd_id text column to draft_command_log for command correlation ID persistence. As a nullable, default-less column, it is auto-added to existing tables by syncSchema's renderAddNullableColumnsIfNotExists — no separate migration needed. | | packages/server-core/src/schema.test.ts | Adds assertion for cmd_id column in the draft_command_log schema contract test. | | apps/server/package.json | Promotes drizzle-orm from devDependencies to dependencies — correct, since getTableName and eq are used in production runtime code. | </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Host participant DraftController participant App as WyStackApp (wrapped) participant SeamDb as FallThroughDraftDb participant Shadow as table__draft participant Log as draft_command_log participant Canonical as canonical tables Host->>DraftController: openDraft() DraftController-->>Host: draftId (UUID) Host->>DraftController: appendToDraft(draftId, batch) DraftController->>App: createTracked() → baseDb loop for each cmd in batch DraftController->>App: runHandler(path, args, baseDb, draftId-context) App->>SeamDb: withDraftSeam → FallThroughDraftDb Note over SeamDb: draftable tables → shadow<br/>non-draftable → canonical SeamDb->>Shadow: write (draftable tables) SeamDb->>Canonical: read (non-draftable: project_meta) App-->>DraftController: handler result end DraftController->>Log: readLog(draftId) → prior DraftController->>DraftController: compactLog([...prior, ...ranSnapshots]) DraftController->>Log: writeLog (tx: delete + insert, re-seq 0..n) DraftController-->>Host: CommandResult[] Host->>DraftController: publishDraft(draftId) DraftController->>Log: readLog(draftId) → compacted commands DraftController->>App: applyCommands(app, log, commit, no-draftId-context) App->>Canonical: atomic commit of all commands App-->>DraftController: CommitResult DraftController->>Log: deleteLog(draftId) [idempotency gate] DraftController->>Shadow: sweepShadows best-effort all 6 tables DraftController-->>Host: CommitResult Note over Host: Host MUST fire onWrite(tablesWritten) Host->>DraftController: discardDraft(draftId) DraftController->>Log: deleteLog(draftId) DraftController->>Shadow: sweepShadows throw on error ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Host participant DraftController participant App as WyStackApp (wrapped) participant SeamDb as FallThroughDraftDb participant Shadow as table__draft participant Log as draft_command_log participant Canonical as canonical tables Host->>DraftController: openDraft() DraftController-->>Host: draftId (UUID) Host->>DraftController: appendToDraft(draftId, batch) DraftController->>App: createTracked() → baseDb loop for each cmd in batch DraftController->>App: runHandler(path, args, baseDb, draftId-context) App->>SeamDb: withDraftSeam → FallThroughDraftDb Note over SeamDb: draftable tables → shadow<br/>non-draftable → canonical SeamDb->>Shadow: write (draftable tables) SeamDb->>Canonical: read (non-draftable: project_meta) App-->>DraftController: handler result end DraftController->>Log: readLog(draftId) → prior DraftController->>DraftController: compactLog([...prior, ...ranSnapshots]) DraftController->>Log: writeLog (tx: delete + insert, re-seq 0..n) DraftController-->>Host: CommandResult[] Host->>DraftController: publishDraft(draftId) DraftController->>Log: readLog(draftId) → compacted commands DraftController->>App: applyCommands(app, log, commit, no-draftId-context) App->>Canonical: atomic commit of all commands App-->>DraftController: CommitResult DraftController->>Log: deleteLog(draftId) [idempotency gate] DraftController->>Shadow: sweepShadows best-effort all 6 tables DraftController-->>Host: CommitResult Note over Host: Host MUST fire onWrite(tablesWritten) Host->>DraftController: discardDraft(draftId) DraftController->>Log: deleteLog(draftId) DraftController->>Shadow: sweepShadows throw on error ``` </a> </details> <sub>Reviews (5): Last reviewed commit: ["chore(server): drop duplicate drizzle-or..."](59f11d7) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=39463883)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9fa2358 commit 46dfe69

7 files changed

Lines changed: 1323 additions & 60 deletions

File tree

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@
3737
"@wystack/db": "workspace:*",
3838
"@wystack/secret-vault": "workspace:*",
3939
"@wystack/server": "workspace:*",
40+
"drizzle-orm": "^0.45.1",
4041
"hono": "^4.12.9",
4142
"zod": "^4.1.12"
4243
},
4344
"devDependencies": {
4445
"@dashframe/eslint-config": "workspace:*",
4546
"@types/bun": "^1.2.0",
46-
"drizzle-orm": "^0.45.1",
4747
"esbuild": "^0.28.0",
4848
"typescript": "5.7.2",
4949
"vitest": "^4.1.6"

apps/server/src/app.ts

Lines changed: 209 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,18 @@ import {
3838
createArrowDataPath,
3939
type ArrowQueryRunner,
4040
} from "@dashframe/engine-server/arrow-data-path";
41+
import { schema } from "@dashframe/server-core";
4142
import { serve as nodeServe } from "@hono/node-server";
4243
import { createNodeWebSocket } from "@hono/node-ws";
44+
import type { DraftTrackedDb, TrackedDb } from "@wystack/db";
4345
import {
4446
isSecretRef,
4547
type SecretRef,
4648
type SecretVault,
4749
} from "@wystack/secret-vault";
4850
import { createRoutes, createWyStack, type WyStackApp } from "@wystack/server";
51+
import type { Table } from "drizzle-orm";
52+
import { getTableName } from "drizzle-orm";
4953
import { Hono, type Context } from "hono";
5054
import { cors } from "hono/cors";
5155
import { createHash, timingSafeEqual } from "node:crypto";
@@ -218,20 +222,151 @@ export interface DashframeServer {
218222
}
219223

220224
/**
221-
* Build the WyStack app with vault injection and onWrite hook wiring — without
222-
* starting an HTTP server.
225+
* Pull a draft handle out of a handler context. A `draftId` in the context bag
226+
* means "execute this write into the draft overlay, not canonical." Returns the
227+
* id string, or `undefined` for the no-draft (canonical) path.
228+
*/
229+
function draftIdFromContext(
230+
context: Record<string, unknown> | undefined,
231+
): string | undefined {
232+
const id = context?.draftId;
233+
return typeof id === "string" && id.length > 0 ? id : undefined;
234+
}
235+
236+
/**
237+
* The CLOSED set of canonical table names that have a `<table>__draft` shadow
238+
* (the draftable artifact tables). This MIRRORS draft-controller.ts's
239+
* DRAFT_SHADOW_TABLES — by the credential-security-boundary design, credential
240+
* and project tables (`secret_mappings`, `project_meta`) intentionally have NO
241+
* shadow, so a draft read against them must coalesce-read NOTHING and fall
242+
* through to canonical (there is no `project_meta__draft` relation to JOIN).
243+
* A new artifact table is a schema change, so this static set is authoritative.
244+
*/
245+
const DRAFTABLE_TABLE_NAMES: ReadonlySet<string> = new Set([
246+
getTableName(schema.dataSources),
247+
getTableName(schema.dataTables),
248+
getTableName(schema.dataFrames),
249+
getTableName(schema.insights),
250+
getTableName(schema.visualizations),
251+
getTableName(schema.dashboards),
252+
]);
253+
254+
/**
255+
* A draft-scoped db handle that FALLS THROUGH to canonical for non-draftable
256+
* tables. `from(table)`/`into(table)` route to the wystack draft overlay only
257+
* when `table` has a `<table>__draft` shadow; for a non-draftable table (e.g.
258+
* `project_meta`, which has no shadow by the security-boundary design) they
259+
* delegate to the base canonical handle, so a handler reading `project_meta`
260+
* inside a draft reads canonical instead of failing on a missing
261+
* `project_meta__draft` relation.
262+
*
263+
* This keeps "handlers run UNMODIFIED inside a draft" true: a query like
264+
* `projectInfo` (reads only `project_meta`) just works; a command touching
265+
* draftable artifacts gets the overlay. The draftable-table POLICY lives here
266+
* in DashFrame (which owns the closed shadow set), not in the generic
267+
* @wystack/db `withDraft` primitive.
223268
*
224-
* Extracted from `createDashframeServer` so the vault-injection seam (the
225-
* anti-shadow merge and the short-circuit path) can be driven by direct unit
226-
* tests without a live socket. `createDashframeServer` calls this internally;
227-
* tests import and exercise it directly.
269+
* Shape note: returned as `DraftTrackedDb` because that is the type the seam
270+
* yields; both base and draft handles share the `from/into/transaction` surface
271+
* handlers use, and `runHandler` already casts to `TrackedDb` (the builder
272+
* return-type difference is never observed by a handler).
273+
*/
274+
export function createFallThroughDraftDb(
275+
base: TrackedDb,
276+
draftId: string,
277+
): DraftTrackedDb {
278+
const draft = base.withDraft(draftId);
279+
const isDraftable = (table: Table): boolean =>
280+
DRAFTABLE_TABLE_NAMES.has(getTableName(table));
281+
return {
282+
// The draft handle reuses the base tracker's sets, so reads/writes through
283+
// EITHER target accumulate into the same tablesRead/tablesWritten — the
284+
// call-result shape sees a draftable write and a canonical read alike.
285+
tablesRead: draft.tablesRead,
286+
tablesWritten: draft.tablesWritten,
287+
raw: draft.raw,
288+
from(table) {
289+
return (
290+
isDraftable(table) ? draft.from(table) : base.from(table)
291+
) as ReturnType<DraftTrackedDb["from"]>;
292+
},
293+
into(table) {
294+
return (
295+
isDraftable(table) ? draft.into(table) : base.into(table)
296+
) as ReturnType<DraftTrackedDb["into"]>;
297+
},
298+
transaction: draft.transaction.bind(draft),
299+
};
300+
}
301+
302+
/**
303+
* The ctx.db draft seam. When a `draftId` is present in the context,
304+
* substitute the `tracked` handle with a draft-scoped overlay so existing
305+
* command handlers write into `<table>__draft` (the withDraft write-path)
306+
* UNMODIFIED — for DRAFTABLE tables. A read/write to a non-draftable table
307+
* (no shadow, e.g. `project_meta`) falls through to canonical so the unmodified
308+
* handler does not hit a missing `<table>__draft` relation. The no-draft path
309+
* returns `tracked` untouched — byte-identical, zero-overhead.
310+
*
311+
* `ctx.db` is set from the `tracked` argument inside @wystack/server's
312+
* `runHandler` (it always wins over the context bag), so the substitution MUST
313+
* happen on the `tracked` argument here, not by injecting a context key.
314+
* `withDraft(draftId)` is a pure @wystack/db primitive that accepts any
315+
* caller-supplied id.
316+
*
317+
* CONSUMER CONSTRAINTS — the seam is dormant in this slice (no host injects a
318+
* draftId). The host that wires draftId into request context owns these:
319+
* - LOG SYNC. A write mutation reached via raw `app.call({draftId})` lands in
320+
* `<table>__draft` but does NOT append to `draft_command_log`; since publish
321+
* replays only the log, that write is visible in the overlay yet dropped on
322+
* publish. Drafted WRITES must route through `DraftController.appendToDraft`
323+
* (which keeps shadow + log in sync); the seam alone is safe for drafted
324+
* READS (coalesced reads need no log).
325+
* - DRAFTABLE COMMANDS. `withDraft` supports PK-pinned reads/writes
326+
* (`where(eq("id", …))`). A command whose handler filters a shadow table by a
327+
* non-PK column (e.g. delete `data_frames` by `insightId`) is not draftable
328+
* as-is; such paths must be PK-addressed or blocked before drafting.
329+
* - CREDENTIAL SIDE EFFECTS. See draft-controller.ts SECURITY BOUNDARY: a
330+
* credentialed handler's `vault.store` is a real side effect even in a draft.
331+
* - AUTHORIZATION. draftId is caller-supplied; a multi-tenant host must
332+
* authorize it against the caller (single-user desktop is exempt).
333+
*/
334+
export function withDraftSeam(
335+
tracked: TrackedDb | DraftTrackedDb,
336+
context: Record<string, unknown> | undefined,
337+
): TrackedDb | DraftTrackedDb {
338+
const draftId = draftIdFromContext(context);
339+
if (draftId === undefined) return tracked;
340+
// A draft handle has no nested `withDraft`; only a base TrackedDb is scoped.
341+
// `call` always passes a fresh base TrackedDb, so this is the live path. The
342+
// fall-through wrapper routes non-draftable tables to canonical.
343+
return "withDraft" in tracked
344+
? createFallThroughDraftDb(tracked, draftId)
345+
: tracked;
346+
}
347+
348+
/**
349+
* Build the WyStack app with the draft seam, vault injection, and the
350+
* onWrite hook — without starting an HTTP server.
351+
*
352+
* Extracted from `createDashframeServer` so the seams (the anti-shadow vault
353+
* merge, the draft-scoped db substitution) can be driven by direct unit tests
354+
* without a live socket. `createDashframeServer` calls this internally; tests
355+
* import and exercise it directly.
228356
*
229357
* Security invariant: `vault` is injected into every handler context via a
230358
* static spread that wins over per-call context. The merge order
231359
* `{ ...(context ?? {}), ...staticContext }` means the vault key cannot be
232360
* shadowed by a caller-supplied context — the vault identity is fixed for the
233361
* lifetime of the returned app.
234362
*
363+
* Draft seam: when the per-request context carries a `draftId`, `call`/
364+
* `runHandler` substitute the tracked handle with a draft-scoped one that routes
365+
* DRAFTABLE-table reads/writes through the `<table>__draft` overlay and falls
366+
* through to canonical for non-draftable tables (project_meta, secrets — no
367+
* shadow by the credential-security boundary). A context with NO draftId is
368+
* unchanged — the canonical path is byte-identical (zero-overhead).
369+
*
235370
* onWrite/runHandler asymmetry — INTENTIONAL and load-bearing:
236371
*
237372
* `onWrite` fires after `call` (`tablesWritten.size > 0`) but NOT after
@@ -243,26 +378,23 @@ export interface DashframeServer {
243378
* executes handlers then forces a transaction rollback; nothing persists, so
244379
* `onWrite` must NOT fire.
245380
*
246-
* 2. `draftLifecycle.append(draftId, batch)` — routes writes through a
247-
* `base.withDraft(draftId)` handle, so every `ctx.db.into/update/delete`
248-
* lands in `<table>__draft` shadow tables. Draft writes are not canonical;
249-
* `onWrite` drives canonical snapshot persistence and must NOT fire for
250-
* draft-overlay writes.
381+
* 2. The draft controller's `appendToDraft` — routes writes through a
382+
* `withDraft(draftId)` handle, so every `ctx.db.into/update/delete` lands in
383+
* `<table>__draft` shadow tables. Draft writes are not canonical; `onWrite`
384+
* drives canonical snapshot persistence and must NOT fire for draft-overlay
385+
* writes.
251386
*
252-
* `draftLifecycle` is not yet wired into the DashFrame server (the draft
253-
* overlay substrate landed in recent wystack + server-core work; route
254-
* integration is a future ticket). When it is wired,
255-
* the `publish` method calls `applyCommands(app, boundLog, { mode: 'commit' })`
256-
* and returns a `CommitResult` with `tablesWritten`. OBLIGATION: the caller that
257-
* wires `publish` into a server route MUST fire `onWrite()` when
258-
* `result.tablesWritten.size > 0` — the lifecycle does not fire it (by design;
259-
* it is a generic WyStack primitive with no DashFrame dependency). Adding
260-
* `onWrite` to this `runHandler` wrapper cannot safely cover that future path
261-
* because: (a) preview also uses canonical `TrackedDb` handles that look
262-
* identical at this level, and (b) `runHandler` is called per-command while
263-
* `tablesWritten` accumulates across the batch — the per-command check would
264-
* fire multiple times or miss the first-write-only case. The clean seam is the
265-
* `publish` return site, not here.
387+
* When the controller's `publishDraft` replays the log via
388+
* `applyCommands(app, log, { mode: 'commit' })` and returns a `CommitResult` with
389+
* `tablesWritten`, OBLIGATION: the caller that wires `publishDraft` into a server
390+
* route MUST fire `onWrite()` when `result.tablesWritten.size > 0` — the
391+
* controller does not fire it (mirroring `applyCommands`' posture). Adding
392+
* `onWrite` to this `runHandler` wrapper cannot safely cover that path because:
393+
* (a) preview also uses canonical `TrackedDb` handles that look identical at this
394+
* level, and (b) `runHandler` is called per-command while `tablesWritten`
395+
* accumulates across the batch — the per-command check would fire multiple times
396+
* or miss the first-write-only case. The clean seam is the `publishDraft` return
397+
* site, not here.
266398
*/
267399
export async function buildDashframeApp(opts: {
268400
db: object;
@@ -278,40 +410,55 @@ export async function buildDashframeApp(opts: {
278410
const staticContext: Record<string, unknown> = vault != null ? { vault } : {};
279411
const hasStaticContext = Object.keys(staticContext).length > 0;
280412

281-
return vault == null && onWrite == null
282-
? rawApp
283-
: {
284-
...rawApp,
285-
async call(path, args, context) {
286-
// Static context wins over per-request context: spread per-request
287-
// first so that static keys (vault) cannot be shadowed by a crafted
288-
// request context. The vault identity must be fixed for the server
289-
// lifetime; a request-supplied vault key would be ignored.
290-
const merged = hasStaticContext
291-
? { ...(context ?? {}), ...staticContext }
292-
: context;
293-
const result = await rawApp.call(path, args, merged);
294-
if (onWrite != null && result.tablesWritten.size > 0) {
295-
try {
296-
onWrite();
297-
} catch (err) {
298-
console.error("[dashframe] onWrite hook threw:", err);
299-
}
300-
}
301-
return result;
302-
},
303-
// runHandler: vault injection only — no onWrite. See the function-level
304-
// comment for the full rationale; short form: all current production
305-
// callers are either preview (rollback) or draft-overlay (non-canonical).
306-
// When draftLifecycle.publish is wired into a server route, THAT site
307-
// must fire onWrite on CommitResult.tablesWritten — not here.
308-
async runHandler(path, args, tracked, context) {
309-
const merged = hasStaticContext
310-
? { ...(context ?? {}), ...staticContext }
311-
: context;
312-
return rawApp.runHandler(path, args, tracked, merged);
313-
},
413+
// The draft seam wraps `call` itself (not just runHandler): `rawApp.call`
414+
// mints its own fresh TrackedDb internally, so a draftId-bearing `call` would
415+
// otherwise hit canonical. We mirror rawApp.call's composition (fresh tracked
416+
// → runHandler → result shape) but pass the draft-scoped handle when a draftId
417+
// is present, leaving the no-draft path identical to rawApp.call.
418+
//
419+
// EQUIVALENCE (load-bearing): rawApp.call is a THIN composition — `const t =
420+
// createTracked(); const result = await runHandler(path, args, t, context);
421+
// return { result, tablesRead: t.tablesRead, tablesWritten: t.tablesWritten }`
422+
// — with no retry, no error normalization, no separate tablesRead pass (see
423+
// @wystack/server create.ts). This decomposition reproduces it byte-for-byte
424+
// on the no-draft path. RE-MIRROR POINT: if a wystack upgrade adds logic INSIDE
425+
// rawApp.call, this wrapper must be updated to match — it cannot delegate to
426+
// rawApp.call because that path mints an internal tracker the seam can't reach.
427+
return {
428+
...rawApp,
429+
async call(path, args, context) {
430+
// Static context wins over per-request context: spread per-request first
431+
// so static keys (vault) cannot be shadowed by a crafted request context.
432+
const merged = hasStaticContext
433+
? { ...(context ?? {}), ...staticContext }
434+
: (context ?? {});
435+
const tracked = rawApp.createTracked();
436+
const effective = withDraftSeam(tracked, merged);
437+
const result = await rawApp.runHandler(path, args, effective, merged);
438+
// `tracked` and `effective` share the same tracker sets (withDraft reuses
439+
// the base tracker), so tablesWritten reflects the write either way.
440+
const tablesWritten = tracked.tablesWritten;
441+
if (onWrite != null && tablesWritten.size > 0) {
442+
try {
443+
onWrite();
444+
} catch (err) {
445+
console.error("[dashframe] onWrite hook threw:", err);
446+
}
447+
}
448+
return {
449+
result,
450+
tablesRead: tracked.tablesRead,
451+
tablesWritten,
314452
};
453+
},
454+
async runHandler(path, args, tracked, context) {
455+
const merged = hasStaticContext
456+
? { ...(context ?? {}), ...staticContext }
457+
: (context ?? {});
458+
const effective = withDraftSeam(tracked, merged);
459+
return rawApp.runHandler(path, args, effective, merged);
460+
},
461+
};
315462
}
316463

317464
export async function createDashframeServer(
@@ -505,4 +652,8 @@ function tokenMatches(actual: string, expected: string): boolean {
505652
return timingSafeEqual(actualBytes, expectedBytes);
506653
}
507654

655+
export {
656+
createDraftController,
657+
type DraftController,
658+
} from "./draft-controller";
508659
export type { Functions } from "./functions";

0 commit comments

Comments
 (0)