overengineeringstudio / overengineeringstudio/effect-utils
notion-md: externalized page identity for code-first tree sync (bindings in/out)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 82
- Forks
- 2
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 121
Description
Revised: the original sketch below proposed a BindingStore service + two layers + a second entrypoint. That over-models identity as a runtime capability. This revision flips the mechanism to identity as data-in/data-out — simpler, more general, and a clean precursor to the WorkspacePort work (#700). Part of #774.
Problem
notion-md tree sync (syncTree) treats Notion page identity (page_id/url) as filesystem-owned: it reads bindings from .nmd frontmatter (scanLocalPages) and writes them back into the files (three writeback sites in tree.ts/sync.ts). A code-first caller that wants to keep generated .nmd content gitignored and identity in its own committed store must currently scrape page_id back out of frontmatter after each sync and inject it before the next — a brittle bridge around the tool.
Why identity is data, not a service
NotionMdGateway (remote HTTP) and NmdStateStore (sidecar I/O) are genuine service seams: the engine calls polymorphic effects mid-run whose impl it can't know. Page identity is not that — it's resolved up front (read the map once) and accumulated at the end (createPage → idForRelPath). So it should be a plain value passed in and returned, not a Context.Tag with layers. No new service, no second entrypoint.
Design — bindings in / bindings out (+ one effectful writeback hook)
export class NotionBinding extends Schema.Class<NotionBinding>('NotionBinding')({
pageId: Schema.String,
url: Schema.optional(Schema.String),
}) {}
/** relPath (from tree root) → identity; the root file's relPath is just another key. */
export const BindingMap = Schema.Record({ key: Schema.String, value: NotionBinding })
export const syncTree: (opts: {
root: string
rootFile?: string; plan?: boolean; fromRemote?: boolean; pushOptions?: PushOptions
/** Identity-IN. When set, the SOLE source of identity (per-node id, root id, trash oracle). */
bindings?: BindingMap
/** The one genuinely effectful identity concern: crash-safe per-bind writeback,
* the moment a page is created/rebound. Idiomatic Effect callback — its R/E
* channels carry whatever the strategy needs (e.g. FileSystem for frontmatter). */
onBind?: (relPath: string, binding: NotionBinding) => Effect.Effect<void, NmdError>
}) => Effect.Effect<
TreeSyncResult & { bindings: BindingMap }, // identity-OUT; `ops` is the existing TreeOp[]
NmdError,
FileSystem.FileSystem | NotionMdGateway | NmdStateStore
>
Nothing in the result is invented: bindings-out = the existing idForRelPath + urls; ops = the existing TreeOp[].
bindings unifies all THREE identity reads (the concrete win over the first sketch)
Identity lives in three places today; externalize all three (not just the first):
- frontmatter
boundPageId(scanLocalPages) →bindings[relPath]?.pageId - root id (
rootPage.boundPageId ?? TreeIndex.root_page_id) →bindings[rootFile]?.pageId - the trash oracle (
TreeIndex.pages— what existed last run) → the live key set ofbindings
(3) matters: a caller that gitignores both .notion-md/ and the content tree has already-degraded trash detection (no workspace.json on a fresh checkout). Sourcing it from the committed bindings map makes trash detection durable. When bindings is omitted, all three fall back to today's behavior. Because identity is no longer a service the persist callback must consult, the treeNodePersist callback is not torn apart — it keeps writing the baseline; its file write simply becomes the frontmatter strategy's onBind.
Frontmatter stays a thin adapter (engine is identity-source-agnostic)
Ship ~20 lines; the engine never mentions frontmatter for identity:
export const readFrontmatterBindings: (opts: { root: string; rootFile?: string }) =>
Effect.Effect<BindingMap, NmdError, FileSystem.FileSystem | NmdStateStore>
export const writeFrontmatterBinding: (opts: { root: string }) =>
(relPath: string, binding: NotionBinding) => Effect.Effect<void, NmdError, FileSystem.FileSystem>
CLI = syncTree({ root, bindings: yield* readFrontmatterBindings({ root }), onBind: writeFrontmatterBinding({ root }) }) — identical behavior today (identity rides with the file → rename-safe; crash-safe mid-run). A code-first caller is the symmetric adapter: read map → syncTree({ bindings }) → write map (no onBind; persist result.bindings once at the end). The engine always takes bindings — no in-engine frontmatter fallback — so frontmatter logic lives only in the adapter + CLI (the clean migration).
Baseline is explicitly out of scope — and un-externalizable
The merge baseline (.notion-md/, page_id → {hash, base}) has a different lifetime: it ≈ the last-pushed body (≈ the content a code-first caller gitignores) and is a lying oracle (tree.ts:51–55: a re-pull is not a truthful baseline because Notion's markdown GET merges blockquote-adjacent blocks). So it can't be regenerated on demand and must stay an injected NmdStateStore effect with self-heal. "One snapshot for all sync state" is therefore the wrong abstraction: externalize identity (a value); leave the baseline injected.
Scope
- New
binding.ts(NotionBinding/BindingMapschemas) + the frontmatter adapter. tree.ts: route the identity read, root-id resolution, and the trash oracle throughbindingswhen set; the three writebacks callonBind+ accumulate intobindings-out; export the newsyncTreeshape + the adapter frommod.ts.sync.ts:treeNodePersistkeeps the baseline write; its file write becomes the CLI'sonBind. No teardown.- Out of scope: externalizing the baseline; content stays a directory path (don't let "identity as data" drag "content as data" in).
Relationship to the roadmap
Precursor to WorkspacePort (#700): the existing LocalWorkspacePort in notion-datasource-sync (scan/claimPath/materialize) already matches this shape (scan ≈ bindings-in, claimPath ≈ onBind). Adopting the port now would invert the dependency graph (notion-datasource-sync depends on notion-md) and import an event-sourced model the tree engine doesn't use; extracting a neutral port is the body of #700. This change folds into it cleanly when that lands. Part of epic #774.
Acceptance
syncTree({ bindings })round-trips: some unbound in → sync →result.bindingshas stable ids for pre-bound pages and new ids for created ones; re-running withresult.bindingsis a noop (no duplicate creates).- With
bindingsset, a page in the input map but absent from the local tree is trash-detected without any.notion-md/workspace.jsonpresent. - Generated
.nmdfiles need not carrypage_idfor the code-first path. - CLI behavior (frontmatter binding, crash-safe mid-run writeback via
onBind) unchanged; existing tests green. - A downstream generated-docs→Notion pipeline can delete its post-sync frontmatter "capture" step and its identity-injection step; a fail-closed unbound-page pre-check becomes a pure check on the input map.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading tree.ts and sync.ts to trace the existing identity reads, writebacks, and treeNodePersist behavior. Then review the new binding.ts and mod.ts scope described here. Done means bindings-in/out support stable reruns and trash detection without workspace.json, while CLI frontmatter behavior and existing tests remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100