Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
44e864a
docs(notion-md): VRS for $EDITOR-based editing — refuse-lossy, one en…
schickling-assistant Jun 15, 2026
ffb6d15
fix(notion-md): refuse pages with not-round-trip-safe body blocks (R3…
schickling-assistant Jun 15, 2026
49c0d2d
fix(notion-md): canonicalize hosted-media URLs everywhere bodies are …
schickling-assistant Jun 15, 2026
e0f8e70
feat(notion-md): editor surfaces cat/put/edit (VRS Group A, R30-R39)
schickling-assistant Jun 15, 2026
23dc68a
feat(notion-md): schema-drift refusal via engine schema_snapshot (Gro…
schickling-assistant Jun 15, 2026
b38e6ff
feat(notion-md,notion-cli): Group G observability tests + notion edit…
schickling-assistant Jun 15, 2026
6ae9123
docs(notion-md): reconcile OTEL span table with implemented notion_md…
schickling-assistant Jun 15, 2026
26e6442
fix(notion-cli): build renderer TUI apps lazily to fix umbrella TDZ c…
schickling-assistant Jun 16, 2026
7b3fc37
feat(notion-md): surface integration token fingerprint on gateway errors
schickling-assistant Jun 16, 2026
39fba1a
docs(notion-cli): link upstream bun#30634 for the #787 lazy-init work…
schickling-assistant Jun 16, 2026
563772d
docs(notion-md): restructure VRS into layered subsystems + sync-progr…
schickling-assistant Jun 16, 2026
d1e03eb
docs(notion-md): add edit --read-only requirement (R46) to 01-editor VRS
schickling-assistant Jun 16, 2026
c267b69
feat(notion-md): add `edit --read-only` inspection-only editor session
schickling-assistant Jun 16, 2026
3fe487e
docs(notion-md): rebalance 03-sync-engine to own leaked push-engine m…
schickling-assistant Jun 16, 2026
3bea5ec
docs(notion-md): fix stale cross-cutting anchor links in 01-editor
schickling-assistant Jun 16, 2026
f0c12df
docs(notion-md): lift leaked impl detail out of VRS requirements into…
schickling-assistant Jun 16, 2026
5abce09
feat(notion-md): force list tightness in canonical body (spread:false)
schickling-assistant Jun 16, 2026
d8e7d7c
feat(notion-md): route pull receive through the canonical body form
schickling-assistant Jun 16, 2026
f2a1ace
refactor(notion-md): make pull body total, drop dead endpoint fallback
schickling-assistant Jun 16, 2026
6c5d635
refactor(notion-effect-client): delete vestigial markdownToBlocks con…
schickling-assistant Jun 16, 2026
6f5cdbe
refactor(notion-md): dedupe stripChildAnchors onto one definition
schickling-assistant Jun 16, 2026
e936846
test(notion-md): lock planMarkdownUpdate over a canonical base/remote
schickling-assistant Jun 16, 2026
2ddc7c0
docs(notion-md): clarify editor-surface frames the canonical body ver…
schickling-assistant Jun 16, 2026
bbd358b
refactor(notion): move canonical body fn into notion-effect-client (O…
schickling-assistant Jun 16, 2026
bc90f7f
docs(notion-md): record canonical-body decision 0019, re-baseline demo
schickling-assistant Jun 16, 2026
8f2327d
fix(nix): refresh pnpm-deps FOD hashes after the canonical-body dep move
schickling-assistant Jun 16, 2026
8c6259a
docs(notion-md): compact VRS decision log — delete 6 superseded recor…
schickling-assistant Jun 16, 2026
425a36c
test(notion-md): pin the canonical-body two-oracle invariant + demo f…
schickling-assistant Jun 16, 2026
48382ac
fix(nix): reconcile notion-md pnpm-deps FOD hash after canonical-body…
schickling-assistant Jun 16, 2026
228cc0d
fix(notion): address PR #786 codex review (media-url host gate, utils…
schickling-assistant Jun 16, 2026
8f4f19a
fix(nix): reconcile pnpm-deps FOD hashes after lockfile change
schickling-assistant Jun 16, 2026
5b064dd
fix(nix): reconcile oxc-config pnpm-deps FOD hash after lockfile change
schickling-assistant Jun 16, 2026
13430b1
fix(notion-effect-client): declare inherited utils peer deps via geni…
schickling-assistant Jun 16, 2026
fd86d27
feat(notion-md): staged sync progress + drift notes for `edit` (R43–R…
schickling-assistant Jun 16, 2026
17e719b
docs(notion-md): add R47 (visible remote drift on the write path)
schickling-assistant Jun 16, 2026
26d6b97
fix(notion-cli): avoid concurrent import TDZ
schickling-assistant Jun 16, 2026
b9b23a5
test(notion-cli): allow cold concurrent import
schickling-assistant Jun 16, 2026
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
28 changes: 28 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion nix/oxc-config-plugin.nix
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ let
pnpm = pinnedPnpm;
};
packageDir = "packages/@overeng/oxc-config";
pnpmDepsHash = "sha256-0MeOm3vZjJiGpmVAyt6fOavjhYfehVswkXvN6DGLsjQ=";
pnpmDepsHash = "sha256-9+0uUy+smhJIvXbDNvODZaEvsZIYgPRRm3cCEMfDaeg=";

srcPath =
if builtins.isAttrs src && builtins.hasAttr "outPath" src then
Expand Down
13 changes: 13 additions & 0 deletions packages/@overeng/notion-cli/docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ _Avoid_: alias, mode
A command implemented directly inside the Bun-compatible root CLI, such as `notion db info`.
_Avoid_: local command

**Editor alias**:
The top-level `notion edit <page>` command, an intentional marquee verb that
delegates to `notion md edit`. The only first-level command outside the
`md`/`schema`/`db` namespaces. Distinct from a retired legacy alias.
_Avoid_: shortcut, legacy alias

**Editor command**:
A `notion md` command for editor-based page editing, owned by
`@overeng/notion-md`: the stateless `cat`/`put` body pipes (stdin/stdout, no local
file) and `edit` (an ephemeral file-engine session over a `$TMPDIR` temp tree).
`--frontmatter` is read-only on `cat` and read/write on `edit`.
_Avoid_: streaming command (it spans the engine-backed `edit`), pipe command

**Node-backed Leaf**:
A `notion db` command that must execute in the packaged Node runtime because datasource-sync imports `node:sqlite`.
_Avoid_: sqlite command, replica namespace
Expand Down
2 changes: 2 additions & 0 deletions packages/@overeng/notion-cli/docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ This document defines package-level requirements for `@overeng/notion-cli`. It i
- **R04 Database namespace:** Database metadata, replica sync, status, conflict, diagnostics, and export workflows must live under `notion db`.
- **R05 Markdown namespace:** Markdown page workflows must live under `notion md` and be composed from `@overeng/notion-md`.
- **R06 Schema namespace:** Schema generation, introspection, config generation, and drift detection must live under `notion schema`.
- **R17 Markdown editor surface:** `notion md` must expose the `@overeng/notion-md` editor commands `cat`, `put`, and `edit` for editor-based two-way page editing (the stateless `cat`/`put` pipes and the engine-backed `edit`).
- **R18 Editor alias:** The root must expose a top-level `notion edit <page>` alias that delegates to `notion md edit`. This is an intentional marquee verb, not a legacy compatibility alias under R03; it is the only first-level command outside the `md`/`schema`/`db` namespaces.

### Must Preserve Runtime Boundaries

Expand Down
5 changes: 4 additions & 1 deletion packages/@overeng/notion-cli/docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ This spec does not define:

## Command Surface

Trace: R01-R06, R11-R13.
Trace: R01-R06, R11-R13, R17-R18.

```text
notion
├── edit <page> # top-level alias → md edit (marquee verb, R18)
├── md ... # @overeng/notion-md command tree
│ ├── cat / put / edit # editor surface: cat/put pipes + engine-backed edit (R17)
│ └── sync / status / plan # file-based surface
├── schema
│ ├── generate
│ ├── introspect
Expand Down
2 changes: 1 addition & 1 deletion packages/@overeng/notion-cli/nix/build.nix
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ let
# Managed by the repo FOD refresh workflow — do not edit manually.
depsBuilds = {
"." = {
hash = "sha256-CuFkj+1ti/aKBhqG8ZnJmJLHq64CKujgwVgxVneOnHo=";
hash = "sha256-B0R/4UJ8T2o8Zz2ol+HkA8aGKr6oam73qjd35l0JfTo=";
};
};
nativeNodePackages = [ opentuiCoreNative ];
Expand Down
5 changes: 4 additions & 1 deletion packages/@overeng/notion-cli/src/cli-command.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ const placeholderCommand = (name: string) =>
Command.make(name, {}, () => Effect.void).pipe(Command.withDescription(`${name} command`))

describe('notion root command composition', () => {
it('does not expose the removed root sqlite command', async () => {
it('exposes md/schema/db plus the top-level edit alias, not the removed sqlite command', async () => {
const command = makeNotionRootCommand({
schemaCommand: placeholderCommand('schema'),
dbCommand: placeholderCommand('db'),
notionMdDispatchCommand: placeholderCommand('md'),
notionEditAliasCommand: placeholderCommand('edit'),
})

const completions = await Effect.runPromise(Command.getBashCompletions(command, 'notion'))
Expand All @@ -22,6 +23,8 @@ describe('notion root command composition', () => {
expect(completionText).toContain('schema')
expect(completionText).toContain('db')
expect(completionText).toContain('md')
// R18: the top-level `notion edit` marquee alias is a first-level command.
expect(completionText).toContain('edit')
expect(completionText).not.toContain('sqlite')
})
})
Expand Down
42 changes: 36 additions & 6 deletions packages/@overeng/notion-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,32 +41,62 @@ const makeNotionRootCommand = <
MdRequirements,
MdError,
MdConfig,
EditName extends string,
EditRequirements,
EditError,
EditConfig,
>({
schemaCommand,
dbCommand,
notionMdDispatchCommand,
notionEditAliasCommand,
}: {
readonly schemaCommand: Command.Command<SchemaName, SchemaRequirements, SchemaError, SchemaConfig>
readonly dbCommand: Command.Command<DbName, DbRequirements, DbError, DbConfig>
readonly notionMdDispatchCommand: Command.Command<MdName, MdRequirements, MdError, MdConfig>
readonly notionEditAliasCommand: Command.Command<
EditName,
EditRequirements,
EditError,
EditConfig
>
}) =>
Command.make('notion').pipe(
Command.withSubcommands([schemaCommand, dbCommand, notionMdDispatchCommand]),
// `edit` is the top-level marquee alias for `md edit` (R18); it is the only
// first-level command outside the md/schema/db namespaces.
Command.withSubcommands([
notionEditAliasCommand,
Comment thread
schickling-assistant marked this conversation as resolved.
schemaCommand,
dbCommand,
notionMdDispatchCommand,
]),
Command.withDescription(
'Notion CLI - database operations, schema generation, and markdown sync',
),
)

const runRootCli = async (argv: ReadonlyArray<string>) => {
const [{ notionMdDispatchCommand }, { dbCommand }, { schemaCommand }] = await Promise.all([
import('@overeng/notion-md/cli-program'),
import('./commands/db/mod.ts'),
import('./commands/schema/mod.ts'),
])
/*
* These trees are imported CONCURRENTLY. That concurrency is what triggers the
* upstream Bun bug oven-sh/bun#30634 (TDZ on a re-exported `const` read during
* parallel dynamic `import()`, Node-fine) — which is why every renderer's TUI
* app is built lazily via `get*App()` instead of at module top level (#787).
* TODO(bun#30634): once the Bun fix (PR oven-sh/bun#30656) ships and we pin a
* Bun version that includes it, the lazy `get*App()` workaround can be reverted
* to plain top-level `const *App = createTuiApp(...)`. See
* `concurrent-import.unit.test.ts` (the regression guard).
*/
const [{ notionMdDispatchCommand, notionEditAliasCommand }, { dbCommand }, { schemaCommand }] =
await Promise.all([
import('@overeng/notion-md/cli-program'),
import('./commands/db/mod.ts'),
import('./commands/schema/mod.ts'),
])
const command = makeNotionRootCommand({
schemaCommand,
dbCommand,
notionMdDispatchCommand,
notionEditAliasCommand,
})
const cli = Command.run(command, {
name: 'notion',
Expand Down
8 changes: 5 additions & 3 deletions packages/@overeng/notion-cli/src/commands/db/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '@overeng/notion-datasource-sync/cli/effect-command'
import { outputOption as tuiOutputOption, outputModeLayer } from '@overeng/tui-react/node'

import { InfoApp } from '../../renderers/InfoOutput/app.ts'
import { getInfoApp } from '../../renderers/InfoOutput/app.ts'
import { InfoView } from '../../renderers/InfoOutput/view.tsx'

/** Re-export internal types for TypeScript declaration emit */
Expand Down Expand Up @@ -49,8 +49,10 @@ const infoCommand = Command.make(
authToken: resolvedToken,
})

const infoApp = getInfoApp()

yield* run(
InfoApp,
infoApp,
(tui) =>
Effect.gen(function* () {
const program = Effect.gen(function* () {
Expand Down Expand Up @@ -90,7 +92,7 @@ const infoCommand = Command.make(
Effect.provide(Layer.merge(configLayer, FetchHttpClient.layer)),
)
}),
{ view: React.createElement(InfoView, { stateAtom: InfoApp.stateAtom }) },
{ view: React.createElement(InfoView, { stateAtom: infoApp.stateAtom }) },
).pipe(Effect.provide(outputModeLayer(output)))
}),
).pipe(Command.withDescription('Display information about a Notion database'))
Expand Down
32 changes: 20 additions & 12 deletions packages/@overeng/notion-cli/src/commands/schema/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import {
import { run } from '@overeng/tui-react'
import { outputOption as tuiOutputOption, outputModeLayer } from '@overeng/tui-react/node'

import { DiffApp } from '../../renderers/DiffOutput/app.ts'
import { getDiffApp } from '../../renderers/DiffOutput/app.ts'
import { DiffView } from '../../renderers/DiffOutput/view.tsx'
import { GenerateConfigApp } from '../../renderers/GenerateConfigOutput/app.ts'
import { getGenerateConfigApp } from '../../renderers/GenerateConfigOutput/app.ts'
import { GenerateConfigView } from '../../renderers/GenerateConfigOutput/view.tsx'
import { GenerateApp } from '../../renderers/GenerateOutput/app.ts'
import { getGenerateApp } from '../../renderers/GenerateOutput/app.ts'
import { GenerateView } from '../../renderers/GenerateOutput/view.tsx'
import { IntrospectApp } from '../../renderers/IntrospectOutput/app.ts'
import { getIntrospectApp } from '../../renderers/IntrospectOutput/app.ts'
import { IntrospectView } from '../../renderers/IntrospectOutput/view.tsx'

/** Re-export internal types for TypeScript declaration emit */
Expand Down Expand Up @@ -215,8 +215,10 @@ const generateCommand = Command.make(
authToken: resolvedToken,
})

const generateApp = getGenerateApp()

yield* run(
GenerateApp,
generateApp,
(tui) =>
Effect.gen(function* () {
const program = Effect.gen(function* () {
Expand Down Expand Up @@ -305,7 +307,7 @@ const generateCommand = Command.make(
Effect.provide(Layer.merge(configLayer, FetchHttpClient.layer)),
)
}),
{ view: React.createElement(GenerateView, { stateAtom: GenerateApp.stateAtom }) },
{ view: React.createElement(GenerateView, { stateAtom: generateApp.stateAtom }) },
).pipe(Effect.provide(outputModeLayer(tuiOutput)))
}),
).pipe(Command.withDescription('Generate Effect schema from a Notion database'))
Expand All @@ -329,8 +331,10 @@ const introspectCommand = Command.make(
authToken: resolvedToken,
})

const introspectApp = getIntrospectApp()

yield* run(
IntrospectApp,
introspectApp,
(tui) =>
Effect.gen(function* () {
const program = Effect.gen(function* () {
Expand Down Expand Up @@ -400,7 +404,7 @@ const introspectCommand = Command.make(
Effect.provide(Layer.merge(configLayer, FetchHttpClient.layer)),
)
}),
{ view: React.createElement(IntrospectView, { stateAtom: IntrospectApp.stateAtom }) },
{ view: React.createElement(IntrospectView, { stateAtom: introspectApp.stateAtom }) },
).pipe(Effect.provide(outputModeLayer(output)))
}),
).pipe(Command.withDescription('Introspect a Notion database and display its schema'))
Expand Down Expand Up @@ -439,8 +443,10 @@ const generateFromConfigCommand = Command.make(
authToken: resolvedToken,
})

const generateConfigApp = getGenerateConfigApp()

yield* run(
GenerateConfigApp,
generateConfigApp,
(tui) =>
Effect.gen(function* () {
const program = Effect.gen(function* () {
Expand Down Expand Up @@ -529,7 +535,7 @@ const generateFromConfigCommand = Command.make(
)
}),
{
view: React.createElement(GenerateConfigView, { stateAtom: GenerateConfigApp.stateAtom }),
view: React.createElement(GenerateConfigView, { stateAtom: generateConfigApp.stateAtom }),
},
).pipe(Effect.provide(outputModeLayer(output)))
}),
Expand Down Expand Up @@ -571,8 +577,10 @@ const diffCommand = Command.make(
authToken: resolvedToken,
})

const diffApp = getDiffApp()

yield* run(
DiffApp,
diffApp,
(tui) =>
Effect.gen(function* () {
const program = Effect.gen(function* () {
Expand Down Expand Up @@ -634,7 +642,7 @@ const diffCommand = Command.make(
Effect.provide(Layer.merge(configLayer, FetchHttpClient.layer)),
)
}),
{ view: React.createElement(DiffView, { stateAtom: DiffApp.stateAtom }) },
{ view: React.createElement(DiffView, { stateAtom: diffApp.stateAtom }) },
).pipe(Effect.provide(outputModeLayer(output)))
}),
).pipe(
Expand Down
25 changes: 25 additions & 0 deletions packages/@overeng/notion-cli/src/concurrent-import.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Reproduction fixture for #787: the umbrella `notion` binary loads its three
* command trees concurrently via `Promise.all` (see `cli.ts`'s `runRootCli`).
*
* Under Bun's concurrent async module evaluation this used to reach a renderer
* `app.ts`'s top-level `createTuiApp(...)` side-effect while the shared
* `@overeng/tui-react` module graph was still mid-initialization, producing a
* TDZ `ReferenceError: Cannot access '…' before initialization`.
*
* Run with Bun (the umbrella binary's runtime). Exits non-zero on the crash.
*/
const main = async () => {
await Promise.all([
import('@overeng/notion-md/cli-program'),
import('./commands/db/mod.ts'),
import('./commands/schema/mod.ts'),
])
process.stdout.write('CONCURRENT_IMPORT_OK\n')
}

main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`CONCURRENT_IMPORT_CRASH: ${message}\n`)
process.exit(1)
})
30 changes: 30 additions & 0 deletions packages/@overeng/notion-cli/src/concurrent-import.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'

import { describe, expect, it } from 'vitest'

/**
* Regression test for #787.
*
* The umbrella `notion` binary runs on Bun and imports its three command trees
* concurrently. A renderer `app.ts` calling `createTuiApp(...)` as a module-load
* side-effect crashed with a TDZ error under Bun's concurrent module evaluation.
*
* This test spawns the fixture with Bun — the binary's real runtime — because the
* crash is specific to Bun's async-evaluation interleaving and does not reproduce
* under vitest's Node runner.
*/
describe('concurrent command-tree import (#787)', () => {
it('loads all three trees concurrently under Bun without a TDZ crash', () => {
const fixture = fileURLToPath(new URL('./concurrent-import.fixture.ts', import.meta.url))

const proc = spawnSync('bun', ['run', fixture], { encoding: 'utf8' })

const stdout = proc.stdout ?? ''
const stderr = proc.stderr ?? ''

expect(stderr, stderr).not.toContain('before initialization')
expect(stdout).toContain('CONCURRENT_IMPORT_OK')
expect(proc.status).toBe(0)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import React from 'react'

import { TuiStoryPreview } from '@overeng/tui-react/storybook'

import { DiffApp } from './DiffOutput/mod.ts'
import { getDiffApp } from './DiffOutput/mod.ts'
import type { DiffState } from './DiffOutput/schema.ts'
import { DiffView } from './DiffOutput/view.tsx'

const DiffApp = getDiffApp()

export default {
title: 'NotionCLI/Diff Output',
component: DiffView,
Expand Down
29 changes: 20 additions & 9 deletions packages/@overeng/notion-cli/src/renderers/DiffOutput/app.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { createTuiApp } from '@overeng/tui-react'
import { type TuiApp, createTuiApp } from '@overeng/tui-react'

import { DiffState, DiffAction, diffReducer } from './schema.ts'

/** TUI app definition for the schema diff command. */
export const DiffApp = createTuiApp({
stateSchema: DiffState,
actionSchema: DiffAction,
initial: { _tag: 'Loading' } as DiffState,
reducer: diffReducer,
exitCode: (state) => (state._tag === 'Error' ? 1 : 0),
})
let cached: TuiApp<DiffState, DiffAction> | undefined

/**
* TUI app definition for the schema diff command.
*
* Constructed lazily (and memoized) rather than at module top level: building it
* eagerly is a module-load side-effect that crashes the umbrella `notion` binary
* under Bun's concurrent command-tree import (#787, upstream oven-sh/bun#30634).
* The five renderer `get*App()` accessors share this workaround; see `cli.ts` for
* the trigger + the TODO to drop it once the Bun fix lands.
*/
export const getDiffApp = (): TuiApp<DiffState, DiffAction> =>
(cached ??= createTuiApp({
stateSchema: DiffState,
actionSchema: DiffAction,
initial: { _tag: 'Loading' } as DiffState,
reducer: diffReducer,
exitCode: (state) => (state._tag === 'Error' ? 1 : 0),
}))
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { DiffState, DiffAction, diffReducer } from './schema.ts'
export type { DiffState as DiffStateType, DiffAction as DiffActionType } from './schema.ts'

// App
export { DiffApp } from './app.ts'
export { getDiffApp } from './app.ts'

// Views
export { DiffView, type DiffViewProps } from './view.tsx'
Loading
Loading