Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ All notable changes to this project will be documented in this file.

### Fixed

- **notion-datasource-sync / notion-cli**: `notion db sync`, `track`, and
`export` now work under the packaged Node runtime without a `tsx` preload. The
CLI sync-progress path loaded the `.tsx` TUI via `Effect.promise(() => import(…))`
wrapped in `Effect.either`; under packaged Node the JSX import rejects, which
surfaces as an Effect **defect** that `Effect.either` does not catch, so the
intended plain-progress fallback was bypassed and the command exited 1 with no
output. The load defect is now promoted onto the error channel
(`Effect.catchAllDefect`, scoped to the load step only) so any TUI load failure
degrades cleanly to plain progress. Separately, the `notion` flake wrapper did
not route `db track` to the Node runtime (only `init|pull|push|sync|export|status|conflicts|forget|restore|doctor`),
so `notion db track` fell through and failed closed; `track` is now routed. A
new unit test guards the fallback with an injected dying loader, and the Nix
smoke test is strengthened from a `--help`-only route check to a REAL
sync-path run: it invokes `notion db track` on an empty workspace, which
reaches `runWithCliSyncProgress` and the real `.tsx` TUI import, and asserts a
structured `CliErrorEnvelope` is emitted (proving the import failed soft and
dispatch was reached). Deleting the `catchAllDefect` line makes the build RED
with "no structured envelope"; dropping `track` routing trips the Bun-guard
check.
- **CI / cargo**: move standalone Rust crate build/test/clippy/fmt semantics into
the `cargo:check` devenv task and make the generated cargo CI lane call that
task, ensuring the `node-cpuprofile` integration test runs with the devenv
Expand Down
44 changes: 43 additions & 1 deletion packages/@overeng/notion-cli/nix/build.nix
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,54 @@ pkgs.runCommand "notion-cli"
''
mkdir -p $out/bin
makeWrapper ${unwrapped}/bin/notion $out/bin/notion \
--run 'if [ "$#" -gt 1 ] && [ "$1" = db ]; then case "$2" in init|pull|push|sync|export|status|conflicts|forget|restore|doctor) shift; exec ${notionDbRuntime}/bin/notion-db-runtime "$@";; esac; fi'
--run 'if [ "$#" -gt 1 ] && [ "$1" = db ]; then case "$2" in init|pull|push|sync|track|export|status|conflicts|forget|restore|doctor) shift; exec ${notionDbRuntime}/bin/notion-db-runtime "$@";; esac; fi'

db_output="$($out/bin/notion db sync --help 2>&1 || true)"
if ! printf '%s\n' "$db_output" | grep -q 'Reconcile an established workspace'; then
printf '%s\n' "$db_output" >&2
echo "notion db sync smoke test failed" >&2
exit 1
fi

# `track` must also route to the Node runtime (needs node:sqlite). A missing

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all this added complexity suggests we probably want to refactor this in a more principled way to avoid the bun vs node complexity

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that removing the Bun/Node runtime split would be the more principled architectural direction. This PR is intentionally narrower: it repairs the packaged notion db track path by adding the missing route and adds failure-capable coverage for the runtime boundary that currently exists. Eliminating that boundary would expand this focused defect repair into a broader CLI/runtime redesign. The PR body already records the immediate structural risk under Follow-ups: the routed-verb list is hand-duplicated from the CLI definitions and can drift again, so it should ultimately come from one source of truth. Do you want that architectural refactor to block this repair, or is it acceptable as follow-up work?

# route falls through to the Bun-backed wrapper, which fails closed. Grep for
# a phrase unique to the Node entrypoint's help block ("Packaged Node-backed
# entrypoint from Nix/devenv" — see renderCliHelpText); the Bun @effect/cli
# help does not emit it, so this specifically proves `track` is routed.
track_output="$($out/bin/notion db track --help 2>&1 || true)"
if ! printf '%s\n' "$track_output" | grep -q 'Packaged Node-backed entrypoint from Nix/devenv'; then
printf '%s\n' "$track_output" >&2
echo "notion db track smoke test failed (track not routed to node runtime?)" >&2
exit 1
fi

# Real sync-path regression: a `--help` route check cannot catch the actual
# defect it guards against. A progress-bearing verb (track) runs through
# `runWithCliSyncProgress`, which dynamically imports the `.tsx`
# `@overeng/tui-react` TUI. Under packaged Node that import REJECTS (JSX is
# not stripped) and surfaces as an Effect *defect*; the top-level handler is
# `Effect.tapError` (failures only), so without the `catchAllDefect` fallback
# the command dies BEFORE emitting any structured output. `track` is the
# cheapest verb that clears argument+context parsing on an empty workspace
# and thus reaches the progress wrapper (`sync` fails earlier as
# WorkspaceNotTracked, never reaching it). The build sandbox has no network,
# so the establish then fails FAST (auth/connection errors are non-retryable)
# with a structured `CliErrorEnvelope` — whose presence proves the TUI import
# failed soft and real command dispatch was reached. Deleting the
# `catchAllDefect` line in runWithCliSyncProgress makes this RED (raw defect,
# no envelope); dropping `track` from the wrapper routing makes it RED via the
# Bun-runtime guard check below.
track_run_dir="$(mktemp -d)"
track_run_output="$(NOTION_API_TOKEN=secret_smoke_invalid_000000000000000000000000 \
timeout 90 $out/bin/notion db track 11111111-1111-4111-8111-111111111111 "$track_run_dir" --mode local 2>&1 || true)"
if ! printf '%s\n' "$track_run_output" | grep -qE '"_tag": "Cli(Error|Result)Envelope"'; then
printf '%s\n' "$track_run_output" >&2
echo "notion db track sync-progress smoke failed: no structured envelope — the .tsx TUI import defect was not caught (runWithCliSyncProgress catchAllDefect regressed?)" >&2
exit 1
fi
if printf '%s\n' "$track_run_output" | grep -q 'require the packaged Nix/devenv Node-backed runtime'; then
printf '%s\n' "$track_run_output" >&2
echo "notion db track fell through to the Bun-runtime guard (not routed to the Node runtime)" >&2
exit 1
fi
''
45 changes: 33 additions & 12 deletions packages/@overeng/notion-datasource-sync/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3149,24 +3149,45 @@ const runWithPlainSyncProgress = <A, E, R>({
),
)

const runWithCliSyncProgress = <A, E, R>({
/**
* Loads the optional TUI progress modules. The `@overeng/tui-react` module is a
* `.tsx` file; under a runtime that cannot strip JSX (plain packaged Node) the
* dynamic `import()` REJECTS, which `Effect.promise` surfaces as a **defect**
* (die), not a typed failure. `runWithCliSyncProgress` therefore catches the
* whole load as a cause — see the `Effect.catchAllDefect` there — so any load
* failure degrades to plain progress rather than crashing the command.
*/
const loadSyncProgressTui = Effect.promise(() => import('./progress.ts')).pipe(
Effect.flatMap((progressModule) =>
Effect.promise(() => import('@overeng/tui-react')).pipe(
Effect.flatMap((tuiReact) =>
Effect.promise(() => import('@overeng/tui-react/node')).pipe(
Effect.map((tuiReactNode) => ({ progressModule, tuiReact, tuiReactNode })),
),
),
),
),
)

export const runWithCliSyncProgress = <A, E, R>({
command,
effect,
loadTui = loadSyncProgressTui,
}: {
readonly command: CliCommand
readonly effect: Effect.Effect<A, E, R>
readonly loadTui?: typeof loadSyncProgressTui
}): Effect.Effect<A, E, R> => {
const loadTuiProgress = Effect.promise(() => import('./progress.ts')).pipe(
Effect.flatMap((progressModule) =>
Effect.promise(() => import('@overeng/tui-react')).pipe(
Effect.flatMap((tuiReact) =>
Effect.promise(() => import('@overeng/tui-react/node')).pipe(
Effect.map((tuiReactNode) => ({ progressModule, tuiReact, tuiReactNode })),
),
),
),
),
Effect.either,
// Loading the optional TUI can fail under a Node runtime that can't strip the
// JSX in `@overeng/tui-react`: the rejected dynamic import surfaces as an
// Effect **defect**, which `Effect.either` alone does NOT catch. Recover the
// defect into a `Left` (keeping the cause in the success channel, so the error
// channel stays clean) so any load failure cleanly falls back to
// `runWithPlainSyncProgress`. This catch wraps ONLY the load step, never
// `effect`, so genuine command errors and interruption still surface.
const loadTuiProgress = loadTui.pipe(
Effect.map(Either.right),
Effect.catchAllDefect((defect) => Effect.succeed(Either.left(defect))),
)

return loadTuiProgress.pipe(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Effect } from 'effect'
import { afterEach, describe, expect, it } from 'vitest'

import { runWithCliSyncProgress } from './main.ts'
import type { CliCommand } from './main.ts'

/**
* Regression guard for the packaged-Node `.tsx` import defect.
*
* `runWithCliSyncProgress` optionally loads the TUI progress UI via dynamic
* `import('@overeng/tui-react')`. `@overeng/tui-react` is a `.tsx` module; under
* a runtime that cannot strip JSX (plain packaged Node) that import REJECTS,
* which `Effect.promise` surfaces as a **defect** (die), not a typed failure.
*
* The bug: the fallback used `Effect.either`, which does NOT catch defects, so a
* failed load bypassed `runWithPlainSyncProgress` and the command died with zero
* output (exit 1). The fix promotes the defect onto the error channel before
* `Effect.either`, so any load failure cleanly degrades to plain progress.
*
* This test injects a loader that reproduces the real failure mode — a rejected
* dynamic import via `Effect.promise` (a die) — and asserts the command still
* completes AND observably ran plain progress (it writes phase lines to stderr).
* No network, no real `.tsx` transpile (vitest transpiles `.tsx` fine, so the
* real import cannot fail here — injecting the raw dying loader is what exercises
* the defect path). Deleting the `catchAllDefect` line in `main.ts` makes this
* test crash, confirming it is a genuine RED guard.
*/
describe('runWithCliSyncProgress TUI-load fallback', () => {
const originalWrite = process.stderr.write.bind(process.stderr)

afterEach(() => {
process.stderr.write = originalWrite
})

// Reproduce the packaged-Node failure: a rejected dynamic import surfaced by
// `Effect.promise` as a defect (die), NOT a typed failure. Injecting past the
// fix (e.g. `Effect.fail`) would prove nothing — the whole point is the defect.
const dyingTuiLoader = Effect.promise(() =>
Promise.reject(new Error('Node cannot strip JSX from @overeng/tui-react (.tsx)')),
) as ReturnType<typeof Effect.succeed<never>>

it('falls back to plain progress when the TUI import dies (defect), instead of crashing', async () => {
const command: CliCommand = { _tag: 'doctor' }

const captured: Array<string> = []
process.stderr.write = ((chunk: string | Uint8Array): boolean => {
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
return true
}) as typeof process.stderr.write

const result = await Effect.runPromise(
runWithCliSyncProgress({
command,
effect: Effect.succeed('command-ran'),
loadTui: dyingTuiLoader,
}),
)

process.stderr.write = originalWrite

// The command's own effect still ran and produced its value...
expect(result).toBe('command-ran')
// ...and the fallback observably used PLAIN progress (stderr phase lines),
// not the TUI, and did not crash on the load defect.
expect(captured.join('')).toContain('notion db doctor complete 100%')
})
})
Loading