From 32f296a764d45b8bd465eb4d8a1ee875fb9bf1a2 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:30:27 +0000
Subject: [PATCH 01/37] docs: publish Flatbread positioning
Summary of changes
- Reframe Flatbread as a Git-native relational content layer for TypeScript apps.
- Add docs/positioning.md as the canonical statement of ICP, jobs, non-goals, and GraphQL's role as an interface.
- Update package README intros so source and transformer packages describe their role in the relational content model instead of positioning GraphQL as the product.
Testing
- pnpm build
- pnpm lint
- proof DAG: dag-flatbread-142-positioning completed 4/4 tasks
Closes #142
Change-Id: Icc64caae46e919e9eca7a42f7659bc39f20386e3
---
docs/positioning.md | 17 +++++++++++++++++
packages/flatbread/README.md | 14 +++++++++++++-
packages/source-filesystem/README.md | 2 +-
packages/transformer-markdown/README.md | 2 +-
packages/transformer-yaml/README.md | 2 +-
5 files changed, 33 insertions(+), 4 deletions(-)
create mode 100644 docs/positioning.md
diff --git a/docs/positioning.md b/docs/positioning.md
new file mode 100644
index 00000000..a4474321
--- /dev/null
+++ b/docs/positioning.md
@@ -0,0 +1,17 @@
+# Flatbread positioning
+
+Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md).
+
+Turn flat files in Git into typed, relational content for your TypeScript app—with [GraphQL](https://graphql.org/) and generated types as the default way to query the model today.
+
+**Flatbread** is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped.
+
+**Who it's for:** Teams shipping TypeScript sites, internal tools, and starters who want **versioned, reviewable content** and **relationships between entries**—without standing up a CMS database or giving up ownership of where content lives.
+
+**Non-goals:**
+
+- Not a hosted CMS, dashboard, or authoring UI: Flatbread is a library and local workflow, not a full content-management product you log into.
+- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
+- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes.
+
+**GraphQL:** In the default setup, GraphQL is the primary **interface** for reading the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product.
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index ef54d87c..a83e028e 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -16,7 +16,19 @@
-Eat your relational markdown data _and query it, too,_ with [GraphQL](https://graphql.org/) inside damn near any framework (statement awaiting peer-review).
+Turn flat files in Git into typed, relational content for your TypeScript app—with [GraphQL](https://graphql.org/) and generated types as the default way to query the model today.
+
+**Flatbread** is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped.
+
+**Who it's for:** Teams shipping TypeScript sites, internal tools, and starters who want **versioned, reviewable content** and **relationships between entries**—without standing up a CMS database or giving up ownership of where content lives.
+
+**Non-goals:**
+
+- Not a hosted CMS, dashboard, or authoring UI: Flatbread is a library and local workflow, not a full content-management product you log into.
+- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
+- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes.
+
+**GraphQL:** In the default setup, GraphQL is the primary **interface** for reading the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md).
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
diff --git a/packages/source-filesystem/README.md b/packages/source-filesystem/README.md
index 681ab87b..800e06f7 100644
--- a/packages/source-filesystem/README.md
+++ b/packages/source-filesystem/README.md
@@ -1,6 +1,6 @@
# @flatbread/source-filesystem 🗃
-> Transform files into content that can be fetched with GraphQL.
+> Load files into Flatbread's relational content model (often queried via GraphQL in the default toolkit).
## 💾 Install
diff --git a/packages/transformer-markdown/README.md b/packages/transformer-markdown/README.md
index 59d78b4a..bf08b264 100644
--- a/packages/transformer-markdown/README.md
+++ b/packages/transformer-markdown/README.md
@@ -1,6 +1,6 @@
# @flatbread/transformer-markdown ⚡
-> Transform [Markdown](https://en.wikipedia.org/wiki/markdown) files into content that can be fetched with GraphQL. If you're using a CMS like NetlifyCMS, you'll want to pair this with the [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/source-filesystem/README.md) plugin.
+> Transform [Markdown](https://en.wikipedia.org/wiki/markdown) into Flatbread collection entries. Pair with [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/source-filesystem/README.md) when content lives on disk; typical setups then read the graph via GraphQL.
## 💾 Install
diff --git a/packages/transformer-yaml/README.md b/packages/transformer-yaml/README.md
index 7b331bfd..d2b1600f 100644
--- a/packages/transformer-yaml/README.md
+++ b/packages/transformer-yaml/README.md
@@ -1,6 +1,6 @@
# @flatbread/transformer-yaml 🐪
-> Transform [YAML](https://en.wikipedia.org/wiki/YAML) files into content that can be fetched with GraphQL.
+> Transform [YAML](https://en.wikipedia.org/wiki/YAML) into Flatbread collection entries (often consumed through GraphQL in the default setup).
## 💾 Install
From 0b8b1d3f93f697638e77ff2b124a0232c6aab8dd Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:33:35 +0000
Subject: [PATCH 02/37] docs: define relational primitives glossary
Summary of changes
- Add docs/glossary.md with practical definitions for collection, record, ID, relation, cardinality, validation, and query interface.
- Link the glossary from the positioning page, package README, and Next.js example docs so onboarding has a shared vocabulary.
- Keep definitions scoped to Flatbread's Git-native content graph and explicitly avoid database/CMS replacement implications.
Testing
- pnpm lint
- proof DAG: dag-flatbread-143-glossary completed 4/4 tasks
Closes #143
Change-Id: I5e4396421ccb1b837b7c49428e54a0d72db82877
---
docs/glossary.md | 37 ++++++++++++++++++++++++++++++++++++
docs/positioning.md | 2 +-
examples/nextjs/README.md | 3 ++-
packages/flatbread/README.md | 2 ++
4 files changed, 42 insertions(+), 2 deletions(-)
create mode 100644 docs/glossary.md
diff --git a/docs/glossary.md b/docs/glossary.md
new file mode 100644
index 00000000..d54d06d5
--- /dev/null
+++ b/docs/glossary.md
@@ -0,0 +1,37 @@
+# Flatbread glossary — relational content primitives
+
+This page defines vocabulary for Flatbread’s **Git-native, flat-file relational content layer** for TypeScript apps. Flatbread turns files in your repo into a coherent **content graph** you can read from your application; it is **not** a hosted CMS, a full authoring product, or a general-purpose database.
+
+**[GraphQL](https://graphql.org/)** is often the **default query interface** in typical setups, but it is one way to read the graph—not the product’s whole identity.
+
+See also: [Flatbread positioning](./positioning.md).
+
+---
+
+### Cardinality
+
+How many related items a field connects—whether a relation resolves to **one** related entry or **many** (for example, a single author versus a list of tags). Cardinality shapes how the graph is exposed to your app (including generated GraphQL fields); it does **not** imply a SQL-style database engine.
+
+### Collection
+
+A **named group** of content of the same kind, declared in your Flatbread config and usually mapped to a folder of source files (for example, all posts under `content/posts`). A collection is a **modeling unit** over files in Git, not a database table or a hosted content bucket.
+
+### ID
+
+An identifier Flatbread uses to **point at one item within a collection** so relations can resolve. Today, Flatbread expects loaded entries to expose an `id`-shaped value that query arguments and `refs` can compare against; future ID work should keep that rule explicit across files, generated types, and query interfaces. IDs wire the graph together **in the repository**; they are not a centralized “primary key service” like a server database would provide.
+
+### Query interface
+
+The **API surface your application uses to read** the built content graph. In many projects today that surface is **GraphQL** (schema plus operations, often with codegen), meaning GraphQL is **an interface**, not the definition of Flatbread. Other ways to consume the same graph may exist in your stack alongside it.
+
+### Record
+
+**One loaded item** in a collection: the structured result of reading a file (metadata, body, derived fields) that your app treats as a single unit. “Record” here means **a document-shaped object in memory**, not a row in a remote database.
+
+### Relation
+
+A **configured link** from entries in one collection to another (for example, `refs` in config mapping a post field to an `Author` collection). Relations express **associations between flat-file content**, not foreign keys managed by a separate database server.
+
+### Validation
+
+Checks that your **Flatbread configuration, plugin wiring, and loaded content graph** are consistent enough to read safely. Near-term validation work should make broken references, duplicate IDs, and unsupported relation shapes clear before they become query-time surprises. This is still scoped to Flatbread’s content graph; it is not a promise of every database constraint or every editorial rule a CMS might enforce.
diff --git a/docs/positioning.md b/docs/positioning.md
index a4474321..268d1693 100644
--- a/docs/positioning.md
+++ b/docs/positioning.md
@@ -1,6 +1,6 @@
# Flatbread positioning
-Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md).
+Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md).
Turn flat files in Git into typed, relational content for your TypeScript app—with [GraphQL](https://graphql.org/) and generated types as the default way to query the model today.
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 24e25b08..685f90ad 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -125,6 +125,7 @@ Check that your GraphQL endpoint is accessible and CORS is configured properly.
## 📚 Learn More
-- [Flatbread Documentation](https://github.com/FlatbreadLabs/flatbread)
+- [Flatbread repo & main README](https://github.com/FlatbreadLabs/flatbread) — onboarding and install
+- [Glossary (relational primitives & query interface)](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md) — collections, relations, IDs; GraphQL is **one** read surface, not the whole product
- [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen)
- [Next.js Documentation](https://nextjs.org/docs)
\ No newline at end of file
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index a83e028e..3e7b2fdf 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -30,6 +30,8 @@ Turn flat files in Git into typed, relational content for your TypeScript app—
**GraphQL:** In the default setup, GraphQL is the primary **interface** for reading the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md).
+**Glossary:** Quick definitions for **collection**, **relation**, **ID**, **cardinality**, **validation**, and **query interface** (GraphQL as one read path, not the whole product)—see [docs/glossary.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
+
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
From cee3c5a18e222934bd2f53ac9d3d8344d25a2b6e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:36:05 +0000
Subject: [PATCH 03/37] docs: add PMF decision rubric
Summary of changes
- Add a PMF decision rubric comparing Flatbread with SQLite-style database workflows, hosted/headless CMS workflows, Contentlayer-like content workflows, and agent artifact/Effort Graph workflows.
- Include setup time, type safety, relation modeling, reference integrity, portability, local dev loop quality, and agent query ergonomics as explicit criteria.
- Link the rubric from positioning and PMF audit docs and state go/no-go signals for agent artifacts as a primary wedge.
Testing
- pnpm lint
- proof DAG: dag-flatbread-144-pmf-rubric completed 4/4 tasks
Closes #144
Change-Id: I6582bf947f99e327d1d36e4c4f2a3d6a92b320ea
---
docs/glossary.md | 2 +-
docs/pmf-decision-rubric.md | 79 +++++++++++++++++++++++++++++++++++++
docs/positioning.md | 2 +-
flatbread-flow-pmf-audit.md | 2 +
4 files changed, 83 insertions(+), 2 deletions(-)
create mode 100644 docs/pmf-decision-rubric.md
diff --git a/docs/glossary.md b/docs/glossary.md
index d54d06d5..2f89e95e 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -4,7 +4,7 @@ This page defines vocabulary for Flatbread’s **Git-native, flat-file relationa
**[GraphQL](https://graphql.org/)** is often the **default query interface** in typical setups, but it is one way to read the graph—not the product’s whole identity.
-See also: [Flatbread positioning](./positioning.md).
+See also: [Flatbread positioning](./positioning.md); [PMF decision rubric](./pmf-decision-rubric.md) (comparative criteria and agent-wedge signals).
---
diff --git a/docs/pmf-decision-rubric.md b/docs/pmf-decision-rubric.md
new file mode 100644
index 00000000..8bc9acfc
--- /dev/null
+++ b/docs/pmf-decision-rubric.md
@@ -0,0 +1,79 @@
+# PMF decision rubric — Flatbread vs adjacent workflows
+
+This page supports product and positioning decisions (e.g. [issue #144](https://github.com/FlatbreadLabs/flatbread/issues/144)) by comparing **Flatbread** to four **buyer-recognizable** workflow families. Use it to avoid mixing “Flatbread vs SQLite” with “Flatbread vs Notion” in the same breath without naming who you are selling to.
+
+**Flatbread in one line:** Git-native **relational content** for TypeScript apps, **backed by flat files** in the repo. **[GraphQL](https://graphql.org/)** is a common **read interface** and codegen driver; it is **not** the whole product identity. The core artifact is the **modeled content graph** (collections, fields, relations, validation).
+
+---
+
+## How to read the matrix
+
+Each row names a **workflow category** a buyer might already use. Columns are **decision criteria** aligned with validation experiments and near-term PMF work. Cells summarize typical tradeoffs **for that category**, not a single vendor scorecard.
+
+**Legend (qualitative):**
+
+- **Strong** — category usually excels here with little extra work.
+- **Medium** — workable with discipline, tooling, or conventions; gaps are predictable.
+- **Weak** — common pain or structural mismatch for this criterion in typical setups.
+- **N/A** — criterion does not apply the same way (call out explicitly).
+
+Where Flatbread is **targeting** behavior that is not fully shipped yet (for example, first-class reference integrity at load time), the cell notes **current vs target** honestly.
+
+---
+
+## Comparative matrix
+
+| Criterion | Flatbread (relational flat files) | SQLite-style database workflows | Hosted / headless CMS workflows | Contentlayer-like content workflows | Agent artifact / Effort Graph workflows |
+| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| **Setup time** | **Medium** — deps, config, content paths, optional GraphQL server/codegen; goal is ~10 minutes to a typed read for a `posts → authors → tags` starter. | **Medium** — schema/migrations, client, connection; very fast for experienced DB users. | **Medium–High** — account, schema/content model, API keys, webhooks; low ops if fully hosted. | **Medium** — build plugin, schemas, content layout; familiar to static/SSG teams. | **High variance** — conventions differ (`AGENTS.md`, `.handoff/`, vaults); **relational** effort graphs rarely work out of the box. |
+| **Type safety** | **Medium (moving target)** — generated types help at the query boundary; config and raw content surfaces may still be looser until model-first typing lands end-to-end. | **Strong** with SQL builders/ORMs; schema is the source of truth. | **Medium** — SDK/OpenAPI/GraphQL types help; CMS field types and draft content can weaken guarantees. | **Strong** for defined content schemas; weaker if everything is MDX/adhoc. | **Weak–Medium** — lots of markdown prose; typed edges often missing unless encoded manually. |
+| **Relation modeling** | **Strong intent** — `refs`, nested reads, filters over a content graph; cardinality must stay **documented** (implied behavior is an audit gap). | **Strong** — joins and constraints are the database’s job. | **Medium–Strong** — reference fields and UI; deeper graph queries depend on API. | **Medium** — relations exist but are optimized for site content, not arbitrary graphs. | **Weak** — links and search, not always **foreign-key-style** relations across tool boundaries. |
+| **Reference integrity** | **Target: Strong / Today: uneven** — buyers expect missing refs, duplicate IDs, and bad shapes to **fail with clear diagnostics at load/validate**, not silent GraphQL `null` chains; full guarantee is **roadmap-critical**, not optional polish. | **Strong** with constraints and transactions (or app-enforced). | **Medium–Strong** — CMS often blocks bad publishes; export/sync paths can still drift. | **Medium** — build fails on schema errors; cross-file refs vary by stack. | **Weak** — broken links and orphan artifacts are common; validation is not standardized. |
+| **Portability** | **Strong (raw files + Git)** — export story should include **JSON/CSV per collection** as a deliberate trust lever; contrast with ad-hoc “query and save.” | **Strong** via `dump`, backups, SQL files; binary portability has ops nuance. | **Medium** — APIs and export formats; lock-in depends on vendor. | **Strong** — content lives in repo; migration is folder moves + schema rewrites. | **Strong** — everything is files; **semantic** portability across tools is harder than byte portability. |
+| **Local dev loop** | **Medium (honest)** — file-backed by nature; **reliable hot reload of content is not a pillar yet**; expect restarts or manual steps today where examples require a server/codegen refresh. | **Strong** — migrations + local DB; ORM dev UX mature. | **Variable** — offline editing depends on sync; preview stacks add latency. | **Medium–Strong** — dev servers often rebuild on file change; watch modes vary. | **Strong for “save file”** — weak for “typed graph updates everywhere” without extra tooling. |
+| **Agent query ergonomics** | **Medium (directional)** — predicate-rich filters and nested reads suit **structured** agent queries; today’s path often touches **GraphQL** or codegen; **MCP / generated TS** as first-class agent surfaces is PMF leverage, not a nice-to-have. | **Strong** — SQL is the universal agent substrate when access is allowed. | **Medium** — HTTP APIs; auth and rate limits add friction for agents. | **Medium** — build-time access is easy; **runtime** ad-hoc queries less natural. | **Weak–Medium** today — keyword/vault MCP and search; **Effort Graph**-style queries want relational filters + integrity. |
+
+---
+
+## Named contrasts (avoid category mixing)
+
+When writing positioning or issues, **name the buyer** and **one primary alternative**:
+
+| If the buyer is deciding against… | Lead with… |
+| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **SQLite / Postgres + app** | Versioned **content** and **review in Git** vs operational DB ergonomics; Flatbread is **not** replacing transactions or multi-writer DB semantics. |
+| **Notion / Contentful / Sanity / etc.** | **Repo ownership** and **flat files** vs editorial APIs and hosted workflows; relations without standing up CMS infrastructure. |
+| **Contentlayer / Velite / similar** | **Cross-collection references and graph reads** in TypeScript vs site-generation-first content pipelines. |
+| **Handoff folders / vault MCP / memory tools** | **Typed relations and validation** over agent artifacts vs search-only or narrative-memory layouts—only after core integrity and watch/export bars are credible. |
+
+---
+
+## Agent artifacts: secondary vertical vs primary wedge
+
+**Secondary vertical (default posture today)** fits when Flatbread’s **near-term bar** is still about relational **content** for apps—schemas, IDs, validation, exports, watch—and agent use inherits the same graph primitives without a bespoke **Effort Graph** product bundle.
+
+**Go signals for treating agent artifacts as primary wedge**
+
+- Reference integrity and diagnostics at **load/validate** are **trusted** on real repos (missing refs, duplicate IDs, invalid shapes fail loudly and actionably).
+- **Model-first** onboarding reaches a **typed** query without demanding GraphQL literacy on day one; generated TypeScript and/or MCP cover the **agent-shaped** query path.
+- **Watch** or an honest, low-friction loop makes **file edit → graph update** usable for harnesses that emit many small artifacts.
+- At least one **reference layout** (for example `.agents/` or handoff-oriented trees) is documented as **indexed and validated** incremental adoption, not a migration cliff.
+- Evaluation buyers consistently compare Flatbread to **vault/handoff/GCC** workflows—not only to CMS or Contentlayer—_and_ the graph answers queries like _blocking decisions for effort X with plan title_ without bespoke glue per repo.
+
+**No-go / hold signals (keep agent artifacts secondary)**
+
+- Broken links and duplicate IDs still **silently** degrade query results; buyers cannot distinguish “no data” from “bad graph.”
+- The **only** documented happy path assumes a running **GraphQL** mental model for authors and agents.
+- Local iteration still **requires full process restart** for ordinary content edits in the primary examples, with no credible watch/export story.
+- Positioning drifts into **database replacement** or **hosted CMS** parity; agent narrative distracts from the core **TypeScript + Git relational content** promise.
+
+**Decision summary:** Agent artifacts are a **credible strategic option** because they amplify demand for the same integrity, typing, and query surfaces the core product needs; they become a **primary wedge** only when those properties are **proven in production-shaped workflows**, not declared in roadmap language alone.
+
+---
+
+## Related docs
+
+- [Flatbread positioning](./positioning.md) — canonical product framing.
+- [Glossary](./glossary.md) — collections, relations, IDs, validation, query interfaces.
+- [Flatbread Flow PMF Audit](../flatbread-flow-pmf-audit.md) — evidence-backed gaps and near-term experiments.
+- [Agent artifact opportunity](../flatbread-agent-artifact-opportunity.md) — Effort Graph and adjacent landscape (deeper than this rubric).
diff --git a/docs/positioning.md b/docs/positioning.md
index 268d1693..9bbe0663 100644
--- a/docs/positioning.md
+++ b/docs/positioning.md
@@ -1,6 +1,6 @@
# Flatbread positioning
-Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md).
+Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md). For **buyer-aware comparisons** (SQLite-style workflows, CMSs, Contentlayer-like stacks, agent artifact graphs) across setup time, typing, integrity, and related criteria—plus **go / no-go** guidance for an agent-artifact wedge—see the [PMF decision rubric](./pmf-decision-rubric.md).
Turn flat files in Git into typed, relational content for your TypeScript app—with [GraphQL](https://graphql.org/) and generated types as the default way to query the model today.
diff --git a/flatbread-flow-pmf-audit.md b/flatbread-flow-pmf-audit.md
index a5d88695..7ed8438d 100644
--- a/flatbread-flow-pmf-audit.md
+++ b/flatbread-flow-pmf-audit.md
@@ -2,6 +2,8 @@
Generated from the DAG task runner audit on May 7, 2026.
+**Buyer-facing comparison rubric** (SQLite, CMS, Contentlayer-like, agent-artifact workflows; issue #144 acceptance-style criteria): [docs/pmf-decision-rubric.md](./docs/pmf-decision-rubric.md).
+
Canvas: `file:///Users/tonyketcham/.cursor/projects/Users-tonyketcham-Code-Github-personal-flatbread/canvases/dag-flatbread-pmf-audit.canvas.tsx`
## Executive Summary
From 2662cfacb40ef0b8f2fffcde176effec9619bc84 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:40:29 +0000
Subject: [PATCH 04/37] docs: reconcile README command guidance
Summary of changes
- Align root, contributor, package, and Next.js example docs around the current pnpm workspace workflow.
- Document `flatbread start` as the valid CLI command, clarify that `flatbread dev` is not a subcommand, and distinguish one-shot codegen from watch-only codegen.
- Update example generated paths, ports, package names, and onboarding guidance to match the current repository layout.
Testing
- pnpm lint
- proof DAG: dag-flatbread-146-readme-command-drift completed 4/4 tasks
Closes #146
Change-Id: I6baf6285ac0c2bd74a4e5b9c6d43a85d1e1cbc45
---
AGENTS.md | 15 +--
CONTRIBUTING.md | 24 ++++-
examples/nextjs/README.md | 156 ++++++++++++----------------
packages/codegen/README.md | 29 +++---
packages/flatbread/README.md | 8 +-
packages/transformer-yaml/README.md | 23 ++--
6 files changed, 123 insertions(+), 132 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 387276f3..319f069a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,20 +4,21 @@
### Overview
-Flatbread is a Git-native relational content layer for TypeScript/JavaScript applications. It's a pnpm monorepo that sources flat files (Markdown, YAML), transforms them into relational data, and auto-generates a GraphQL API. See `CONTRIBUTING.md` for full development workflow.
+Flatbread is Git-native **relational content for TypeScript/JavaScript apps**: flat files become a typed graph; **GraphQL is one read surface**, not the whole product. It's a pnpm monorepo. See `CONTRIBUTING.md` for the canonical onboarding path.
### Key commands
See `CONTRIBUTING.md` for full details. Quick reference:
-- **Install**: `pnpm install`
-- **Build**: `pnpm build`
+- **Install**: `pnpm install` (enforces pnpm via `preinstall` script)
+- **Build**: `pnpm build` (builds all packages except examples via tsup)
- **Lint**: `pnpm lint` (prettier)
- **Lint fix (after edits)**: `pnpm lint:fix:fast` (writes formatting repo-wide to match `pnpm lint`; staged-only: `pnpm lint:fix`, also runs via `.husky/pre-commit`)
- **Typecheck**: `pnpm typecheck`
-- **Test**: `pnpm test` (builds, then runs ava + vitest suites)
+- **Test**: `pnpm test` (builds, then runs ava + vitest suites, including `@flatbread/proof` bounded-loop coverage). For the focused proof loop suite: `pnpm -F @flatbread/proof test`. Vitest packages use `pnpm -F @flatbread/utils exec vitest run` / `pnpm -F @flatbread/codegen exec vitest run` (`run` avoids watch mode).
- **Full verify**: `pnpm verify` (lint + typecheck + build + test)
-- **Dev server**: `pnpm play` (GraphQL on port 5057, Next.js on port 3000)
+- **Proof loop contract**: explicit `DAG.loops[].reexecute.tasks` subsets must be dependency-closed, multiple loops must have disjoint re-execution sets, and `DAG.loops` must not be combined with `--converge-on`.
+- **Dev server**: `pnpm play` (GraphQL on port 5057, Next.js on port 3000). From `examples/nextjs`, prefer `pnpm exec flatbread start -- next dev --turbopack`. Use `flatbread start` — `flatbread dev` is not a CLI command.
### Mergify Stacks
@@ -32,9 +33,9 @@ The repo uses Mergify stacks for PR management. The `mergify-cli` is installed v
- **`@flatbread/proof` requires `CURSOR_RIPGREP_PATH`.** The proof package uses `@cursor/sdk` which expects a bundled ripgrep. In Cloud Agent VMs, set `export CURSOR_RIPGREP_PATH=/usr/bin/rg` to use the system ripgrep (included in the update script).
- **Native build scripts are approved in `pnpm-workspace.yaml`.** The `onlyBuiltDependencies` list allows esbuild, sharp, @swc/core, etc. to run their postinstall scripts automatically during `pnpm install`.
- **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit.
-- **`flatbread` CLI is not on PATH.** Use `npx flatbread` when running from a shell. The `pnpm play` script from the root handles this automatically.
+- **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically.
- **Build before test.** All packages must be built (`pnpm build`) before running tests or starting dev servers. `pnpm test` handles this automatically.
-- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `npx flatbread start -- next dev --turbopack`.
+- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `pnpm exec flatbread start -- next dev --turbopack`.
- **Full local CI parity check:** `pnpm verify` runs lint, typecheck, build, and all tests.
### Weave merge driver
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e8a8e030..bb353832 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,18 +2,34 @@
Thanks for your interest in contributing! This guide covers local development and the release process (bumping versions and publishing packages).
+**Flatbread** is **relational, Git-tracked content for TypeScript apps**: flat files in the repo become a typed content graph. **GraphQL is one consumer** of that graph (see `docs/glossary.md`), not the whole product story.
+
## Prerequisites
- Node 20.19+
- pnpm 10.33.x via Corepack (`corepack enable && corepack prepare pnpm@10.33.0 --activate`)
- Clean git working tree (commit/stash your work first)
+
+## Recommended onboarding (try Flatbread in the Next.js example)
+
+Use this single path first; it matches how CI and most contributors exercise the stack:
+
+1. From the **monorepo root**: `pnpm install` then `pnpm build` (builds all packages except `examples/*`).
+2. `cd examples/nextjs`
+3. One-shot codegen: `pnpm exec flatbread codegen --verbose` (output: `generated/graphql.ts`; globs and dirs come from `flatbread.config.js`).
+4. Run the app **and** Flatbread together with **`flatbread start`** (there is **no** `flatbread dev` subcommand):
+ - **`pnpm dev`** — Next dev with local HTTPS + Flatbread (GraphQL on **5057**, Next on **3000**).
+ - Headless / no HTTPS: `pnpm exec flatbread start -- next dev --turbopack`.
+
+Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nextjs && pnpm dev`** — same as step 4 above, not a separate product command.
+
## Local development
-- Install dependencies: `pnpm -w i`
+- Install dependencies: `pnpm install` (or `pnpm -w i`)
- Build all packages: `pnpm build`
-- Run dev across packages: `pnpm dev`
-- Work in examples (Next.js preferred): `pnpm play`
+- **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example.
+- **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias.
- Check local CI parity before opening a PR: `pnpm verify`
## Working on a package
@@ -23,7 +39,7 @@ Open another terminal tab while keeping the dev server running.
- Option 1 (preferred): use the Next.js example as a demo project
- Work in the full context of a Flatbread instance as an end-user would, while tinkering with `packages/*` internals.
- - Command: `pnpm play` (starts the Next.js example)
+ - Commands: follow [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example), or from root run **`pnpm play`** (`cd examples/nextjs && pnpm dev`).
- Good when you want to test without creating per-package temporary clutter.
- Option 2: scope to a specific package
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 685f90ad..1d69cc5e 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -1,131 +1,111 @@
# Flatbread Next.js Example with TypeScript Codegen
-This example demonstrates how to use Flatbread with Next.js and automatic TypeScript type generation.
+This example shows **Flatbread** as **relational, Git-tracked content for a TypeScript app**: markdown and YAML under version control are loaded into a typed model; **GraphQL is one read interface** (alongside anything else you build). Next.js uses generated operation types against the Flatbread GraphQL endpoint.
-## 🚀 Quick Start
+## Quick start (from monorepo root)
-1. **Install dependencies:**
- ```bash
- npm install
- ```
+Flatbread development assumes this **pnpm** workspace. Use one path end-to-end:
-2. **Generate TypeScript types from GraphQL schema:**
- ```bash
- npx flatbread codegen --documents "src/queries/**/*.graphql" --verbose
- ```
+1. **Install and build packages** (excluding examples):
-3. **Start the Flatbread server:**
```bash
- npx flatbread dev
+ pnpm install
+ pnpm build
```
-4. **In another terminal, start the Next.js development server:**
+2. **Work in this example:**
+
```bash
- npm run dev
+ cd examples/nextjs
```
-5. **Open your browser to** `http://localhost:3000`
-
-## 📁 Project Structure
+3. **Generate TypeScript types once** (paths and globs come from `flatbread.config.js`; default output is `generated/graphql.ts`):
-- `flatbread.config.js` - Flatbread configuration
-- `src/generated/graphql.ts` - Auto-generated TypeScript types
-- `src/queries/posts.graphql` - GraphQL queries for type generation
-- `src/lib/graphql.ts` - GraphQL client utilities
-- `src/components/` - React components using generated types
-- `app/page.tsx` - Main page displaying content
-
-## 🏗️ Generated Types
+ ```bash
+ pnpm exec flatbread codegen --verbose
+ ```
-The example uses `@flatbread/codegen` to automatically generate TypeScript types from your Flatbread GraphQL schema. Types are generated based on:
+4. **Run Next.js and the Flatbread GraphQL server together** via the CLI (**there is no `flatbread dev` subcommand** — use **`flatbread start`**):
-1. **GraphQL Schema** - Generated from your Flatbread configuration
-2. **GraphQL Documents** - Queries defined in `src/queries/`
+ - **Default (local HTTPS for Next):** `pnpm dev` — runs `flatbread start --https -- next dev --turbopack`.
+ - **Headless / no HTTPS** (e.g. agents, CI): `pnpm exec flatbread start -- next dev --turbopack`.
-### Regenerating Types
+5. Open **[http://localhost:3000](http://localhost:3000)** for the app. The Flatbread GraphQL HTTP endpoint defaults to **`http://localhost:5057/graphql`** (not the Next port).
-When you change your Flatbread configuration or GraphQL queries, regenerate types:
+### Scripts in this package
-```bash
-npx flatbread codegen --verbose
-```
+| Script | Purpose |
+|--------|--------|
+| `pnpm dev` | **`flatbread start`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
+| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
+| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread. Use only if you already have GraphQL served elsewhere. |
+| `pnpm run codegen` | **Watch-only:** `flatbread codegen --watch` — regenerate types when config, content, or documents change. |
-### Watching for Changes
+### Watch-only codegen
-For development, you can watch for changes and auto-regenerate:
+For iterative work, you can run the watcher in a second terminal (leave it running until you stop it):
```bash
-npx flatbread codegen --watch --verbose
+pnpm run codegen
```
-## 🎯 Features Demonstrated
-
-- ✅ **Type-Safe GraphQL Queries** - Using generated TypeScript types
-- ✅ **Intelligent Caching** - Avoids regeneration when config unchanged
-- ✅ **Component Composition** - React components with proper typing
-- ✅ **Server-Side Rendering** - Next.js App Router with async data fetching
-- ✅ **Error Handling** - Graceful fallbacks for data loading errors
+## Project structure
-## 📝 GraphQL Queries
+Aligned with the App Router layout in this repo:
-Example queries in `src/queries/posts.graphql`:
+- `app/` — routes and components (`page.tsx`, `post/[id]/`, etc.)
+- `lib/graphql.ts` — GraphQL client helpers (default endpoint `http://localhost:5057/graphql`)
+- `generated/graphql.ts` — generated TypeScript types and documents (`flatbread codegen`)
+- `queries/*.graphql` — GraphQL documents included via `flatbread.config.js`
+- `flatbread.config.js` — sources, transformers, collections, and codegen options
+- `content` → `../content` — shared example content (symlink to `examples/content`)
-- `GetPostCategories` - Fetch all post categories with authors and images
-- `GetAllPosts` - Fetch all posts with basic information
-- `GetAuthors` - Fetch all authors with skills and images
+## Configuration snippets
-## 🔧 Configuration
-
-### Flatbread Config (`flatbread.config.js`)
-
-Standard Flatbread configuration with content sources and transformers.
-
-### Codegen Config
-
-You can customize codegen behavior in your `flatbread.config.js`:
+Codegen in `flatbread.config.js` matches the checked-in file — excerpt:
```javascript
-export default defineConfig({
- // ... your existing config
- codegen: {
- enabled: true,
- outputDir: './src/generated',
- outputFile: 'graphql.ts',
- documents: ['src/queries/**/*.graphql'],
- watch: false,
- cache: true,
- },
-});
+codegen: {
+ enabled: true,
+ outputDir: './generated',
+ outputFile: 'graphql.ts',
+ documents: [
+ './**/*.graphql',
+ './**/*.gql',
+ './components/**/*.graphql',
+ ],
+ // ...
+},
```
-## 🎨 Styling
-
-This example uses Tailwind CSS for styling, similar to the SvelteKit example. The layout features:
-
-- **Split Pane Layout** - JSON output on left, rendered UI on right
-- **PostCard Components** - Displays posts with authors, ratings, and content
-- **Responsive Design** - Works on different screen sizes
+## Regenerating types
-## 🚫 Troubleshooting
+After changing Flatbread config, content, or `.graphql` documents:
-### "No posts found"
-Make sure the Flatbread server is running on `http://localhost:5057`:
```bash
-npx flatbread dev
+pnpm exec flatbread codegen --verbose
```
-### TypeScript Errors
-Regenerate types if your schema changed:
+Force regeneration (clear cache):
+
```bash
-npx flatbread codegen --clear-cache --verbose
+pnpm exec flatbread codegen --clear-cache --verbose
```
-### Network Errors
-Check that your GraphQL endpoint is accessible and CORS is configured properly.
+## Troubleshooting
+
+### "No posts found" or network errors
+
+Ensure something is serving Flatbread at **`http://localhost:5057/graphql`** — typically by running **`pnpm dev`** or **`pnpm exec flatbread start -- next dev --turbopack`** from this directory, not `pnpm start` alone.
+
+### TypeScript errors after schema changes
+
+Run **`pnpm exec flatbread codegen --clear-cache --verbose`**.
-## 📚 Learn More
+## Learn more
-- [Flatbread repo & main README](https://github.com/FlatbreadLabs/flatbread) — onboarding and install
-- [Glossary (relational primitives & query interface)](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md) — collections, relations, IDs; GraphQL is **one** read surface, not the whole product
+- [Flatbread package README](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/flatbread#readme) — install and **`flatbread start`**
+- [Glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md) — collections, relations; GraphQL as one surface
+- [Contributing / monorepo workflow](https://github.com/FlatbreadLabs/flatbread/blob/main/CONTRIBUTING.md)
- [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen)
-- [Next.js Documentation](https://nextjs.org/docs)
\ No newline at end of file
+- [Next.js Documentation](https://nextjs.org/docs)
diff --git a/packages/codegen/README.md b/packages/codegen/README.md
index 7e976ad0..f00f4f58 100644
--- a/packages/codegen/README.md
+++ b/packages/codegen/README.md
@@ -2,6 +2,8 @@
> Automatic TypeScript type generation for Flatbread GraphQL schemas
+Flatbread treats repo files as **relational, Git-tracked content** for TypeScript apps; GraphQL is a common **read interface**. Codegen keeps operations typed against the schema Flatbread derives from your config.
+
## 💾 Install
Use `pnpm`, `npm`, or `yarn`:
@@ -33,7 +35,7 @@ export default defineConfig({
// Add codegen configuration
codegen: {
enabled: true,
- outputDir: './src/generated',
+ outputDir: './generated',
outputFile: 'graphql.ts',
plugins: ['typescript', 'typescript-operations', 'typed-document-node'],
},
@@ -73,9 +75,9 @@ const posts: Post[] = await request(`
`);
```
-## 👀 Watch Mode
+## 👀 Watch Mode (watch-only)
-The `--watch` flag enables automatic regeneration of TypeScript types whenever your source files change. This is particularly useful during development to keep your types in sync with your content and schema changes.
+The `--watch` flag enables automatic regeneration while the process stays running—**watch-only**; use one-shot `flatbread codegen` when you need a single generation (for example in CI).
### How Watch Mode Works
@@ -99,8 +101,8 @@ npx flatbread codegen --watch --verbose
🥯 Flatbread TypeScript Code Generator
Generating GraphQL schema...
🔍 Watching for changes...
-Watching patterns: flatbread.config.*, content/posts/**/*.{md,mdx,markdown}, src/**/*.graphql
-✓ Generated TypeScript types: /path/to/src/generated/graphql.ts
+Watching patterns: flatbread.config.*, content/**/*.{md,mdx,markdown,yml,yaml}, **/*.graphql
+✓ Generated TypeScript types: /path/to/generated/graphql.ts
👀 Ready for changes
📝 File changed: content/posts/new-article.md
@@ -126,7 +128,7 @@ export interface CodegenOptions {
enabled?: boolean; // default: false
// Output directory for generated types
- outputDir?: string; // default: './src/generated'
+ outputDir?: string; // default: './generated'
// Output filename for generated types
outputFile?: string; // default: 'graphql.ts'
@@ -171,7 +173,7 @@ export default defineConfig({
},
// Include GraphQL documents from your app
- documents: ['./src/**/*.graphql', './src/**/*.gql'],
+ documents: ['./queries/**/*.graphql', './**/*.graphql'],
// Custom GraphQL Code Generator configuration
codegenConfig: {
@@ -203,7 +205,7 @@ const schema = await generateSchema(configResult);
// Generate TypeScript types
const result = await generateTypes(schema, configResult.config, {
enabled: true,
- outputDir: './src/generated',
+ outputDir: './generated',
outputFile: 'types.ts',
});
@@ -226,12 +228,13 @@ Types are only regenerated when one of these changes. You can force regeneration
```bash
# Clear cache and regenerate
-npx flatbread codegen --clear-cache
+pnpm exec flatbread codegen --clear-cache
-# Disable caching entirely
-npx flatbread codegen --no-cache
+# Or set codegen.cache to false in flatbread.config.* for non-cached runs
```
+Watch mode (`--watch`) is **watch-only**: leave it running during development; use a one-shot `flatbread codegen` (without `--watch`) when you only need a single generation.
+
## 🎛️ CLI Options
```bash
@@ -323,7 +326,7 @@ import { GetPostsDocument, type GetPostsQuery } from './generated/graphql';
export async function getStaticProps() {
const data = await request(
- 'http://localhost:5050/graphql',
+ 'http://localhost:5057/graphql',
GetPostsDocument
);
@@ -343,7 +346,7 @@ import { GetPostsDocument, type GetPostsQuery } from './generated/graphql';
export async function load() {
const data = await request(
- 'http://localhost:5050/graphql',
+ 'http://localhost:5057/graphql',
GetPostsDocument
);
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 3e7b2fdf..1bb8b95b 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -16,7 +16,7 @@
-Turn flat files in Git into typed, relational content for your TypeScript app—with [GraphQL](https://graphql.org/) and generated types as the default way to query the model today.
+Turn flat files in Git into typed, relational content for your TypeScript app. **[GraphQL](https://graphql.org/)** and codegen are a common **read interface** for that content graph—not the only surface you can build; see [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md).
**Flatbread** is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped.
@@ -28,7 +28,7 @@ Turn flat files in Git into typed, relational content for your TypeScript app—
- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes.
-**GraphQL:** In the default setup, GraphQL is the primary **interface** for reading the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md).
+**GraphQL:** In the default toolkit, GraphQL is a common **read interface** for the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md).
**Glossary:** Quick definitions for **collection**, **relation**, **ID**, **cardinality**, **validation**, and **query interface** (GraphQL as one read path, not the whole product)—see [docs/glossary.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
@@ -121,6 +121,8 @@ Now hit your `package.json` and put the keys in the truck:
},
```
+The Flatbread CLI runs **`flatbread start`** and, by default, serves GraphQL at **`http://localhost:5057/graphql`**. Pass your framework dev/build command after **`--`** so Flatbread and your app run together. **`flatbread dev` is not a valid subcommand.** Your **`pnpm run dev`** (after you wire scripts like below) is separate from **`next start`**, which is production Next without Flatbread unless you wrap it yourself.
+
The Flatbread CLI will capture any script you add in after the `--` and appropriately unite them to live in a land of fairies and wonder while they dance into the sunset as you query your brand spankin new GraphQL server however you'd like from within your app.
## Run that shit 🏃♀️
@@ -325,4 +327,4 @@ Accepts a function which takes in field names and transforms them for the GraphQ
# ☀️ Contributing
-See [CONTRIBUTING.md](./CONTRIBUTING.md) for the release workflow (bumping versions and publishing).
+See [CONTRIBUTING.md](https://github.com/FlatbreadLabs/flatbread/blob/main/CONTRIBUTING.md) for the release workflow (bumping versions and publishing).
diff --git a/packages/transformer-yaml/README.md b/packages/transformer-yaml/README.md
index d2b1600f..35bd0625 100644
--- a/packages/transformer-yaml/README.md
+++ b/packages/transformer-yaml/README.md
@@ -16,27 +16,16 @@ Pair this with a compatible source plugin in your `flatbread.config.js` file:
```js
// flatbread.config.js
-import defineConfig from '@flatbread/config';
-import transformer from '@flatbread/transformer-markdown';
-import filesystem from '@flatbread/source-filesystem';
+import { defineConfig, sourceFilesystem, transformerMarkdown } from 'flatbread';
+import transformerYaml from '@flatbread/transformer-yaml';
export default defineConfig({
- source: filesystem(),
- transformer: transformer(),
+ source: sourceFilesystem(),
+ transformer: [transformerMarkdown(), transformerYaml()],
content: [
{
- path: 'content/posts',
- collection: 'Post',
- refs: {
- authors: 'Author',
- },
- },
- {
- path: 'content/authors',
- collection: 'Author',
- refs: {
- friend: 'Author',
- },
+ path: 'content/yaml/posts',
+ collection: 'YamlPost',
},
],
});
From eee4cbf6b22dc220fc5233151a6c04459e01fa86 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:44:31 +0000
Subject: [PATCH 05/37] docs: map agent artifacts to Effort Graph
Summary of changes
- Add an issue #167 experiment report mapping the in-repo Cursor proof skill layout to Effort Graph collections.
- Add representative Effort, Plan, Session, and Decision markdown fixtures that preserve the harness layout while exposing queryable frontmatter refs.
- Include a single GraphQL retrieval surface for blocking decisions with linked plan/session context and concrete follow-up issue drafts for friction points.
Testing
- pnpm lint
- proof DAG: dag-flatbread-167-effort-graph-wire-up completed 4/4 tasks
Closes #167
Change-Id: I07075abaab8248babc58df421dc6d3eeb9aad64b
---
.../cursor-proof-skill-effort-graph/README.md | 12 ++
.../167-blocking-reference-layout.md | 17 ++
.../efforts/pmf-audit-dag.md | 13 ++
.../plans/flatbread-flow-pmf-audit-dag.md | 13 ++
.../sessions/proof-cli-session-20260508.md | 12 ++
.../issue-167-effort-graph-layout-mapping.md | 159 ++++++++++++++++++
6 files changed, 226 insertions(+)
create mode 100644 docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md
create mode 100644 docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md
create mode 100644 docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md
create mode 100644 docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md
create mode 100644 docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md
create mode 100644 docs/experiments/issue-167-effort-graph-layout-mapping.md
diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md
new file mode 100644
index 00000000..78209c91
--- /dev/null
+++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/README.md
@@ -0,0 +1,12 @@
+# Fixture: Cursor `proof` skill → Effort Graph rows
+
+**Purpose:** Representative markdown files showing how **existing** agent harness paths under [`.cursor/skills/proof/`](../../../../.cursor/skills/proof/) map to **Effort Graph** collections without moving or rewriting the harness.
+
+| File here | Collection | Maps from |
+| -------------------------------------------- | ---------- | ----------------------------------------------------------------- |
+| `efforts/pmf-audit-dag.md` | Effort | Logical thread for the PMF audit DAG work |
+| `plans/flatbread-flow-pmf-audit-dag.md` | Plan | Title + provenance ↔ `examples/dag-flatbread-flow-pmf-audit.json` |
+| `sessions/proof-cli-session-20260508.md` | Session | Synthetic “one run” of the proof skill / CLI |
+| `decisions/167-blocking-reference-layout.md` | Decision | Issue #167 acceptance: layout indexed + queryable context |
+
+Use with the config excerpt in [issue-167-effort-graph-layout-mapping.md](../../issue-167-effort-graph-layout-mapping.md).
diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md
new file mode 100644
index 00000000..7b8b41b8
--- /dev/null
+++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions/167-blocking-reference-layout.md
@@ -0,0 +1,17 @@
+---
+id: decision-167-reference-layout
+effort: pmf-audit-dag
+plan: plan-pmf-audit-dag
+session: session-proof-20260508
+title: 'Issue #167 — Reference Effort Graph layout must index agent proof artifacts'
+status: open
+blocking: true
+decided_at: null
+tool: cursor-proof
+---
+
+# Decision
+
+**Acceptance (issue #167):** One **real or representative** agent artifact layout is mapped to Effort Graph–style collections; a **single** query surface can return **blocking** decisions for the current effort with **plan** and **session** context in one response.
+
+This row models an **open, blocking** gate: shipping broader Effort Graph marketing before **ID normalization**, **ref validation**, and **watch** is explicitly discouraged in the PMF audit and artifact opportunity docs; confirm scope on GitHub #167 vs full Session/Run automation.
diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md
new file mode 100644
index 00000000..d853b24b
--- /dev/null
+++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts/pmf-audit-dag.md
@@ -0,0 +1,13 @@
+---
+id: pmf-audit-dag
+status: active
+canonical_branch: cursor/docs-positioning-non-goals-18a9
+external_issue: '167'
+focus: 'Flatbread Flow PMF audit — DAG-shaped planner output'
+---
+
+# Effort: PMF audit DAG (proof skill)
+
+This effort groups agentic work where the **proof** Cursor skill authors a JSON DAG (see `.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json`) and executes it with local subagents.
+
+The **Effort** row is the stable anchor forPlans, Sessions, and Decisions that must be queryable as a graph.
diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md
new file mode 100644
index 00000000..9fed4cfb
--- /dev/null
+++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans/flatbread-flow-pmf-audit-dag.md
@@ -0,0 +1,13 @@
+---
+id: plan-pmf-audit-dag
+effort: pmf-audit-dag
+title: 'Flatbread Flow PMF Audit (no sub-sub-agents)'
+source_artifact: .cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json
+framing: 'Treat Flatbread as Git-native relational content for TypeScript apps, backed by flat files. GraphQL is one interface, not the whole product identity.'
+---
+
+# Plan body (derived from DAG)
+
+The canonical DAG spec lives at `source_artifact`. This markdown row exists so Flatbread can index **title**, **effort** ref, and narrative in **one** `Plan` collection while the JSON remains the machine-native task graph.
+
+Top-level tasks include `map-current-flow`, `relational-content-needs`, `recommend-roadmap`, and merge nodes—suitable for PMF positioning and architecture audits.
diff --git a/docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md
new file mode 100644
index 00000000..f92f57b4
--- /dev/null
+++ b/docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions/proof-cli-session-20260508.md
@@ -0,0 +1,12 @@
+---
+id: session-proof-20260508
+effort: pmf-audit-dag
+runner: proof-cli
+canvas_pattern: '.canvas.tsx (hot-recompiled DAG status)'
+---
+
+# Session: proof harness run (representative)
+
+Synthetic but representative **Session** for a single proof DAG execution: parent agent loads `dag-flatbread-flow-pmf-audit.json`, streams subagent status into the canvas, and persists human-facing summaries elsewhere.
+
+Full **Run**-level fidelity (append-only tool traces, per-task tokens) is **out of scope** for this fixture; see [flatbread-agent-artifact-opportunity.md §10](../../../../../flatbread-agent-artifact-opportunity.md).
diff --git a/docs/experiments/issue-167-effort-graph-layout-mapping.md b/docs/experiments/issue-167-effort-graph-layout-mapping.md
new file mode 100644
index 00000000..f7f95a6a
--- /dev/null
+++ b/docs/experiments/issue-167-effort-graph-layout-mapping.md
@@ -0,0 +1,159 @@
+# Experiment: Issue #167 — Effort Graph reference layout (agent artifacts → indexed graph)
+
+**Scope:** Map one real in-repo agent artifact layout to the [Effort Graph sketch](../../flatbread-agent-artifact-opportunity.md) (§8): `Effort` → `Plan`, `Session`, `Decision` with `refs`. Demonstrate a **single retrieval surface** query—here, **GraphQL**—that returns **blocking decisions** for a chosen effort with **nested plan and session context**. This satisfies the “reference layout indexed + validated” bar from the [PMF decision rubric](../pmf-decision-rubric.md) as an **experiment**, not a shipped preset.
+
+**Non-goals (explicit):** Full **Session** / **Run** fidelity, importer scripts, or turning this repo’s proof harness into production artifact storage. GraphQL is **one** interface; the same filter object is intended to work against codegen-backed TypeScript or MCP when those surfaces expose the shared filter DSL ([§9 agent artifact opportunity](../../flatbread-agent-artifact-opportunity.md)).
+
+---
+
+## 1. Source layout mapped (agent artifacts)
+
+**Canonical folder:** [`.cursor/skills/proof/`](../../.cursor/skills/proof/) — Cursor **Skill** for DAG-style proof runs.
+
+| Existing path | Role in harness | Effort Graph mapping |
+| -------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `SKILL.md` | Human + agent docs for the skill | **Unindexed narrative** in v1; optional later **`Artifact`** row body or symlinked markdown |
+| `examples/dag-flatbread-flow-pmf-audit.json` | Machine-authored **DAG spec** (title, tasks, models) | **`Plan`** row: `title` + provenance; body summarizes DAG; `source_artifact` frontmatter points back to this path |
+| _(synthetic)_ proof CLI invocation | One **multi-step run** with canvas streaming | **`Session`** row: `runner`, `effort` ref, short body describing the run surface |
+| _(synthetic)_ governance row | **Blocking** acceptance check | **`Decision`** row: `blocking`, `effort` / `plan` / `session` refs |
+
+This is **incremental adoption**: only new markdown under a dedicated tree needs frontmatter; harness files **stay in place** ([agent artifact opportunity §9.6](../../flatbread-agent-artifact-opportunity.md)).
+
+---
+
+## 2. Target tree (preset-shaped)
+
+Representative fixtures live under:
+
+[`fixtures/cursor-proof-skill-effort-graph/`](./fixtures/cursor-proof-skill-effort-graph/)
+
+Suggested production mirror (from §8 sketch):
+
+```text
+.flatbread-efforts/
+ efforts/
+ plans/
+ sessions/
+ decisions/
+```
+
+---
+
+## 3. Minimal `flatbread` config excerpt
+
+Wire the content arrays to the fixture paths (or to `.flatbread-efforts/*` once copied into a consumer repo):
+
+```javascript
+import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread';
+
+export default defineConfig({
+ source: sourceFilesystem(),
+ transformer: transformerMarkdown({ markdown: { gfm: true } }),
+ content: [
+ {
+ path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/efforts',
+ collection: 'Effort',
+ },
+ {
+ path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/plans',
+ collection: 'Plan',
+ refs: { effort: 'Effort' },
+ },
+ {
+ path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/sessions',
+ collection: 'Session',
+ refs: { effort: 'Effort' },
+ },
+ {
+ path: 'docs/experiments/fixtures/cursor-proof-skill-effort-graph/decisions',
+ collection: 'Decision',
+ refs: { effort: 'Effort', plan: 'Plan', session: 'Session' },
+ },
+ ],
+});
+```
+
+**Validation story:** Today, **broken `refs`** (typos in `effort` / `plan` / `session`) surface as missing relations at query time; duplicate `id` values within a collection remain a **roadmap** hardening item ([PMF audit §4](../../flatbread-flow-pmf-audit.md), [rubric](../pmf-decision-rubric.md)).
+
+---
+
+## 4. Example query — one retrieval surface (GraphQL)
+
+**Intent:** “All **blocking** decisions for effort `pmf-audit-dag`, with **plan title** and **session** context.”
+
+```graphql
+query BlockingDecisionsForEffort {
+ allDecisions(
+ filter: { effort: { eq: "pmf-audit-dag" }, blocking: { eq: true } }
+ sortBy: "decided_at"
+ order: DESC
+ ) {
+ id
+ title
+ status
+ blocking
+ decided_at
+ plan {
+ id
+ title
+ source_artifact
+ }
+ session {
+ id
+ runner
+ }
+ }
+}
+```
+
+**Expected shape (illustrative):** One row for the #167 **reference layout** decision, with nested `Plan` matching the DAG JSON title and `Session` describing a proof-cli style run. **TS / MCP parity:** use the same `filter` JSON against the list resolver the app exposes ([agent artifact opportunity §9](../../flatbread-agent-artifact-opportunity.md)).
+
+---
+
+## 5. Friction observed (concrete follow-ups)
+
+### Issue draft: **[Preset] Effort Graph field naming and codegen**
+
+**Problem:** GraphQL and docs benefit from **one canonical naming** policy (`blocking` vs `severity`, `decided_at` vs `decidedAt`). Today’s default `fieldNameTransform` only normalizes **spaces**, not snake_case→camelCase.
+
+**Acceptance criteria:** Document preset field names; optionally ship `fieldNameTransform: lodash.camelCase` for Effort Graph preset only; regenerate example GraphQL operations.
+
+---
+
+### Issue draft: **[Core] Ref integrity diagnostics for agent presets**
+
+**Problem:** Missing `plan` / `session` on a blocking decision is a **product risk** ([rubric integrity bar](../pmf-decision-rubric.md)); today users discover gaps via empty nested selections, not necessarily a validator error.
+
+**Acceptance criteria:** Configurable **hard fail** (or structured diagnostic) when `Decision.blocking: true` and `plan` ref does not resolve; integration test from `diag-query-surface` notes.
+
+---
+
+### Issue draft: **[MCP] Single-call “blocking decisions + context” for effort id**
+
+**Problem:** Agents should not re-learn GraphQL shapes per repo.
+
+**Acceptance criteria:** MCP tool accepts `effortId`, returns the same object shape as the query above (or executes the shared filter internally).
+
+---
+
+### Project note (no issue number)
+
+**Canonical effort identity** (branch vs slug vs GitHub `#167`) is still a **human checkpoint**; this fixture uses **`pmf-audit-dag`** as a stable slug and **`external_issue: "167"`** on the Effort row for traceability.
+
+---
+
+## 6. How to extend without migration day one
+
+1. Add **one** `Effort` row per research thread or feature.
+2. When a DAG JSON exists, add a **Plan** row pointing at the file path in `source_artifact`.
+3. For each proof run worth querying later, add a **Session** row.
+4. Add **Decision** rows only for gates that must be machine-queryable (blocking / open decisions).
+
+---
+
+## References
+
+- [flatbread-agent-artifact-opportunity.md §8 sketch](../../flatbread-agent-artifact-opportunity.md)
+- [flatbread-flow-pmf-audit.md — Effort Graph positioning](../../flatbread-flow-pmf-audit.md)
+- [pmf-decision-rubric.md](../pmf-decision-rubric.md)
+- Proof DAG example: [`.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json`](../../.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json)
From 3a598a56d524217c2f13267ee31baead2e54670c Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:49:44 +0000
Subject: [PATCH 06/37] docs: test Effort Graph across harness layouts
Summary of changes
- Add an issue #168 adversarial schema report covering Claude-oriented, Cursor-oriented, and GCC-style harness layouts.
- Add representative layout snippets and an acceptance matrix to pressure-test one Effort Graph schema against three artifact conventions.
- Identify stable entities/fields, tool-specific mapping requirements, and recommend one canonical schema plus explicit layout profiles as viable.
Testing
- pnpm lint
- proof DAG: dag-flatbread-168-adversarial-effort-graph completed 4/4 tasks
Closes #168
Change-Id: I40a33f70c93e4e89b931293b7d972a5767d57238
---
.../issue-168-three-layout-snippets/README.md | 11 +++
.../acceptance-test-matrix.md | 19 ++++
.../layout-claude-code/skill-stub-excerpt.md | 16 ++++
.../layout-cursor/rules-and-dag-excerpt.md | 24 +++++
.../layout-gcc/representative-tree.md | 32 +++++++
...sue-168-adversarial-multi-layout-schema.md | 95 +++++++++++++++++++
6 files changed, 197 insertions(+)
create mode 100644 docs/experiments/fixtures/issue-168-three-layout-snippets/README.md
create mode 100644 docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md
create mode 100644 docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md
create mode 100644 docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md
create mode 100644 docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md
create mode 100644 docs/experiments/issue-168-adversarial-multi-layout-schema.md
diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/README.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/README.md
new file mode 100644
index 00000000..5d9415f4
--- /dev/null
+++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/README.md
@@ -0,0 +1,11 @@
+# Fixtures: Issue #168 — Three harness layout snippets
+
+**Purpose:** Small, **non-authoritative** excerpts illustrating **L1** Claude-oriented skills, **L2** Cursor rules + skill/DAG, and **L3** synthetic GCC-style branch context. They support the adversarial schema report [issue-168-adversarial-multi-layout-schema.md](../../issue-168-adversarial-multi-layout-schema.md).
+
+| Path | Layout |
+| ---------------------------------------------------------------------------------------- | ------------------------------------------------ |
+| [`layout-claude-code/skill-stub-excerpt.md`](./layout-claude-code/skill-stub-excerpt.md) | Claude Code–style skill stub (YAML + body) |
+| [`layout-cursor/rules-and-dag-excerpt.md`](./layout-cursor/rules-and-dag-excerpt.md) | Cursor `.mdc` rule + DAG JSON excerpt |
+| [`layout-gcc/representative-tree.md`](./layout-gcc/representative-tree.md) | Synthetic `.GCC/` tree description + example row |
+
+**Acceptance test contract:** [`acceptance-test-matrix.md`](./acceptance-test-matrix.md)
diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md
new file mode 100644
index 00000000..59f93442
--- /dev/null
+++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md
@@ -0,0 +1,19 @@
+# Acceptance test matrix — Issue #168 (three harness layouts)
+
+**Intent:** Executable **markdown contract** for the adversarial schema experiment: each layout row must map to the **same** canonical collections (`Effort`, `Plan`, `Session`, `Decision`) without changing collection names.
+
+| TC | Layout | Harness source (fixture or repo path) | Prove (design / review) |
+| -------- | ------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **TC-1** | **L1 — Claude-oriented** | [`layout-claude-code/skill-stub-excerpt.md`](./layout-claude-code/skill-stub-excerpt.md) | At least one **`Plan`** or **`Artifact`** mapping rule is defined for skill-style markdown; **`Effort.id`** is chosen independently of frontmatter `name` if they differ |
+| **TC-2** | **L2 — Cursor rules + skills** | [`layout-cursor/rules-and-dag-excerpt.md`](./layout-cursor/rules-and-dag-excerpt.md) | **Split sources**: `.mdc` maps to **`Artifact`** (or explicit exclude) **and** DAG JSON maps to **`Plan.source_artifact`**; refs remain valid across both |
+| **TC-3** | **L3 — GCC branch tree** | [`layout-gcc/representative-tree.md`](./layout-gcc/representative-tree.md) | Profile documents **branch-scoped paths** → canonical repo paths; **identity risk** (`Effort.external_branch`) is enumerated; merge behavior marked **policy**, not automatic |
+
+## Field stability assertions (must hold for all TCs)
+
+- **Stable after ingest:** `Effort.id`, `Decision.blocking`, `refs` targets (`effort`, `plan`, `session`).
+- **Layout-specific mapping:** paths in `Plan.source_artifact`, session runner labels, inclusion of rule files as graph rows.
+
+## Pass / fail bar
+
+- **Pass:** Report [issue-168](../../issue-168-adversarial-multi-layout-schema.md) documents per-TC outcomes and concludes on **single schema + mapping layer** viability.
+- **Fail:** Any TC requires **renaming collections** or **forking schemas** without a bridging profile — escalate to roadmap.
diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md
new file mode 100644
index 00000000..80e87e98
--- /dev/null
+++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-claude-code/skill-stub-excerpt.md
@@ -0,0 +1,16 @@
+##
+
+name: example-claude-skill
+description: Example skill for layout stress — not installed product documentation.
+allowed-tools: Bash(example:\*)
+hidden: false
+
+---
+
+# example-claude-skill
+
+Body is narrative; there is **no** companion JSON DAG in this excerpt. Mapping options for Flatbread:
+
+- **Plan.title** ← first `#` heading or frontmatter `name`.
+- **Plan.source_artifact** ← path to this `SKILL.md`.
+- **Effort** row may set `external_issue: "168"` for traceability while `id` remains a team-chosen slug.
diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md
new file mode 100644
index 00000000..58aa54c6
--- /dev/null
+++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-cursor/rules-and-dag-excerpt.md
@@ -0,0 +1,24 @@
+# Representative L2: Cursor rule frontmatter + DAG JSON shape (excerpt)
+
+## Rule file (`.cursor/rules/*.mdc` pattern)
+
+```yaml
+---
+description: typescript, .tsx
+alwaysApply: false
+---
+# TypeScript Best Practices
+```
+
+**Mapping note:** `alwaysApply` + path are **tool-specific** metadata; if indexed, use **`Artifact`** with `kind: cursor-rule` (conceptual) or exclude from graph per team policy.
+
+## DAG JSON (proof skill example — truncated)
+
+```json
+{
+ "title": "Flatbread flow — PMF audit DAG",
+ "tasks": [{ "id": "t1", "subtask_prompt": "Plan the audit scope." }]
+}
+```
+
+**Mapping note:** **`Plan.title`** and **`Plan.source_artifact`** point here; **`Effort`** slug is **not** implied by the JSON filename — declare explicitly on the Effort row (see [#167](../../cursor-proof-skill-effort-graph/) fixtures).
diff --git a/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md
new file mode 100644
index 00000000..0d109e72
--- /dev/null
+++ b/docs/experiments/fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md
@@ -0,0 +1,32 @@
+# Representative L3: GCC-style `.GCC/` tree (synthetic)
+
+**Note:** This repository does not ship a live `.GCC/` directory; this file describes the **expected shape** for adversarial mapping (per [Git Context Controller](https://arxiv.org/html/2508.00031v2) style branch knowledge).
+
+## Example tree (conceptual)
+
+```text
+.GCC/
+ branches/
+ feature-pmf-audit/
+ CONTEXT.md
+ DECISIONS.yaml
+ sessions/
+ 2026-05-08-run.md
+```
+
+## Example `DECISIONS.yaml` fragment
+
+```yaml
+decisions:
+ - id: gcc-decision-001
+ blocking: true
+ status: open
+ effort_slug: pmf-audit-dag
+ relates_to_plan: ../CONTEXT.md
+```
+
+## Mapping stressors
+
+- **`Effort.external_branch`:** `feature-pmf-audit` vs canonical `Effort.id: pmf-audit-dag`.
+- **Path relativity:** ingest must normalize branch-relative paths to repo anchors for `source_artifact`.
+- **Merge:** closing or linking efforts when a branch merges is a **human / team policy** — core should not assume automatic row merges.
diff --git a/docs/experiments/issue-168-adversarial-multi-layout-schema.md b/docs/experiments/issue-168-adversarial-multi-layout-schema.md
new file mode 100644
index 00000000..8c97781d
--- /dev/null
+++ b/docs/experiments/issue-168-adversarial-multi-layout-schema.md
@@ -0,0 +1,95 @@
+# Experiment: Issue #168 — Adversarial Effort Graph schema across three harness layouts
+
+**Scope:** Execute [agent artifact opportunity §12.2](../../flatbread-agent-artifact-opportunity.md) — stress one **Effort Graph**–shaped model against **three** representative tool trees. Document which **entities and fields** stay stable, which need **tool-specific mapping**, and whether **one canonical schema + a mapping layer** remains a viable product bet.
+
+**Product framing:** Flatbread is **Git-native relational content** for TypeScript apps, materialized from flat files. **GraphQL** is **one** query adapter alongside generated TypeScript and MCP; it does not define the whole product.
+
+**Related:** Issue [#167 reference layout](./issue-167-effort-graph-layout-mapping.md) (single Cursor `proof` skill → indexed rows). This report generalizes that pattern across layouts.
+
+**Non-goals:** Importers, core validator code, or moving production harness files. **Fixtures are snippets** under [`fixtures/issue-168-three-layout-snippets/`](./fixtures/issue-168-three-layout-snippets/).
+
+---
+
+## 1. Harness layouts under test
+
+| ID | Layout | Representative paths (this repo or synthetic) | Role in adversarial test |
+| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| **L1** | **Claude Code–oriented** (skills / agent packets) | [`.agents/skills/*/SKILL.md`](../../.agents/skills/) | YAML frontmatter + narrative body; skills as discoverable units without a single DAG file per effort |
+| **L2** | **Cursor rules + skills** | [`.cursor/rules/*.mdc`](../../.cursor/rules/), [`.cursor/skills/proof/`](../../.cursor/skills/proof/) | Split between **rules** (policy) and **skills** (workflows + JSON DAG examples) |
+| **L3** | **GCC-style branch context** (synthetic) | [`fixtures/issue-168-three-layout-snippets/layout-gcc/`](./fixtures/issue-168-three-layout-snippets/layout-gcc/representative-tree.md) | Per-branch knowledge tree; identity and merge semantics are the stressor |
+
+The machine-readable **acceptance matrix** (checkbox test contract) lives in [`acceptance-test-matrix.md`](./fixtures/issue-168-three-layout-snippets/acceptance-test-matrix.md).
+
+---
+
+## 2. Canonical schema (held constant across layouts)
+
+Collections (names align with [#167](./issue-167-effort-graph-layout-mapping.md) and opportunity §8):
+
+- `Effort` — thread of work; stable slug `id`; optional `external_issue`, `external_branch`
+- `Plan` — structured intent; `title`, `source_artifact`, `effort` ref
+- `Session` — one run / invocation; `runner`, `effort` ref
+- `Decision` — gate; `blocking`, `status`, `effort` / `plan` / `session` refs
+- `Artifact` _(optional in v1)_ — indexed file bodies (rules, manifests) when teams choose not to treat them as narrative-only
+
+---
+
+## 3. Entity and field stability
+
+### 3.1 Stable across layouts (same semantic column in the graph)
+
+| Entity | Fields / behavior | Why stable |
+| --------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
+| `Effort` | `id` (slug), optional `external_*` | Chosen **canonical identity**; tools do not agree on branch vs issue — the row **declares** the slug |
+| All row types | `refs` to other collections (`effort`, `plan`, `session`) | Relational shape is the product promise |
+| `Decision` | `blocking`, `status`, temporal fields (e.g. `decided_at`) | Gate semantics are layout-agnostic once ingested |
+| Query surfaces | Filter object over the same field names (GraphQL / TS / MCP) | One mental model for consumers |
+| **Disk policy** | New markdown under `.flatbread-efforts/` (or preset path); harness files **unmoved** | Matches [#167 incremental adoption](./issue-167-effort-graph-layout-mapping.md) and migration notes from upstream diagnostics |
+
+### 3.2 Requires tool-specific mapping (profile / ingest rules)
+
+| Concern | L1 Claude-oriented | L2 Cursor | L3 GCC |
+| ------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| **Where “the plan” lives** | Often **narrative** `SKILL.md` or distributed docs; may lack one JSON DAG | **Split**: rules vs `SKILL.md` vs `examples/*.json` | Session / design files per branch; path encodes **branch** |
+| **Plan row `source_artifact`** | Globs on skill roots; may need **multi-file** summary or primary file pick | Point to **JSON** for machine title; optional second row for skill narrative | Map branch-relative path → repo-relative at ingest time |
+| **Session identity** | CLI / agent-reported run id varies | proof CLI / IDE session labels | GCC **run** or commit-scoped ids (tool-defined) |
+| **Rules / manifests** | Less central in stub skills | `.mdc` with `alwaysApply` — candidate **`Artifact`** or excluded | Policy files may mirror branch |
+| **Effort identity collision** | Skill name vs GitHub issue | Branch name vs `pmf-audit-dag` slug | **Branch name vs slug** — highest duplication risk when branches fold |
+
+---
+
+## 4. Where a single schema breaks (unless mapping layer exists)
+
+1. **Identity:** Without a documented **canonical `Effort.id`** and `external_*` fields, the same work is forked across tools ([§5 human checkpoint #167](./issue-167-effort-graph-layout-mapping.md)).
+2. **Partial graphs:** Blocking `Decision` rows with missing `plan` / `session` refs are worse when three layouts multiply ingest paths — **validation / diagnostics** become product-critical (per upstream **diag-stability-mapping**).
+3. **Noise:** Promoting every rule file to `Artifact` explodes row count; needs **`kind`** + **`always_on`** (or equivalent) filtering policy.
+4. **GCC lifecycle:** Branch merge does not imply graph merge — **Sessions**, **Efforts**, and **links** need team policy (no automatic semantics in core).
+
+None of these require **abandoning** a unified collection schema; they require **profiles** (globs, field extraction, optional joins) and **integrity rules**.
+
+---
+
+## 5. Recommendation
+
+**Verdict: One canonical Effort Graph schema + an explicit mapping / profile layer is viable.** The opportunity is **not** too fragmented for a single relational model: the fragmentation is in **harness conventions and identity policy**, not in the core nouns (`Effort`, `Plan`, `Session`, `Decision`).
+
+- **Ship** a small set of **layout profiles** (at minimum: Claude-oriented skills tree, Cursor rules+skills, GCC branch tree) as **configuration**, not separate schemas.
+- **Invest** early in **ref integrity diagnostics** and **blocking-decision invariants** so multi-layout ingest cannot silently degrade.
+- **Defer** promising automatic merge semantics for GCC branches until a human policy is written.
+
+If the team cannot commit to **canonical slugs** and **validation**, the same schema technically works but **operational** fragmentation will **feel** like multiple products — that is a **process** failure mode, not a schema impossibility.
+
+---
+
+## 6. Traceability and human gate
+
+- Align this experiment with the real tracker issue **#168** (scope, acceptance criteria, and whether it stays distinct from **#167** documentation).
+- Before scaling fixtures: approve **`Effort.id`** scheme and whether `.mdc` / root manifests are **`Artifact` rows** vs narrative-only ([#167 §5](./issue-167-effort-graph-layout-mapping.md)).
+
+---
+
+## References
+
+- [flatbread-agent-artifact-opportunity.md §12](../../flatbread-agent-artifact-opportunity.md)
+- [issue-167-effort-graph-layout-mapping.md](./issue-167-effort-graph-layout-mapping.md)
+- [PMF decision rubric](../pmf-decision-rubric.md)
From 833cf63f0253deef87526c1b7aa6806561861042 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:54:08 +0000
Subject: [PATCH 07/37] docs: add canonical posts authors tags quickstart
Summary of changes
- Rewrite the root quickstart around the Next.js example as the canonical first-success path from the monorepo root.
- Lead with the posts/authors/tags content model, then introduce GraphQL codegen as the current read interface that produces a typed query result.
- Update contributing, example, glossary, positioning, and content docs so commands and terminology point back to the same onboarding path.
Testing
- pnpm lint
- proof DAG: dag-flatbread-145-root-quickstart completed 4/4 tasks
Closes #145
Change-Id: I72c4fb6e4d60345b6dc8ea867dd0120bb2ff6741
---
CONTRIBUTING.md | 4 +-
docs/glossary.md | 6 +-
docs/positioning.md | 6 +-
examples/content/README.md | 16 +++-
examples/nextjs/README.md | 39 +++++---
packages/flatbread/README.md | 172 ++++++++++++++++++++++-------------
6 files changed, 158 insertions(+), 85 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bb353832..1bd13d31 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,6 +4,8 @@ Thanks for your interest in contributing! This guide covers local development an
**Flatbread** is **relational, Git-tracked content for TypeScript apps**: flat files in the repo become a typed content graph. **GraphQL is one consumer** of that graph (see `docs/glossary.md`), not the whole product story.
+For the **canonical posts / authors / tags** onboarding narrative (collections, `refs`, codegen, then GraphQL), see the root [README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/README.md#quickstart-posts-authors-and-tags).
+
## Prerequisites
- Node 20.19+
@@ -13,7 +15,7 @@ Thanks for your interest in contributing! This guide covers local development an
## Recommended onboarding (try Flatbread in the Next.js example)
-Use this single path first; it matches how CI and most contributors exercise the stack:
+Use this single path first; it matches how CI and most contributors exercise the stack (**shared content** under `examples/content`, symlinked from the Next app as `content/`):
1. From the **monorepo root**: `pnpm install` then `pnpm build` (builds all packages except `examples/*`).
2. `cd examples/nextjs`
diff --git a/docs/glossary.md b/docs/glossary.md
index 2f89e95e..5f99d3aa 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -10,7 +10,11 @@ See also: [Flatbread positioning](./positioning.md); [PMF decision rubric](./pmf
### Cardinality
-How many related items a field connects—whether a relation resolves to **one** related entry or **many** (for example, a single author versus a list of tags). Cardinality shapes how the graph is exposed to your app (including generated GraphQL fields); it does **not** imply a SQL-style database engine.
+How many related items a field connects—whether a relation resolves to **one** related entry or **many** (for example, a single author versus a list of tag strings on a post). Cardinality shapes how the graph is exposed to your app (including generated GraphQL fields); it does **not** imply a SQL-style database engine.
+
+### Tag (facet) vs `Tag` collection
+
+A **facet** is metadata stored on a record (often a **YAML list of strings** such as `tags: [a, b]` on a post). It becomes a **scalar list** in the read interface and is **not** the same as **`refs`** resolving to another collection. A **`Tag` collection** means one file per tag (or equivalent) and **`refs`** from **`Post`** → **`Tag`** so tag entries are **normalized records** in the graph—use that when tags need shared descriptions, stable ids, or relational edges of their own.
### Collection
diff --git a/docs/positioning.md b/docs/positioning.md
index 9bbe0663..81b84c87 100644
--- a/docs/positioning.md
+++ b/docs/positioning.md
@@ -2,7 +2,7 @@
Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md). For **buyer-aware comparisons** (SQLite-style workflows, CMSs, Contentlayer-like stacks, agent artifact graphs) across setup time, typing, integrity, and related criteria—plus **go / no-go** guidance for an agent-artifact wedge—see the [PMF decision rubric](./pmf-decision-rubric.md).
-Turn flat files in Git into typed, relational content for your TypeScript app—with [GraphQL](https://graphql.org/) and generated types as the default way to query the model today.
+Turn flat files in Git into typed, relational content for your TypeScript app. The core artifact is an in-repo **content graph** (collections, records, **`refs`**). **Generated types plus [GraphQL](https://graphql.org/) operations** layer on top today as the most common **read interface** — they describe how many apps consume that graph at build/run time; they do not redefine what Flatbread **is**.
**Flatbread** is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped.
@@ -14,4 +14,6 @@ Turn flat files in Git into typed, relational content for your TypeScript app—
- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes.
-**GraphQL:** In the default setup, GraphQL is the primary **interface** for reading the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product.
+**GraphQL:** In the default setup, GraphQL is a primary **interface** for reading an already-loaded content graph (`schema → operations → codegen`). Prefer thinking **files → model → typed read path** rather than treating GraphQL alone as Flatbread.
+
+**Skimming from GraphQL-first experience:** Jump to **`refs` + relations** in [glossary](./glossary.md), then codegen and your app’s **`flatbread codegen`** docs — the relational layer is upstream of the queries you write.
diff --git a/examples/content/README.md b/examples/content/README.md
index 7507ebdf..8a7dc709 100644
--- a/examples/content/README.md
+++ b/examples/content/README.md
@@ -1,5 +1,17 @@
# Example content
-This is a central content store including various directories of content used for example integrations with Flatbread. This content is also used in our internal testing.
+Central store for markdown and YAML used by **`examples/nextjs`** and other integrations. To avoid drift, **`examples/nextjs`** uses a symlink: **`examples/nextjs/content` → `../content`** (this directory).
-To keep things DRY and easier to maintain, we've pointed or symlinked all content uses to this set.
+## Layout for the primary onboarding story (posts · authors · tags)
+
+```text
+markdown/posts/ # Post collection — frontmatter: id, title, authors (ids), tags (string list), …
+markdown/authors/ # Author collection — referenced from posts via Flatbread `refs`
+yaml/ # Extra YAML-backed samples (e.g. YamlAuthor); secondary to markdown onboarding
+```
+
+- **Relations:** **`authors`** in post frontmatter lists **author ids** that match **`id`** in author files. Flatbread resolves them through **`refs: { authors: 'Author' }`** in **`flatbread.config.js`** relative to **`examples/nextjs`**.
+
+- **Tags:** lists like **`tags: [cats, science]`** in post frontmatter are **string facets** on each **`Post`** (arrays of scalars through the schema). That is **not** the same as a **`refs`-backed **`Tag`** collection**; normalized tag files require an extra **`Tag`** collection and explicit **`refs`** in config.
+
+Canonical commands and the full relational walkthrough live in the [root README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/README.md#quickstart-posts-authors-and-tags).
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 1d69cc5e..9fd33b12 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -1,10 +1,10 @@
# Flatbread Next.js Example with TypeScript Codegen
-This example shows **Flatbread** as **relational, Git-tracked content for a TypeScript app**: markdown and YAML under version control are loaded into a typed model; **GraphQL is one read interface** (alongside anything else you build). Next.js uses generated operation types against the Flatbread GraphQL endpoint.
+This example is the repo’s **default first success path**: **relational Git-backed markdown** ( **`Post`** ↔ **`Author`** via `refs`; **`tags`** as string arrays on posts) compiled into a typed shape. **GraphQL plus codegen** are the **read interface baked into this demo**—not Flatbread’s only story; see [`docs/positioning.md`](../../docs/positioning.md).
-## Quick start (from monorepo root)
+**Note:** `flatbread.config.js` here also declares **PostCategory**, **OverrideTest**, **YamlAuthor**, etc. for integration tests. Treat those as **secondary**; the onboarding narrative is **posts + authors + tags** on the **`Post`** row.
-Flatbread development assumes this **pnpm** workspace. Use one path end-to-end:
+## Quick start (from monorepo root)
1. **Install and build packages** (excluding examples):
@@ -13,45 +13,54 @@ Flatbread development assumes this **pnpm** workspace. Use one path end-to-end:
pnpm build
```
-2. **Work in this example:**
+2. **Enter this example:**
```bash
cd examples/nextjs
```
-3. **Generate TypeScript types once** (paths and globs come from `flatbread.config.js`; default output is `generated/graphql.ts`):
+3. **Generate TypeScript types once** (paths and globs come from `flatbread.config.js`; output: `generated/graphql.ts`):
```bash
pnpm exec flatbread codegen --verbose
```
-4. **Run Next.js and the Flatbread GraphQL server together** via the CLI (**there is no `flatbread dev` subcommand** — use **`flatbread start`**):
+ Add or edit **`.graphql`** files under `queries/` (or globs in config), then rerun codegen so **`tags`**, **`authors`**, and other fields stay in sync.
+
+4. **Serve the GraphQL read interface alongside Next** (**there is no `flatbread dev`** — use **`flatbread start`**):
- **Default (local HTTPS for Next):** `pnpm dev` — runs `flatbread start --https -- next dev --turbopack`.
- **Headless / no HTTPS** (e.g. agents, CI): `pnpm exec flatbread start -- next dev --turbopack`.
-5. Open **[http://localhost:3000](http://localhost:3000)** for the app. The Flatbread GraphQL HTTP endpoint defaults to **`http://localhost:5057/graphql`** (not the Next port).
+5. Open **[http://localhost:3000](http://localhost:3000)** for the app. Flatbread defaults to **`http://localhost:5057/graphql`** (not the Next port).
### Scripts in this package
-| Script | Purpose |
-|--------|--------|
-| `pnpm dev` | **`flatbread start`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
-| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
-| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread. Use only if you already have GraphQL served elsewhere. |
+| Script | Purpose |
+|------------------|-------------------------------------------------------------------------------------------|
+| `pnpm dev` | **`flatbread start`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
+| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
+| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread unless you arrange it. |
| `pnpm run codegen` | **Watch-only:** `flatbread codegen --watch` — regenerate types when config, content, or documents change. |
### Watch-only codegen
-For iterative work, you can run the watcher in a second terminal (leave it running until you stop it):
+For iterative work, run the watcher in a second terminal:
```bash
pnpm run codegen
```
-## Project structure
+## Content path
-Aligned with the App Router layout in this repo:
+Markdown and YAML for this demo live under **`examples/content`**; this package uses a **`content` → `../content`** symlink so config paths stay `content/markdown/...`.
+
+- **Posts:** `examples/content/markdown/posts/` (`tags` in frontmatter → `[String]` on **`Post`** in the schema.)
+- **Authors:** `examples/content/markdown/authors/` (referenced by id from **`Post`** **`authors`**.)
+
+Canonical layout is described alongside commands in the [root README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/README.md#quickstart-posts-authors-and-tags).
+
+## Project structure
- `app/` — routes and components (`page.tsx`, `post/[id]/`, etc.)
- `lib/graphql.ts` — GraphQL client helpers (default endpoint `http://localhost:5057/graphql`)
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 1bb8b95b..3127d6a9 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -36,112 +36,156 @@ For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime su
Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
-# Install + Use
+## Quickstart (posts, authors, and tags)
-🚧 This project is currently experimental, and the API may change considerably before `v1.0`. Feel free to hop in and contribute some issues or PRs!
+🚧 This project is experimental; the API may change before `v1.0`.
-To use the most common setup for markdown files sourced from the filesystem, Flatbread interally ships with + exposes the [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) + [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins.
+This repo’s **canonical first success path** is the **Next.js example** (`examples/nextjs`). It reads shared markdown under **`examples/content`** (mounted in that app as `content/` via symlink). Commands below are exact for that layout.
-The following example takes you through the default flatbread setup.
+### 1 · What you are modeling
-```bash
-pnpm i flatbread@latest
+- **Collections** (`Post`, `Author`) map to folders of files; see the [glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
+- **Relations:** posts declare `authors:` in frontmatter as a list of **author ids**; Flatbread resolves them through **`refs`** in config (same idea as joins, over files—**not** a remote database).
+- **Tags:** in the bundled example, each post exposes **`tags`** as a **YAML string list** in frontmatter. That becomes a **`[String]`** field on **`Post`** in the generated schema. That is **facet-style metadata** repeated per post—not the same machinery as **`refs`** to another collection. If you need normalized tag **records** shared across posts, model a **`Tag`** collection and wire **`refs`** yourself (advanced).
+
+Illustrative frontmatter:
+
+```yaml
+---
+id: your-post-id
+title: Example
+authors:
+ - author-id-one
+tags:
+ - typescript
+ - content-graph
+---
```
-Automatically create a `flatbread.config.js` file:
+Markdown **below** the closing `---` is the post body.
+
+### 2 · Content layout (this monorepo)
+
+From the repo root, the markdown that backs the relational story lives here:
+
+```text
+examples/content/markdown/posts/ # Post collection (incl. example-post.md, …)
+examples/content/markdown/authors/ # Author collection
+```
+
+The Next example points `flatbread.config.js` at `content/markdown/...` **relative to `examples/nextjs`**, where `content` is the symlink to `../content`.
+
+### 3 · Run it from the repo root
+
+Prerequisites: **Node 20.19+**, **pnpm 10.33.x** (see [CONTRIBUTING.md](CONTRIBUTING.md)).
```bash
-npx flatbread init
+pnpm install
+pnpm build
+cd examples/nextjs
+pnpm exec flatbread codegen --verbose
```
-> If you're lookin for different use cases, take a peek through the various [`packages`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages) to see if any of those plugins fit your needs. You can find the relevant usage API contained therein.
-
-Take this example where we have a content folder in our repo containing posts and author data:
-
-```gql
-content/
-├─ posts/
-│ ├─ example-post.md
-│ ├─ funky-monkey-friday.md
-├─ authors/
-│ ├─ me.md
-│ ├─ my-cat.md
-...
-flatbread.config.js
-package.json
+That writes **`generated/graphql.ts`**: TypeScript types and typed document nodes for your **`.graphql`** operations (configure globs under `codegen.documents` in `flatbread.config.js`).
+
+Add a `.graphql` file (see `queries/posts.graphql` in the example), then rerun **`pnpm exec flatbread codegen --verbose`** so the operation reflects **`tags`**, **`authors`**, etc. Illustrative operation you can paste into `queries/`:
+
+```graphql
+query GetPostsAuthorsAndTags {
+ allPosts(limit: 5) {
+ id
+ title
+ tags
+ authors {
+ id
+ name
+ }
+ }
+ allAuthors {
+ id
+ name
+ }
+}
```
-In reference to that structure, set up a `flatbread.config.js` in the root of your project:
+After codegen, your app imports types from **`./generated/graphql`**. The **result shape** of that operation is typed (for example **`GetPostsAuthorsAndTagsQuery`**)—relations resolve to **`Author`** objects while **`tags`** stay a **string array** on **`Post`**, matching the file metadata.
+
+Default filesystem + markdown wiring uses the bundled [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) and [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins (`flatbread` re-exports them).
+
+### 4 · Minimal relational config (mental model)
+
+The example’s production config loads extra collections for tests; **the core onboarding shape** is:
```js
import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread';
-const transformerConfig = {
- markdown: {
- gfm: true,
- externalLinks: true,
- },
-};
export default defineConfig({
source: sourceFilesystem(),
- transformer: transformerMarkdown(transformerConfig),
-
+ transformer: transformerMarkdown({
+ markdown: { gfm: true, externalLinks: true },
+ }),
content: [
{
- path: 'content/posts',
+ path: 'content/markdown/posts',
collection: 'Post',
- refs: {
- authors: 'Author',
- },
+ refs: { authors: 'Author' },
},
{
- path: 'content/authors',
+ path: 'content/markdown/authors',
collection: 'Author',
- refs: {
- friend: 'Author',
- },
+ refs: { friend: 'Author' },
},
],
});
```
-Now hit your `package.json` and put the keys in the truck:
+### 5 · Reading the graph: GraphQL (after the model exists)
+
+Flatbread builds a content **graph from files**. In the default toolchain, **GraphQL is one read interface**: schema + resolver shape over that graph—not “Flatbread is a GraphQL CMS.”
+
+Wire your framework so the CLI wraps dev/build (**`flatbread start`** passes through your command after **`--`**). There is **no** `flatbread dev` subcommand.
```js
-// before
-"scripts": {
- "dev": "svelte-kit dev",
- "build": "svelte-kit build",
-},
-
-// after becoming based and flatbread-pilled
-"scripts": {
- "dev": "flatbread start -- svelte-kit dev",
- "build": "flatbread start -- svelte-kit build",
-},
+// package.json scripts (adapt the part after `--` to your framework)
+{
+ "scripts": {
+ "dev": "flatbread start -- next dev --turbopack",
+ "build": "flatbread start -- next build"
+ }
+}
```
-The Flatbread CLI runs **`flatbread start`** and, by default, serves GraphQL at **`http://localhost:5057/graphql`**. Pass your framework dev/build command after **`--`** so Flatbread and your app run together. **`flatbread dev` is not a valid subcommand.** Your **`pnpm run dev`** (after you wire scripts like below) is separate from **`next start`**, which is production Next without Flatbread unless you wrap it yourself.
+In the Next example from **`examples/nextjs`**, **`pnpm dev`** enables HTTPS locally and pairs Next with Flatbread. The GraphQL HTTP endpoint defaults to **`http://localhost:5057/graphql`**; the Next app is on **`3000`**. **`pnpm run dev`** here is distinct from **`next start`** alone (production Next without Flatbread unless you arrange serving yourself).
+
+```bash
+pnpm dev
+```
-The Flatbread CLI will capture any script you add in after the `--` and appropriately unite them to live in a land of fairies and wonder while they dance into the sunset as you query your brand spankin new GraphQL server however you'd like from within your app.
+If the server starts cleanly, Flatbread prints the **`graphql`** URL. Opening it launches Apollo Studio against the generated schema—you can iterate on queries there, then freeze them into **`.graphql`** files and rerun **`flatbread codegen`**.
-## Run that shit 🏃♀️
+Live reload of markdown while the process runs is **[not reliable yet](https://github.com/FlatbreadLabs/flatbread/issues/65)**—restart dev after content changes.
+
+## Install Flatbread in your own repo
+
+Outside this monorepo:
```bash
-pnpm run dev
+pnpm add flatbread@latest
```
-## Construct queries 👩🍳
+Scaffold **`flatbread.config.js`**:
-If everything goes well, you'll see a pretty `graphql` endpoint echoed out to your console by Flatbread. If you open that link in your browser, Apollo Studio will open for you to explore the schema Flatbread generated. Apollo Studio has some nice auto-prediction and gives you helpers in the schema explorer for building your queries.
+```bash
+pnpm exec flatbread init
+```
-You can query that same endpoint in your app in any way you'd like. Flatbread doesn't care what framework you use.
+Point **`content`** entries at **your** `posts/` and **`authors/`** folders, reuse the relational ideas above, and add **`codegen`** in config when you want **`generated/graphql.ts`**. Browse [`packages`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages) for plugins and resolver helpers.
-> NOTE: detecting changes to your content while Flatbread is running is [not yet supported](https://github.com/FlatbreadLabs/flatbread/issues/65). You'll have to restart the process to get updated content.
+More detail on the bundled example (scripts, codegen watch, troubleshooting): **`examples/nextjs/README.md`**.
-## Query arguments
+## Query arguments (GraphQL read interface)
-The following arguments are listed in their order of operation.
+When **GraphQL** is your read interface, list fields use the following arguments in order of application.
### `filter`
@@ -265,9 +309,9 @@ Skips the specified number of entries. Accepts an integer.
Limits the number of returned entries to the specified amount. Accepts an integer.
-## Query within your app ❓❓
+## Query from your app
-[Check out the example integrations](https://github.com/FlatbreadLabs/flatbread/tree/main/examples) of using Flatbread with frameworks like SvelteKit and Next.js.
+Follow [Quickstart (posts, authors, and tags)](#quickstart-posts-authors-and-tags) for the relational model, **codegen**, and typed results. For framework wiring and scripts, use **[examples/nextjs](https://github.com/FlatbreadLabs/flatbread/tree/main/examples/nextjs)** or [other examples](https://github.com/FlatbreadLabs/flatbread/tree/main/examples) (for example SvelteKit).
## Field overrides
From 5020789166457154d3227a787f797c566bd5646b Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 06:56:43 +0000
Subject: [PATCH 08/37] docs: build relation-first example narrative
Summary of changes
- Expand the canonical quickstart into a relation-first trace from backing files to config to generated GraphQL/codegen output.
- Add glossary terms for tag facets versus Tag collections and generated schema/operation types.
- Update example content and Next.js docs so posts, authors, and tags point to one shared model and visible query-result shape.
Testing
- pnpm lint
- proof DAG: dag-flatbread-147-relation-first-narrative completed 4/4 tasks
Closes #147
Change-Id: I82bbc51a53766270ab783cedeb4386ad9a3ea95d
---
CONTRIBUTING.md | 2 +-
docs/glossary.md | 6 ++++++
docs/positioning.md | 2 +-
examples/content/README.md | 10 +++++++--
examples/nextjs/README.md | 2 +-
packages/flatbread/README.md | 42 ++++++++++++++++++++++++++++++++++--
6 files changed, 57 insertions(+), 7 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1bd13d31..6f2e0bc4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,7 @@ Thanks for your interest in contributing! This guide covers local development an
**Flatbread** is **relational, Git-tracked content for TypeScript apps**: flat files in the repo become a typed content graph. **GraphQL is one consumer** of that graph (see `docs/glossary.md`), not the whole product story.
-For the **canonical posts / authors / tags** onboarding narrative (collections, `refs`, codegen, then GraphQL), see the root [README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/README.md#quickstart-posts-authors-and-tags).
+For the **canonical posts / authors / tags** onboarding narrative (collections, `refs`, codegen, then GraphQL), see the [Flatbread package README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/flatbread/README.md#quickstart-posts-authors-and-tags) (traceability: **files → config → query interface**, tied to **`docs/glossary.md`**).
## Prerequisites
diff --git a/docs/glossary.md b/docs/glossary.md
index 5f99d3aa..e04a7c05 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -28,6 +28,12 @@ An identifier Flatbread uses to **point at one item within a collection** so rel
The **API surface your application uses to read** the built content graph. In many projects today that surface is **GraphQL** (schema plus operations, often with codegen), meaning GraphQL is **an interface**, not the definition of Flatbread. Other ways to consume the same graph may exist in your stack alongside it.
+### Generated schema and operation types (GraphQL)
+
+When GraphQL is your **query interface**, the **generated GraphQL schema** describes how **collections** and fields are exposed at read time: list fields such as `allPosts` / `allAuthors` correspond to **collections**; nested selections follow **`refs`** (**relations**) and resolve to related **records**; scalar list fields that come from frontmatter (for example **`tags`** on a post) align with **Tag (facet)** in this glossary—not a **`Tag` collection** unless you add one.
+
+**Generated TypeScript** from GraphQL document codegen (for example operation result types such as `GetPostsAuthorsAndTagsQuery`) types **that read path only**. It does not redefine Flatbread’s domain model: the **records** and **relations** still originate in repo files and config. A future non-GraphQL generated TypeScript read surface, if shipped, would be documented separately so it does not blur this boundary.
+
### Record
**One loaded item** in a collection: the structured result of reading a file (metadata, body, derived fields) that your app treats as a single unit. “Record” here means **a document-shaped object in memory**, not a row in a remote database.
diff --git a/docs/positioning.md b/docs/positioning.md
index 81b84c87..c54661a0 100644
--- a/docs/positioning.md
+++ b/docs/positioning.md
@@ -14,6 +14,6 @@ Turn flat files in Git into typed, relational content for your TypeScript app. T
- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
- Reliable live reload of content while the dev server runs is [not a supported pillar yet](https://github.com/FlatbreadLabs/flatbread/issues/65); expect to restart to pick up file changes.
-**GraphQL:** In the default setup, GraphQL is a primary **interface** for reading an already-loaded content graph (`schema → operations → codegen`). Prefer thinking **files → model → typed read path** rather than treating GraphQL alone as Flatbread.
+**GraphQL:** In the default setup, GraphQL is a primary **interface** for reading an already-loaded content graph (`schema → operations → codegen`). Prefer thinking **files → model → typed read path** rather than treating GraphQL alone as Flatbread. For **traceability** from **backing files** (posts, authors, tag facets on posts) through **config** to generated schema and operation types—aligned with the [glossary](./glossary.md)—see the **Quickstart** and **Traceability** sections of [`packages/flatbread/README.md`](../packages/flatbread/README.md#quickstart-posts-authors-and-tags).
**Skimming from GraphQL-first experience:** Jump to **`refs` + relations** in [glossary](./glossary.md), then codegen and your app’s **`flatbread codegen`** docs — the relational layer is upstream of the queries you write.
diff --git a/examples/content/README.md b/examples/content/README.md
index 8a7dc709..7087ad23 100644
--- a/examples/content/README.md
+++ b/examples/content/README.md
@@ -10,8 +10,14 @@ markdown/authors/ # Author collection — referenced from posts via Flatbread `
yaml/ # Extra YAML-backed samples (e.g. YamlAuthor); secondary to markdown onboarding
```
+| Piece | Backing in this example |
+| --- | --- |
+| **Posts** | One file per post under `markdown/posts/` |
+| **Authors** | One file per author under `markdown/authors/` |
+| **Tags** | `tags:` list **in each post file’s frontmatter** (facet on **Post**). No separate `markdown/tags/` tree unless you introduce a **`Tag` collection** yourself. |
+
- **Relations:** **`authors`** in post frontmatter lists **author ids** that match **`id`** in author files. Flatbread resolves them through **`refs: { authors: 'Author' }`** in **`flatbread.config.js`** relative to **`examples/nextjs`**.
-- **Tags:** lists like **`tags: [cats, science]`** in post frontmatter are **string facets** on each **`Post`** (arrays of scalars through the schema). That is **not** the same as a **`refs`-backed **`Tag`** collection**; normalized tag files require an extra **`Tag`** collection and explicit **`refs`** in config.
+- **Tags:** lists like **`tags: [cats, science]`** in post frontmatter are **string facets** on each **`Post`** (arrays of scalars through the schema). That is **not** the same as a **`refs`-backed `Tag` collection**; normalized tag files require an extra **`Tag`** collection and explicit **`refs`** in config.
-Canonical commands and the full relational walkthrough live in the [root README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/README.md#quickstart-posts-authors-and-tags).
+Canonical commands and the end-to-end traceability story (same relation model: **files → config → GraphQL / codegen**) live in the [Flatbread README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/flatbread/README.md#quickstart-posts-authors-and-tags), including the **§ Traceability** walkthrough tied to the [glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 9fd33b12..a10a03e0 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -58,7 +58,7 @@ Markdown and YAML for this demo live under **`examples/content`**; this package
- **Posts:** `examples/content/markdown/posts/` (`tags` in frontmatter → `[String]` on **`Post`** in the schema.)
- **Authors:** `examples/content/markdown/authors/` (referenced by id from **`Post`** **`authors`**.)
-Canonical layout is described alongside commands in the [root README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/README.md#quickstart-posts-authors-and-tags).
+Canonical layout, **backing files for tags** (facet on each post), and **traceability** (same **relation model** from files through config to the GraphQL read interface and illustrative query JSON) are documented in the [Flatbread README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/flatbread/README.md#traceability-same-relation-model-files-config-query-interface) and [glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
## Project structure
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 3127d6a9..9befd895 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -30,7 +30,7 @@ Turn flat files in Git into typed, relational content for your TypeScript app. *
**GraphQL:** In the default toolkit, GraphQL is a common **read interface** for the content graph—schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: [docs/positioning.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/positioning.md).
-**Glossary:** Quick definitions for **collection**, **relation**, **ID**, **cardinality**, **validation**, and **query interface** (GraphQL as one read path, not the whole product)—see [docs/glossary.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
+**Glossary:** Quick definitions for **collection**, **relation**, **ID**, **cardinality**, **validation**, **query interface**, and how the **generated GraphQL schema / operation types** map to those terms (GraphQL as one read path, not the whole product)—see [docs/glossary.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
@@ -75,6 +75,44 @@ examples/content/markdown/authors/ # Author collection
The Next example points `flatbread.config.js` at `content/markdown/...` **relative to `examples/nextjs`**, where `content` is the symlink to `../content`.
+**Backing files for posts, authors, and tags (this example):**
+
+| What | Where it lives | Glossary terms |
+| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Posts** | `examples/content/markdown/posts/*.md` — one **record** per file | [Collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#collection), [Record](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#record) |
+| **Authors** | `examples/content/markdown/authors/*.md` — one **record** per file | Same; **IDs** in frontmatter wire **relations** |
+| **Tags** | The `tags:` YAML list **in each post’s frontmatter** (facet metadata on that **Post**). There is **no** `markdown/tags/` directory here. | [Tag (facet) vs `Tag` collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#tag-facet-vs-tag-collection) |
+
+### Traceability: same relation model (files, config, query interface)
+
+The table below ties the **Git-native** model to the default **GraphQL** read layer without implying GraphQL is the product’s whole identity—GraphQL is one [**query interface**](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#query-interface); files and config remain the source of truth.
+
+| Layer | You see… | Glossary |
+| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Files** | `authors:` ids in a post file match `id:` in author files; `tags:` is a string list on the post | [Relation](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#relation), [ID](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#id), [Tag (facet) vs `Tag` collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#tag-facet-vs-tag-collection) |
+| **`flatbread.config.js`** | `content` entries with `collection: 'Post' \| 'Author'` and `refs: { authors: 'Author' }` | [Collection](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#collection), [Relation](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#relation) |
+| **Generated GraphQL schema + codegen TS** | `allPosts { tags authors { id name } }` — **refs** resolve to **`Author`** objects; **`tags`** stays a scalar list on **`Post`** | [Generated schema and operation types (GraphQL)](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#generated-schema-and-operation-types-graphql), [Cardinality](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md#cardinality) |
+
+**Illustrative query result** (same **relation model** as [`examples/content/markdown/posts/example-post.md`](https://github.com/FlatbreadLabs/flatbread/blob/main/examples/content/markdown/posts/example-post.md): authors `2a3e` / `40s3`, **tags** from frontmatter). Values are from that file and its resolved **authors**; the shape matches the **`GetPostsAuthorsAndTags`** operation in **§3** after you include **`tags`** and **`authors`** in your **`.graphql`** document (see also `queries/posts.graphql`, which you can extend the same way):
+
+```json
+{
+ "allPosts": [
+ {
+ "id": "sdfsdf-23423-sdfsd-23444-dfghf",
+ "title": "The Art of Measuring Cats in Fruit Units",
+ "tags": ["cats", "measurements", "fruit-science", "important-research"],
+ "authors": [
+ { "id": "2a3e", "name": "Tony" },
+ { "id": "40s3", "name": "Eva" }
+ ]
+ }
+ ]
+}
+```
+
+Add **`tags`** (and any other fields) to your **`.graphql`** documents and rerun codegen so operations and `generated/graphql.ts` stay aligned with the files—snippets in docs are **illustrative** until your checked-in queries match.
+
### 3 · Run it from the repo root
Prerequisites: **Node 20.19+**, **pnpm 10.33.x** (see [CONTRIBUTING.md](CONTRIBUTING.md)).
@@ -108,7 +146,7 @@ query GetPostsAuthorsAndTags {
}
```
-After codegen, your app imports types from **`./generated/graphql`**. The **result shape** of that operation is typed (for example **`GetPostsAuthorsAndTagsQuery`**)—relations resolve to **`Author`** objects while **`tags`** stay a **string array** on **`Post`**, matching the file metadata.
+After codegen, your app imports types from **`./generated/graphql`**. The **result shape** of that operation is typed (for example **`GetPostsAuthorsAndTagsQuery`**)—relations resolve to **`Author`** objects while **`tags`** stay a **string array** on **`Post`**, matching the file metadata—the same row as the [illustrative JSON](#traceability-same-relation-model-files-config-query-interface) under **Traceability**.
Default filesystem + markdown wiring uses the bundled [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) and [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins (`flatbread` re-exports them).
From ada96c87f3eb1d605378ab94c86a4aba963a348f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 07:25:02 +0000
Subject: [PATCH 09/37] feat: normalize Flatbread ID semantics
Summary of changes
- Add shared ID normalization helpers and use them across find-by-id, find-many, relation resolution, and top-level ID equality/membership filters.
- Switch GraphQL lookup arguments to the ID scalar so string and integer ID literals normalize through the same resolver path.
- Validate invalid and duplicate normalized record IDs before schema use, with aggregated diagnostics that include source paths.
- Document the current ID normalization rules and add AVA fixtures for accepted, rejected, and duplicate ID shapes.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*ID*'
- pnpm test:ava -- --match='*Sift*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-148-id-semantics-review-r3 completed 3/3 tasks
Closes #148
Change-Id: Iaf137000630587edc0b50908315207efcac97b62
---
docs/glossary.md | 2 +
packages/core/src/generators/arguments.ts | 4 +-
packages/core/src/generators/schema.ts | 71 +++++++-
packages/core/src/index.ts | 6 +
packages/core/src/providers/test/base.test.ts | 166 ++++++++++++++++++
.../authors/numeric.md | 6 +
.../id-semantics-duplicates/authors/string.md | 6 +
.../id-semantics-duplicates/posts/valid.md | 7 +
.../authors/boolean-id.md | 7 +
.../id-semantics-invalid/authors/empty-id.md | 6 +
.../id-semantics-invalid/posts/valid.md | 7 +
.../fixtures/id-semantics/authors/numeric.md | 7 +
.../id-semantics/posts/numeric-author.md | 7 +
packages/core/src/resolvers/arguments.ts | 18 +-
packages/core/src/utils/ids.ts | 70 ++++++++
packages/core/src/utils/sift.ts | 56 +++++-
packages/core/src/utils/tests/sift.test.ts | 11 ++
17 files changed, 443 insertions(+), 14 deletions(-)
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md
create mode 100644 packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md
create mode 100644 packages/core/src/utils/ids.ts
diff --git a/docs/glossary.md b/docs/glossary.md
index e04a7c05..dcc07647 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -24,6 +24,8 @@ A **named group** of content of the same kind, declared in your Flatbread config
An identifier Flatbread uses to **point at one item within a collection** so relations can resolve. Today, Flatbread expects loaded entries to expose an `id`-shaped value that query arguments and `refs` can compare against; future ID work should keep that rule explicit across files, generated types, and query interfaces. IDs wire the graph together **in the repository**; they are not a centralized “primary key service” like a server database would provide.
+Current normalization rule: IDs may be **non-empty strings** or **finite numbers**. Flatbread compares record lookup arguments through a normalized string form, so a record with `id: 123`, a GraphQL argument `id: "123"`, and a GraphQL `ID` integer literal `id: 123` refer to the same record. Top-level equality and membership filters on a collection record’s `id` use the same normalized comparison; ordered filters (`lt`, `gt`, etc.) continue to use normal scalar comparison and should not be treated as stable ID semantics. String IDs are trimmed before comparison, so `id: " 123 "` normalizes to `"123"`. Empty strings, `null`, `undefined`, booleans, objects, `NaN`, and infinite numbers are rejected as invalid record IDs; if more than one record is invalid, Flatbread reports the invalid IDs together. Duplicate IDs after normalization (for example `123` and `"123"` in the same collection) are invalid because they would otherwise resolve inconsistently.
+
### Query interface
The **API surface your application uses to read** the built content graph. In many projects today that surface is **GraphQL** (schema plus operations, often with codegen), meaning GraphQL is **an interface**, not the definition of Flatbread. Other ways to consume the same graph may exist in your stack alongside it.
diff --git a/packages/core/src/generators/arguments.ts b/packages/core/src/generators/arguments.ts
index f6c1ded9..939f329a 100644
--- a/packages/core/src/generators/arguments.ts
+++ b/packages/core/src/generators/arguments.ts
@@ -18,7 +18,7 @@ export const generateArgsForAllItemQuery = (pluralType: string) => ({
*/
export const generateArgsForManyItemQuery = (pluralType: string) => ({
ids: {
- type: '[String]',
+ type: '[ID]',
},
...skip(),
...limit(pluralType),
@@ -32,7 +32,7 @@ export const generateArgsForManyItemQuery = (pluralType: string) => ({
*/
export const generateArgsForSingleItemQuery = () => ({
id: {
- type: 'String',
+ type: 'ID',
},
});
diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts
index 72dba8a3..1485d241 100644
--- a/packages/core/src/generators/schema.ts
+++ b/packages/core/src/generators/schema.ts
@@ -17,6 +17,11 @@ import {
Transformer,
} from '../types';
import { map } from '../utils/map';
+import {
+ getNodeIdentifier,
+ normalizeIdentifier,
+ normalizeOptionalIdentifier,
+} from '../utils/ids';
import { generateCollection } from './generateCollection';
interface RootQueries {
@@ -55,6 +60,7 @@ export async function generateSchema(
allContentNodes,
config
);
+ validateCollectionIdentifiers(allContentNodesJSON);
const preknownSchemaFragments = fetchPreknownSchemaFragments(config);
@@ -114,10 +120,20 @@ export async function generateSchema(
type: () => schema,
description: `Find one ${type} by its ID`,
args: generateArgsForSingleItemQuery(),
- resolve: (rp: Record) =>
- cloneDeep(allContentNodesJSON[type]).find(
- (node: EntryNode) => node.id === rp.args.id
- ),
+ resolve: (rp: Record) => {
+ const idToFind = normalizeOptionalIdentifier(
+ rp.args.id,
+ `${type} query argument "id"`
+ );
+
+ if (idToFind === undefined) {
+ return undefined;
+ }
+
+ return cloneDeep(allContentNodesJSON[type]).find(
+ (node: EntryNode) => getNodeIdentifier(node, type) === idToFind
+ );
+ },
});
schema.addResolver({
@@ -126,10 +142,12 @@ export async function generateSchema(
description: `Find many ${pluralType} by their IDs`,
args: generateArgsForManyItemQuery(pluralType),
resolve: (rp: Record) => {
- const idsToFind = rp.args.ids ?? [];
+ const idsToFind = (rp.args.ids ?? []).map((id: unknown): string =>
+ normalizeIdentifier(id, `${type} query argument "ids"`)
+ );
const matches =
cloneDeep(allContentNodesJSON[type])?.filter((node: EntryNode) =>
- idsToFind?.includes(node.id)
+ idsToFind?.includes(getNodeIdentifier(node, type))
) ?? [];
return resolveQueryArgs(matches, rp.args, config, {
type: {
@@ -240,6 +258,47 @@ const fetchPreknownSchemaFragments = (
);
};
+function validateCollectionIdentifiers(
+ allContentNodesJSON: Record
+): void {
+ const errors: string[] = [];
+
+ Object.entries(allContentNodesJSON).forEach(([collection, nodes]) => {
+ const seen = new Map();
+
+ nodes.forEach((node) => {
+ try {
+ const normalizedId = getNodeIdentifier(node, collection);
+ const existing = seen.get(normalizedId);
+
+ if (existing) {
+ errors.push(
+ `${collection} record id "${normalizedId}" is duplicated after normalization${sourceContext(
+ existing
+ )}${sourceContext(node)}`
+ );
+ } else {
+ seen.set(normalizedId, node);
+ }
+ } catch (error) {
+ errors.push(error instanceof Error ? error.message : String(error));
+ }
+ });
+ });
+
+ if (errors.length > 0) {
+ throw new Error(
+ `Flatbread found ${errors.length} invalid record ID${
+ errors.length === 1 ? '' : 's'
+ }:\n${errors.map((message) => `- ${message}`).join('\n')}`
+ );
+ }
+}
+
+function sourceContext(node: EntryNode): string {
+ return typeof node._path === 'string' ? ` (${node._path})` : '';
+}
+
function getTransformerExtensionMap(transformer: Transformer[]) {
const transformerMap = new Map();
transformer.forEach((t) => {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 14800794..0bb84461 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,5 +1,11 @@
export { generateSchema } from './generators/schema';
export { initializeConfig } from './utils/initializeConfig';
+export {
+ getNodeIdentifier,
+ isIdentifierField,
+ normalizeIdentifier,
+ normalizeOptionalIdentifier,
+} from './utils/ids';
export * from './types';
export { FlatbreadProvider } from './providers/base';
diff --git a/packages/core/src/providers/test/base.test.ts b/packages/core/src/providers/test/base.test.ts
index 145c2f45..6948b24d 100644
--- a/packages/core/src/providers/test/base.test.ts
+++ b/packages/core/src/providers/test/base.test.ts
@@ -25,6 +25,28 @@ function basicProject() {
});
}
+function idSemanticsProject(
+ path = 'packages/core/src/providers/test/fixtures/id-semantics'
+) {
+ return new FlatbreadProvider({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: `${path}/authors`,
+ collection: 'Author',
+ },
+ {
+ path: `${path}/posts`,
+ collection: 'Post',
+ refs: {
+ author: 'Author',
+ },
+ },
+ ],
+ });
+}
+
test('basic query', async (t) => {
const flatbread = basicProject();
@@ -42,6 +64,150 @@ test('basic query', async (t) => {
t.snapshot(result);
});
+test('normalizes numeric record IDs and string query args', async (t) => {
+ const flatbread = idSemanticsProject();
+
+ const result = await flatbread.query({
+ source: `
+ query NumericAuthor {
+ Author(id: "123") {
+ id
+ name
+ }
+ }
+ `,
+ });
+
+ t.deepEqual(result.data, {
+ Author: {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ });
+});
+
+test('accepts GraphQL ID integer literals for numeric record IDs', async (t) => {
+ const flatbread = idSemanticsProject();
+
+ const result = await flatbread.query({
+ source: `
+ query NumericAuthorIntegerLiteral {
+ Author(id: 123) {
+ id
+ name
+ }
+ }
+ `,
+ });
+
+ t.deepEqual(result.data, {
+ Author: {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ });
+});
+
+test('normalizes numeric relation targets when resolving refs', async (t) => {
+ const flatbread = idSemanticsProject();
+
+ const result = await flatbread.query({
+ source: `
+ query NumericAuthorRelation {
+ allPosts {
+ id
+ title
+ author {
+ id
+ name
+ }
+ }
+ }
+ `,
+ });
+
+ t.deepEqual(result.data, {
+ allPosts: [
+ {
+ id: 'numeric-author-post',
+ title: 'Numeric Author Post',
+ author: {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ },
+ ],
+ });
+});
+
+test('normalizes ID filter values against numeric record IDs', async (t) => {
+ const flatbread = idSemanticsProject();
+
+ const result = await flatbread.query({
+ source: `
+ query NumericAuthorFilter {
+ allAuthors(filter: {id: {eq: "123"}}) {
+ id
+ name
+ }
+ }
+ `,
+ });
+
+ t.deepEqual(result.data, {
+ allAuthors: [
+ {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ ],
+ });
+});
+
+test('rejects invalid record IDs before schema use', async (t) => {
+ const flatbread = idSemanticsProject(
+ 'packages/core/src/providers/test/fixtures/id-semantics-invalid'
+ );
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query InvalidId {
+ allAuthors {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ t.regex(error?.message ?? '', /Flatbread found 2 invalid record IDs/);
+ t.regex(error?.message ?? '', /empty-id\.md/);
+ t.regex(error?.message ?? '', /boolean-id\.md/);
+});
+
+test('rejects duplicate normalized record IDs before schema use', async (t) => {
+ const flatbread = idSemanticsProject(
+ 'packages/core/src/providers/test/fixtures/id-semantics-duplicates'
+ );
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query DuplicateId {
+ allAuthors {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ t.regex(error?.message ?? '', /Author record id "123" is duplicated/);
+ t.regex(error?.message ?? '', /numeric\.md/);
+ t.regex(error?.message ?? '', /string\.md/);
+});
+
test('relational filter query', async (t) => {
const flatbread = basicProject();
diff --git a/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md
new file mode 100644
index 00000000..beb4c245
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md
@@ -0,0 +1,6 @@
+---
+id: 123
+name: Numeric Author
+---
+
+This fixture collides with the string ID after normalization.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md
new file mode 100644
index 00000000..8a0e15ed
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md
@@ -0,0 +1,6 @@
+---
+id: '123'
+name: String Author
+---
+
+This fixture collides with the numeric ID after normalization.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md
new file mode 100644
index 00000000..dd8a1da0
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics-duplicates/posts/valid.md
@@ -0,0 +1,7 @@
+---
+id: duplicate-id-post
+title: Duplicate ID Post
+author: 123
+---
+
+This post only exists so the duplicate-id fixture has every configured folder.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md
new file mode 100644
index 00000000..abea50db
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/boolean-id.md
@@ -0,0 +1,7 @@
+---
+id: true
+name: Boolean ID Author
+---
+
+This fixture should fail alongside the empty ID fixture so diagnostics aggregate
+multiple invalid IDs.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md
new file mode 100644
index 00000000..f19785d3
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics-invalid/authors/empty-id.md
@@ -0,0 +1,6 @@
+---
+id: ''
+name: Empty ID Author
+---
+
+This fixture should fail ID validation before a schema is usable.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md b/packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md
new file mode 100644
index 00000000..2bc65a05
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics-invalid/posts/valid.md
@@ -0,0 +1,7 @@
+---
+id: valid-post
+title: Valid Post
+author: ''
+---
+
+This post only exists so the invalid-id fixture has every configured folder.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md b/packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md
new file mode 100644
index 00000000..5388586d
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics/authors/numeric.md
@@ -0,0 +1,7 @@
+---
+id: 123
+name: Numeric Author
+---
+
+This author intentionally uses a numeric ID to prove query args and relation
+targets normalize to the same comparison form.
diff --git a/packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md b/packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md
new file mode 100644
index 00000000..a3e78337
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/id-semantics/posts/numeric-author.md
@@ -0,0 +1,7 @@
+---
+id: numeric-author-post
+title: Numeric Author Post
+author: 123
+---
+
+This post points at an author through a numeric relation target.
diff --git a/packages/core/src/resolvers/arguments.ts b/packages/core/src/resolvers/arguments.ts
index 9c727db0..d6a6aec3 100644
--- a/packages/core/src/resolvers/arguments.ts
+++ b/packages/core/src/resolvers/arguments.ts
@@ -5,6 +5,7 @@ import sift, {
} from '../utils/sift';
import { ContentNode, FlatbreadConfig } from '../types';
import { FlatbreadProvider } from '../providers/base';
+import { getNodeIdentifier, normalizeIdentifier } from '../utils/ids';
interface ResolveQueryArgsOptions {
type: {
name: string;
@@ -27,8 +28,9 @@ const resolveQueryArgs = async (
if (filter) {
// Place the nodes into a keyed object by ID so we can easily filter by ID without doing tons of looping.
// TODO: store all nodes in an ID-keyed object.
- // TODO: replace id field with user-defined/fallback identifier field.
- const nodeById = keyBy(nodes, 'id');
+ const nodeById = keyBy(nodes, (node: ContentNode) =>
+ getNodeIdentifier(node, options.type.name)
+ );
// Turn the filter into a GraphQL subquery that returns an array of matching content node IDs.
const listOfNodeIDsToFilter = await resolveFilter(filter, config, options);
@@ -122,7 +124,7 @@ export const resolveFilter = async (
filter: Record,
config: FlatbreadConfig,
options: ResolveQueryArgsOptions
-): Promise<(string | number)[]> => {
+): Promise => {
// Seperate the filter into its parts:
// - the path leading to the field we want to compare
// - the comparator expression.
@@ -134,7 +136,6 @@ export const resolveFilter = async (
// Build a GraphQL query fragment that will be used to resolve content nodes in a structure expected by the sift function, for the given filter.
const filterQueryFragment = buildFilterQueryFragment(filterSetManifest);
- // TODO: replace id field with user-defined/fallback identifier field
const queryString = `
query ${options.type.pluralQueryName}_FilterSubquery {
${options.type.pluralQueryName} {
@@ -150,7 +151,14 @@ export const resolveFilter = async (
const result = data?.[options.type.pluralQueryName] as ContentNode[];
- return result.filter(sift(filter)).map((node) => node.id);
+ return result
+ .filter(sift(filter))
+ .map((node) =>
+ normalizeIdentifier(
+ node.id,
+ `${options.type.name} filter subquery result id`
+ )
+ );
};
/**
diff --git a/packages/core/src/utils/ids.ts b/packages/core/src/utils/ids.ts
new file mode 100644
index 00000000..c6a1cd5d
--- /dev/null
+++ b/packages/core/src/utils/ids.ts
@@ -0,0 +1,70 @@
+import { EntryNode, IdentifierField } from '../types';
+
+export type NormalizedIdentifier = string;
+
+/**
+ * Normalize a Flatbread record or relation identifier to the single comparison
+ * form used by internal resolvers and GraphQL query arguments.
+ */
+export function normalizeIdentifier(
+ value: unknown,
+ context = 'ID'
+): NormalizedIdentifier {
+ if (typeof value === 'string') {
+ const normalized = value.trim();
+ if (normalized.length > 0) {
+ return normalized;
+ }
+ }
+
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value);
+ }
+
+ throw new Error(
+ `${context} must be a non-empty string or finite number identifier.`
+ );
+}
+
+/**
+ * Normalize a GraphQL/query argument identifier if it was supplied. Flatbread's
+ * GraphQL ID arguments are optional today, so omitted IDs should preserve the
+ * existing "no match" behavior rather than throwing.
+ */
+export function normalizeOptionalIdentifier(
+ value: unknown,
+ context = 'ID'
+): NormalizedIdentifier | undefined {
+ if (value === null || value === undefined) {
+ return undefined;
+ }
+
+ return normalizeIdentifier(value, context);
+}
+
+/**
+ * Return a content node's normalized identifier with collection-aware context
+ * for diagnostics.
+ */
+export function getNodeIdentifier(
+ node: EntryNode,
+ collection: string
+): NormalizedIdentifier {
+ return normalizeIdentifier(
+ node.id,
+ `${collection} record id${sourceContext(node)}`
+ );
+}
+
+export function isIdentifierField(value: unknown): value is IdentifierField {
+ try {
+ normalizeIdentifier(value);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function sourceContext(node: EntryNode): string {
+ return typeof node._path === 'string' ? ` (${node._path})` : '';
+}
diff --git a/packages/core/src/utils/sift.ts b/packages/core/src/utils/sift.ts
index 05b1b896..e9eca950 100644
--- a/packages/core/src/utils/sift.ts
+++ b/packages/core/src/utils/sift.ts
@@ -3,6 +3,7 @@ import { get } from 'lodash-es';
import deepEntries from './deepEntries';
import reduceBooleans from './reduceBooleans';
import { isMatch as isWildcardMatch } from 'matcher';
+import { normalizeIdentifier } from './ids';
/**
* Return a callable sifting function that can be used to filter an array of objects with the given filter object.
@@ -31,8 +32,16 @@ const createFilterFunction = (
for (let { path, comparator } of filterSetManifest) {
// Retrieve the value of interest from the node.
const needle = get(node, path, undefined);
+ const comparisonNeedle = shouldNormalizeIdComparator(path, comparator)
+ ? normalizeSiftId(needle, 'filter id value', true)
+ : needle;
+ const comparisonComparator = shouldNormalizeIdComparator(path, comparator)
+ ? normalizeIdComparator(comparator)
+ : comparator;
// Compare the value of interest to the target value, and store the result of the evaluated expression.
- evaluatedFilterSet.push(generateComparisonFunction(comparator)(needle));
+ evaluatedFilterSet.push(
+ generateComparisonFunction(comparisonComparator)(comparisonNeedle)
+ );
}
// Combine the filter set results with the union operation.
@@ -41,6 +50,51 @@ const createFilterFunction = (
};
export default createFilterFunction;
+function normalizeSiftId(
+ value: unknown,
+ context: string,
+ allowMissing = false
+): unknown {
+ if (allowMissing && (value === null || value === undefined)) {
+ return value;
+ }
+
+ return normalizeIdentifier(value, context);
+}
+
+function normalizeIdComparator(comparator: Comparator): Comparator {
+ const { operation, value } = comparator;
+
+ if (operation === 'exists' || operation === 'strictlyExists') {
+ return comparator;
+ }
+
+ if (Array.isArray(value)) {
+ return {
+ operation,
+ value: value.map((item) =>
+ normalizeSiftId(item, `filter id comparator "${operation}"`)
+ ),
+ };
+ }
+
+ return {
+ operation,
+ value: normalizeSiftId(value, `filter id comparator "${operation}"`),
+ };
+}
+
+function shouldNormalizeIdComparator(
+ path: string[],
+ comparator: Comparator
+): boolean {
+ if (path.length !== 1 || path[0] !== 'id') {
+ return false;
+ }
+
+ return ['eq', 'ne', 'in', 'nin'].includes(comparator.operation);
+}
+
/**
* Generate a comparison function that can be used to compare a variable `a` (the field in each node) to a constant value `value` (target value in filter argument).
*
diff --git a/packages/core/src/utils/tests/sift.test.ts b/packages/core/src/utils/tests/sift.test.ts
index 767e53ee..ef8883ce 100644
--- a/packages/core/src/utils/tests/sift.test.ts
+++ b/packages/core/src/utils/tests/sift.test.ts
@@ -19,6 +19,17 @@ test('Sift for nodes with name equal to "foo"', (t) => {
t.deepEqual(nodes.filter(sift({ name: { eq: 'foo' } })), [nodes[0]]);
});
+test('Sift normalizes ID filters before strict comparison', (t) => {
+ t.deepEqual(nodes.filter(sift({ id: { eq: '1' } })), [nodes[0]]);
+});
+
+test('Sift rejects invalid ID filter comparators', (t) => {
+ t.throws(() => nodes.filter(sift({ id: { eq: '' } })), {
+ message:
+ 'filter id comparator "eq" must be a non-empty string or finite number identifier.',
+ });
+});
+
test('Sift for nodes with nested object "child" having age greater than or equal to 18', (t) => {
t.deepEqual(nodes.filter(sift({ child: { age: { gte: 18 } } })), [
nodes[0],
From 94f82e2bd1e0728b9cd99919e86f627ec4a012f0 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 07:53:47 +0000
Subject: [PATCH 10/37] feat: validate missing relation references
Summary of changes
- Add collection reference validation during schema generation so missing refs fail before query-time relation surprises.
- Aggregate diagnostics with source collection, record context, ref field path, target collection, and missing/invalid target ID details.
- Add posts/authors/tags-style AVA fixtures covering missing array refs, missing tag refs, invalid scalar ref shapes, and clean successful refs.
- Correct bundled example content refs that the new validation surfaced as broken.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*reference*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-149-missing-reference-validation reached implementation before being terminated after runner stall
- proof DAG: dag-flatbread-149-reference-validation-review completed 3/3 tasks
Closes #149
Change-Id: I754093e78f1c1609557377a60cefb98701a0245c
---
examples/content/markdown/authors/alex.md | 2 +-
examples/content/markdown/posts/soup.md | 4 +-
examples/content/yaml/authors/dr-caffeine.yml | 2 +-
packages/core/src/generators/schema.ts | 2 +
packages/core/src/index.ts | 1 +
.../authors/known-author.md | 8 +
.../missing-refs-clean/posts/known-post.md | 12 ++
.../missing-refs-clean/tags/known-tag.md | 6 +
.../missing-refs/authors/known-author.md | 7 +
.../posts/has-bad-author-shape.md | 10 +
.../missing-refs/posts/has-missing-author.md | 10 +
.../missing-refs/posts/has-missing-tag.md | 13 ++
.../fixtures/missing-refs/tags/known-tag.md | 7 +
.../src/providers/test/references.test.ts | 151 +++++++++++++++
packages/core/src/utils/references.ts | 182 ++++++++++++++++++
15 files changed, 413 insertions(+), 4 deletions(-)
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md
create mode 100644 packages/core/src/providers/test/references.test.ts
create mode 100644 packages/core/src/utils/references.ts
diff --git a/examples/content/markdown/authors/alex.md b/examples/content/markdown/authors/alex.md
index 9310b2ca..228148ac 100644
--- a/examples/content/markdown/authors/alex.md
+++ b/examples/content/markdown/authors/alex.md
@@ -8,7 +8,7 @@ enjoys:
- buying plants optimistically
- researching why plants died
- apologizing to houseplants
-friend: tony
+friend: 2a3e
image: eva.svg # placeholder until we get alex.svg
date_joined: 2023-08-12T14:30:00.000Z
pronouns: they/them
diff --git a/examples/content/markdown/posts/soup.md b/examples/content/markdown/posts/soup.md
index 306e31c9..caec3c87 100644
--- a/examples/content/markdown/posts/soup.md
+++ b/examples/content/markdown/posts/soup.md
@@ -2,8 +2,8 @@
id: jksfd4-234fdh-5345fj-3455-09836
title: 'The Great Soup Tier List: A Comprehensive Ranking'
authors:
- - daes
- - caffeine-researcher
+ - ab2c
+ - 2a3e
rating: 96
category: food
research_duration: '2 winters'
diff --git a/examples/content/yaml/authors/dr-caffeine.yml b/examples/content/yaml/authors/dr-caffeine.yml
index 2a22d62e..aacd0b18 100644
--- a/examples/content/yaml/authors/dr-caffeine.yml
+++ b/examples/content/yaml/authors/dr-caffeine.yml
@@ -7,7 +7,7 @@ enjoys:
- "data visualization"
- "converting people to the ways of good coffee"
- "late-night research sessions"
-friend: "tony"
+friend: "2a3e"
date_joined: "2023-01-15T08:30:00.000Z"
pronouns: "she/her"
location: "Portland, OR (where else?)"
diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts
index 1485d241..e3516f07 100644
--- a/packages/core/src/generators/schema.ts
+++ b/packages/core/src/generators/schema.ts
@@ -22,6 +22,7 @@ import {
normalizeIdentifier,
normalizeOptionalIdentifier,
} from '../utils/ids';
+import { validateCollectionReferences } from '../utils/references';
import { generateCollection } from './generateCollection';
interface RootQueries {
@@ -61,6 +62,7 @@ export async function generateSchema(
config
);
validateCollectionIdentifiers(allContentNodesJSON);
+ validateCollectionReferences(allContentNodesJSON, config.content);
const preknownSchemaFragments = fetchPreknownSchemaFragments(config);
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 0bb84461..6467866f 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -6,6 +6,7 @@ export {
normalizeIdentifier,
normalizeOptionalIdentifier,
} from './utils/ids';
+export { validateCollectionReferences } from './utils/references';
export * from './types';
export { FlatbreadProvider } from './providers/base';
diff --git a/packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md b/packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md
new file mode 100644
index 00000000..928734f5
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs-clean/authors/known-author.md
@@ -0,0 +1,8 @@
+---
+id: known-author
+name: Known Author
+---
+
+Green-path sibling for the missing-refs fixture tree: identical to its
+counterpart so we can prove the validator does not over-trigger when refs
+resolve.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md b/packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md
new file mode 100644
index 00000000..4296763c
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md
@@ -0,0 +1,12 @@
+---
+id: known-post
+title: Post With Resolved Refs
+author: known-author
+authors:
+ - known-author
+tags:
+ - known-tag
+---
+
+All references resolve, so missing-ref validation must remain silent and the
+schema should build cleanly.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md b/packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md
new file mode 100644
index 00000000..4b4a5343
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs-clean/tags/known-tag.md
@@ -0,0 +1,6 @@
+---
+id: known-tag
+name: Known Tag
+---
+
+Green-path sibling for the missing-refs fixture tree.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md b/packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md
new file mode 100644
index 00000000..53cb26ae
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/authors/known-author.md
@@ -0,0 +1,7 @@
+---
+id: known-author
+name: Known Author
+---
+
+This author is the only valid target for refs in this fixture tree, so any
+post pointing elsewhere will produce a missing-reference diagnostic.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md
new file mode 100644
index 00000000..27536f5c
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md
@@ -0,0 +1,10 @@
+---
+id: post-bad-shape
+title: Post With Non-Identifier Author
+author: true
+authors:
+ - known-author
+---
+
+`author` (scalar relation) is set to a boolean to drive the invalid-shape
+diagnostic path through the relation context.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md
new file mode 100644
index 00000000..99508a1a
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md
@@ -0,0 +1,10 @@
+---
+id: post-missing-author
+title: Post With Ghost Author
+authors:
+ - known-author
+ - ghost-author
+---
+
+The second entry in `authors` points at an Author record that does not exist in
+the fixture tree, so the missing-ref validator must surface it before schema use.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md
new file mode 100644
index 00000000..c84e321f
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md
@@ -0,0 +1,13 @@
+---
+id: post-missing-tag
+title: Post With Ghost Tag
+authors:
+ - known-author
+tags:
+ - known-tag
+ - ghost-tag
+---
+
+This fixture exercises the "tag is a relation, not a facet" case from the
+glossary: `tags` is configured as a `Tag` collection ref so the validator
+should report `ghost-tag` as missing.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md b/packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md
new file mode 100644
index 00000000..705c6035
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/tags/known-tag.md
@@ -0,0 +1,7 @@
+---
+id: known-tag
+name: Known Tag
+---
+
+A normalized `Tag` collection record so we can prove `refs: { tags: 'Tag' }`
+catches missing target ids the same way scalar relations do.
diff --git a/packages/core/src/providers/test/references.test.ts b/packages/core/src/providers/test/references.test.ts
new file mode 100644
index 00000000..f1dc22de
--- /dev/null
+++ b/packages/core/src/providers/test/references.test.ts
@@ -0,0 +1,151 @@
+import test from 'ava';
+import filesystem from '@flatbread/source-filesystem';
+import markdownTransformer from '@flatbread/transformer-markdown';
+import { FlatbreadProvider } from '../base';
+
+function missingRefsProject(
+ path = 'packages/core/src/providers/test/fixtures/missing-refs'
+) {
+ return new FlatbreadProvider({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: `${path}/authors`,
+ collection: 'Author',
+ },
+ {
+ path: `${path}/tags`,
+ collection: 'Tag',
+ },
+ {
+ path: `${path}/posts`,
+ collection: 'Post',
+ refs: {
+ author: 'Author',
+ authors: 'Author',
+ tags: 'Tag',
+ },
+ },
+ ],
+ });
+}
+
+test('rejects missing array reference targets before schema use', async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query MissingAuthorRef {
+ allPosts {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(message, /Flatbread found \d+ broken reference/);
+ t.regex(
+ message,
+ /Post\.authors\[1\][\s\S]*has-missing-author\.md[\s\S]*ghost-author[\s\S]*Author/
+ );
+});
+
+test('rejects missing array reference into a Tag collection', async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query MissingTagRef {
+ allPosts {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(
+ message,
+ /Post\.tags\[1\][\s\S]*has-missing-tag\.md[\s\S]*ghost-tag[\s\S]*Tag/
+ );
+});
+
+test('rejects invalid scalar reference shape before schema use', async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query BadAuthorShape {
+ allPosts {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(
+ message,
+ /Post\.author[\s\S]*has-bad-author-shape\.md[\s\S]*invalid reference value[\s\S]*Author/
+ );
+});
+
+test('builds and queries cleanly when every reference resolves', async (t) => {
+ const flatbread = missingRefsProject(
+ 'packages/core/src/providers/test/fixtures/missing-refs-clean'
+ );
+
+ const result = await flatbread.query({
+ source: `
+ query KnownPost {
+ allPosts {
+ id
+ author {
+ id
+ name
+ }
+ authors {
+ id
+ }
+ tags {
+ id
+ }
+ }
+ }
+ `,
+ });
+
+ t.is(result.errors, undefined);
+ t.deepEqual(result.data, {
+ allPosts: [
+ {
+ id: 'known-post',
+ author: {
+ id: 'known-author',
+ name: 'Known Author',
+ },
+ authors: [
+ {
+ id: 'known-author',
+ },
+ ],
+ tags: [
+ {
+ id: 'known-tag',
+ },
+ ],
+ },
+ ],
+ });
+});
diff --git a/packages/core/src/utils/references.ts b/packages/core/src/utils/references.ts
new file mode 100644
index 00000000..c522f325
--- /dev/null
+++ b/packages/core/src/utils/references.ts
@@ -0,0 +1,182 @@
+import { Content, EntryNode } from '../types';
+import { getNodeIdentifier, normalizeIdentifier } from './ids';
+
+/**
+ * Validate that every relation declared via a collection's `refs` config
+ * resolves to a record that actually exists in the target collection.
+ *
+ * Runs after content transforms and ID normalization so it can rely on the
+ * same normalized identifier semantics that the GraphQL resolvers and ID
+ * validation use. Like ID validation, it aggregates every problem it finds
+ * and throws a single error before the schema is built so consumers see all
+ * broken edges before query-time surprises.
+ */
+export function validateCollectionReferences(
+ allContentNodesJSON: Record,
+ content: Content
+): void {
+ const idsByCollection = collectIdsByCollection(allContentNodesJSON);
+ const errors: string[] = [];
+
+ for (const collectionConfig of content) {
+ const collection = String(collectionConfig.collection);
+ const refs = collectionConfig.refs as Record | undefined;
+ if (!refs) continue;
+
+ const nodes = allContentNodesJSON[collection];
+ if (!nodes) continue;
+
+ for (const node of nodes) {
+ for (const [refField, target] of Object.entries(refs)) {
+ const targetCollection = String(target);
+ const value = (node as Record)[refField];
+
+ if (value === null || value === undefined) continue;
+
+ const targetIds = idsByCollection.get(targetCollection);
+
+ if (Array.isArray(value)) {
+ value.forEach((entry, index) => {
+ const failure = checkReference(entry, targetIds);
+ if (failure) {
+ errors.push(
+ formatDiagnostic({
+ collection,
+ node,
+ refField: `${refField}[${index}]`,
+ targetCollection,
+ failure,
+ })
+ );
+ }
+ });
+ } else {
+ const failure = checkReference(value, targetIds);
+ if (failure) {
+ errors.push(
+ formatDiagnostic({
+ collection,
+ node,
+ refField,
+ targetCollection,
+ failure,
+ })
+ );
+ }
+ }
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ throw new Error(
+ `Flatbread found ${errors.length} broken reference${
+ errors.length === 1 ? '' : 's'
+ }:\n${errors.map((message) => `- ${message}`).join('\n')}`
+ );
+ }
+}
+
+type ReferenceFailure =
+ | { kind: 'missing'; missingId: string }
+ | { kind: 'unknownTarget' }
+ | { kind: 'invalidShape'; reason: string };
+
+function checkReference(
+ value: unknown,
+ targetIds: Set | undefined
+): ReferenceFailure | undefined {
+ let normalized: string;
+ try {
+ normalized = normalizeIdentifier(value, 'reference value');
+ } catch (error) {
+ return {
+ kind: 'invalidShape',
+ reason: error instanceof Error ? error.message : String(error),
+ };
+ }
+
+ if (!targetIds) {
+ return { kind: 'unknownTarget' };
+ }
+
+ if (!targetIds.has(normalized)) {
+ return { kind: 'missing', missingId: normalized };
+ }
+
+ return undefined;
+}
+
+interface DiagnosticInput {
+ collection: string;
+ node: EntryNode;
+ refField: string;
+ targetCollection: string;
+ failure: ReferenceFailure;
+}
+
+function formatDiagnostic({
+ collection,
+ node,
+ refField,
+ targetCollection,
+ failure,
+}: DiagnosticInput): string {
+ const fieldPath = `${collection}.${refField}`;
+ const recordContext = describeRecord(node);
+
+ switch (failure.kind) {
+ case 'missing':
+ return `${fieldPath}${recordContext} references "${failure.missingId}" but no record with that id exists in collection ${targetCollection}`;
+ case 'unknownTarget':
+ return `${fieldPath}${recordContext} declares a reference to collection ${targetCollection}, but no such collection is configured`;
+ case 'invalidShape':
+ return `${fieldPath}${recordContext} has an invalid reference value for collection ${targetCollection}: ${failure.reason}`;
+ }
+}
+
+function describeRecord(node: EntryNode): string {
+ const parts: string[] = [];
+
+ if (typeof node._path === 'string' && node._path.length > 0) {
+ parts.push(node._path);
+ }
+
+ let recordId: string | undefined;
+ try {
+ recordId = normalizeIdentifier(node.id);
+ } catch {
+ recordId = undefined;
+ }
+
+ if (recordId !== undefined) {
+ parts.push(`record id "${recordId}"`);
+ }
+
+ if (parts.length === 0) {
+ return '';
+ }
+
+ return ` (in ${parts.join(', ')})`;
+}
+
+function collectIdsByCollection(
+ allContentNodesJSON: Record
+): Map> {
+ const idsByCollection = new Map>();
+
+ for (const [collection, nodes] of Object.entries(allContentNodesJSON)) {
+ const ids = new Set();
+ for (const node of nodes) {
+ try {
+ ids.add(getNodeIdentifier(node, collection));
+ } catch {
+ // Invalid ids are surfaced by validateCollectionIdentifiers; skip
+ // them here so the missing-ref pass can still report what it can.
+ }
+ }
+ idsByCollection.set(collection, ids);
+ }
+
+ return idsByCollection;
+}
From c0c95f69d30968586c435d2d8a27bbc88a7309d1 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 08:21:42 +0000
Subject: [PATCH 11/37] fix: harden duplicate ID diagnostics
Summary of changes
- Validate content IDs before returning cached schemas so content-only duplicate regressions cannot bypass pre-schema validation.
- Reset graphql-compose's global schema composer before fresh schema builds and serialize AVA files to avoid schema-building test races.
- Preserve source VFile paths after transformation so duplicate-ID diagnostics include stable source locations even if parsed content contains reserved context keys.
- Add a cache-regression test that proves duplicate IDs still fail after a warm schema generation.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*duplicate*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-150-duplicate-id-review-r3 completed 3/3 tasks
Closes #150
Change-Id: Ibeb96f5e9555b72c8ef0bf6da633a65f67543f47
---
ava.config.js | 4 +
packages/core/src/generators/schema.ts | 30 ++-
packages/core/src/providers/test/base.test.ts | 223 ++++++++++++------
.../src/providers/test/references.test.ts | 176 +++++++-------
4 files changed, 271 insertions(+), 162 deletions(-)
diff --git a/ava.config.js b/ava.config.js
index 621bd6a6..f83dbb9c 100644
--- a/ava.config.js
+++ b/ava.config.js
@@ -1,4 +1,8 @@
export default {
+ // GraphQL schema generation currently uses graphql-compose's process-global
+ // schemaComposer. Run AVA files serially so schema-building tests do not
+ // mutate that shared composer concurrently.
+ concurrency: 1,
files: [
'packages/**/*.test.(j|t)s',
// Exclude Vitest suites located under __tests__ so AVA doesn't try to run them
diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts
index e3516f07..878048f3 100644
--- a/packages/core/src/generators/schema.ts
+++ b/packages/core/src/generators/schema.ts
@@ -43,13 +43,6 @@ export async function generateSchema(
throw new Error('Config is not defined');
}
- // Let's see if we have a cached version of the schema. If so, short-circuit and return it.
- const cachedSchema = checkCacheForSchema(config);
-
- if (cachedSchema) {
- return cachedSchema;
- }
-
// Invoke initialize function if it exists and provide loaded config
config.source.initialize?.(config);
@@ -64,6 +57,19 @@ export async function generateSchema(
validateCollectionIdentifiers(allContentNodesJSON);
validateCollectionReferences(allContentNodesJSON, config.content);
+ // Content validation must run before returning a cached schema because the
+ // cache key is derived from config, while invalid IDs/refs live in content.
+ const cachedSchema = checkCacheForSchema(config);
+
+ if (cachedSchema) {
+ return cachedSchema;
+ }
+
+ // graphql-compose's default schemaComposer is process-global. Reset it before
+ // building a fresh Flatbread schema so prior schemas with the same collection
+ // names do not leak fields or resolvers into this generation pass.
+ schemaComposer.clear();
+
const preknownSchemaFragments = fetchPreknownSchemaFragments(config);
/**
@@ -338,9 +344,17 @@ const optionallyTransformContentNodes = (
if (!transformer?.parse) {
throw new Error(`no transformer found for ${node.path}`);
}
- return transformer.parse(node);
+ return withSourceContext(transformer.parse(node), node);
});
}
return allContentNodes;
};
+
+function withSourceContext(entry: EntryNode, sourceNode: VFile): EntryNode {
+ return {
+ ...entry,
+ _path: sourceNode.path,
+ _filename: sourceNode.basename,
+ };
+}
diff --git a/packages/core/src/providers/test/base.test.ts b/packages/core/src/providers/test/base.test.ts
index 6948b24d..6d6905f3 100644
--- a/packages/core/src/providers/test/base.test.ts
+++ b/packages/core/src/providers/test/base.test.ts
@@ -2,6 +2,10 @@ import test from 'ava';
import filesystem from '@flatbread/source-filesystem';
import markdownTransformer from '@flatbread/transformer-markdown';
import { FlatbreadProvider } from '../base';
+import { generateSchema } from '../../generators/schema';
+import { initializeConfig } from '../../utils/initializeConfig';
+import type { EntryNode, Transformer } from '../../types';
+import { VFile } from 'vfile';
function basicProject() {
return new FlatbreadProvider({
@@ -47,7 +51,7 @@ function idSemanticsProject(
});
}
-test('basic query', async (t) => {
+test.serial('basic query', async (t) => {
const flatbread = basicProject();
const result = await flatbread.query({
@@ -64,11 +68,13 @@ test('basic query', async (t) => {
t.snapshot(result);
});
-test('normalizes numeric record IDs and string query args', async (t) => {
- const flatbread = idSemanticsProject();
+test.serial(
+ 'normalizes numeric record IDs and string query args',
+ async (t) => {
+ const flatbread = idSemanticsProject();
- const result = await flatbread.query({
- source: `
+ const result = await flatbread.query({
+ source: `
query NumericAuthor {
Author(id: "123") {
id
@@ -76,21 +82,24 @@ test('normalizes numeric record IDs and string query args', async (t) => {
}
}
`,
- });
+ });
- t.deepEqual(result.data, {
- Author: {
- id: 123,
- name: 'Numeric Author',
- },
- });
-});
+ t.deepEqual(result.data, {
+ Author: {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ });
+ }
+);
-test('accepts GraphQL ID integer literals for numeric record IDs', async (t) => {
- const flatbread = idSemanticsProject();
+test.serial(
+ 'accepts GraphQL ID integer literals for numeric record IDs',
+ async (t) => {
+ const flatbread = idSemanticsProject();
- const result = await flatbread.query({
- source: `
+ const result = await flatbread.query({
+ source: `
query NumericAuthorIntegerLiteral {
Author(id: 123) {
id
@@ -98,21 +107,24 @@ test('accepts GraphQL ID integer literals for numeric record IDs', async (t) =>
}
}
`,
- });
+ });
- t.deepEqual(result.data, {
- Author: {
- id: 123,
- name: 'Numeric Author',
- },
- });
-});
+ t.deepEqual(result.data, {
+ Author: {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ });
+ }
+);
-test('normalizes numeric relation targets when resolving refs', async (t) => {
- const flatbread = idSemanticsProject();
+test.serial(
+ 'normalizes numeric relation targets when resolving refs',
+ async (t) => {
+ const flatbread = idSemanticsProject();
- const result = await flatbread.query({
- source: `
+ const result = await flatbread.query({
+ source: `
query NumericAuthorRelation {
allPosts {
id
@@ -124,27 +136,30 @@ test('normalizes numeric relation targets when resolving refs', async (t) => {
}
}
`,
- });
+ });
- t.deepEqual(result.data, {
- allPosts: [
- {
- id: 'numeric-author-post',
- title: 'Numeric Author Post',
- author: {
- id: 123,
- name: 'Numeric Author',
+ t.deepEqual(result.data, {
+ allPosts: [
+ {
+ id: 'numeric-author-post',
+ title: 'Numeric Author Post',
+ author: {
+ id: 123,
+ name: 'Numeric Author',
+ },
},
- },
- ],
- });
-});
+ ],
+ });
+ }
+);
-test('normalizes ID filter values against numeric record IDs', async (t) => {
- const flatbread = idSemanticsProject();
+test.serial(
+ 'normalizes ID filter values against numeric record IDs',
+ async (t) => {
+ const flatbread = idSemanticsProject();
- const result = await flatbread.query({
- source: `
+ const result = await flatbread.query({
+ source: `
query NumericAuthorFilter {
allAuthors(filter: {id: {eq: "123"}}) {
id
@@ -152,19 +167,20 @@ test('normalizes ID filter values against numeric record IDs', async (t) => {
}
}
`,
- });
+ });
- t.deepEqual(result.data, {
- allAuthors: [
- {
- id: 123,
- name: 'Numeric Author',
- },
- ],
- });
-});
+ t.deepEqual(result.data, {
+ allAuthors: [
+ {
+ id: 123,
+ name: 'Numeric Author',
+ },
+ ],
+ });
+ }
+);
-test('rejects invalid record IDs before schema use', async (t) => {
+test.serial('rejects invalid record IDs before schema use', async (t) => {
const flatbread = idSemanticsProject(
'packages/core/src/providers/test/fixtures/id-semantics-invalid'
);
@@ -186,29 +202,32 @@ test('rejects invalid record IDs before schema use', async (t) => {
t.regex(error?.message ?? '', /boolean-id\.md/);
});
-test('rejects duplicate normalized record IDs before schema use', async (t) => {
- const flatbread = idSemanticsProject(
- 'packages/core/src/providers/test/fixtures/id-semantics-duplicates'
- );
+test.serial(
+ 'rejects duplicate normalized record IDs before schema use',
+ async (t) => {
+ const flatbread = idSemanticsProject(
+ 'packages/core/src/providers/test/fixtures/id-semantics-duplicates'
+ );
- const error = await t.throwsAsync(() =>
- flatbread.query({
- source: `
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
query DuplicateId {
allAuthors {
id
}
}
`,
- })
- );
+ })
+ );
- t.regex(error?.message ?? '', /Author record id "123" is duplicated/);
- t.regex(error?.message ?? '', /numeric\.md/);
- t.regex(error?.message ?? '', /string\.md/);
-});
+ t.regex(error?.message ?? '', /Author record id "123" is duplicated/);
+ t.regex(error?.message ?? '', /numeric\.md/);
+ t.regex(error?.message ?? '', /string\.md/);
+ }
+);
-test('relational filter query', async (t) => {
+test.serial('relational filter query', async (t) => {
const flatbread = basicProject();
const result = await flatbread.query({
@@ -222,5 +241,65 @@ test('relational filter query', async (t) => {
`,
});
- t.snapshot(result);
+ t.deepEqual(result.data, {
+ allAuthors: [
+ {
+ enjoys: ['cats', 'coffee', 'design'],
+ name: 'Daes',
+ },
+ {
+ enjoys: ['cats', 'tea', 'making this'],
+ name: 'Tony',
+ },
+ ],
+ });
});
+
+test.serial(
+ 'validates duplicate IDs before returning a cached schema',
+ async (t) => {
+ let authorEntries: EntryNode[] = [{ id: 'author-one', name: 'Author One' }];
+ const collection = 'CacheDuplicateAuthor';
+ const transformer: Transformer = {
+ extensions: ['.json'],
+ inspect: (input) => JSON.stringify(input),
+ parse: (input) => input.data.entry as EntryNode,
+ };
+ const config = initializeConfig({
+ source: {
+ fetch: async () => ({
+ [collection]: authorEntries.map((entry) => {
+ const file = new VFile({
+ path: `virtual/authors/${String(entry.id)}.json`,
+ });
+ file.data.entry = entry;
+ return file;
+ }),
+ }),
+ },
+ transformer,
+ content: [
+ {
+ path: 'virtual/authors',
+ collection,
+ },
+ ],
+ });
+
+ await generateSchema({ config });
+
+ authorEntries = [
+ { id: 'author-one', name: 'Author One' },
+ { id: ' author-one ', name: 'Duplicate Author One' },
+ ];
+
+ const error = await t.throwsAsync(() => generateSchema({ config }));
+
+ t.regex(
+ error?.message ?? '',
+ /CacheDuplicateAuthor record id "author-one" is duplicated/
+ );
+ t.regex(error?.message ?? '', /virtual\/authors\/author-one\.json/);
+ t.regex(error?.message ?? '', /virtual\/authors\/ author-one \.json/);
+ }
+);
diff --git a/packages/core/src/providers/test/references.test.ts b/packages/core/src/providers/test/references.test.ts
index f1dc22de..a6ea7ff7 100644
--- a/packages/core/src/providers/test/references.test.ts
+++ b/packages/core/src/providers/test/references.test.ts
@@ -31,83 +31,94 @@ function missingRefsProject(
});
}
-test('rejects missing array reference targets before schema use', async (t) => {
- const flatbread = missingRefsProject();
-
- const error = await t.throwsAsync(() =>
- flatbread.query({
- source: `
+test.serial(
+ 'rejects missing array reference targets before schema use',
+ async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
query MissingAuthorRef {
allPosts {
id
}
}
`,
- })
- );
-
- const message = error?.message ?? '';
-
- t.regex(message, /Flatbread found \d+ broken reference/);
- t.regex(
- message,
- /Post\.authors\[1\][\s\S]*has-missing-author\.md[\s\S]*ghost-author[\s\S]*Author/
- );
-});
-
-test('rejects missing array reference into a Tag collection', async (t) => {
- const flatbread = missingRefsProject();
-
- const error = await t.throwsAsync(() =>
- flatbread.query({
- source: `
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(message, /Flatbread found \d+ broken reference/);
+ t.regex(
+ message,
+ /Post\.authors\[1\][\s\S]*has-missing-author\.md[\s\S]*ghost-author[\s\S]*Author/
+ );
+ }
+);
+
+test.serial(
+ 'rejects missing array reference into a Tag collection',
+ async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
query MissingTagRef {
allPosts {
id
}
}
`,
- })
- );
-
- const message = error?.message ?? '';
-
- t.regex(
- message,
- /Post\.tags\[1\][\s\S]*has-missing-tag\.md[\s\S]*ghost-tag[\s\S]*Tag/
- );
-});
-
-test('rejects invalid scalar reference shape before schema use', async (t) => {
- const flatbread = missingRefsProject();
-
- const error = await t.throwsAsync(() =>
- flatbread.query({
- source: `
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(
+ message,
+ /Post\.tags\[1\][\s\S]*has-missing-tag\.md[\s\S]*ghost-tag[\s\S]*Tag/
+ );
+ }
+);
+
+test.serial(
+ 'rejects invalid scalar reference shape before schema use',
+ async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
query BadAuthorShape {
allPosts {
id
}
}
`,
- })
- );
-
- const message = error?.message ?? '';
-
- t.regex(
- message,
- /Post\.author[\s\S]*has-bad-author-shape\.md[\s\S]*invalid reference value[\s\S]*Author/
- );
-});
-
-test('builds and queries cleanly when every reference resolves', async (t) => {
- const flatbread = missingRefsProject(
- 'packages/core/src/providers/test/fixtures/missing-refs-clean'
- );
-
- const result = await flatbread.query({
- source: `
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(
+ message,
+ /Post\.author[\s\S]*has-bad-author-shape\.md[\s\S]*invalid reference value[\s\S]*Author/
+ );
+ }
+);
+
+test.serial(
+ 'builds and queries cleanly when every reference resolves',
+ async (t) => {
+ const flatbread = missingRefsProject(
+ 'packages/core/src/providers/test/fixtures/missing-refs-clean'
+ );
+
+ const result = await flatbread.query({
+ source: `
query KnownPost {
allPosts {
id
@@ -124,28 +135,29 @@ test('builds and queries cleanly when every reference resolves', async (t) => {
}
}
`,
- });
-
- t.is(result.errors, undefined);
- t.deepEqual(result.data, {
- allPosts: [
- {
- id: 'known-post',
- author: {
- id: 'known-author',
- name: 'Known Author',
- },
- authors: [
- {
+ });
+
+ t.is(result.errors, undefined);
+ t.deepEqual(result.data, {
+ allPosts: [
+ {
+ id: 'known-post',
+ author: {
id: 'known-author',
+ name: 'Known Author',
},
- ],
- tags: [
- {
- id: 'known-tag',
- },
- ],
- },
- ],
- });
-});
+ authors: [
+ {
+ id: 'known-author',
+ },
+ ],
+ tags: [
+ {
+ id: 'known-tag',
+ },
+ ],
+ },
+ ],
+ });
+ }
+);
From 18d136cb1447f76f5f5fd29ae2b2a1cbde01a360 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 08:37:22 +0000
Subject: [PATCH 12/37] test: define relation cardinality validation
Summary of changes
- Document supported relation cardinality shapes in the glossary: scalar one-to-one refs, list one-to-many refs, explicit reciprocal-list many-to-many modeling, and unsupported shapes.
- Add AVA coverage for scalar refs, list refs, explicit many-to-many list refs, object ref values, and nested-array ref values.
- Reuse missing-reference validation so unsupported cardinality shapes fail before schema use instead of resolving as nulls.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*cardinality*'
- pnpm test:ava -- --match='*reference*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-151-relation-cardinality-review-r2 completed 3/3 tasks
Closes #151
Change-Id: Ia6e0ecca5787ee252a6ddaf4b15ac78dfdd69648
---
docs/glossary.md | 7 +
.../posts/known-post.md | 9 +
.../tags/known-tag.md | 8 +
.../posts/has-nested-authors-shape.md | 9 +
.../posts/has-object-author-shape.md | 9 +
.../src/providers/test/references.test.ts | 162 ++++++++++++++++++
6 files changed, 204 insertions(+)
create mode 100644 packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md
create mode 100644 packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md
create mode 100644 packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md
diff --git a/docs/glossary.md b/docs/glossary.md
index dcc07647..d3493e65 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -12,6 +12,13 @@ See also: [Flatbread positioning](./positioning.md); [PMF decision rubric](./pmf
How many related items a field connects—whether a relation resolves to **one** related entry or **many** (for example, a single author versus a list of tag strings on a post). Cardinality shapes how the graph is exposed to your app (including generated GraphQL fields); it does **not** imply a SQL-style database engine.
+Current relation cardinality rules are intentionally small:
+
+- **One-to-one:** a `refs` field whose content value is a single ID (`author: 2a3e`) resolves to one related record.
+- **One-to-many:** a `refs` field whose content value is a list of IDs (`authors: [2a3e, 40s3]`) resolves to a list of related records.
+- **Many-to-many:** model each side as one-to-many lists when both collections need to point at each other; Flatbread does not infer a hidden join table or reciprocal edge.
+- **Unsupported / invalid:** booleans, objects, nested arrays, and other non-ID shapes in a `refs` field fail validation before schema use instead of silently resolving to `null`.
+
### Tag (facet) vs `Tag` collection
A **facet** is metadata stored on a record (often a **YAML list of strings** such as `tags: [a, b]` on a post). It becomes a **scalar list** in the read interface and is **not** the same as **`refs`** resolving to another collection. A **`Tag` collection** means one file per tag (or equivalent) and **`refs`** from **`Post`** → **`Tag`** so tag entries are **normalized records** in the graph—use that when tags need shared descriptions, stable ids, or relational edges of their own.
diff --git a/packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md
new file mode 100644
index 00000000..27d881d6
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/posts/known-post.md
@@ -0,0 +1,9 @@
+---
+id: known-post
+title: Known Post
+tags:
+ - known-tag
+---
+
+This post and tag point at each other with list refs to model a many-to-many
+relationship without an inferred join table.
diff --git a/packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md
new file mode 100644
index 00000000..e650c544
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/cardinality-many-to-many/tags/known-tag.md
@@ -0,0 +1,8 @@
+---
+id: known-tag
+label: Known Tag
+posts:
+ - known-post
+---
+
+This tag points back at posts with its own list ref.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md
new file mode 100644
index 00000000..b0f84d2e
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md
@@ -0,0 +1,9 @@
+---
+id: post-nested-authors
+title: Post With Nested Authors
+authors:
+ - - known-author
+---
+
+Nested arrays are not supported as relation values; refs should be scalar IDs
+or flat arrays of scalar IDs.
diff --git a/packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md
new file mode 100644
index 00000000..1168d405
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md
@@ -0,0 +1,9 @@
+---
+id: post-object-author
+title: Post With Object Author
+author:
+ id: known-author
+---
+
+Objects are not supported as relation values; refs should be scalar IDs or
+arrays of scalar IDs.
diff --git a/packages/core/src/providers/test/references.test.ts b/packages/core/src/providers/test/references.test.ts
index a6ea7ff7..8535c4c5 100644
--- a/packages/core/src/providers/test/references.test.ts
+++ b/packages/core/src/providers/test/references.test.ts
@@ -31,6 +31,119 @@ function missingRefsProject(
});
}
+function manyToManyProject() {
+ const path =
+ 'packages/core/src/providers/test/fixtures/cardinality-many-to-many';
+
+ return new FlatbreadProvider({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: `${path}/posts`,
+ collection: 'Post',
+ refs: {
+ tags: 'Tag',
+ },
+ },
+ {
+ path: `${path}/tags`,
+ collection: 'Tag',
+ refs: {
+ posts: 'Post',
+ },
+ },
+ ],
+ });
+}
+
+test.serial(
+ 'supports one-to-one and one-to-many relation cardinality',
+ async (t) => {
+ const flatbread = missingRefsProject(
+ 'packages/core/src/providers/test/fixtures/missing-refs-clean'
+ );
+
+ const result = await flatbread.query({
+ source: `
+ query RelationCardinality {
+ allPosts {
+ id
+ author {
+ id
+ }
+ authors {
+ id
+ }
+ tags {
+ id
+ }
+ }
+ }
+ `,
+ });
+
+ t.deepEqual(result.data, {
+ allPosts: [
+ {
+ id: 'known-post',
+ author: {
+ id: 'known-author',
+ },
+ authors: [
+ {
+ id: 'known-author',
+ },
+ ],
+ tags: [
+ {
+ id: 'known-tag',
+ },
+ ],
+ },
+ ],
+ });
+ }
+);
+
+test.serial('supports explicit many-to-many list refs', async (t) => {
+ const flatbread = manyToManyProject();
+
+ const result = await flatbread.query({
+ source: `
+ query ManyToManyCardinality {
+ allPosts {
+ id
+ tags {
+ id
+ posts {
+ id
+ }
+ }
+ }
+ }
+ `,
+ });
+
+ t.deepEqual(result.data, {
+ allPosts: [
+ {
+ id: 'known-post',
+ tags: [
+ {
+ id: 'known-tag',
+ posts: [
+ {
+ id: 'known-post',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+});
+
test.serial(
'rejects missing array reference targets before schema use',
async (t) => {
@@ -110,6 +223,55 @@ test.serial(
}
);
+test.serial('rejects object relation values before schema use', async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query BadObjectAuthorShape {
+ allPosts {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(
+ message,
+ /Post\.author[\s\S]*has-object-author-shape\.md[\s\S]*invalid reference value[\s\S]*Author/
+ );
+});
+
+test.serial(
+ 'rejects nested array relation values before schema use',
+ async (t) => {
+ const flatbread = missingRefsProject();
+
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query BadNestedAuthorsShape {
+ allPosts {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ const message = error?.message ?? '';
+
+ t.regex(
+ message,
+ /Post\.authors\[0\][\s\S]*has-nested-authors-shape\.md[\s\S]*invalid reference value[\s\S]*Author/
+ );
+ }
+);
+
test.serial(
'builds and queries cleanly when every reference resolves',
async (t) => {
From c80c69c73f22f71d085b03af9697108b9a249ed4 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 08:56:21 +0000
Subject: [PATCH 13/37] test: snapshot validation diagnostics
Summary of changes
- Add validation diagnostic snapshot tests for missing references, unknown target collections, duplicate IDs, invalid relation shapes, and required ID errors.
- Add a required-field fixture for missing record IDs.
- Sort aggregated validation diagnostics before throwing so snapshot output remains deterministic.
Testing
- pnpm --filter @flatbread/core build
- pnpm exec ava packages/core/src/providers/test/validationSnapshots.test.ts --update-snapshots
- pnpm exec ava packages/core/src/providers/test/validationSnapshots.test.ts
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-152-validation-snapshots-review-r2 completed 3/3 tasks
Closes #152
Change-Id: I85369f9faa10f5851f74e8bc143ca50725f30b21
---
packages/core/src/generators/schema.ts | 1 +
.../required-fields/authors/missing-id.md | 5 +
.../snapshots/validationSnapshots.test.ts.md | 37 +++++++
.../validationSnapshots.test.ts.snap | Bin 0 -> 696 bytes
.../test/validationSnapshots.test.ts | 103 ++++++++++++++++++
packages/core/src/utils/references.ts | 1 +
6 files changed, 147 insertions(+)
create mode 100644 packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md
create mode 100644 packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md
create mode 100644 packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.snap
create mode 100644 packages/core/src/providers/test/validationSnapshots.test.ts
diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts
index 878048f3..29b872c0 100644
--- a/packages/core/src/generators/schema.ts
+++ b/packages/core/src/generators/schema.ts
@@ -295,6 +295,7 @@ function validateCollectionIdentifiers(
});
if (errors.length > 0) {
+ errors.sort();
throw new Error(
`Flatbread found ${errors.length} invalid record ID${
errors.length === 1 ? '' : 's'
diff --git a/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md b/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md
new file mode 100644
index 00000000..89545d5b
--- /dev/null
+++ b/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md
@@ -0,0 +1,5 @@
+---
+name: Missing ID Author
+---
+
+Flatbread requires every record to expose an `id`.
diff --git a/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md
new file mode 100644
index 00000000..50c700cb
--- /dev/null
+++ b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.md
@@ -0,0 +1,37 @@
+# Snapshot report for `packages/core/src/providers/test/validationSnapshots.test.ts`
+
+The actual snapshot is saved in `validationSnapshots.test.ts.snap`.
+
+Generated by [AVA](https://avajs.dev).
+
+## validation snapshot: missing references and invalid relation shapes
+
+> Snapshot 1
+
+ `Flatbread found 5 broken references:␊
+ - Post.author (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-bad-author-shape.md, record id "post-bad-shape") has an invalid reference value for collection Author: reference value must be a non-empty string or finite number identifier.␊
+ - Post.author (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-object-author-shape.md, record id "post-object-author") has an invalid reference value for collection Author: reference value must be a non-empty string or finite number identifier.␊
+ - Post.authors[0] (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-nested-authors-shape.md, record id "post-nested-authors") has an invalid reference value for collection Author: reference value must be a non-empty string or finite number identifier.␊
+ - Post.authors[1] (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-author.md, record id "post-missing-author") references "ghost-author" but no record with that id exists in collection Author␊
+ - Post.tags[1] (in /packages/core/src/providers/test/fixtures/missing-refs/posts/has-missing-tag.md, record id "post-missing-tag") references "ghost-tag" but no record with that id exists in collection Tag`
+
+## validation snapshot: unknown target collection
+
+> Snapshot 1
+
+ `Flatbread found 1 broken reference:␊
+ - Post.author (in /packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md, record id "known-post") declares a reference to collection MissingCollection, but no such collection is configured`
+
+## validation snapshot: duplicate normalized IDs
+
+> Snapshot 1
+
+ `Flatbread found 1 invalid record ID:␊
+ - Author record id "123" is duplicated after normalization (/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/numeric.md) (/packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors/string.md)`
+
+## validation snapshot: required ID field
+
+> Snapshot 1
+
+ `Flatbread found 1 invalid record ID:␊
+ - Author record id (/packages/core/src/providers/test/fixtures/required-fields/authors/missing-id.md) must be a non-empty string or finite number identifier.`
diff --git a/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.snap b/packages/core/src/providers/test/snapshots/validationSnapshots.test.ts.snap
new file mode 100644
index 0000000000000000000000000000000000000000..e108ae554cf48323db8419261dcf0f15a97cc963
GIT binary patch
literal 696
zcmV;p0!RHpRzVRLfQLbL;?!xPA|fKyCu?_h=l?&i|Gi9;!kel5_({rI(8ci?
z7Hm=3I}nGJv0u}QWIaVaxEtKz<)}rEvorAlB`-A2g
z?hGE@dA#$wKurRnh1A#1!gEN1pOWi!IC>DlTQ54nT5}&@mmRz~_hBj(8hRO5Xr`Ev
z#Ks4TC78Gh{){aJiIqg-lrOXnB=KB*6eyLr@}d&wC{cnon(ZC6x+kUGgFway3v6N3
zRJKL!>}U)*Hnw}N=ThMei<%(y0gNvSGA)Z&?bb&tWo0c22?2uhE~2v1uOJ#4ygoq6
zj+MaGWkLbil2cBZ!erYteDax${vF6t-R*)RAD?~t|6m-6Qg>411~SWqw++qy7SMFV
zvmR@qESHSN{RkOlc~df-DSYn@uZQpcO89Okdm~d2x;ffbe$>2Q
zr+PIr8iOTMU})}}-jAWa&+5&r#qnZfufr|1$@`ToV7yB?s{`4NZ?u-Lt*e3!Houk+
zB^LaRES#RKx!Z<^OL(8G!F!bet;y4qhVSh7_Koi!9F7`|i{2J6RSF9UGm*QSeF?Ua
zP>D|2NVI4n@jM`6SCh3-G2ZG54oY12><|>0ZX3%
literal 0
HcmV?d00001
diff --git a/packages/core/src/providers/test/validationSnapshots.test.ts b/packages/core/src/providers/test/validationSnapshots.test.ts
new file mode 100644
index 00000000..3f8d22b9
--- /dev/null
+++ b/packages/core/src/providers/test/validationSnapshots.test.ts
@@ -0,0 +1,103 @@
+import test from 'ava';
+import type { ExecutionContext } from 'ava';
+import filesystem from '@flatbread/source-filesystem';
+import markdownTransformer from '@flatbread/transformer-markdown';
+import { FlatbreadProvider } from '../base';
+
+function project(path: string, refs: Record = {}) {
+ return new FlatbreadProvider({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: `${path}/authors`,
+ collection: 'Author',
+ },
+ {
+ path: `${path}/tags`,
+ collection: 'Tag',
+ },
+ {
+ path: `${path}/posts`,
+ collection: 'Post',
+ refs,
+ },
+ ],
+ });
+}
+
+function authorOnlyProject(path: string) {
+ return new FlatbreadProvider({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: `${path}/authors`,
+ collection: 'Author',
+ },
+ ],
+ });
+}
+
+async function validationMessage(
+ t: ExecutionContext,
+ flatbread: FlatbreadProvider
+): Promise {
+ const error = await t.throwsAsync(() =>
+ flatbread.query({
+ source: `
+ query ValidationSnapshot {
+ allAuthors {
+ id
+ }
+ }
+ `,
+ })
+ );
+
+ return normalizeMessage(error?.message ?? '');
+}
+
+function normalizeMessage(message: string): string {
+ return message.replaceAll(process.cwd(), '');
+}
+
+test('validation snapshot: missing references and invalid relation shapes', async (t) => {
+ const flatbread = project(
+ 'packages/core/src/providers/test/fixtures/missing-refs',
+ {
+ author: 'Author',
+ authors: 'Author',
+ tags: 'Tag',
+ }
+ );
+
+ t.snapshot(await validationMessage(t, flatbread));
+});
+
+test('validation snapshot: unknown target collection', async (t) => {
+ const flatbread = project(
+ 'packages/core/src/providers/test/fixtures/missing-refs-clean',
+ {
+ author: 'MissingCollection',
+ }
+ );
+
+ t.snapshot(await validationMessage(t, flatbread));
+});
+
+test('validation snapshot: duplicate normalized IDs', async (t) => {
+ const flatbread = authorOnlyProject(
+ 'packages/core/src/providers/test/fixtures/id-semantics-duplicates'
+ );
+
+ t.snapshot(await validationMessage(t, flatbread));
+});
+
+test('validation snapshot: required ID field', async (t) => {
+ const flatbread = authorOnlyProject(
+ 'packages/core/src/providers/test/fixtures/required-fields'
+ );
+
+ t.snapshot(await validationMessage(t, flatbread));
+});
diff --git a/packages/core/src/utils/references.ts b/packages/core/src/utils/references.ts
index c522f325..449cc07a 100644
--- a/packages/core/src/utils/references.ts
+++ b/packages/core/src/utils/references.ts
@@ -69,6 +69,7 @@ export function validateCollectionReferences(
}
if (errors.length > 0) {
+ errors.sort();
throw new Error(
`Flatbread found ${errors.length} broken reference${
errors.length === 1 ? '' : 's'
From 12e44a2c51fbb9ac11f0fc33d1efb59e63c9be71 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 09:50:47 +0000
Subject: [PATCH 14/37] feat: emit Flatbread content model types
Summary of changes
- Append generated Flatbread content-model helper types to codegen output using the configured collections and refs.
- Emit collection-name, record-by-collection, relation-target collection, relation cardinality, and related-record helper types without requiring handwritten GraphQL documents.
- Keep the generated helper block idempotent across cache hits and add compile assertions proving the generated types match the representative config.
Testing
- pnpm --filter @flatbread/codegen build
- pnpm -F @flatbread/codegen exec vitest run
- pnpm lint
- proof DAG: dag-flatbread-153-ts-schema-types-review-r6 completed 3/3 tasks
Closes #153
Change-Id: I7205a76b8cc8c579c95e91dd5d5471dbf30a8621
---
packages/codegen/src/__tests__/e2e.test.ts | 90 ++++++++++-
packages/codegen/src/generator.ts | 179 ++++++++++++++++++++-
2 files changed, 266 insertions(+), 3 deletions(-)
diff --git a/packages/codegen/src/__tests__/e2e.test.ts b/packages/codegen/src/__tests__/e2e.test.ts
index 7a6924a3..8269c27a 100644
--- a/packages/codegen/src/__tests__/e2e.test.ts
+++ b/packages/codegen/src/__tests__/e2e.test.ts
@@ -8,6 +8,7 @@ import { clearCache } from '../cache.js';
import type { LoadedFlatbreadConfig } from '@flatbread/core';
import type { CodegenOptions } from '../types.js';
import { buildSchema } from 'graphql';
+import ts from 'typescript';
/**
* End-to-end tests for the codegen package
@@ -69,6 +70,17 @@ describe('codegen end-to-end', () => {
{
collection: 'Post',
path: join(tempDir, 'content/posts'),
+ refs: {
+ author: 'Author',
+ },
+ },
+ {
+ collection: 'Author',
+ path: join(tempDir, 'content/authors'),
+ },
+ {
+ collection: 'Draft',
+ path: join(tempDir, 'content/drafts'),
},
],
fieldNameTransform: (field: string) => field,
@@ -79,6 +91,8 @@ describe('codegen end-to-end', () => {
// Create content directory structure
await fs.mkdir(join(tempDir, 'content/posts'), { recursive: true });
+ await fs.mkdir(join(tempDir, 'content/authors'), { recursive: true });
+ await fs.mkdir(join(tempDir, 'content/drafts'), { recursive: true });
});
afterEach(async () => {
@@ -97,7 +111,7 @@ describe('codegen end-to-end', () => {
outputDir: testOutputDir,
outputFile: 'graphql.ts',
plugins: ['typescript'],
- cache: false, // Disable cache for predictable testing
+ cache: true,
};
const result = await generateTypes(testSchema, mockConfig, options);
@@ -118,6 +132,61 @@ describe('codegen end-to-end', () => {
expect(content).toContain('Post');
expect(content).toContain('Author');
expect(content).toContain('Query');
+ expect(content).toContain(
+ 'export type FlatbreadCollectionName = "Post" | "Author" | "Draft"'
+ );
+ expect(content).toContain('export type FlatbreadRecordByCollection');
+ expect(content).toContain('"Post": Post;');
+ expect(content).toContain('"Author": Author;');
+ expect(content).toContain('"Draft": Record;');
+ expect(content).toContain(
+ 'export type FlatbreadRelationTargetByCollection'
+ );
+ expect(content).toContain(
+ '"author": { target: "Author"; cardinality: "one"; };'
+ );
+ expect(content).toContain('FlatbreadRelationTargetCollection');
+ expect(content).toContain('FlatbreadRelationCardinality');
+ expect(content).toContain('@flatbread/content-model-types:start');
+
+ const usageFile = join(testOutputDir, 'usage.ts');
+ await fs.writeFile(
+ usageFile,
+ `
+ import type {
+ FlatbreadCollectionName,
+ FlatbreadRecord,
+ FlatbreadRelationTarget,
+ FlatbreadRelationCardinality,
+ FlatbreadRelationTargetCollection,
+ } from './graphql';
+
+ const collection: FlatbreadCollectionName = 'Post';
+ type PostRecord = FlatbreadRecord<'Post'>;
+ type RelatedAuthor = FlatbreadRelationTarget<'Post', 'author'>;
+ const relation: FlatbreadRelationTargetCollection<'Post', 'author'> = 'Author';
+ const cardinality: FlatbreadRelationCardinality<'Post', 'author'> = 'one';
+
+ export type Assertions = {
+ collection: typeof collection;
+ post: PostRecord;
+ relatedAuthor: RelatedAuthor;
+ relationCollection: typeof relation;
+ cardinality: typeof cardinality;
+ };
+ `
+ );
+
+ expectTypeScriptToCompile([generatedFile, usageFile]);
+
+ const secondResult = await generateTypes(testSchema, mockConfig, options);
+ expect(secondResult.success).toBe(true);
+ expect(secondResult.fromCache).toBe(true);
+
+ const cachedContent = await fs.readFile(generatedFile, 'utf-8');
+ expect(
+ cachedContent.match(/@flatbread\/content-model-types:start/g)
+ ).toHaveLength(1);
});
it('should generate types with operations when documents are provided', async () => {
@@ -310,3 +379,22 @@ describe('codegen end-to-end', () => {
});
});
});
+
+function expectTypeScriptToCompile(files: string[]) {
+ const program = ts.createProgram(files, {
+ noEmit: true,
+ strict: true,
+ target: ts.ScriptTarget.ES2020,
+ module: ts.ModuleKind.NodeNext,
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
+ skipLibCheck: true,
+ types: [],
+ });
+ const diagnostics = ts.getPreEmitDiagnostics(program);
+
+ expect(
+ diagnostics.map((diagnostic) =>
+ ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
+ )
+ ).toEqual([]);
+}
diff --git a/packages/codegen/src/generator.ts b/packages/codegen/src/generator.ts
index a5573d42..30dcf6cf 100644
--- a/packages/codegen/src/generator.ts
+++ b/packages/codegen/src/generator.ts
@@ -1,7 +1,14 @@
import { generate } from '@graphql-codegen/cli';
-import { printSchema, type GraphQLSchema } from 'graphql';
+import {
+ isListType,
+ isNonNullType,
+ isObjectType,
+ printSchema,
+ type GraphQLSchema,
+ type GraphQLType,
+} from 'graphql';
import { join, resolve } from 'path';
-import { ensureDir } from 'fs-extra';
+import { ensureDir, readFile, writeFile } from 'fs-extra';
import { existsSync } from 'fs';
import kleur from 'kleur';
// @ts-ignore - chokidar types will be available after npm install
@@ -67,6 +74,7 @@ export async function generateTypes(
// Check cache
const cache = await loadCache(outputDir);
if (isCacheValid(cache, configHash, schemaHash, mergedOptions)) {
+ await upsertFlatbreadContentModelTypes(outputFilePath, config, schema);
console.log(kleur.green('✓ Using cached TypeScript types'));
return {
success: true,
@@ -134,6 +142,7 @@ export async function generateTypes(
// Generate types
await generate(codegenConfig, true);
+ await upsertFlatbreadContentModelTypes(outputFilePath, config, schema);
const generatedFiles = [outputFilePath];
@@ -167,6 +176,172 @@ export async function generateTypes(
}
}
+const CONTENT_MODEL_TYPES_START = '/* @flatbread/content-model-types:start */';
+const CONTENT_MODEL_TYPES_END = '/* @flatbread/content-model-types:end */';
+
+async function upsertFlatbreadContentModelTypes(
+ outputFilePath: string,
+ config: LoadedFlatbreadConfig,
+ schema: GraphQLSchema
+): Promise {
+ const contentModelTypes = generateFlatbreadContentModelTypes(config, schema);
+ if (!contentModelTypes) return;
+
+ const current = await readFile(outputFilePath, 'utf-8');
+ const block = `${CONTENT_MODEL_TYPES_START}\n${contentModelTypes}\n${CONTENT_MODEL_TYPES_END}`;
+ const existingBlockPattern = new RegExp(
+ `\\n?${escapeRegExp(CONTENT_MODEL_TYPES_START)}[\\s\\S]*?${escapeRegExp(
+ CONTENT_MODEL_TYPES_END
+ )}`
+ );
+ const next = existingBlockPattern.test(current)
+ ? current.replace(existingBlockPattern, `\n${block}`)
+ : `${current.trimEnd()}\n\n${block}\n`;
+
+ await writeFile(outputFilePath, next);
+}
+
+function generateFlatbreadContentModelTypes(
+ config: LoadedFlatbreadConfig,
+ schema: GraphQLSchema
+): string {
+ const collections = config.content.map((contentType) =>
+ String(contentType.collection)
+ );
+
+ if (collections.length === 0) {
+ return '';
+ }
+
+ const recordEntries = collections
+ .map(
+ (collection) =>
+ ` ${JSON.stringify(collection)}: ${toTypeReference(
+ collection,
+ schema
+ )};`
+ )
+ .join('\n');
+
+ const relationEntries = config.content
+ .map((contentType) => {
+ const collection = String(contentType.collection);
+ const refs = contentType.refs as Record | undefined;
+ const relationFields = refs
+ ? Object.entries(refs)
+ .map(
+ ([field, target]) =>
+ ` ${JSON.stringify(field)}: { target: ${JSON.stringify(
+ String(target)
+ )}; cardinality: ${JSON.stringify(
+ getRelationCardinality(schema, collection, field)
+ )}; };`
+ )
+ .join('\n')
+ : '';
+
+ return ` ${JSON.stringify(collection)}: {${
+ relationFields ? `\n${relationFields}\n ` : ''
+ }};`;
+ })
+ .join('\n');
+
+ return `/**
+ * Flatbread content model types generated from flatbread.config.*.
+ * These describe configured collections and refs before any GraphQL operation documents are required.
+ */
+export type FlatbreadCollectionName = ${collections
+ .map((collection) => JSON.stringify(collection))
+ .join(' | ')};
+
+export type FlatbreadRecordByCollection = {
+${recordEntries}
+};
+
+export type FlatbreadRelationTargetByCollection = {
+${relationEntries}
+};
+
+export type FlatbreadRecord<
+ Collection extends FlatbreadCollectionName,
+> = FlatbreadRecordByCollection[Collection];
+
+export type FlatbreadRelationTarget<
+ Collection extends FlatbreadCollectionName,
+ Field extends keyof FlatbreadRelationTargetByCollection[Collection],
+> = FlatbreadRecord<
+ Extract<
+ FlatbreadRelationTargetCollection,
+ FlatbreadCollectionName
+ >
+> extends infer TargetRecord
+ ? FlatbreadRelationCardinality extends 'many'
+ ? ReadonlyArray
+ : TargetRecord
+ : never;
+
+export type FlatbreadRelationTargetCollection<
+ Collection extends FlatbreadCollectionName,
+ Field extends keyof FlatbreadRelationTargetByCollection[Collection],
+> = FlatbreadRelationTargetByCollection[Collection][Field] extends {
+ target: infer Target;
+}
+ ? Target
+ : never;
+
+export type FlatbreadRelationCardinality<
+ Collection extends FlatbreadCollectionName,
+ Field extends keyof FlatbreadRelationTargetByCollection[Collection],
+> = FlatbreadRelationTargetByCollection[Collection][Field] extends {
+ cardinality: infer Cardinality;
+}
+ ? Cardinality
+ : never;`;
+}
+
+function toTypeReference(collection: string, schema: GraphQLSchema): string {
+ const schemaType = schema.getType(collection);
+
+ return isObjectType(schemaType) &&
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(collection)
+ ? collection
+ : 'Record';
+}
+
+function getRelationCardinality(
+ schema: GraphQLSchema,
+ collection: string,
+ field: string
+): 'one' | 'many' {
+ const schemaType = schema.getType(collection);
+ if (!isObjectType(schemaType)) {
+ return 'one';
+ }
+
+ const fieldConfig = schemaType.getFields()[field];
+ if (!fieldConfig) {
+ return 'one';
+ }
+
+ return isListLikeType(fieldConfig.type) ? 'many' : 'one';
+}
+
+function isListLikeType(type: GraphQLType): boolean {
+ if (isListType(type)) {
+ return true;
+ }
+
+ if (isNonNullType(type)) {
+ return isListLikeType(type.ofType);
+ }
+
+ return false;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
/**
* Generate TypeScript types with support for document files
*/
From e94e56de5474ef76c81fb1ef7ba6711ac9b1d2b1 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 11:22:34 +0000
Subject: [PATCH 15/37] feat: generate TypeScript read API
Summary of changes
- Generate a prototype TypeScript read API alongside GraphQL codegen output, including collection readers, default schema-derived selections, relation metadata, and cache-versioned output.
- Regenerate the Next.js example types and add a read API helper that queries posts, authors, and tags through createFlatbreadReadApi while keeping the existing GraphQL path available.
- Exercise the generated read API from the Next.js home page, document the prototype, and fix example content refs surfaced by validation.
- Add compile/runtime-oriented codegen tests for generated content model/read API types, cache behavior, relation cardinality, and cache-busting output versioning.
Testing
- pnpm --filter @flatbread/codegen build
- pnpm -F @flatbread/codegen exec vitest run
- pnpm build
- pnpm --filter nextjs exec flatbread codegen --clear-cache --verbose
- pnpm --filter nextjs build
- pnpm lint
- proof DAG: dag-flatbread-154-ts-read-api-review-r8 completed 4/4 tasks
Closes #154
Change-Id: Ie93611adaef1dcc3b1b90bec40dfc7d7e30d57c3
---
.../markdown/posts/food/perfect-toast.md | 4 +-
.../posts/gaming/speedrun-disasters.md | 4 +-
.../markdown/posts/tech/debugging-at-3am.md | 2 +-
examples/nextjs/README.md | 22 +-
examples/nextjs/app/page.tsx | 30 ++-
examples/nextjs/generated/graphql.ts | 189 ++++++++++++++-
examples/nextjs/lib/read.ts | 55 +++++
examples/nextjs/queries/posts.graphql | 2 +-
packages/codegen/src/__tests__/e2e.test.ts | 38 ++-
packages/codegen/src/__tests__/hash.test.ts | 5 +
packages/codegen/src/generator.ts | 220 +++++++++++++++++-
packages/codegen/src/hash.ts | 3 +
packages/flatbread/README.md | 2 +
13 files changed, 552 insertions(+), 24 deletions(-)
create mode 100644 examples/nextjs/lib/read.ts
diff --git a/examples/content/markdown/posts/food/perfect-toast.md b/examples/content/markdown/posts/food/perfect-toast.md
index 559de3dd..bccad1ee 100644
--- a/examples/content/markdown/posts/food/perfect-toast.md
+++ b/examples/content/markdown/posts/food/perfect-toast.md
@@ -2,8 +2,8 @@
id: toast-manifesto-2024
title: 'A Manifesto on the Perfect Toast: An Engineering Approach'
authors:
- - daes
- - eva
+ - ab2c
+ - 40s3
rating: 88
category: food
precision_level: unnecessary
diff --git a/examples/content/markdown/posts/gaming/speedrun-disasters.md b/examples/content/markdown/posts/gaming/speedrun-disasters.md
index 62d848b7..fc83eaf3 100644
--- a/examples/content/markdown/posts/gaming/speedrun-disasters.md
+++ b/examples/content/markdown/posts/gaming/speedrun-disasters.md
@@ -2,8 +2,8 @@
id: gaming-fails-2024
title: 'When Speedruns Go Spectacularly Wrong'
authors:
- - ushi
- - yoshi
+ - 1111
+ - r3c6
rating: 92
category: gaming
difficulty: legendary
diff --git a/examples/content/markdown/posts/tech/debugging-at-3am.md b/examples/content/markdown/posts/tech/debugging-at-3am.md
index 1559f416..b6819430 100644
--- a/examples/content/markdown/posts/tech/debugging-at-3am.md
+++ b/examples/content/markdown/posts/tech/debugging-at-3am.md
@@ -2,7 +2,7 @@
id: debugging-adventures-3am
title: 'Debugging at 3 AM: A Horror Story'
authors:
- - tony
+ - 2a3e
rating: 95
category: tech
time_spent: '4.5 hours'
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index a10a03e0..0da26c1b 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -25,7 +25,7 @@ This example is the repo’s **default first success path**: **relational Git-ba
pnpm exec flatbread codegen --verbose
```
- Add or edit **`.graphql`** files under `queries/` (or globs in config), then rerun codegen so **`tags`**, **`authors`**, and other fields stay in sync.
+ Add or edit **`.graphql`** files under `queries/` (or globs in config), then rerun codegen so **`tags`**, **`authors`**, and other fields stay in sync. Codegen also emits a prototype **generated TypeScript read API** in `generated/graphql.ts`; see `lib/read.ts` for the posts/authors/tags example that calls `createFlatbreadReadApi()`.
4. **Serve the GraphQL read interface alongside Next** (**there is no `flatbread dev`** — use **`flatbread start`**):
@@ -101,6 +101,26 @@ Force regeneration (clear cache):
pnpm exec flatbread codegen --clear-cache --verbose
```
+## Generated TypeScript read API prototype
+
+The demo still keeps GraphQL available, but `flatbread codegen` now also emits typed helpers from the configured content model:
+
+- `createFlatbreadReadApi(execute)` — builds collection readers with generated default selections.
+- `FlatbreadRecord<'Post'>` — typed record for a configured collection.
+- `FlatbreadRelationTarget<'Post', 'authors'>` — typed relation result (`ReadonlyArray` for this example).
+
+`lib/read.ts` wires those helpers to this app's existing `graphqlFetch` client:
+
+```typescript
+import { getPostsAuthorsAndTagsViaReadApi } from './lib/read';
+
+const posts = await getPostsAuthorsAndTagsViaReadApi();
+const authorNames = posts[0]?.authors?.map((author) => author.name);
+const tags = posts[0]?.tags;
+```
+
+That path queries **posts**, **authors**, and **tags** through the generated TypeScript API while the GraphQL endpoint remains the underlying read interface. The lower-level generated methods still accept an optional GraphQL selection string for experimentation, but the canonical example uses the generated default selection so the call site does not hand-write a GraphQL document.
+
## Troubleshooting
### "No posts found" or network errors
diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx
index a2a114ad..381806ce 100644
--- a/examples/nextjs/app/page.tsx
+++ b/examples/nextjs/app/page.tsx
@@ -1,4 +1,5 @@
import { graphqlFetch, queries } from '../lib/graphql';
+import { getAuthorsViaReadApi, getPostsAuthorsAndTagsViaReadApi } from '../lib/read';
import type { PostCategory } from '../generated/graphql';
import BlogIndex from './components/BlogIndex';
import QueryPanel from './components/QueryPanel';
@@ -18,7 +19,32 @@ async function getData(): Promise {
}
export default async function Home() {
- const data = await getData();
+ const [data, readApiResults] = await Promise.all([
+ getData(),
+ Promise.allSettled([
+ getPostsAuthorsAndTagsViaReadApi(),
+ getAuthorsViaReadApi(),
+ ]),
+ ]);
+ const [readApiPostsResult, readApiAuthorsResult] = readApiResults;
+ const readApiError =
+ readApiPostsResult.status === 'rejected' ||
+ readApiAuthorsResult.status === 'rejected'
+ ? {
+ posts:
+ readApiPostsResult.status === 'rejected'
+ ? String(readApiPostsResult.reason)
+ : undefined,
+ authors:
+ readApiAuthorsResult.status === 'rejected'
+ ? String(readApiAuthorsResult.reason)
+ : undefined,
+ }
+ : undefined;
+ const readApiPosts =
+ readApiPostsResult.status === 'fulfilled' ? readApiPostsResult.value : [];
+ const readApiAuthors =
+ readApiAuthorsResult.status === 'fulfilled' ? readApiAuthorsResult.value : [];
return (
@@ -31,7 +57,7 @@ export default async function Home() {
{/* Query Panel */}
diff --git a/examples/nextjs/generated/graphql.ts b/examples/nextjs/generated/graphql.ts
index d45fdd70..e22a7495 100644
--- a/examples/nextjs/generated/graphql.ts
+++ b/examples/nextjs/generated/graphql.ts
@@ -8,6 +8,7 @@ export type MakeEmpty =
export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export interface Scalars {
+ /** The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. */
ID: { input: string; output: string; }
/** The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. */
String: { input: string; output: string; }
@@ -397,32 +398,32 @@ export interface Query {
export interface QueryAuthorArgs {
- id?: InputMaybe;
+ id?: InputMaybe;
}
export interface QueryOverrideTestArgs {
- id?: InputMaybe;
+ id?: InputMaybe;
}
export interface QueryPostArgs {
- id?: InputMaybe;
+ id?: InputMaybe;
}
export interface QueryPostCategoryArgs {
- id?: InputMaybe;
+ id?: InputMaybe;
}
export interface QueryPostCategoryBlobArgs {
- id?: InputMaybe;
+ id?: InputMaybe;
}
export interface QueryYamlAuthorArgs {
- id?: InputMaybe;
+ id?: InputMaybe;
}
@@ -561,7 +562,7 @@ export type GetAllPostsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetAllPostsQuery = { __typename?: 'Query', allPosts?: Array<{ __typename?: 'Post', id?: string | null, title?: string | null, _content?: { __typename?: 'Post__content', html?: string | null, excerpt?: string | null, timeToRead?: number | null } | null, authors?: Array<{ __typename?: 'Author', id?: string | null, name?: string | null } | null> | null } | null> | null };
export type GetPostByIdQueryVariables = Exact<{
- id: Scalars['String']['input'];
+ id: Scalars['ID']['input'];
}>;
@@ -581,6 +582,176 @@ export type PostsummaryFragment = { __typename?: 'Post', id?: string | null, tit
export const PostsummaryFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Postsummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Post"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode;
export const GetAllPostsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAllPosts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allPosts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode;
-export const GetPostByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPostById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Post"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"friend"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
+export const GetPostByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPostById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"Post"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"friend"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const GetPostCategoriesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPostCategories"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allPostCategories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"StringValue","value":"title","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"EnumValue","value":"DESC"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_collection"}},{"kind":"Field","name":{"kind":"Name","value":"_filename"}},{"kind":"Field","name":{"kind":"Name","value":"_slug"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"_content"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"raw"}},{"kind":"Field","name":{"kind":"Name","value":"html"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"_slug"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"entity"}},{"kind":"Field","name":{"kind":"Name","value":"enjoys"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"srcset"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetwebp"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetavif"}},{"kind":"Field","name":{"kind":"Name","value":"placeholder"}},{"kind":"Field","name":{"kind":"Name","value":"aspectratio"}}]}},{"kind":"Field","name":{"kind":"Name","value":"friend"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}}]}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}},{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sitting"}},{"kind":"Field","name":{"kind":"Name","value":"breathing"}},{"kind":"Field","name":{"kind":"Name","value":"liquid_consumption"}},{"kind":"Field","name":{"kind":"Name","value":"existence"}},{"kind":"Field","name":{"kind":"Name","value":"sports"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
-export const GetAuthorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"entity"}},{"kind":"Field","name":{"kind":"Name","value":"enjoys"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"srcset"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetwebp"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetavif"}},{"kind":"Field","name":{"kind":"Name","value":"placeholder"}},{"kind":"Field","name":{"kind":"Name","value":"aspectratio"}}]}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}},{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sitting"}},{"kind":"Field","name":{"kind":"Name","value":"breathing"}},{"kind":"Field","name":{"kind":"Name","value":"liquid_consumption"}},{"kind":"Field","name":{"kind":"Name","value":"existence"}},{"kind":"Field","name":{"kind":"Name","value":"sports"}}]}}]}}]}}]} as unknown as DocumentNode;
\ No newline at end of file
+export const GetAuthorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allAuthors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"entity"}},{"kind":"Field","name":{"kind":"Name","value":"enjoys"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"srcset"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetwebp"}},{"kind":"Field","name":{"kind":"Name","value":"srcsetavif"}},{"kind":"Field","name":{"kind":"Name","value":"placeholder"}},{"kind":"Field","name":{"kind":"Name","value":"aspectratio"}}]}},{"kind":"Field","name":{"kind":"Name","value":"date_joined"}},{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sitting"}},{"kind":"Field","name":{"kind":"Name","value":"breathing"}},{"kind":"Field","name":{"kind":"Name","value":"liquid_consumption"}},{"kind":"Field","name":{"kind":"Name","value":"existence"}},{"kind":"Field","name":{"kind":"Name","value":"sports"}}]}}]}}]}}]} as unknown as DocumentNode;
+
+/* @flatbread/content-model-types:start */
+/**
+ * Flatbread content model types generated from flatbread.config.*.
+ * These describe configured collections and refs before any GraphQL operation documents are required.
+ */
+export type FlatbreadCollectionName = "Post" | "PostCategory" | "PostCategoryBlob" | "Author" | "YamlAuthor" | "OverrideTest";
+
+export type FlatbreadRecordByCollection = {
+ "Post": Post;
+ "PostCategory": PostCategory;
+ "PostCategoryBlob": PostCategoryBlob;
+ "Author": Author;
+ "YamlAuthor": YamlAuthor;
+ "OverrideTest": OverrideTest;
+};
+
+export type FlatbreadRelationTargetByCollection = {
+ "Post": {
+ "authors": { target: "Author"; cardinality: "many"; };
+ };
+ "PostCategory": {
+ "authors": { target: "Author"; cardinality: "many"; };
+ };
+ "PostCategoryBlob": {
+ "authors": { target: "Author"; cardinality: "many"; };
+ };
+ "Author": {
+ "friend": { target: "Author"; cardinality: "one"; };
+ };
+ "YamlAuthor": {
+ "friend": { target: "YamlAuthor"; cardinality: "one"; };
+ };
+ "OverrideTest": {};
+};
+
+export type FlatbreadRecord<
+ Collection extends FlatbreadCollectionName,
+> = FlatbreadRecordByCollection[Collection];
+
+export type FlatbreadRelationTarget<
+ Collection extends FlatbreadCollectionName,
+ Field extends keyof FlatbreadRelationTargetByCollection[Collection],
+> = FlatbreadRecord<
+ Extract<
+ FlatbreadRelationTargetCollection,
+ FlatbreadCollectionName
+ >
+> extends infer TargetRecord
+ ? FlatbreadRelationCardinality extends 'many'
+ ? ReadonlyArray | null
+ : TargetRecord | null
+ : never;
+
+export type FlatbreadRelationTargetCollection<
+ Collection extends FlatbreadCollectionName,
+ Field extends keyof FlatbreadRelationTargetByCollection[Collection],
+> = FlatbreadRelationTargetByCollection[Collection][Field] extends {
+ target: infer Target;
+}
+ ? Target
+ : never;
+
+export type FlatbreadRelationCardinality<
+ Collection extends FlatbreadCollectionName,
+ Field extends keyof FlatbreadRelationTargetByCollection[Collection],
+> = FlatbreadRelationTargetByCollection[Collection][Field] extends {
+ cardinality: infer Cardinality;
+}
+ ? Cardinality
+ : never;
+
+export type FlatbreadReadableCollectionName = "Post" | "PostCategory" | "PostCategoryBlob" | "Author" | "YamlAuthor" | "OverrideTest";
+
+export type FlatbreadGraphQLExecutor = (
+ source: string,
+ variables?: Record,
+) => Promise;
+
+/**
+ * Experimental generated read API over the Flatbread content model.
+ *
+ * The API owns collection names, root query names, IDs, and result typing. The
+ * current prototype still accepts a GraphQL selection string for fields; invalid
+ * or drifting selections are runtime GraphQL errors, not type errors.
+ */
+export type FlatbreadReadApi = {
+ "Post": {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };
+ "PostCategory": {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };
+ "PostCategoryBlob": {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };
+ "Author": {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };
+ "YamlAuthor": {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };
+ "OverrideTest": {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };
+};
+
+const flatbreadReadApiQueries = {
+ "Post": { all: "allPosts", find: "Post", idType: "ID", selection: "_filename\n_path\n_slug\nid\ntitle\nauthors { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\nrating\n_content { raw\nhtml\nexcerpt\ntimeToRead }\ncategory\ntags\nresearch_duration\nsoups_tested\ntemperature_preference\nslurp_factor\ncontroversial_opinions\n_collection" },
+ "PostCategory": { all: "allPostCategories", find: "PostCategory", idType: "ID", selection: "_filename\n_path\n_slug\ncategory\nslug\nid\ntitle\nauthors { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\nrating\n_content { raw\nhtml\nexcerpt\ntimeToRead }\nprecision_level\nbread_types_tested\nbutter_temperature\ntoast_settings { darkness\ncrunch_factor\nbutter_distribution }\ndifficulty\nattempts\nbugs_encountered\nplants_murdered\nsuccess_rate\ncurrent_survivors\nwatering_schedule\nplant_types_attempted { succulents\nherbs\nsnake_plant\nbamboo }\ntime_spent\ncoffee_consumed\nsanity_level\nbug_severity\ndebugging_attempts { rubber_duck_debugging\nstack_overflow_diving\nprayer_to_tech_gods\nritual_coffee_sacrifice }\n_collection" },
+ "PostCategoryBlob": { all: "allPostCategoryBlobs", find: "PostCategoryBlob", idType: "ID", selection: "_filename\n_path\n_slug\nid\ntitle\nauthors { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\nrating\n_content { raw\nhtml\nexcerpt\ntimeToRead }\ncategory\nprecision_level\nbread_types_tested\nbutter_temperature\ntoast_settings { darkness\ncrunch_factor\nbutter_distribution }\ndifficulty\nattempts\nbugs_encountered\nplants_murdered\nsuccess_rate\ncurrent_survivors\nwatering_schedule\nplant_types_attempted { succulents\nherbs\nsnake_plant\nbamboo }\ntime_spent\ncoffee_consumed\nsanity_level\nbug_severity\ndebugging_attempts { rubber_duck_debugging\nstack_overflow_diving\nprayer_to_tech_gods\nritual_coffee_sacrifice }\n_collection" },
+ "Author": { all: "allAuthors", find: "Author", idType: "ID", selection: "image { srcset\nsrcsetwebp\nsrcsetavif\nplaceholder\naspectratio }\n_filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\nfriend { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\nfavorite_technologies\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\nfavorite_activities\ncertifications\n_collection }\ndate_joined\npronouns\nlocation\nfavorite_technologies\nskills { sitting\nbreathing\nliquid_consumption\nexistence\nsports\nplant_care\ndebugging\noptimistic_plant_purchasing\nkeyboard_walking\nmeeting_interruption }\nplant_murder_count\ncurrent_survivors\nplant_store_reputation\n_content { raw\nhtml\nexcerpt\ntimeToRead }\nfavorite_activities\ncertifications\n_collection" },
+ "YamlAuthor": { all: "allYamlAuthors", find: "YamlAuthor", idType: "ID", selection: "_filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\nfriend { _filename\n_path\n_slug\nid\nname\nentity\nbio\nenjoys\ndate_joined\npronouns\nlocation\ncertifications\neducation\nfavorite_technologies\nresearch_focus\ncurrent_projects\ncoffee_consumption_daily\nfavorite_brewing_methods\n_collection }\ndate_joined\npronouns\nlocation\ncertifications\neducation\nfavorite_technologies\nresearch_focus\nskills { sitting\nbreathing\nliquid_consumption\nexistence\nsports\ncoffee_brewing\ndata_analysis\nspreadsheet_mastery\ncat_pat }\ncurrent_projects\ncoffee_consumption_daily\nfavorite_brewing_methods\n_content { html\nexcerpt\ntimeToRead }\n_collection" },
+ "OverrideTest": { all: "allOverrideTests", find: "OverrideTest", idType: "ID", selection: "deeply { nested }\narray\narray2 { obj }\n_filename\n_path\n_slug\nid\ntitle\n_content { raw\nhtml\nexcerpt\ntimeToRead }\n_collection" }
+} as const;
+
+export function createFlatbreadReadApi(
+ execute: FlatbreadGraphQLExecutor,
+): FlatbreadReadApi {
+ return Object.fromEntries(
+ Object.entries(flatbreadReadApiQueries).map(([collection, queries]) => [
+ collection,
+ {
+ all: async (selection = queries.selection) => {
+ const readSelection = normalizeFlatbreadReadSelection(selection);
+ const operationName = flatbreadReadApiOperationName(collection, 'All');
+ const data = await execute>>(
+ `query ${operationName} { ${queries.all} { ${readSelection} } }`,
+ );
+ return data[queries.all] ?? [];
+ },
+ find: async (id: string | number, selection = queries.selection) => {
+ const readSelection = normalizeFlatbreadReadSelection(selection);
+ const operationName = flatbreadReadApiOperationName(collection, 'Find');
+ const data = await execute>(
+ `query ${operationName}($id: ${queries.idType}) { ${queries.find}(id: $id) { ${readSelection} } }`,
+ { id },
+ );
+ return data[queries.find] ?? null;
+ },
+ },
+ ]),
+ ) as FlatbreadReadApi;
+}
+
+function normalizeFlatbreadReadSelection(selection: string): string {
+ const normalized = selection.trim();
+ if (!normalized) {
+ throw new Error('Flatbread read API selection must not be empty.');
+ }
+ return normalized;
+}
+
+function flatbreadReadApiOperationName(
+ collection: string,
+ action: string,
+): string {
+ const safeCollection = collection.replace(/[^A-Za-z0-9_]/g, '_');
+ const suffix = safeCollection && !/^\d/.test(safeCollection)
+ ? safeCollection
+ : `_${safeCollection || 'Collection'}`;
+ return `FlatbreadRead_${suffix}_${action}`;
+}
+/* @flatbread/content-model-types:end */
diff --git a/examples/nextjs/lib/read.ts b/examples/nextjs/lib/read.ts
new file mode 100644
index 00000000..8f4ddb5e
--- /dev/null
+++ b/examples/nextjs/lib/read.ts
@@ -0,0 +1,55 @@
+import {
+ createFlatbreadReadApi,
+ type FlatbreadRecord,
+ type FlatbreadRelationCardinality,
+ type FlatbreadRelationTarget,
+} from '../generated/graphql';
+import { graphqlFetch } from './graphql';
+
+export type PostAuthorsRelation = FlatbreadRelationTarget<'Post', 'authors'>;
+export type PostAuthorsCardinality = FlatbreadRelationCardinality<
+ 'Post',
+ 'authors'
+>;
+
+export type PostsAuthorsTagsReadItem = Partial<
+ Pick, 'id' | 'tags' | 'title'>
+> & {
+ authors?: PostAuthorsRelation;
+};
+
+const postAuthorsCardinality: PostAuthorsCardinality = 'many';
+
+export const flatbreadRead = createFlatbreadReadApi(
+ async (source: string, variables?: Record) =>
+ graphqlFetch(source, variables)
+);
+
+/**
+ * Generated TypeScript read API example for the canonical onboarding model.
+ *
+ * The default GraphQL selection is generated inside `createFlatbreadReadApi`,
+ * so this call site does not hand-write a GraphQL document or selection string.
+ */
+export async function getPostsAuthorsAndTagsViaReadApi(): Promise<
+ ReadonlyArray
+> {
+ const posts = await flatbreadRead.Post.all();
+
+ if (postAuthorsCardinality !== 'many') {
+ throw new Error('Expected Post.authors to be generated as a many relation.');
+ }
+
+ return posts.map((post) => ({
+ authors: post?.authors,
+ id: post?.id,
+ tags: post?.tags,
+ title: post?.title,
+ }));
+}
+
+export async function getAuthorsViaReadApi(): Promise<
+ ReadonlyArray>>
+> {
+ return flatbreadRead.Author.all();
+}
diff --git a/examples/nextjs/queries/posts.graphql b/examples/nextjs/queries/posts.graphql
index 697113a2..39794a07 100644
--- a/examples/nextjs/queries/posts.graphql
+++ b/examples/nextjs/queries/posts.graphql
@@ -16,7 +16,7 @@ query GetAllPosts {
}
}
-query GetPostById($id: String!) {
+query GetPostById($id: ID!) {
Post(id: $id) {
id
title
diff --git a/packages/codegen/src/__tests__/e2e.test.ts b/packages/codegen/src/__tests__/e2e.test.ts
index 8269c27a..c6e0a82b 100644
--- a/packages/codegen/src/__tests__/e2e.test.ts
+++ b/packages/codegen/src/__tests__/e2e.test.ts
@@ -37,6 +37,7 @@ describe('codegen end-to-end', () => {
publishedAt: Date
metadata: JSON
author: Author
+ tags: [Tag!]!
}
type Author {
@@ -44,6 +45,11 @@ describe('codegen end-to-end', () => {
name: String!
email: String
}
+
+ type Tag {
+ id: String!
+ label: String!
+ }
`);
beforeEach(async () => {
@@ -72,12 +78,17 @@ describe('codegen end-to-end', () => {
path: join(tempDir, 'content/posts'),
refs: {
author: 'Author',
+ tags: 'Tag',
},
},
{
collection: 'Author',
path: join(tempDir, 'content/authors'),
},
+ {
+ collection: 'Tag',
+ path: join(tempDir, 'content/tags'),
+ },
{
collection: 'Draft',
path: join(tempDir, 'content/drafts'),
@@ -92,6 +103,7 @@ describe('codegen end-to-end', () => {
// Create content directory structure
await fs.mkdir(join(tempDir, 'content/posts'), { recursive: true });
await fs.mkdir(join(tempDir, 'content/authors'), { recursive: true });
+ await fs.mkdir(join(tempDir, 'content/tags'), { recursive: true });
await fs.mkdir(join(tempDir, 'content/drafts'), { recursive: true });
});
@@ -133,11 +145,12 @@ describe('codegen end-to-end', () => {
expect(content).toContain('Author');
expect(content).toContain('Query');
expect(content).toContain(
- 'export type FlatbreadCollectionName = "Post" | "Author" | "Draft"'
+ 'export type FlatbreadCollectionName = "Post" | "Author" | "Tag" | "Draft"'
);
expect(content).toContain('export type FlatbreadRecordByCollection');
expect(content).toContain('"Post": Post;');
expect(content).toContain('"Author": Author;');
+ expect(content).toContain('"Tag": Tag;');
expect(content).toContain('"Draft": Record;');
expect(content).toContain(
'export type FlatbreadRelationTargetByCollection'
@@ -145,8 +158,14 @@ describe('codegen end-to-end', () => {
expect(content).toContain(
'"author": { target: "Author"; cardinality: "one"; };'
);
+ expect(content).toContain(
+ '"tags": { target: "Tag"; cardinality: "many"; };'
+ );
expect(content).toContain('FlatbreadRelationTargetCollection');
expect(content).toContain('FlatbreadRelationCardinality');
+ expect(content).toContain('export function createFlatbreadReadApi');
+ expect(content).toContain('"Post": { all: "posts", find: "post"');
+ expect(content).toContain('author { id');
expect(content).toContain('@flatbread/content-model-types:start');
const usageFile = join(testOutputDir, 'usage.ts');
@@ -160,28 +179,45 @@ describe('codegen end-to-end', () => {
FlatbreadRelationCardinality,
FlatbreadRelationTargetCollection,
} from './graphql';
+ import { createFlatbreadReadApi } from './graphql';
const collection: FlatbreadCollectionName = 'Post';
type PostRecord = FlatbreadRecord<'Post'>;
type RelatedAuthor = FlatbreadRelationTarget<'Post', 'author'>;
+ type RelatedTags = FlatbreadRelationTarget<'Post', 'tags'>;
const relation: FlatbreadRelationTargetCollection<'Post', 'author'> = 'Author';
const cardinality: FlatbreadRelationCardinality<'Post', 'author'> = 'one';
+ const tagCardinality: FlatbreadRelationCardinality<'Post', 'tags'> = 'many';
+ const read = createFlatbreadReadApi(async () => ({}) as never);
+
+ async function readPosts() {
+ const posts = await read.Post.all();
+ const post: Partial | undefined = posts[0];
+ const author: RelatedAuthor | undefined = post?.author ?? undefined;
+ return { post, author };
+ }
export type Assertions = {
collection: typeof collection;
post: PostRecord;
relatedAuthor: RelatedAuthor;
+ relatedTags: RelatedTags;
relationCollection: typeof relation;
cardinality: typeof cardinality;
+ tagCardinality: typeof tagCardinality;
+ readPosts: Awaited>;
};
`
);
expectTypeScriptToCompile([generatedFile, usageFile]);
+ const beforeCacheMtime = (await fs.stat(generatedFile)).mtimeMs;
const secondResult = await generateTypes(testSchema, mockConfig, options);
expect(secondResult.success).toBe(true);
expect(secondResult.fromCache).toBe(true);
+ const afterCacheMtime = (await fs.stat(generatedFile)).mtimeMs;
+ expect(afterCacheMtime).toBe(beforeCacheMtime);
const cachedContent = await fs.readFile(generatedFile, 'utf-8');
expect(
diff --git a/packages/codegen/src/__tests__/hash.test.ts b/packages/codegen/src/__tests__/hash.test.ts
index caa870cf..91f375eb 100644
--- a/packages/codegen/src/__tests__/hash.test.ts
+++ b/packages/codegen/src/__tests__/hash.test.ts
@@ -4,6 +4,7 @@ import {
hashSchema,
hashDocuments,
hashCodegenInputs,
+ CODEGEN_OUTPUT_VERSION,
} from '../hash.js';
import type { LoadedFlatbreadConfig } from '@flatbread/core';
import type { CodegenOptions } from '../types.js';
@@ -45,6 +46,10 @@ describe('hash functions', () => {
expect(hash1).toHaveLength(64); // SHA256 hex length
});
+ it('should expose a cache-busting output version', () => {
+ expect(CODEGEN_OUTPUT_VERSION).toBeGreaterThan(0);
+ });
+
it('should generate different hashes for different configurations', () => {
const options2 = { ...mockOptions, outputDir: './dist' };
diff --git a/packages/codegen/src/generator.ts b/packages/codegen/src/generator.ts
index 30dcf6cf..5883e8d9 100644
--- a/packages/codegen/src/generator.ts
+++ b/packages/codegen/src/generator.ts
@@ -3,13 +3,16 @@ import {
isListType,
isNonNullType,
isObjectType,
+ isScalarType,
+ isEnumType,
printSchema,
type GraphQLSchema,
type GraphQLType,
} from 'graphql';
import { join, resolve } from 'path';
-import { ensureDir, readFile, writeFile } from 'fs-extra';
+import { ensureDir } from 'fs-extra';
import { existsSync } from 'fs';
+import { readFile, writeFile } from 'node:fs/promises';
import kleur from 'kleur';
// @ts-ignore - chokidar types will be available after npm install
import chokidar from 'chokidar';
@@ -74,7 +77,6 @@ export async function generateTypes(
// Check cache
const cache = await loadCache(outputDir);
if (isCacheValid(cache, configHash, schemaHash, mergedOptions)) {
- await upsertFlatbreadContentModelTypes(outputFilePath, config, schema);
console.log(kleur.green('✓ Using cached TypeScript types'));
return {
success: true,
@@ -245,6 +247,41 @@ function generateFlatbreadContentModelTypes(
}};`;
})
.join('\n');
+ const readableCollections = collections.filter((collection) =>
+ Boolean(
+ getReadQueries(schema, collection) &&
+ getDefaultSelection(schema, collection)
+ )
+ );
+ const readApiEntries = readableCollections
+ .map((collection) => {
+ const queries = getReadQueries(schema, collection);
+ return ` ${JSON.stringify(collection)}: {
+ all(selection?: string): Promise>>>;
+ find(id: string | number, selection?: string): Promise> | null>;
+ };`;
+ })
+ .join('\n');
+ const readApiRuntimeEntries = readableCollections
+ .map((collection) => {
+ const queries = getReadQueries(schema, collection);
+ const defaultSelection = getDefaultSelection(schema, collection);
+ if (!queries || !defaultSelection) {
+ return '';
+ }
+
+ return ` ${JSON.stringify(collection)}: { all: ${JSON.stringify(
+ queries.all
+ )}, find: ${JSON.stringify(queries.find)}, idType: ${JSON.stringify(
+ queries.idType
+ )}, selection: ${JSON.stringify(defaultSelection)} }`;
+ })
+ .filter(Boolean)
+ .join(',\n');
return `/**
* Flatbread content model types generated from flatbread.config.*.
@@ -276,8 +313,8 @@ export type FlatbreadRelationTarget<
>
> extends infer TargetRecord
? FlatbreadRelationCardinality extends 'many'
- ? ReadonlyArray
- : TargetRecord
+ ? ReadonlyArray | null
+ : TargetRecord | null
: never;
export type FlatbreadRelationTargetCollection<
@@ -296,7 +333,83 @@ export type FlatbreadRelationCardinality<
cardinality: infer Cardinality;
}
? Cardinality
- : never;`;
+ : never;
+
+export type FlatbreadReadableCollectionName = ${
+ readableCollections.length > 0
+ ? readableCollections
+ .map((collection) => JSON.stringify(collection))
+ .join(' | ')
+ : 'never'
+ };
+
+export type FlatbreadGraphQLExecutor = (
+ source: string,
+ variables?: Record,
+) => Promise;
+
+/**
+ * Experimental generated read API over the Flatbread content model.
+ *
+ * The API owns collection names, root query names, IDs, and result typing. The
+ * current prototype still accepts a GraphQL selection string for fields; invalid
+ * or drifting selections are runtime GraphQL errors, not type errors.
+ */
+export type FlatbreadReadApi = {
+${readApiEntries}
+};
+
+const flatbreadReadApiQueries = {
+${readApiRuntimeEntries}
+} as const;
+
+export function createFlatbreadReadApi(
+ execute: FlatbreadGraphQLExecutor,
+): FlatbreadReadApi {
+ return Object.fromEntries(
+ Object.entries(flatbreadReadApiQueries).map(([collection, queries]) => [
+ collection,
+ {
+ all: async (selection = queries.selection) => {
+ const readSelection = normalizeFlatbreadReadSelection(selection);
+ const operationName = flatbreadReadApiOperationName(collection, 'All');
+ const data = await execute>>(
+ \`query \${operationName} { \${queries.all} { \${readSelection} } }\`,
+ );
+ return data[queries.all] ?? [];
+ },
+ find: async (id: string | number, selection = queries.selection) => {
+ const readSelection = normalizeFlatbreadReadSelection(selection);
+ const operationName = flatbreadReadApiOperationName(collection, 'Find');
+ const data = await execute>(
+ \`query \${operationName}($id: \${queries.idType}) { \${queries.find}(id: $id) { \${readSelection} } }\`,
+ { id },
+ );
+ return data[queries.find] ?? null;
+ },
+ },
+ ]),
+ ) as FlatbreadReadApi;
+}
+
+function normalizeFlatbreadReadSelection(selection: string): string {
+ const normalized = selection.trim();
+ if (!normalized) {
+ throw new Error('Flatbread read API selection must not be empty.');
+ }
+ return normalized;
+}
+
+function flatbreadReadApiOperationName(
+ collection: string,
+ action: string,
+): string {
+ const safeCollection = collection.replace(/[^A-Za-z0-9_]/g, '_');
+ const suffix = safeCollection && !/^\\d/.test(safeCollection)
+ ? safeCollection
+ : \`_\${safeCollection || 'Collection'}\`;
+ return \`FlatbreadRead_\${suffix}_\${action}\`;
+}`;
}
function toTypeReference(collection: string, schema: GraphQLSchema): string {
@@ -338,6 +451,103 @@ function isListLikeType(type: GraphQLType): boolean {
return false;
}
+function getReadQueries(
+ schema: GraphQLSchema,
+ collection: string
+): { all: string; find: string; idType: string } | undefined {
+ const queryType = schema.getQueryType();
+ if (!queryType) {
+ return undefined;
+ }
+
+ const fields = queryType.getFields();
+ const find =
+ (fields[collection] ? collection : undefined) ??
+ Object.keys(fields).find(
+ (fieldName) =>
+ isNamedType(fields[fieldName].type, collection) &&
+ fields[fieldName].args.some((arg) => arg.name === 'id')
+ );
+ const all = Object.keys(fields).find((fieldName) =>
+ isListOfType(fields[fieldName].type, collection)
+ );
+
+ return find && all
+ ? {
+ all,
+ find,
+ idType:
+ fields[find].args.find((arg) => arg.name === 'id')?.type.toString() ??
+ 'ID!',
+ }
+ : undefined;
+}
+
+function getDefaultSelection(
+ schema: GraphQLSchema,
+ collection: string,
+ depth = 0
+): string | undefined {
+ const schemaType = schema.getType(collection);
+ if (!isObjectType(schemaType)) {
+ return undefined;
+ }
+
+ const selections = Object.values(schemaType.getFields())
+ .map((field) => {
+ const namedType = unwrapType(field.type);
+
+ if (isScalarType(namedType) || isEnumType(namedType)) {
+ return field.name;
+ }
+
+ if (depth === 0 && isObjectType(namedType)) {
+ const nestedSelection = getDefaultSelection(
+ schema,
+ namedType.name,
+ depth + 1
+ );
+
+ return nestedSelection
+ ? `${field.name} { ${nestedSelection} }`
+ : undefined;
+ }
+
+ return undefined;
+ })
+ .filter((selection): selection is string => Boolean(selection));
+
+ return selections.length > 0 ? selections.join('\n') : undefined;
+}
+
+function unwrapType(type: GraphQLType): GraphQLType {
+ if (isNonNullType(type) || isListType(type)) {
+ return unwrapType(type.ofType);
+ }
+
+ return type;
+}
+
+function isListOfType(type: GraphQLType, collection: string): boolean {
+ if (isNonNullType(type)) {
+ return isListOfType(type.ofType, collection);
+ }
+
+ if (isListType(type)) {
+ return isNamedType(type.ofType, collection);
+ }
+
+ return false;
+}
+
+function isNamedType(type: GraphQLType, collection: string): boolean {
+ if (isNonNullType(type)) {
+ return isNamedType(type.ofType, collection);
+ }
+
+ return isObjectType(type) && type.name === collection;
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
diff --git a/packages/codegen/src/hash.ts b/packages/codegen/src/hash.ts
index e73f648e..d92c4ef7 100644
--- a/packages/codegen/src/hash.ts
+++ b/packages/codegen/src/hash.ts
@@ -2,6 +2,8 @@ import { createHash } from 'crypto';
import type { LoadedFlatbreadConfig } from '@flatbread/core';
import type { CodegenOptions } from './types.js';
+export const CODEGEN_OUTPUT_VERSION = 2;
+
/**
* Generate a hash for the Flatbread configuration
* This is used to determine if types need to be regenerated
@@ -36,6 +38,7 @@ export function hashConfig(
fieldNameTransform: 'function',
loaded: config.loaded,
codegen: options,
+ outputVersion: CODEGEN_OUTPUT_VERSION,
},
null,
2
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 9befd895..a0402a70 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -148,6 +148,8 @@ query GetPostsAuthorsAndTags {
After codegen, your app imports types from **`./generated/graphql`**. The **result shape** of that operation is typed (for example **`GetPostsAuthorsAndTagsQuery`**)—relations resolve to **`Author`** objects while **`tags`** stay a **string array** on **`Post`**, matching the file metadata—the same row as the [illustrative JSON](#traceability-same-relation-model-files-config-query-interface) under **Traceability**.
+The generated file also exposes a prototype **TypeScript read API** derived from the configured content model. In the Next.js example, [`examples/nextjs/lib/read.ts`](https://github.com/FlatbreadLabs/flatbread/blob/main/examples/nextjs/lib/read.ts) wires **`createFlatbreadReadApi()`** to the existing GraphQL fetcher and reads **posts**, **authors**, and **tags** with a generated default selection—no hand-written GraphQL document at the call site.
+
Default filesystem + markdown wiring uses the bundled [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) and [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins (`flatbread` re-exports them).
### 4 · Minimal relational config (mental model)
From ec20712169e3206ff09aac20ed79e3af1f670a7d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 11:32:13 +0000
Subject: [PATCH 16/37] docs: explain GraphQL and TypeScript read interfaces
Summary of changes
- Clarify when to use GraphQL operations versus the prototype generated TypeScript read API.
- Tie both read interfaces back to the typed content model and canonical posts/authors/tags quickstart.
- Align codegen and example docs with the prototype status and the current pnpm exec flatbread command style.
Testing
- pnpm --filter @flatbread/codegen build
- pnpm -F @flatbread/codegen exec vitest run
- pnpm --filter nextjs build
- pnpm lint
- proof DAG: dag-flatbread-155-graphql-interface-docs completed 3/3 tasks
Closes #155
Change-Id: I68a3c0dfd8e224a4765659bcb86206532156afe7
---
examples/nextjs/README.md | 10 +++++-----
packages/codegen/README.md | 33 ++++++++++++++++++++++++++-------
packages/flatbread/README.md | 8 ++++++++
3 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 0da26c1b..583f79f7 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -1,6 +1,6 @@
# Flatbread Next.js Example with TypeScript Codegen
-This example is the repo’s **default first success path**: **relational Git-backed markdown** ( **`Post`** ↔ **`Author`** via `refs`; **`tags`** as string arrays on posts) compiled into a typed shape. **GraphQL plus codegen** are the **read interface baked into this demo**—not Flatbread’s only story; see [`docs/positioning.md`](../../docs/positioning.md).
+This example is the repo’s **default first success path**: **relational Git-backed markdown** ( **`Post`** ↔ **`Author`** via `refs`; **`tags`** as string arrays on posts) compiled into a typed shape. **GraphQL plus codegen** are one read path baked into this demo, and the generated TypeScript read API gives simple app reads a collection-shaped interface over the same typed model; see [Choosing a read interface](../../README.md#choosing-a-read-interface).
**Note:** `flatbread.config.js` here also declares **PostCategory**, **OverrideTest**, **YamlAuthor**, etc. for integration tests. Treat those as **secondary**; the onboarding narrative is **posts + authors + tags** on the **`Post`** row.
@@ -25,7 +25,7 @@ This example is the repo’s **default first success path**: **relational Git-ba
pnpm exec flatbread codegen --verbose
```
- Add or edit **`.graphql`** files under `queries/` (or globs in config), then rerun codegen so **`tags`**, **`authors`**, and other fields stay in sync. Codegen also emits a prototype **generated TypeScript read API** in `generated/graphql.ts`; see `lib/read.ts` for the posts/authors/tags example that calls `createFlatbreadReadApi()`.
+ Add or edit **`.graphql`** files under `queries/` (or globs in config), then rerun codegen so **`tags`**, **`authors`**, and other fields stay in sync. Codegen also emits the prototype **generated TypeScript read API** in `generated/graphql.ts`; see `lib/read.ts` for the posts/authors/tags example that calls `createFlatbreadReadApi()`, and see [Choosing a read interface](../../README.md#choosing-a-read-interface) for when to use each read path.
4. **Serve the GraphQL read interface alongside Next** (**there is no `flatbread dev`** — use **`flatbread start`**):
@@ -58,7 +58,7 @@ Markdown and YAML for this demo live under **`examples/content`**; this package
- **Posts:** `examples/content/markdown/posts/` (`tags` in frontmatter → `[String]` on **`Post`** in the schema.)
- **Authors:** `examples/content/markdown/authors/` (referenced by id from **`Post`** **`authors`**.)
-Canonical layout, **backing files for tags** (facet on each post), and **traceability** (same **relation model** from files through config to the GraphQL read interface and illustrative query JSON) are documented in the [Flatbread README quickstart](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/flatbread/README.md#traceability-same-relation-model-files-config-query-interface) and [glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
+Canonical layout, **backing files for tags** (facet on each post), **traceability** (same **relation model** from files through config to read interfaces and illustrative query JSON), and guidance on GraphQL versus the generated TypeScript read API are documented in the [Flatbread README quickstart](../../README.md#quickstart-posts-authors-and-tags), [Choosing a read interface](../../README.md#choosing-a-read-interface), and [glossary](../../docs/glossary.md).
## Project structure
@@ -119,7 +119,7 @@ const authorNames = posts[0]?.authors?.map((author) => author.name);
const tags = posts[0]?.tags;
```
-That path queries **posts**, **authors**, and **tags** through the generated TypeScript API while the GraphQL endpoint remains the underlying read interface. The lower-level generated methods still accept an optional GraphQL selection string for experimentation, but the canonical example uses the generated default selection so the call site does not hand-write a GraphQL document.
+That path queries **posts**, **authors**, and **tags** through the generated TypeScript API while GraphQL remains the underlying execution layer. The lower-level generated methods still accept an optional GraphQL selection string for experimentation, but the canonical example uses the generated default selection so the call site does not hand-write a GraphQL document. For custom selections, persisted operations, or direct GraphQL clients, use operation documents instead; the root [Choosing a read interface](../../README.md#choosing-a-read-interface) section is the canonical contract.
## Troubleshooting
@@ -133,7 +133,7 @@ Run **`pnpm exec flatbread codegen --clear-cache --verbose`**.
## Learn more
-- [Flatbread package README](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/flatbread#readme) — install and **`flatbread start`**
+- [Flatbread package README](../../README.md) — quickstart, install, **`flatbread start`**, and choosing GraphQL or the generated TypeScript read API
- [Glossary](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md) — collections, relations; GraphQL as one surface
- [Contributing / monorepo workflow](https://github.com/FlatbreadLabs/flatbread/blob/main/CONTRIBUTING.md)
- [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen)
diff --git a/packages/codegen/README.md b/packages/codegen/README.md
index f00f4f58..ebcaf5dc 100644
--- a/packages/codegen/README.md
+++ b/packages/codegen/README.md
@@ -1,8 +1,8 @@
# @flatbread/codegen 🏗️
-> Automatic TypeScript type generation for Flatbread GraphQL schemas
+> TypeScript generation for Flatbread read interfaces
-Flatbread treats repo files as **relational, Git-tracked content** for TypeScript apps; GraphQL is a common **read interface**. Codegen keeps operations typed against the schema Flatbread derives from your config.
+Flatbread treats repo files as **relational, Git-tracked content** for TypeScript apps; GraphQL is one **read interface** over the typed model Flatbread derives from flat files and config. Codegen keeps GraphQL operations typed and emits model-derived TypeScript helpers for collection-shaped reads.
## 💾 Install
@@ -14,7 +14,9 @@ pnpm add @flatbread/codegen
## 🎯 Overview
-This package automatically generates TypeScript types from your Flatbread GraphQL schema, providing type safety for your GraphQL operations. It uses [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen) under the hood with intelligent caching to avoid unnecessary regeneration.
+This package generates TypeScript from the Flatbread model so apps can read typed content through the interface that fits the call site. It uses [GraphQL Code Generator](https://www.the-guild.dev/graphql/codegen) under the hood for schema and operation types, and also emits a prototype generated TypeScript read API derived from your configured collections, fields, and refs.
+
+For the canonical posts/authors/tags walkthrough and the contract for choosing GraphQL versus the generated TypeScript read API, see the root [Quickstart](../../README.md#quickstart-posts-authors-and-tags) and [Choosing a read interface](../../README.md#choosing-a-read-interface).
## 👩🍳 Basic Usage
@@ -48,16 +50,18 @@ export default defineConfig({
```bash
# Generate types once
-npx flatbread codegen
+pnpm exec flatbread codegen
# Watch for changes and regenerate
-npx flatbread codegen --watch
+pnpm exec flatbread codegen --watch
# Force regeneration (clear cache)
-npx flatbread codegen --clear-cache
+pnpm exec flatbread codegen --clear-cache
```
-### 4. Use generated types in your application:
+### 4. Use the generated output in your application:
+
+Use **GraphQL operations** when you want explicit documents, custom selections, GraphQL clients, persisted operations, or direct access to the GraphQL endpoint:
```ts
import type { Post, GetPostsQuery } from './generated/graphql';
@@ -75,6 +79,21 @@ const posts: Post[] = await request(`
`);
```
+Use the prototype **generated TypeScript read API** when you want collection-shaped helpers for common reads from the configured content model. In the canonical Next.js example, `createFlatbreadReadApi()` reads posts, authors, and tags with a generated default selection while executing through the GraphQL layer:
+
+```ts
+import { createFlatbreadReadApi } from './generated/graphql';
+import { graphqlFetch } from './lib/graphql';
+
+const read = createFlatbreadReadApi(
+ async (source: string, variables?: Record) =>
+ graphqlFetch(source, variables)
+);
+const posts = await read.Post.all();
+const authorNames = posts[0]?.authors?.map((author) => author.name);
+const tags = posts[0]?.tags;
+```
+
## 👀 Watch Mode (watch-only)
The `--watch` flag enables automatic regeneration while the process stays running—**watch-only**; use one-shot `flatbread codegen` when you need a single generation (for example in CI).
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index a0402a70..3dead7ed 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -150,6 +150,14 @@ After codegen, your app imports types from **`./generated/graphql`**. The **resu
The generated file also exposes a prototype **TypeScript read API** derived from the configured content model. In the Next.js example, [`examples/nextjs/lib/read.ts`](https://github.com/FlatbreadLabs/flatbread/blob/main/examples/nextjs/lib/read.ts) wires **`createFlatbreadReadApi()`** to the existing GraphQL fetcher and reads **posts**, **authors**, and **tags** with a generated default selection—no hand-written GraphQL document at the call site.
+#### Choosing a read interface
+
+Flatbread starts with **Git-native relational content** for TypeScript apps: flat files define records, frontmatter fields, ids, and refs; `flatbread.config.js` tells Flatbread how those files become typed collections. **GraphQL is one interface over that typed model**, and the generated TypeScript read API is another app-facing surface generated from the same model.
+
+Use **GraphQL operations** when your app needs explicit query documents, custom selections, Apollo or other GraphQL clients, persisted operations, or direct access to the GraphQL endpoint. Add `.graphql` documents, include fields like **`tags`** and **`authors`**, and rerun codegen so operation types such as **`GetPostsAuthorsAndTagsQuery`** match the posts/authors/tags graph.
+
+Use the prototype **generated TypeScript read API** when your app wants collection-shaped helpers for common reads from the configured Flatbread model, especially simple app reads such as posts, authors, tags, and resolved relations without writing GraphQL at each call site. The generated helpers currently execute through the GraphQL layer and still offer an experimental selection-string escape hatch, so both paths expose the same typed content graph backed by the same flat files while GraphQL remains the stable low-level interface.
+
Default filesystem + markdown wiring uses the bundled [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem) and [`transformer-markdown`](https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown) plugins (`flatbread` re-exports them).
### 4 · Minimal relational config (mental model)
From 401a75c147df93c646b28e0c9ea48e1cc5500b6d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 12:47:36 +0000
Subject: [PATCH 17/37] refactor: narrow core content type surfaces
Summary of changes
- Narrow core public content surfaces from broad any types toward unknown, typed ContentEntry refs, typed Source fetch inputs, and typed Override resolve boundaries.
- Tighten resolver argument handling, sort typing, deep entry traversal, field overrides, transformKeys, and schema generation boundaries without changing valid content behavior.
- Add compile-time AVA type assertions for common record/relation/source/override paths and expand sift coverage for ID and array string filters.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*content types*'
- pnpm test:ava -- --match='*Sift*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-156-final-review completed 2/2 tasks
Closes #156
Change-Id: I66ef0a4af96378d3d92e90361bb0bb4c9bb13c8d
---
.../core/src/generators/generateCollection.ts | 20 +-
packages/core/src/generators/schema.ts | 56 ++++--
.../test/validationSnapshots.test.ts | 2 +-
packages/core/src/resolvers/arguments.ts | 33 +++-
packages/core/src/types.test.ts | 71 +++++++
packages/core/src/types.ts | 50 +++--
packages/core/src/utils/deepEntries.ts | 10 +-
packages/core/src/utils/fieldOverrides.ts | 10 +-
packages/core/src/utils/sift.ts | 179 ++++++++++++++++--
.../src/utils/tests/fieldOverrides.test.ts | 10 +-
packages/core/src/utils/tests/sift.test.ts | 14 ++
packages/core/src/utils/transformKeys.ts | 11 +-
12 files changed, 382 insertions(+), 84 deletions(-)
create mode 100644 packages/core/src/types.test.ts
diff --git a/packages/core/src/generators/generateCollection.ts b/packages/core/src/generators/generateCollection.ts
index d939fba9..2b98b093 100644
--- a/packages/core/src/generators/generateCollection.ts
+++ b/packages/core/src/generators/generateCollection.ts
@@ -1,5 +1,5 @@
import { defaultsDeep, merge } from 'lodash-es';
-import { LoadedFlatbreadConfig } from '../types';
+import { EntryNode, LoadedFlatbreadConfig } from '../types';
import { getFieldOverrides } from '../utils/fieldOverrides';
import transformKeys from '../utils/transformKeys';
@@ -7,7 +7,7 @@ interface GenerateCollectionArgs {
collection: string;
nodes: T[];
config: LoadedFlatbreadConfig;
- preknownSchemaFragments: Record;
+ preknownSchemaFragments: Record;
}
export function generateCollection({
@@ -15,8 +15,8 @@ export function generateCollection({
preknownSchemaFragments,
config,
nodes,
-}: GenerateCollectionArgs) {
- return transformKeys(
+}: GenerateCollectionArgs): EntryNode {
+ const transformed = transformKeys(
defaultsDeep(
{},
getFieldOverrides(collection, config),
@@ -24,4 +24,16 @@ export function generateCollection({
),
config.fieldNameTransform
);
+
+ if (!isEntryNode(transformed)) {
+ throw new Error(
+ `Generated collection "${collection}" did not produce an object schema.`
+ );
+ }
+
+ return transformed;
+}
+
+function isEntryNode(value: unknown): value is EntryNode {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
}
diff --git a/packages/core/src/generators/schema.ts b/packages/core/src/generators/schema.ts
index 29b872c0..01107308 100644
--- a/packages/core/src/generators/schema.ts
+++ b/packages/core/src/generators/schema.ts
@@ -12,6 +12,7 @@ import {
import resolveQueryArgs from '../resolvers/arguments';
import {
ConfigResult,
+ ContentNode,
EntryNode,
LoadedFlatbreadConfig,
Transformer,
@@ -30,6 +31,10 @@ interface RootQueries {
maybeReturnsList: string[];
}
+interface ResolverPayload {
+ args: Record;
+}
+
/**
* Generates a GraphQL schema from content nodes.
*
@@ -54,7 +59,8 @@ export async function generateSchema(
allContentNodes,
config
);
- validateCollectionIdentifiers(allContentNodesJSON);
+ const contentNodesByCollection =
+ validateCollectionIdentifiers(allContentNodesJSON);
validateCollectionReferences(allContentNodesJSON, config.content);
// Content validation must run before returning a cached schema because the
@@ -128,7 +134,7 @@ export async function generateSchema(
type: () => schema,
description: `Find one ${type} by its ID`,
args: generateArgsForSingleItemQuery(),
- resolve: (rp: Record) => {
+ resolve: (rp: ResolverPayload) => {
const idToFind = normalizeOptionalIdentifier(
rp.args.id,
`${type} query argument "id"`
@@ -138,8 +144,8 @@ export async function generateSchema(
return undefined;
}
- return cloneDeep(allContentNodesJSON[type]).find(
- (node: EntryNode) => getNodeIdentifier(node, type) === idToFind
+ return cloneDeep(contentNodesByCollection[type]).find(
+ (node: ContentNode) => getNodeIdentifier(node, type) === idToFind
);
},
});
@@ -149,13 +155,20 @@ export async function generateSchema(
type: () => [schema],
description: `Find many ${pluralType} by their IDs`,
args: generateArgsForManyItemQuery(pluralType),
- resolve: (rp: Record) => {
- const idsToFind = (rp.args.ids ?? []).map((id: unknown): string =>
+ resolve: (rp: ResolverPayload) => {
+ if (rp.args.ids !== undefined && !Array.isArray(rp.args.ids)) {
+ throw new Error(
+ `${type} query argument "ids" must be an array of identifiers.`
+ );
+ }
+ const idsArg = rp.args.ids ?? [];
+ const idsToFind = idsArg.map((id: unknown): string =>
normalizeIdentifier(id, `${type} query argument "ids"`)
);
const matches =
- cloneDeep(allContentNodesJSON[type])?.filter((node: EntryNode) =>
- idsToFind?.includes(getNodeIdentifier(node, type))
+ cloneDeep(contentNodesByCollection[type])?.filter(
+ (node: ContentNode) =>
+ idsToFind?.includes(getNodeIdentifier(node, type))
) ?? [];
return resolveQueryArgs(matches, rp.args, config, {
type: {
@@ -172,8 +185,8 @@ export async function generateSchema(
args: generateArgsForAllItemQuery(pluralType),
type: () => [schema],
description: `Return a set of ${pluralType}`,
- resolve: (rp: Record) => {
- const nodes = cloneDeep(allContentNodesJSON[type]);
+ resolve: (rp: ResolverPayload) => {
+ const nodes = cloneDeep(contentNodesByCollection[type]);
return resolveQueryArgs(nodes, rp.args, config, {
type: {
name: type,
@@ -259,7 +272,7 @@ export async function generateSchema(
*/
const fetchPreknownSchemaFragments = (
config: LoadedFlatbreadConfig
-): Record | {} => {
+): Record => {
return config.transformer.reduce(
(all, next) => merge(all, next.preknownSchemaFragments?.() || {}),
{}
@@ -268,11 +281,13 @@ const fetchPreknownSchemaFragments = (
function validateCollectionIdentifiers(
allContentNodesJSON: Record
-): void {
+): Record {
const errors: string[] = [];
+ const contentNodesByCollection: Record = {};
Object.entries(allContentNodesJSON).forEach(([collection, nodes]) => {
const seen = new Map();
+ contentNodesByCollection[collection] = [];
nodes.forEach((node) => {
try {
@@ -288,6 +303,7 @@ function validateCollectionIdentifiers(
} else {
seen.set(normalizedId, node);
}
+ contentNodesByCollection[collection].push(node as ContentNode);
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
@@ -302,14 +318,18 @@ function validateCollectionIdentifiers(
}:\n${errors.map((message) => `- ${message}`).join('\n')}`
);
}
+
+ return contentNodesByCollection;
}
function sourceContext(node: EntryNode): string {
return typeof node._path === 'string' ? ` (${node._path})` : '';
}
-function getTransformerExtensionMap(transformer: Transformer[]) {
- const transformerMap = new Map();
+function getTransformerExtensionMap(
+ transformer: Transformer[]
+): Map {
+ const transformerMap = new Map();
transformer.forEach((t) => {
t.extensions.forEach((extension) => {
transformerMap.set(extension, t);
@@ -325,9 +345,9 @@ function getTransformerExtensionMap(transformer: Transformer[]) {
* @param config Flatbread config object
*/
const optionallyTransformContentNodes = (
- allContentNodes: Record,
+ allContentNodes: Record,
config: LoadedFlatbreadConfig
-): Record => {
+): Record => {
if (config.transformer) {
const transformerMap = getTransformerExtensionMap(config.transformer);
// const globs = Object.entries(transformers);
@@ -341,7 +361,7 @@ const optionallyTransformContentNodes = (
* */
return map(allContentNodes, (node: VFile) => {
- const transformer = transformerMap.get(node.extname);
+ const transformer = transformerMap.get(node.extname ?? '');
if (!transformer?.parse) {
throw new Error(`no transformer found for ${node.path}`);
}
@@ -349,7 +369,7 @@ const optionallyTransformContentNodes = (
});
}
- return allContentNodes;
+ return allContentNodes as unknown as Record;
};
function withSourceContext(entry: EntryNode, sourceNode: VFile): EntryNode {
diff --git a/packages/core/src/providers/test/validationSnapshots.test.ts b/packages/core/src/providers/test/validationSnapshots.test.ts
index 3f8d22b9..81fe5dd4 100644
--- a/packages/core/src/providers/test/validationSnapshots.test.ts
+++ b/packages/core/src/providers/test/validationSnapshots.test.ts
@@ -59,7 +59,7 @@ async function validationMessage(
}
function normalizeMessage(message: string): string {
- return message.replaceAll(process.cwd(), '');
+ return message.split(process.cwd()).join('');
}
test('validation snapshot: missing references and invalid relation shapes', async (t) => {
diff --git a/packages/core/src/resolvers/arguments.ts b/packages/core/src/resolvers/arguments.ts
index d6a6aec3..19230cd8 100644
--- a/packages/core/src/resolvers/arguments.ts
+++ b/packages/core/src/resolvers/arguments.ts
@@ -14,15 +14,24 @@ interface ResolveQueryArgsOptions {
};
}
+interface QueryArgs {
+ filter?: Record;
+ limit?: number;
+ order?: 'ASC' | 'DESC';
+ skip?: number;
+ sortBy?: string;
+ [key: string]: unknown;
+}
+
/**
* Resolvers for query arguments.
*/
const resolveQueryArgs = async (
- nodes: any[],
- args: any,
+ nodes: ContentNode[],
+ args: QueryArgs,
config: FlatbreadConfig,
options: ResolveQueryArgsOptions
-) => {
+): Promise => {
const { skip, limit, order, sortBy, filter } = args;
if (filter) {
@@ -121,7 +130,7 @@ function buildFilterQueryFragment(filterSetManifest: TargetAndComparator) {
* @param filter the filter argument
*/
export const resolveFilter = async (
- filter: Record,
+ filter: Record,
config: FlatbreadConfig,
options: ResolveQueryArgsOptions
): Promise => {
@@ -167,15 +176,15 @@ export const resolveFilter = async (
* @param sortBy the field to sort by
* @param nodes the array of nodes to sort
*/
-export const resolveSortBy = (sortBy: string, nodes: any[]): void => {
- nodes.sort((nodeA: { [x: string]: any }, nodeB: { [x: string]: any }) => {
+export const resolveSortBy = (sortBy: string, nodes: ContentNode[]): void => {
+ nodes.sort((nodeA, nodeB) => {
const fieldA = nodeA[sortBy];
const fieldB = nodeB[sortBy];
- if (fieldA < fieldB) {
+ if (isSortable(fieldA) && isSortable(fieldB) && fieldA < fieldB) {
return -1;
}
- if (fieldA > fieldB) {
+ if (isSortable(fieldA) && isSortable(fieldB) && fieldA > fieldB) {
return 1;
}
// fields must be equal
@@ -183,4 +192,12 @@ export const resolveSortBy = (sortBy: string, nodes: any[]): void => {
});
};
+function isSortable(value: unknown): value is string | number | boolean {
+ return (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ );
+}
+
export default resolveQueryArgs;
diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts
new file mode 100644
index 00000000..00aef624
--- /dev/null
+++ b/packages/core/src/types.test.ts
@@ -0,0 +1,71 @@
+import test from 'ava';
+import type {
+ Content,
+ ContentEntry,
+ ContentNode,
+ EntryNode,
+ IdentifierField,
+ Override,
+ Source,
+} from './types';
+import type { VFile } from 'vfile';
+
+type Equal = (() => T extends A ? 1 : 2) extends () => T extends B
+ ? 1
+ : 2
+ ? true
+ : false;
+
+type Assert = T;
+
+type ContentEntryRefsAreTyped = Assert<
+ Equal, Record>
+>;
+
+type ContentNodeKeepsUnknownFields = Assert<
+ Equal
+>;
+type SourceFetchUsesContent = Assert<
+ Equal[0], Content>
+>;
+type SourceFetchByTypeReturnsVFiles = Assert<
+ Equal>, Promise>
+>;
+type ContentNodeIdUsesIdentifierField = Assert<
+ Equal
+>;
+type OverrideResolveReturnsUnknown = Assert<
+ Equal, unknown>
+>;
+
+test('core public content types expose narrowed relation surfaces', (t) => {
+ const entry: ContentEntry = {
+ collection: 'Post',
+ refs: {
+ author: 'Author',
+ },
+ };
+
+ const node: ContentNode = {
+ id: 'post-one',
+ customField: 'value',
+ };
+
+ const untypedEntry: EntryNode = {
+ customField: 'value',
+ };
+
+ // @ts-expect-error EntryNode values are unknown until narrowed.
+ const unsafeString: string = untypedEntry.customField;
+
+ t.is(entry.refs?.author, 'Author');
+ t.is(node.customField, 'value');
+ t.is(unsafeString, 'value');
+});
+
+void (0 as unknown as ContentEntryRefsAreTyped);
+void (0 as unknown as ContentNodeKeepsUnknownFields);
+void (0 as unknown as SourceFetchUsesContent);
+void (0 as unknown as SourceFetchByTypeReturnsVFiles);
+void (0 as unknown as ContentNodeIdUsesIdentifierField);
+void (0 as unknown as OverrideResolveReturnsUnknown);
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 2b28b649..a8811041 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -9,8 +9,8 @@ export type CodegenOptions = {
outputDir?: string;
outputFile?: string;
plugins?: string[];
- codegenConfig?: Record;
- pluginConfig?: Record>;
+ codegenConfig?: Record;
+ pluginConfig?: Record>;
watch?: boolean;
cache?: boolean;
documents?: string[];
@@ -29,9 +29,9 @@ export type BaseContentNode = {
id: IdentifierField;
};
-export type ContentNode = BaseContentNode & {
- [key: string]: unknown;
-};
+export type ContentNode<
+ TFields extends Record = Record
+> = BaseContentNode & TFields;
/**
* Flatbread's configuration interface.
@@ -76,7 +76,7 @@ export interface Transformer {
* @param input Node to transform
*/
parse?: (input: VFile) => EntryNode;
- preknownSchemaFragments?: () => Record;
+ preknownSchemaFragments?: () => Record;
inspect: (input: EntryNode) => string;
extensions: string[];
}
@@ -86,7 +86,17 @@ export type TransformerPlugin = (config?: Config) => Transformer;
/**
* A representation of the content of a flat file.
*/
-export type EntryNode = Record;
+export type EntryNode = Record;
+
+export interface ContentEntry<
+ TRefs extends Record = Record
+> {
+ collection: string;
+ path?: string;
+ refs?: TRefs;
+ overrides?: Override[];
+ [key: string]: unknown;
+}
/**
* The result of an invoked `Source` plugin which contains methods on how to retrieve content nodes in
@@ -94,13 +104,13 @@ export type EntryNode = Record;
*/
export interface Source {
initialize?: (flatbreadConfig: LoadedFlatbreadConfig) => void;
- fetchByType?: (path: string) => Promise;
- fetch: (
- allContentTypes: Record[]
- ) => Promise>;
+ fetchByType?: (path: string) => Promise;
+ fetch: (allContentTypes: Content) => Promise>;
}
-export type SourcePlugin = (sourceConfig?: Record) => Source;
+export type SourcePlugin<
+ TConfig extends Record = Record
+> = (sourceConfig?: TConfig) => Source;
/**
* An override can be used to declare a custom resolve for a field in content
@@ -112,9 +122,13 @@ export interface Override {
args?: GraphQLFieldConfigArgumentMap;
description?: Maybe;
resolve: (
- data: any,
- extended: { source: any; context: any; args: any }
- ) => any;
+ data: unknown,
+ extended: {
+ source: unknown;
+ context: unknown;
+ args: Record;
+ }
+ ) => unknown;
}
/**
@@ -122,8 +136,4 @@ export interface Override {
*
* This is paired with a `Source` (and, *optionally*, a `Transformer`) plugin.
*/
-export type Content = {
- collection: string;
- overrides?: Override[];
- [key: string]: any;
-}[];
+export type Content = ContentEntry[];
diff --git a/packages/core/src/utils/deepEntries.ts b/packages/core/src/utils/deepEntries.ts
index 02198ed1..fada8d07 100644
--- a/packages/core/src/utils/deepEntries.ts
+++ b/packages/core/src/utils/deepEntries.ts
@@ -9,18 +9,18 @@ import typeOf from './typeOf';
* @returns a tuple with a path array and value which that path leads to
*/
const deepEntries = (
- obj: Record,
+ obj: unknown,
path: string[] = [],
- stack: any[] = []
-): [string[], any] => {
+ stack: [string[], unknown][] = []
+): [string[], unknown][] => {
if (typeOf(obj) === 'object') {
- for (let [key, value] of Object.entries(obj)) {
+ for (let [key, value] of Object.entries(obj as Record)) {
stack = deepEntries(value, [...path, key], stack);
}
} else {
stack.push([path, obj]);
}
- return stack as [string[], any];
+ return stack;
};
export default deepEntries;
diff --git a/packages/core/src/utils/fieldOverrides.ts b/packages/core/src/utils/fieldOverrides.ts
index f3106bb9..a389c826 100644
--- a/packages/core/src/utils/fieldOverrides.ts
+++ b/packages/core/src/utils/fieldOverrides.ts
@@ -1,6 +1,8 @@
import { FlatbreadConfig, Override } from '../types';
import { get, set } from 'lodash-es';
+type FieldOverrideTree = Record;
+
/**
* Get an object containing functions nested in an object structure
* aligning to the listed overrides in the config
@@ -16,7 +18,7 @@ export function getFieldOverrides(collection: string, config: FlatbreadConfig) {
if (!content?.overrides) return {};
const overrides = content.overrides;
- return overrides.reduce((fields: any, override: Override) => {
+ return overrides.reduce((fields: FieldOverrideTree, override: Override) => {
const { field, type, ...rest } = override;
let path = field.replace(/\[\]/g, '[0]');
const endsWithArray = path.endsWith('[0]');
@@ -27,7 +29,11 @@ export function getFieldOverrides(collection: string, config: FlatbreadConfig) {
set(fields, path, () => ({
type: endsWithArray ? `[${override.type}]` : override.type,
...rest,
- resolve: (source: any, context: any, args: any) => {
+ resolve: (
+ source: unknown,
+ context: unknown,
+ args: Record
+ ) => {
return override.resolve(get(source, getPath), {
source,
context,
diff --git a/packages/core/src/utils/sift.ts b/packages/core/src/utils/sift.ts
index e9eca950..40f22165 100644
--- a/packages/core/src/utils/sift.ts
+++ b/packages/core/src/utils/sift.ts
@@ -84,6 +84,83 @@ function normalizeIdComparator(comparator: Comparator): Comparator {
};
}
+function assertArrayComparator(
+ value: unknown,
+ operation: ComparatorOperation
+): readonly unknown[] {
+ if (!Array.isArray(value)) {
+ throw new Error(`Comparator "${operation}" requires an array value.`);
+ }
+
+ return value;
+}
+
+function assertRegExpComparator(
+ value: unknown,
+ operation: ComparatorOperation
+): RegExp {
+ if (!(value instanceof RegExp)) {
+ throw new Error(`Comparator "${operation}" requires a RegExp value.`);
+ }
+
+ return value;
+}
+
+function assertStringComparator(
+ value: unknown,
+ operation: ComparatorOperation
+): string {
+ if (typeof value !== 'string') {
+ throw new Error(`Comparator "${operation}" requires a string value.`);
+ }
+
+ return value;
+}
+
+function includesValue(
+ source: unknown,
+ value: unknown,
+ operation: ComparatorOperation
+): boolean {
+ if (Array.isArray(source)) {
+ return source.includes(value);
+ }
+
+ if (typeof source === 'string') {
+ return source.includes(assertStringComparator(value, operation));
+ }
+
+ throw new Error(
+ `Comparator "${operation}" requires an array or string field.`
+ );
+}
+
+function matchesRegExp(source: unknown, value: RegExp): boolean {
+ if (typeof source === 'string') {
+ return value.test(source);
+ }
+
+ if (Array.isArray(source)) {
+ return source.some((item) => typeof item === 'string' && value.test(item));
+ }
+
+ throw new Error('Comparator "regex" requires an array or string field.');
+}
+
+function matchesWildcard(source: unknown, value: string): boolean {
+ if (typeof source === 'string') {
+ return isWildcardMatch(source, value);
+ }
+
+ if (Array.isArray(source)) {
+ return source.some(
+ (item) => typeof item === 'string' && isWildcardMatch(item, value)
+ );
+ }
+
+ throw new Error('Comparator "wildcard" requires an array or string field.');
+}
+
function shouldNormalizeIdComparator(
path: string[],
comparator: Comparator
@@ -107,33 +184,48 @@ function generateComparisonFunction(
const { operation, value } = comparator;
switch (operation) {
case 'eq':
- return (a: any) => a === value;
+ return (a: unknown) => a === value;
case 'ne':
- return (a: any) => a !== value;
+ return (a: unknown) => a !== value;
case 'lt':
- return (a: any) => a < value;
+ return (a: unknown) =>
+ (a as string | number | boolean) < (value as string | number | boolean);
case 'lte':
- return (a: any) => a <= value;
+ return (a: unknown) =>
+ (a as string | number | boolean) <=
+ (value as string | number | boolean);
case 'gt':
- return (a: any) => a > value;
+ return (a: unknown) =>
+ (a as string | number | boolean) > (value as string | number | boolean);
case 'gte':
- return (a: any) => a >= value;
+ return (a: unknown) =>
+ (a as string | number | boolean) >=
+ (value as string | number | boolean);
case 'in':
- return (a: any) => value.includes(a);
+ return (a: unknown) =>
+ (value as { includes: (item: unknown) => boolean }).includes(a);
case 'nin':
- return (a: any) => !value.includes(a);
+ return (a: unknown) =>
+ !(value as { includes: (item: unknown) => boolean }).includes(a);
case 'includes':
- return (a: any) => a.includes(value);
+ return (a: unknown) =>
+ (a as { includes: (item: unknown) => boolean }).includes(value);
case 'excludes':
- return (a: any) => !a.includes(value);
+ return (a: unknown) =>
+ !(a as { includes: (item: unknown) => boolean }).includes(value);
case 'regex':
- return (a: any) => value.test(a);
+ return (a: unknown) =>
+ (value as { test: (item: unknown) => boolean }).test(a);
case 'wildcard':
- return (a: any) => isWildcardMatch(a, value);
+ return (a: unknown) =>
+ isWildcardMatch(
+ a as string | readonly string[],
+ value as string | readonly string[]
+ );
case 'exists':
- return (a: any) => (value ? a != undefined : a == undefined);
+ return (a: unknown) => (value ? a != undefined : a == undefined);
case 'strictlyExists':
- return (a: any) => (value ? a !== undefined : a === undefined);
+ return (a: unknown) => (value ? a !== undefined : a === undefined);
default:
throw new Error(`Unsupported operation: ${operation}`);
}
@@ -150,6 +242,9 @@ export const generateFilterSetManifest = (
): TargetAndComparator => {
return deepEntries(filterArgs).map(([path, value]) => {
const operation = path.pop();
+ if (!isComparatorOperation(operation)) {
+ throw new Error(`Unsupported operation: ${String(operation)}`);
+ }
return {
path,
@@ -161,12 +256,36 @@ export const generateFilterSetManifest = (
});
};
+function isComparatorOperation(
+ operation: unknown
+): operation is ComparatorOperation {
+ return (
+ typeof operation === 'string' &&
+ [
+ 'eq',
+ 'ne',
+ 'lt',
+ 'lte',
+ 'gt',
+ 'gte',
+ 'in',
+ 'nin',
+ 'includes',
+ 'excludes',
+ 'regex',
+ 'wildcard',
+ 'exists',
+ 'strictlyExists',
+ ].includes(operation)
+ );
+}
+
/**
* The filter argument object using a MongoDB-like syntax, inspired by how Gatsby does it.
*
* @see [Gatsby's query filters](https://github.com/gatsbyjs/gatsby/blob/d56c1f12ad2b3e7fa245f4ff9a74e81d0585b79e/docs/docs/query-filters.md) for API details.
*/
-type SiftArgs = Record;
+type SiftArgs = Record;
/**
* An array of target and comparator objects
@@ -178,7 +297,7 @@ export type TargetAndComparator = { path: string[]; comparator: Comparator }[];
*/
type Comparator = {
operation: ComparatorOperation;
- value: any;
+ value: unknown;
};
/**
@@ -221,4 +340,30 @@ type ComparatorOperation =
/**
* Compare a value to a constant target value.
*/
-type CompareValueAgainstConstant = (a: any) => boolean;
+type CompareValueAgainstConstant = (a: unknown) => boolean;
+
+type Comparable = string | number | boolean;
+
+function compareComparable(
+ left: unknown,
+ right: unknown,
+ compare: (left: Comparable, right: Comparable) => boolean
+): boolean {
+ if (!isComparable(left) || !isComparable(right)) {
+ throw new Error('Ordered comparators require comparable primitive values.');
+ }
+
+ if (typeof left !== typeof right) {
+ throw new Error('Ordered comparators require matching value types.');
+ }
+
+ return compare(left, right);
+}
+
+function isComparable(value: unknown): value is Comparable {
+ return (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ );
+}
diff --git a/packages/core/src/utils/tests/fieldOverrides.test.ts b/packages/core/src/utils/tests/fieldOverrides.test.ts
index fca200ee..884c6cea 100644
--- a/packages/core/src/utils/tests/fieldOverrides.test.ts
+++ b/packages/core/src/utils/tests/fieldOverrides.test.ts
@@ -17,7 +17,7 @@ test('basic override', (t) => {
},
},
])
- );
+ ) as any;
t.snapshot(result);
t.is(result.basic().resolve({ basic: 'test' }), true);
});
@@ -34,7 +34,7 @@ test('nested basic override', (t) => {
},
},
])
- );
+ ) as any;
t.snapshot(result);
t.is(result.nested.basic().resolve({ basic: 'test' }), true);
});
@@ -51,7 +51,7 @@ test('basic array override', (t) => {
},
},
])
- );
+ ) as any;
t.snapshot(result);
t.deepEqual(result.basic().resolve({ basic: [''] }), [true]);
});
@@ -68,7 +68,7 @@ test('basic object array override', (t) => {
},
},
])
- );
+ ) as any;
t.snapshot(result);
t.deepEqual(result.basic[0].obj().resolve({ obj: 'test' }), true);
});
@@ -85,7 +85,7 @@ test('override with custom type', (t) => {
},
},
])
- );
+ ) as any;
t.snapshot(result);
t.deepEqual(result.basic().resolve({ basic: 'test' }), true);
});
diff --git a/packages/core/src/utils/tests/sift.test.ts b/packages/core/src/utils/tests/sift.test.ts
index ef8883ce..ced147f7 100644
--- a/packages/core/src/utils/tests/sift.test.ts
+++ b/packages/core/src/utils/tests/sift.test.ts
@@ -30,6 +30,20 @@ test('Sift rejects invalid ID filter comparators', (t) => {
});
});
+test('Sift supports wildcard and regex filters against string arrays', (t) => {
+ const taggedNodes = [
+ { id: 1, tags: ['alpha', 'beta'] },
+ { id: 2, tags: ['gamma'] },
+ ];
+
+ t.deepEqual(taggedNodes.filter(sift({ tags: { wildcard: '*ta' } })), [
+ taggedNodes[0],
+ ]);
+ t.deepEqual(taggedNodes.filter(sift({ tags: { regex: /^gam/ } })), [
+ taggedNodes[1],
+ ]);
+});
+
test('Sift for nodes with nested object "child" having age greater than or equal to 18', (t) => {
t.deepEqual(nodes.filter(sift({ child: { age: { gte: 18 } } })), [
nodes[0],
diff --git a/packages/core/src/utils/transformKeys.ts b/packages/core/src/utils/transformKeys.ts
index d33c4f4e..618cbc53 100644
--- a/packages/core/src/utils/transformKeys.ts
+++ b/packages/core/src/utils/transformKeys.ts
@@ -17,14 +17,17 @@ class IllegalFieldNameError extends Error {
}
}
-function isObject(obj: any): obj is Object {
- return obj != null && obj.constructor.name === 'Object';
+function isObject(obj: unknown): obj is Record {
+ return (
+ obj != null &&
+ (obj as { constructor?: { name?: string } }).constructor?.name === 'Object'
+ );
}
export default function transformKeys(
- obj: any,
+ obj: unknown,
transform: (key: string) => string
-): any {
+): unknown {
if (Array.isArray(obj))
return obj.map((item) => transformKeys(item, transform));
if (!isObject(obj)) return obj;
From cf1edceeb35eb5afe72afb188acef75d63d812da Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 13:07:34 +0000
Subject: [PATCH 18/37] docs: define local dev loop boundaries
Summary of changes
- Add local dev loop documentation covering loader reload, schema rebuild, codegen refresh, and framework restart boundaries.
- Document the canonical Next.js two-terminal happy path and current manual restart requirements.
- Add a current reload matrix, failure semantics, draft unified watch design, known limitations, and implementation follow-up seams.
- Link the dev-loop contract from the root/package README and Next.js example docs.
Testing
- pnpm lint
- proof DAG: dag-flatbread-157-watch-loop-review-r2 completed 2/2 tasks
Closes #157
Change-Id: I3de62d3fdc3bbcb248ebbd4a449ad443fc15aa49
---
docs/local-dev-loop.md | 135 +++++++++++++++++++++++++++++++++++
examples/nextjs/README.md | 9 ++-
packages/flatbread/README.md | 2 +
3 files changed, 144 insertions(+), 2 deletions(-)
create mode 100644 docs/local-dev-loop.md
diff --git a/docs/local-dev-loop.md b/docs/local-dev-loop.md
new file mode 100644
index 00000000..894f6ca3
--- /dev/null
+++ b/docs/local-dev-loop.md
@@ -0,0 +1,135 @@
+# Local dev loop and watch boundaries
+
+Flatbread's local loop has four moving parts:
+
+1. **Loader reload** — source plugins read flat files from the configured
+ content paths.
+2. **Schema rebuild** — `@flatbread/core` turns loaded records and refs into a
+ GraphQL schema after ID/ref validation.
+3. **Codegen refresh** — `flatbread codegen --watch` regenerates TypeScript
+ artifacts when config, content, or GraphQL documents change.
+4. **Framework restart / refresh** — `flatbread start -- `
+ runs the GraphQL server beside your app command.
+
+Today these pieces are partly automated. Codegen has a watch loop; the
+GraphQL server started by `flatbread start` still builds its schema at process
+startup. That means some edits update generated TypeScript automatically, while
+runtime query behavior still needs a restart until the server grows a live
+schema swap.
+
+## Canonical Next.js happy path
+
+From the repo root:
+
+```bash
+pnpm install
+pnpm build
+cd examples/nextjs
+pnpm exec flatbread codegen --verbose
+```
+
+For development, use two terminals. This path avoids the example package's
+HTTPS convenience script and keeps the Flatbread GraphQL endpoint on plain HTTP
+port `5057`.
+
+```bash
+# terminal 1 — regenerate TypeScript artifacts
+pnpm exec flatbread codegen --watch --verbose
+```
+
+```bash
+# terminal 2 — serve GraphQL + Next.js without HTTPS for headless/dev agents
+pnpm exec flatbread start -- next dev --turbopack
+```
+
+Expected behavior:
+
+- Editing a `.graphql` document or a content/config file triggers the codegen
+ watcher and updates `generated/graphql.ts`.
+- The generated content-model types and prototype read API are refreshed by
+ the same codegen command.
+- The running GraphQL endpoint at `http://localhost:5057/graphql` continues to
+ use the schema it built at startup.
+- Restart `pnpm exec flatbread start -- next dev --turbopack` after changing
+ content, refs, collection config, transformers, or validation-sensitive data
+ if you need the live endpoint/app render to reflect the new graph.
+
+## Current reload matrix
+
+| Change | Codegen watcher behavior | Running GraphQL server | Framework app | Action required today |
+| --------------------------------------- | ------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------- |
+| Markdown/YAML field value | Regenerates if watched path matches | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start` to update live query results |
+| New/removed content file | Regenerates if watched path matches | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start` to update live query results |
+| `.graphql` document | Regenerates operation types | No restart unless query text used by app changed | Framework dev server normally recompiles importing files | No Flatbread restart unless app code needs it |
+| `flatbread.config.*` content/ref change | Attempts config reload from the current cwd | Keeps previous startup schema/data | Keeps rendering whatever the endpoint returns | Restart `flatbread start`; run watcher from config dir |
+| Transformer/source package code | Does not rebuild package code | Keeps previous imported package code | May keep previous imported package code | Rebuild/watch package separately, rerun codegen, restart |
+| `generated/graphql.ts` | Output of codegen | No direct effect | Framework dev server recompiles imports | No Flatbread restart |
+
+## Failure semantics today
+
+- If content becomes invalid while `flatbread codegen --watch` is running, the
+ watcher logs the validation/codegen error and keeps watching. Existing
+ generated files are left as-is until a later successful regeneration.
+- In one-shot mode (`flatbread codegen` without `--watch`), validation or
+ codegen errors exit non-zero and do not prove the live server changed.
+- If the running GraphQL server was started before the invalid edit, it keeps
+ serving the schema/data it already loaded. Restarting it surfaces the
+ validation error at startup.
+- There is no partial hot-swap mode yet: generated TypeScript can refresh while
+ the live GraphQL server remains on the old content graph.
+
+## Draft unified watch design (not implemented)
+
+The unified loop should eventually make this one command:
+
+```bash
+flatbread start --watch -- next dev --turbopack
+```
+
+Design contract:
+
+1. Watch the same content/config/document paths that `flatbread codegen --watch`
+ already derives from `LoadedFlatbreadConfig`.
+2. On content changes, reload records, rerun ID/ref/cardinality validation,
+ rebuild the schema, refresh generated TypeScript, and swap the GraphQL
+ server schema only if the new graph validates. If validation fails, keep the
+ previous schema active and log the failure.
+3. On config changes, reload config, rebuild watch globs, rebuild schema,
+ refresh generated TypeScript, and restart only the Flatbread GraphQL server
+ boundary if a safe hot swap is not possible. A safe hot swap means replacing
+ schema/data without losing the child framework process, open port, or
+ in-flight request handling state.
+4. On GraphQL document changes, refresh generated TypeScript only.
+5. Keep framework restarts explicit. Flatbread should not assume every
+ framework can be restarted safely; it should document whether the app command
+ is left running, restarted, or expected to recompile through its own dev
+ server.
+
+## Known limitations
+
+- `flatbread start` does **not** currently hot-swap schema or content.
+- `flatbread codegen --watch` is a long-running process; do not use it in CI or
+ one-shot scripts.
+- The Next.js example `pnpm dev` includes `--https` for local convenience, but
+ the Flatbread GraphQL endpoint remains documented as HTTP on `5057`. In
+ headless environments prefer `pnpm exec flatbread start -- next dev --turbopack`.
+- Generated TypeScript can update before the running GraphQL endpoint does.
+ Treat codegen success as a type artifact refresh, not proof that the live
+ server has reloaded.
+- `flatbread.config.*` watching is relative to the process cwd today. Run
+ `flatbread codegen --watch` from the directory that contains the config.
+- Port `5057` collisions are not resolved automatically; stop the old
+ Flatbread process before starting another server.
+
+## Follow-up implementation seams
+
+- Add a `flatbread start --watch` flag that composes schema reload and codegen
+ refresh.
+- Factor codegen's watch-pattern derivation into a shared helper used by both
+ `@flatbread/codegen` and the CLI.
+- Add an integration test that edits a fixture post and proves the GraphQL
+ endpoint returns the updated value without a manual restart once hot swap is
+ implemented.
+- Add a current-behavior integration test that edits a fixture post and proves
+ the running server does **not** change until restart, so future hot-swap work
+ has a concrete test to flip.
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 583f79f7..645b6147 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -29,8 +29,8 @@ This example is the repo’s **default first success path**: **relational Git-ba
4. **Serve the GraphQL read interface alongside Next** (**there is no `flatbread dev`** — use **`flatbread start`**):
- - **Default (local HTTPS for Next):** `pnpm dev` — runs `flatbread start --https -- next dev --turbopack`.
- - **Headless / no HTTPS** (e.g. agents, CI): `pnpm exec flatbread start -- next dev --turbopack`.
+ - **Recommended / headless-safe:** `pnpm exec flatbread start -- next dev --turbopack`.
+ - **Package shortcut:** `pnpm dev` — currently passes `--https` for local convenience, but the Flatbread GraphQL endpoint remains documented as HTTP on `5057`.
5. Open **[http://localhost:3000](http://localhost:3000)** for the app. Flatbread defaults to **`http://localhost:5057/graphql`** (not the Next port).
@@ -51,6 +51,11 @@ For iterative work, run the watcher in a second terminal:
pnpm run codegen
```
+For the full loader/schema/codegen/framework boundary contract, see
+[`docs/local-dev-loop.md`](../../docs/local-dev-loop.md). In short: codegen can
+watch content/config/document files, but the running GraphQL server still needs
+a restart for schema or content changes today.
+
## Content path
Markdown and YAML for this demo live under **`examples/content`**; this package uses a **`content` → `../content`** symlink so config paths stay `content/markdown/...`.
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 3dead7ed..705e72f8 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -32,6 +32,8 @@ Turn flat files in Git into typed, relational content for your TypeScript app. *
**Glossary:** Quick definitions for **collection**, **relation**, **ID**, **cardinality**, **validation**, **query interface**, and how the **generated GraphQL schema / operation types** map to those terms (GraphQL as one read path, not the whole product)—see [docs/glossary.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/glossary.md).
+**Local dev loop:** Codegen watch, schema rebuild, content reload, and framework restart boundaries are documented in [docs/local-dev-loop.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/local-dev-loop.md).
+
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
From 55f041ff30cff143ac55da9c3fd876a37c714870 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 13:46:52 +0000
Subject: [PATCH 19/37] docs: add edit-file query update demo
Summary of changes
- Add a reproducible edit-file-see-query-update demo for the canonical posts/authors/tags flow.
- Add a focused Next.js watcher script that rebuilds the Flatbread graph per file event and prints updated Markdown post plus YAML author relation query results without a manual restart.
- Add edit/restore helper scripts and document the clean-checkout commands and current limitations.
Testing
- pnpm build
- pnpm --filter nextjs exec flatbread codegen --verbose
- pnpm lint
- pnpm --filter nextjs run demo:watch-query with demo:edit/demo:restore; verified original, live-edit, and restored Markdown/YAML query output in /tmp/flatbread-watch-demo.log
- proof DAG: dag-flatbread-158-final-review-r3 completed 2/2 tasks
Closes #158
Change-Id: Id8909a3bf87bf5cb5f8de176e69123727ee39201
---
docs/edit-file-see-query-update-demo.md | 87 +++++++++++++++++
examples/nextjs/README.md | 25 +++--
examples/nextjs/package.json | 3 +
examples/nextjs/scripts/demo-edit.mjs | 25 +++++
examples/nextjs/scripts/demo-restore.mjs | 25 +++++
.../nextjs/scripts/watch-content-query.mjs | 97 +++++++++++++++++++
pnpm-lock.yaml | 12 ++-
7 files changed, 265 insertions(+), 9 deletions(-)
create mode 100644 docs/edit-file-see-query-update-demo.md
create mode 100644 examples/nextjs/scripts/demo-edit.mjs
create mode 100644 examples/nextjs/scripts/demo-restore.mjs
create mode 100644 examples/nextjs/scripts/watch-content-query.mjs
diff --git a/docs/edit-file-see-query-update-demo.md b/docs/edit-file-see-query-update-demo.md
new file mode 100644
index 00000000..3679d604
--- /dev/null
+++ b/docs/edit-file-see-query-update-demo.md
@@ -0,0 +1,87 @@
+# Edit file → see query update demo
+
+This is a single-process demo harness, not the long-running `flatbread start`
+server. Production live-editing still requires the unified watch design
+described in [local-dev-loop.md](./local-dev-loop.md).
+
+This demo is the current reproducible path for issue #158. It proves the core
+edit/query loop for the canonical **posts → authors + tags** model without
+requiring a manual process restart in this focused demo path.
+
+The full `flatbread start` GraphQL server still builds its schema at startup
+(see [local dev loop boundaries](./local-dev-loop.md)). This demo therefore
+uses a tiny watcher script that rebuilds the Flatbread schema per file event
+and executes the same posts/authors/tags query shape the generated TypeScript
+read API uses in the Next.js example.
+
+## Run it from a clean checkout
+
+```bash
+pnpm install
+pnpm build
+cd examples/nextjs
+pnpm exec flatbread codegen --verbose
+pnpm run demo:watch-query
+```
+
+The script watches both Markdown and YAML relation data:
+
+```text
+examples/content/markdown/posts/example-post.md
+examples/content/yaml/authors/dr-caffeine.yml
+```
+
+It prints JSON like:
+
+```json
+{
+ "data": {
+ "allPosts": [
+ {
+ "id": "sdfsdf-23423-sdfsd-23444-dfghf",
+ "title": "The Art of Measuring Cats in Fruit Units",
+ "tags": ["cats", "measurements", "fruit-science", "important-research"],
+ "authors": [
+ { "id": "40s3", "name": "Eva" },
+ { "id": "2a3e", "name": "Tony" }
+ ]
+ }
+ ],
+ "allYamlAuthors": [
+ {
+ "id": "caffeine-researcher",
+ "name": "Dr. Maya Espresso",
+ "friend": { "id": "2a3e", "name": "Tony" }
+ }
+ ]
+ }
+}
+```
+
+## Try the edit
+
+In another terminal, edit the watched Markdown post title plus a YAML author
+name and `friend` relation:
+
+```bash
+pnpm --filter nextjs run demo:edit
+```
+
+The watcher prints fresh query results with the edited Markdown title, edited
+YAML author name, and changed YAML `friend` relation. Restore the files after
+the demo:
+
+```bash
+pnpm --filter nextjs run demo:restore
+```
+
+## What this does and does not prove
+
+- ✅ Editing relation-backed Markdown and YAML updates the query result without
+ a manual restart in this demo path.
+- ✅ The query includes post fields, tag facets, resolved Markdown author
+ records, and resolved YAML author records.
+- ✅ The demo is reproducible from the monorepo root with pnpm commands.
+- ⚠️ The long-running `flatbread start` server still needs restart for content
+ and schema changes today.
+- ⚠️ The watcher script is a demo harness, not the final `flatbread start --watch` implementation described in [local-dev-loop.md](./local-dev-loop.md).
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 645b6147..c04a7156 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -36,12 +36,14 @@ This example is the repo’s **default first success path**: **relational Git-ba
### Scripts in this package
-| Script | Purpose |
-|------------------|-------------------------------------------------------------------------------------------|
-| `pnpm dev` | **`flatbread start`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
-| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
-| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread unless you arrange it. |
-| `pnpm run codegen` | **Watch-only:** `flatbread codegen --watch` — regenerate types when config, content, or documents change. |
+| Script | Purpose |
+| ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| `pnpm dev` | **`flatbread start`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
+| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
+| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread unless you arrange it. |
+| `pnpm run codegen` | **Watch-only:** `flatbread codegen --watch` — regenerate types when config/content/documents change. |
+| `pnpm run demo:watch-query` | Watch `example-post.md` and print updated posts/authors/tags query results. |
+| `pnpm run demo:edit` / `demo:restore` | Edit and restore the watched post title for the demo loop. |
### Watch-only codegen
@@ -56,6 +58,17 @@ For the full loader/schema/codegen/framework boundary contract, see
watch content/config/document files, but the running GraphQL server still needs
a restart for schema or content changes today.
+To see a Markdown/YAML edit/query loop without manually restarting a server, run
+the focused demo watcher:
+
+```bash
+pnpm run demo:watch-query
+```
+
+Then run `pnpm run demo:edit`; the terminal prints updated Markdown
+posts/authors/tags and YAML author query results. Full walkthrough:
+[`docs/edit-file-see-query-update-demo.md`](../../docs/edit-file-see-query-update-demo.md).
+
## Content path
Markdown and YAML for this demo live under **`examples/content`**; this package uses a **`content` → `../content`** symlink so config paths stay `content/markdown/...`.
diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json
index bfb9d9ea..1fad3030 100644
--- a/examples/nextjs/package.json
+++ b/examples/nextjs/package.json
@@ -5,6 +5,9 @@
"scripts": {
"dev": "flatbread start --https -- next dev --turbopack",
"codegen": "flatbread codegen --watch",
+ "demo:edit": "node scripts/demo-edit.mjs",
+ "demo:restore": "node scripts/demo-restore.mjs",
+ "demo:watch-query": "node scripts/watch-content-query.mjs",
"build": "flatbread start -- next build",
"start": "next start",
"lint": "next lint"
diff --git a/examples/nextjs/scripts/demo-edit.mjs b/examples/nextjs/scripts/demo-edit.mjs
new file mode 100644
index 00000000..805127ca
--- /dev/null
+++ b/examples/nextjs/scripts/demo-edit.mjs
@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+
+import { readFile, writeFile } from 'node:fs/promises';
+
+const postFile = new URL('../content/markdown/posts/example-post.md', import.meta.url);
+const yamlAuthorFile = new URL('../content/yaml/authors/dr-caffeine.yml', import.meta.url);
+const original = "title: 'The Art of Measuring Cats in Fruit Units'";
+const edited = "title: 'The Art of Measuring Cats in Fruit Units — live edit'";
+const originalYamlName = 'name: "Dr. Maya Espresso"';
+const editedYamlName = 'name: "Dr. Maya Espresso — live edit"';
+const originalYamlFriend = 'friend: "2a3e"';
+const editedYamlFriend = 'friend: "40s3"';
+
+const postText = await readFile(postFile, 'utf-8');
+await writeFile(postFile, postText.replace(original, edited));
+
+const yamlText = await readFile(yamlAuthorFile, 'utf-8');
+await writeFile(
+ yamlAuthorFile,
+ yamlText
+ .replace(originalYamlName, editedYamlName)
+ .replace(originalYamlFriend, editedYamlFriend)
+);
+
+console.log('Edited Markdown post title plus YAML author name and friend ref for the Flatbread watch demo.');
diff --git a/examples/nextjs/scripts/demo-restore.mjs b/examples/nextjs/scripts/demo-restore.mjs
new file mode 100644
index 00000000..a6ed231c
--- /dev/null
+++ b/examples/nextjs/scripts/demo-restore.mjs
@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+
+import { readFile, writeFile } from 'node:fs/promises';
+
+const postFile = new URL('../content/markdown/posts/example-post.md', import.meta.url);
+const yamlAuthorFile = new URL('../content/yaml/authors/dr-caffeine.yml', import.meta.url);
+const original = "title: 'The Art of Measuring Cats in Fruit Units'";
+const edited = "title: 'The Art of Measuring Cats in Fruit Units — live edit'";
+const originalYamlName = 'name: "Dr. Maya Espresso"';
+const editedYamlName = 'name: "Dr. Maya Espresso — live edit"';
+const originalYamlFriend = 'friend: "2a3e"';
+const editedYamlFriend = 'friend: "40s3"';
+
+const postText = await readFile(postFile, 'utf-8');
+await writeFile(postFile, postText.replace(edited, original));
+
+const yamlText = await readFile(yamlAuthorFile, 'utf-8');
+await writeFile(
+ yamlAuthorFile,
+ yamlText
+ .replace(editedYamlName, originalYamlName)
+ .replace(editedYamlFriend, originalYamlFriend)
+);
+
+console.log('Restored Markdown post title plus YAML author name and friend ref after the Flatbread watch demo.');
diff --git a/examples/nextjs/scripts/watch-content-query.mjs b/examples/nextjs/scripts/watch-content-query.mjs
new file mode 100644
index 00000000..cc4d8f29
--- /dev/null
+++ b/examples/nextjs/scripts/watch-content-query.mjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+
+import { watch } from 'node:fs';
+import { resolve } from 'node:path';
+import { loadConfig } from '@flatbread/config';
+import { FlatbreadProvider } from '@flatbread/core';
+
+const cwd = process.cwd();
+const watchedFiles = [
+ resolve(cwd, 'content/markdown/posts/example-post.md'),
+ resolve(cwd, 'content/yaml/authors/dr-caffeine.yml'),
+];
+
+const query = `
+ query DemoPost {
+ allPosts(filter: { id: { eq: "sdfsdf-23423-sdfsd-23444-dfghf" } }) {
+ id
+ title
+ tags
+ authors {
+ id
+ name
+ }
+ }
+ allYamlAuthors(filter: { id: { eq: "caffeine-researcher" } }) {
+ id
+ name
+ friend {
+ id
+ name
+ }
+ }
+ }
+`;
+
+let renderInFlight = false;
+let renderAgain = false;
+
+async function loadFreshProvider() {
+ const result = await loadConfig({ cwd });
+ if (!result.config) {
+ throw new Error('Flatbread config did not load.');
+ }
+
+ // generateSchema caches by config, but this demo intentionally rebuilds the
+ // content graph on every file event to show edit -> query update without a
+ // server restart.
+ const config = {
+ ...result.config,
+ content: result.config.content.map((entry) => ({
+ ...entry,
+ __demoCacheBust: Date.now(),
+ })),
+ };
+
+ return new FlatbreadProvider(config);
+}
+
+async function render() {
+ if (renderInFlight) {
+ renderAgain = true;
+ return;
+ }
+
+ renderInFlight = true;
+ try {
+ const provider = await loadFreshProvider();
+ const response = await provider.query({ source: query });
+ const payload = {
+ renderedAt: new Date().toISOString(),
+ data: response.data,
+ errors: response.errors?.map((error) => error.message),
+ };
+
+ console.log(JSON.stringify(payload, null, 2));
+ } finally {
+ renderInFlight = false;
+ if (renderAgain) {
+ renderAgain = false;
+ await render();
+ }
+ }
+}
+
+console.log(`Watching ${watchedFiles.join(', ')}`);
+await render();
+
+for (const watchedFile of watchedFiles) {
+ watch(watchedFile, { persistent: true }, () => {
+ setTimeout(() => {
+ render().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ });
+ }, 100);
+ });
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52872d85..b3cfc9d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -98,7 +98,7 @@ importers:
dependencies:
'@graphql-typed-document-node/core':
specifier: ^3.2.0
- version: 3.2.0(graphql@16.11.0)
+ version: 3.2.0(graphql@16.14.0)
next:
specifier: 15.4.4
version: 15.4.4(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.89.2)
@@ -5609,6 +5609,10 @@ packages:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+ graphql@16.14.0:
+ resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==}
+ engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+
graphql@16.5.0:
resolution: {integrity: sha512-qbHgh8Ix+j/qY+a/ZcJnFQ+j8ezakqPiHwPiZhV/3PgGlgf96QMBB5/f2rkiC9sgLoy/xvT6TSiaf2nTHJh5iA==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
@@ -10642,9 +10646,9 @@ snapshots:
graphql: 16.5.0
tslib: 2.8.1
- '@graphql-typed-document-node/core@3.2.0(graphql@16.11.0)':
+ '@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)':
dependencies:
- graphql: 16.11.0
+ graphql: 16.14.0
'@graphql-typed-document-node/core@3.2.0(graphql@16.5.0)':
dependencies:
@@ -14587,6 +14591,8 @@ snapshots:
graphql@16.11.0: {}
+ graphql@16.14.0: {}
+
graphql@16.5.0: {}
gray-matter@4.0.3:
From e914a1dd16fef34f3da0647e3af5af4bc0eca9f8 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 14:04:14 +0000
Subject: [PATCH 20/37] feat: add JSON collection export API
Summary of changes
- Add exportCollectionsAsJson to @flatbread/core for selected collection JSON snapshots.
- Normalize exported record IDs and configured relation fields with Flatbread ID semantics, sort collections/records/object keys deterministically, and emit source paths relative to pathRoot.
- Reuse schema validation before exporting so duplicate IDs and missing refs fail before output is returned.
- Add representative AVA coverage for selected exports, unknown collections, and validation failures, plus JSON export documentation.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*export*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-159-json-export-review-r2 completed 2/2 tasks
Closes #159
Change-Id: Ie21000ebbed3d35e6fe2ae66d260d3c92465f553
---
docs/json-export.md | 42 +++++
packages/core/src/export/json.ts | 177 ++++++++++++++++++++
packages/core/src/export/tests/json.test.ts | 92 ++++++++++
packages/core/src/index.ts | 2 +
packages/flatbread/README.md | 2 +
5 files changed, 315 insertions(+)
create mode 100644 docs/json-export.md
create mode 100644 packages/core/src/export/json.ts
create mode 100644 packages/core/src/export/tests/json.test.ts
diff --git a/docs/json-export.md b/docs/json-export.md
new file mode 100644
index 00000000..ea9b0022
--- /dev/null
+++ b/docs/json-export.md
@@ -0,0 +1,42 @@
+# JSON snapshot export
+
+`@flatbread/core` exposes `exportCollectionsAsJson(configResult, options)` for
+stable collection snapshots. It is currently an API surface rather than a CLI
+command.
+
+## Stability contract
+
+- Selected collection names are sorted by Unicode codepoint order.
+- Records are sorted by normalized record ID.
+- Object keys are sorted recursively by Unicode codepoint order.
+- Record IDs and configured relation fields use Flatbread's normalized ID
+ semantics.
+- `_path` is emitted relative to `options.pathRoot` (default:
+ `process.cwd()`); `_filename`, `_slug`, and transformer-provided fields are
+ preserved.
+- ID and reference validation runs before export output is returned, so broken
+ refs and duplicate IDs fail the same way they fail schema generation.
+
+## Example
+
+```ts
+import { exportCollectionsAsJson } from '@flatbread/core';
+import { loadConfig } from '@flatbread/config';
+
+const configResult = await loadConfig({ cwd: process.cwd() });
+const snapshot = await exportCollectionsAsJson(configResult, {
+ collections: ['Post', 'Author'],
+ pathRoot: process.cwd(),
+});
+
+console.log(JSON.stringify(snapshot, null, 2));
+```
+
+## Current scope
+
+- JSON export is read-only; it does not mutate source files.
+- Relation values are exported as normalized IDs, not expanded nested records.
+- Source metadata is included today so snapshots are actionable during review.
+ A future option may strip `_path` / `_filename` for content-only diffs.
+- CSV export is tracked separately and should define its own relation-flattening
+ policy.
diff --git a/packages/core/src/export/json.ts b/packages/core/src/export/json.ts
new file mode 100644
index 00000000..2117e892
--- /dev/null
+++ b/packages/core/src/export/json.ts
@@ -0,0 +1,177 @@
+import { VFile } from 'vfile';
+import { relative } from 'node:path';
+import { generateSchema } from '../generators/schema';
+import {
+ ConfigResult,
+ ContentEntry,
+ EntryNode,
+ LoadedFlatbreadConfig,
+ Transformer,
+} from '../types';
+import { normalizeIdentifier } from '../utils/ids';
+
+export interface JsonExportOptions {
+ collections?: readonly string[];
+ pathRoot?: string;
+}
+
+export type JsonExportResult = Record;
+
+/**
+ * Export selected Flatbread collections as deterministic JSON-ready objects.
+ *
+ * Stability contract:
+ * - collection names, record IDs, and object keys are sorted by Unicode
+ * codepoint order;
+ * - record IDs and configured relation fields are normalized with the same ID
+ * semantics used by query resolvers;
+ * - `_path` is emitted relative to `pathRoot` (default: `process.cwd()`);
+ * - invalid IDs/refs fail through the same validation gate as schema
+ * generation before export output is returned.
+ */
+export async function exportCollectionsAsJson(
+ configResult: ConfigResult,
+ options: JsonExportOptions = {}
+): Promise {
+ const { config } = configResult;
+ if (!config) {
+ throw new Error('Config is not defined');
+ }
+
+ // Reuse schema generation as the validation gate so exports cannot silently
+ // serialize broken ids or refs.
+ await generateSchema(configResult);
+
+ config.source.initialize?.(config);
+ const rawNodes = await config.source.fetch(config.content);
+ const transformerByExtension = getTransformerExtensionMap(config.transformer);
+ const selected = new Set(
+ options.collections ?? config.content.map((entry) => entry.collection)
+ );
+ const contentByCollection = new Map(
+ config.content.map((entry) => [entry.collection, entry])
+ );
+ const unknownCollections = [...selected].filter(
+ (collection) => !contentByCollection.has(collection)
+ );
+ if (unknownCollections.length > 0) {
+ throw new Error(
+ `Cannot export unknown collection${
+ unknownCollections.length === 1 ? '' : 's'
+ }: ${unknownCollections.join(', ')}`
+ );
+ }
+ const result: JsonExportResult = {};
+
+ for (const [collection, nodes] of Object.entries(rawNodes)) {
+ if (!selected.has(collection)) continue;
+
+ const contentEntry = contentByCollection.get(collection);
+ const records = nodes
+ .map((node) => parseNode(node, transformerByExtension))
+ .map((entry) =>
+ normalizeRecord(entry, contentEntry, options.pathRoot ?? process.cwd())
+ )
+ .sort((a, b) =>
+ compareCodepoint(
+ normalizeIdentifier(a.id, `${collection} export id`),
+ normalizeIdentifier(b.id, `${collection} export id`)
+ )
+ );
+
+ result[collection] = records.map(sortObjectKeys) as EntryNode[];
+ }
+
+ return Object.fromEntries(
+ Object.entries(result).sort(([collectionA], [collectionB]) =>
+ compareCodepoint(collectionA, collectionB)
+ )
+ );
+}
+
+function getTransformerExtensionMap(
+ transformer: Transformer[]
+): Map {
+ const transformerMap = new Map();
+ transformer.forEach((nextTransformer) => {
+ nextTransformer.extensions.forEach((extension) => {
+ transformerMap.set(extension, nextTransformer);
+ });
+ });
+ return transformerMap;
+}
+
+function parseNode(
+ node: VFile,
+ transformerByExtension: Map
+): EntryNode {
+ const transformer = transformerByExtension.get(node.extname ?? '');
+ if (!transformer?.parse) {
+ throw new Error(`no transformer found for ${node.path}`);
+ }
+
+ return {
+ ...transformer.parse(node),
+ _path: node.path,
+ _filename: node.basename,
+ };
+}
+
+function normalizeRecord(
+ entry: EntryNode,
+ contentEntry: ContentEntry | undefined,
+ pathRoot: string
+): EntryNode {
+ const normalized: EntryNode = {
+ ...entry,
+ id: normalizeIdentifier(entry.id, 'export record id'),
+ };
+
+ if (typeof normalized._path === 'string') {
+ normalized._path = relative(pathRoot, normalized._path);
+ }
+
+ for (const refField of Object.keys(contentEntry?.refs ?? {})) {
+ const value = normalized[refField];
+ if (Array.isArray(value)) {
+ normalized[refField] = value.map((item) =>
+ normalizeIdentifier(item, `export relation "${refField}"`)
+ );
+ } else if (value !== null && value !== undefined) {
+ normalized[refField] = normalizeIdentifier(
+ value,
+ `export relation "${refField}"`
+ );
+ }
+ }
+
+ return normalized;
+}
+
+function sortObjectKeys(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(sortObjectKeys);
+ }
+
+ if (isPlainObject(value)) {
+ return Object.fromEntries(
+ Object.entries(value)
+ .sort(([keyA], [keyB]) => compareCodepoint(keyA, keyB))
+ .map(([key, nestedValue]) => [key, sortObjectKeys(nestedValue)])
+ );
+ }
+
+ return value;
+}
+
+function compareCodepoint(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ Object.getPrototypeOf(value) === Object.prototype
+ );
+}
diff --git a/packages/core/src/export/tests/json.test.ts b/packages/core/src/export/tests/json.test.ts
new file mode 100644
index 00000000..d73edb53
--- /dev/null
+++ b/packages/core/src/export/tests/json.test.ts
@@ -0,0 +1,92 @@
+import test from 'ava';
+import filesystem from '@flatbread/source-filesystem';
+import markdownTransformer from '@flatbread/transformer-markdown';
+import { exportCollectionsAsJson } from '../json';
+import { initializeConfig } from '../../utils/initializeConfig';
+
+test('exports selected collections as stable normalized JSON', async (t) => {
+ const config = initializeConfig({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors',
+ collection: 'Author',
+ },
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/tags',
+ collection: 'Tag',
+ },
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts',
+ collection: 'Post',
+ refs: {
+ author: 'Author',
+ authors: 'Author',
+ tags: 'Tag',
+ },
+ },
+ ],
+ });
+
+ const result = await exportCollectionsAsJson(
+ { config },
+ { collections: ['Post'] }
+ );
+
+ t.deepEqual(Object.keys(result), ['Post']);
+ t.deepEqual(result.Post, [
+ {
+ _content: {
+ raw: '\nAll references resolve, so missing-ref validation must remain silent and the\nschema should build cleanly.\n',
+ },
+ _filename: 'known-post.md',
+ _path:
+ 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md',
+ _slug: 'known-post',
+ author: 'known-author',
+ authors: ['known-author'],
+ id: 'known-post',
+ tags: ['known-tag'],
+ title: 'Post With Resolved Refs',
+ },
+ ]);
+});
+
+test('rejects unknown selected collections', async (t) => {
+ const config = initializeConfig({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors',
+ collection: 'Author',
+ },
+ ],
+ });
+
+ const error = await t.throwsAsync(() =>
+ exportCollectionsAsJson({ config }, { collections: ['Missing'] })
+ );
+
+ t.is(error?.message, 'Cannot export unknown collection: Missing');
+});
+
+test('reuses validation diagnostics before exporting JSON', async (t) => {
+ const config = initializeConfig({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: 'packages/core/src/providers/test/fixtures/id-semantics-duplicates/authors',
+ collection: 'Author',
+ },
+ ],
+ });
+
+ const error = await t.throwsAsync(() =>
+ exportCollectionsAsJson({ config }, { collections: ['Author'] })
+ );
+
+ t.regex(error?.message ?? '', /Author record id "123" is duplicated/);
+});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6467866f..78385974 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,4 +1,6 @@
export { generateSchema } from './generators/schema';
+export { exportCollectionsAsJson } from './export/json';
+export type { JsonExportOptions, JsonExportResult } from './export/json';
export { initializeConfig } from './utils/initializeConfig';
export {
getNodeIdentifier,
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 705e72f8..a6608c88 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -34,6 +34,8 @@ Turn flat files in Git into typed, relational content for your TypeScript app. *
**Local dev loop:** Codegen watch, schema rebuild, content reload, and framework restart boundaries are documented in [docs/local-dev-loop.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/local-dev-loop.md).
+**Portability:** Stable JSON snapshot export is available as a core API and documented in [docs/json-export.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/json-export.md).
+
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
From dc36282f4c23e1650e686209699bf689ff4ea6a7 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 14:13:29 +0000
Subject: [PATCH 21/37] feat: add CSV collection export API
Summary of changes
- Add exportCollectionsAsCsv to @flatbread/core for flat selected collection CSV views.
- Include scalar fields and scalar arrays, keep relation fields as normalized reference IDs joined by a configurable separator, and omit nested object fields by design.
- Support comma, semicolon, and tab delimiters with CSV escaping.
- Document CSV behavior alongside JSON snapshots and add AVA coverage for headers, rows, escaping, relation IDs, and custom delimiters/separators.
Testing
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*CSV*'
- pnpm test:ava
- pnpm lint
- proof DAG: dag-flatbread-160-csv-export-review completed 2/2 tasks
Closes #160
Change-Id: I29485ae56de6c80be6ea31db1abe5a3790c7caf3
---
docs/json-export.md | 41 ++++++-
packages/core/src/export/csv.ts | 120 +++++++++++++++++++
packages/core/src/export/tests/csv.test.ts | 127 +++++++++++++++++++++
packages/core/src/index.ts | 2 +
4 files changed, 285 insertions(+), 5 deletions(-)
create mode 100644 packages/core/src/export/csv.ts
create mode 100644 packages/core/src/export/tests/csv.test.ts
diff --git a/docs/json-export.md b/docs/json-export.md
index ea9b0022..d2f6c03c 100644
--- a/docs/json-export.md
+++ b/docs/json-export.md
@@ -1,8 +1,9 @@
-# JSON snapshot export
+# Snapshot export
`@flatbread/core` exposes `exportCollectionsAsJson(configResult, options)` for
-stable collection snapshots. It is currently an API surface rather than a CLI
-command.
+stable collection snapshots and `exportCollectionsAsCsv(configResult, options)`
+for flat collection views. They are currently API surfaces rather than CLI
+commands.
## Stability contract
@@ -38,5 +39,35 @@ console.log(JSON.stringify(snapshot, null, 2));
- Relation values are exported as normalized IDs, not expanded nested records.
- Source metadata is included today so snapshots are actionable during review.
A future option may strip `_path` / `_filename` for content-only diffs.
-- CSV export is tracked separately and should define its own relation-flattening
- policy.
+
+## CSV flat views
+
+CSV export is intentionally a flat view over the same validated JSON snapshot:
+
+- scalar fields become columns;
+- scalar arrays and relation-id arrays are joined with `;` by default;
+- relation fields remain normalized reference IDs rather than expanded records;
+- nested objects such as `_content` are omitted because they do not yet have a
+ stable flat representation.
+- the delimiter defaults to `,`; `;` and tab are also supported;
+- joined array/relation values default to `;`, configurable with
+ `relationSeparator`.
+
+```ts
+import { exportCollectionsAsCsv } from '@flatbread/core';
+
+const csv = await exportCollectionsAsCsv(configResult, {
+ collections: ['Post'],
+ delimiter: ',',
+ relationSeparator: ';',
+});
+
+console.log(csv.Post);
+```
+
+Example output:
+
+```csv
+id,_filename,_path,_slug,author,authors,tags,title
+known-post,known-post.md,content/posts/known-post.md,known-post,known-author,known-author,known-tag,Post With Resolved Refs
+```
diff --git a/packages/core/src/export/csv.ts b/packages/core/src/export/csv.ts
new file mode 100644
index 00000000..c47370ff
--- /dev/null
+++ b/packages/core/src/export/csv.ts
@@ -0,0 +1,120 @@
+import { ConfigResult, EntryNode, LoadedFlatbreadConfig } from '../types';
+import { exportCollectionsAsJson, JsonExportOptions } from './json';
+
+export interface CsvExportOptions extends JsonExportOptions {
+ delimiter?: ',' | ';' | '\t';
+ relationSeparator?: string;
+}
+
+export type CsvExportResult = Record;
+
+/**
+ * Export selected Flatbread collections as deterministic flat CSV views.
+ *
+ * CSV is intentionally a flat view:
+ * - scalar fields are emitted as columns;
+ * - scalar arrays and relation-id arrays are joined with `relationSeparator`;
+ * - object-valued fields are omitted because they do not have a stable flat
+ * representation yet.
+ */
+export async function exportCollectionsAsCsv(
+ configResult: ConfigResult,
+ options: CsvExportOptions = {}
+): Promise {
+ const json = await exportCollectionsAsJson(configResult, options);
+ const delimiter = options.delimiter ?? ',';
+ const relationSeparator = options.relationSeparator ?? ';';
+
+ return Object.fromEntries(
+ Object.entries(json).map(([collection, records]) => [
+ collection,
+ serializeCollection(records, delimiter, relationSeparator),
+ ])
+ );
+}
+
+function serializeCollection(
+ records: EntryNode[],
+ delimiter: string,
+ relationSeparator: string
+): string {
+ const headers = collectHeaders(records);
+ if (headers.length === 0) {
+ return '';
+ }
+
+ const rows = records.map((record) =>
+ headers.map((header) => formatCell(record[header], relationSeparator))
+ );
+
+ return [headers, ...rows]
+ .map((row) =>
+ row.map((cell) => escapeCsvCell(cell, delimiter)).join(delimiter)
+ )
+ .join('\n')
+ .concat('\n');
+}
+
+function collectHeaders(records: EntryNode[]): string[] {
+ const headers = new Set();
+ for (const record of records) {
+ for (const [key, value] of Object.entries(record)) {
+ if (isCsvValue(value)) {
+ headers.add(key);
+ }
+ }
+ }
+
+ return [...headers].sort((left, right) =>
+ left === 'id' ? -1 : right === 'id' ? 1 : compareCodepoint(left, right)
+ );
+}
+
+function formatCell(value: unknown, relationSeparator: string): string {
+ if (value === null || value === undefined) {
+ return '';
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) => formatScalar(item)).join(relationSeparator);
+ }
+
+ return formatScalar(value);
+}
+
+function formatScalar(value: unknown): string {
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ return '';
+}
+
+function escapeCsvCell(cell: string, delimiter: string): string {
+ if (cell.includes(delimiter) || /["\n\r]/.test(cell)) {
+ return `"${cell.replace(/"/g, '""')}"`;
+ }
+
+ return cell;
+}
+
+function isCsvValue(value: unknown): boolean {
+ return (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean' ||
+ (Array.isArray(value) &&
+ value.every(
+ (item) =>
+ typeof item === 'string' ||
+ typeof item === 'number' ||
+ typeof item === 'boolean' ||
+ item === null ||
+ item === undefined
+ ))
+ );
+}
+
+function compareCodepoint(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
diff --git a/packages/core/src/export/tests/csv.test.ts b/packages/core/src/export/tests/csv.test.ts
new file mode 100644
index 00000000..ae6449c3
--- /dev/null
+++ b/packages/core/src/export/tests/csv.test.ts
@@ -0,0 +1,127 @@
+import test from 'ava';
+import filesystem from '@flatbread/source-filesystem';
+import markdownTransformer from '@flatbread/transformer-markdown';
+import { exportCollectionsAsCsv } from '../csv';
+import { initializeConfig } from '../../utils/initializeConfig';
+
+test('exports selected collections as flat CSV with relation IDs', async (t) => {
+ const config = initializeConfig({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors',
+ collection: 'Author',
+ },
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/tags',
+ collection: 'Tag',
+ },
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts',
+ collection: 'Post',
+ refs: {
+ author: 'Author',
+ authors: 'Author',
+ tags: 'Tag',
+ },
+ },
+ ],
+ });
+
+ const result = await exportCollectionsAsCsv(
+ { config },
+ { collections: ['Post'] }
+ );
+
+ t.deepEqual(Object.keys(result), ['Post']);
+ t.is(
+ result.Post,
+ [
+ 'id,_filename,_path,_slug,author,authors,tags,title',
+ 'known-post,known-post.md,packages/core/src/providers/test/fixtures/missing-refs-clean/posts/known-post.md,known-post,known-author,known-author,known-tag,Post With Resolved Refs',
+ '',
+ ].join('\n')
+ );
+});
+
+test('escapes CSV cells with delimiters, quotes, and newlines', async (t) => {
+ const config = initializeConfig({
+ source: {
+ fetch: async () => ({
+ Quote: [
+ {
+ basename: 'quote.md',
+ extname: '.md',
+ path: `${process.cwd()}/quote.md`,
+ value: 'ignored',
+ },
+ ] as never,
+ }),
+ },
+ transformer: [
+ {
+ extensions: ['.md'],
+ inspect: () => 'quote',
+ parse: () => ({
+ id: 'quote',
+ title: 'Comma, "quote"\nand newline',
+ }),
+ },
+ ],
+ content: [
+ {
+ path: 'virtual/quotes',
+ collection: 'Quote',
+ },
+ ],
+ });
+
+ const result = await exportCollectionsAsCsv(
+ { config },
+ { collections: ['Quote'] }
+ );
+
+ t.is(
+ result.Quote,
+ 'id,_filename,_path,title\nquote,quote.md,quote.md,"Comma, ""quote""\nand newline"\n'
+ );
+});
+
+test('supports custom delimiters and relation separators', async (t) => {
+ const config = initializeConfig({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: [
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/authors',
+ collection: 'Author',
+ },
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/tags',
+ collection: 'Tag',
+ },
+ {
+ path: 'packages/core/src/providers/test/fixtures/missing-refs-clean/posts',
+ collection: 'Post',
+ refs: {
+ author: 'Author',
+ authors: 'Author',
+ tags: 'Tag',
+ },
+ },
+ ],
+ });
+
+ const result = await exportCollectionsAsCsv(
+ { config },
+ {
+ collections: ['Post'],
+ delimiter: '\t',
+ relationSeparator: '|',
+ }
+ );
+
+ t.true(result.Post.startsWith('id\t_filename\t_path\t_slug'));
+ t.true(result.Post.includes('\tknown-author\tknown-author\tknown-tag\t'));
+});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 78385974..284097db 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,4 +1,6 @@
export { generateSchema } from './generators/schema';
+export { exportCollectionsAsCsv } from './export/csv';
+export type { CsvExportOptions, CsvExportResult } from './export/csv';
export { exportCollectionsAsJson } from './export/json';
export type { JsonExportOptions, JsonExportResult } from './export/json';
export { initializeConfig } from './utils/initializeConfig';
From e6c331e255a9095c1e098053a1c67a6052659cf6 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 14:27:05 +0000
Subject: [PATCH 22/37] docs: document data ownership exit story
Summary of changes
- Add a data ownership and exit story that frames raw files and Git history as the source of truth.
- Document JSON and CSV snapshots, GraphQL introspection, and generated TypeScript artifacts as portability surfaces with current limitations.
- Cross-link ownership guidance from positioning, snapshot export docs, and the main README.
Testing
- pnpm lint
- proof DAG: dag-flatbread-161-data-ownership-review-r2 completed 2/2 tasks
Closes #161
Change-Id: Ie01aa41c5b809005e5ee7cf194ba57035d9021e0
---
docs/data-ownership.md | 90 ++++++++++++++++++++++++++++++++++++
docs/json-export.md | 6 +++
docs/positioning.md | 4 +-
packages/flatbread/README.md | 2 +
4 files changed, 101 insertions(+), 1 deletion(-)
create mode 100644 docs/data-ownership.md
diff --git a/docs/data-ownership.md b/docs/data-ownership.md
new file mode 100644
index 00000000..28ceb243
--- /dev/null
+++ b/docs/data-ownership.md
@@ -0,0 +1,90 @@
+# Data ownership and exit story
+
+Flatbread's portability story starts with a simple constraint: **your flat files
+remain the source of truth**. Markdown, YAML, and any other source files live in
+your repository, move through normal Git workflows, and can be reviewed without
+a hosted dashboard.
+
+## What you own
+
+- **Raw content files** — posts, authors, tags, and other records are ordinary
+ repo files.
+- **Git history** — every content change can be branched, reviewed, reverted,
+ and diffed with the same tools as code.
+- **Flatbread config** — collection paths, refs, sources, transformers, and
+ codegen options are explicit project files.
+- **Generated artifacts** — GraphQL schema/types, generated read helpers, JSON
+ snapshots, and CSV flat views can be regenerated from the repo. Generated
+ read helpers are Flatbread runtime helpers; operation types and snapshots are
+ the more portable exit artifacts.
+
+## Exit paths
+
+| Surface | What it gives you | Exit use |
+| --------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| Raw files | Original Markdown/YAML content and frontmatter | Move to another static/content pipeline without export first |
+| Git history | Reviewable content lineage | Audit, revert, or migrate by commit range |
+| JSON snapshots | Stable collection records with normalized IDs/refs | Feed another app, script, archive, or migration |
+| CSV flat views | Spreadsheet-friendly scalar fields and reference IDs; nested objects are omitted | Review simple collections, hand off to non-developers, seed tabular tools |
+| GraphQL introspection | The generated read schema | Discover API shape or generate external clients while Flatbread serves the graph |
+| Generated TypeScript | Operation types and model helper types | Preserve typed query/result contracts while changing framework integration |
+
+## JSON and CSV exports
+
+`@flatbread/core` currently exposes export APIs:
+
+```ts
+import {
+ exportCollectionsAsCsv,
+ exportCollectionsAsJson,
+} from '@flatbread/core';
+import { loadConfig } from '@flatbread/config';
+
+const configResult = await loadConfig({ cwd: process.cwd() });
+
+const json = await exportCollectionsAsJson(configResult, {
+ collections: ['Post', 'Author'],
+});
+
+const csv = await exportCollectionsAsCsv(configResult, {
+ collections: ['Post'],
+});
+```
+
+Both exports validate the content graph before returning output. Broken refs or
+duplicate IDs fail before snapshots are produced, which keeps the export story
+aligned with Flatbread's relational integrity work.
+
+See [snapshot export docs](./json-export.md) for sort order, path behavior,
+relation handling, and CSV flattening details.
+
+## GraphQL schema and generated types
+
+GraphQL is one read interface over the same repo-backed model. While a
+Flatbread server is running, standard GraphQL tooling can introspect
+`http://localhost:5057/graphql` to discover the generated schema. The checked-in
+GraphQL documents and generated TypeScript operation types are useful migration
+artifacts because they show the read shapes your app depended on.
+
+If you leave Flatbread, the prototype generated read API should be treated as a
+convenience wrapper to replace or reimplement; the raw files, JSON/CSV
+snapshots, GraphQL operation documents, and operation result types are the more
+durable exit surfaces.
+
+## What Flatbread does not lock in
+
+- You do not need a hosted CMS account to read your source data.
+- You do not need a proprietary database dump to recover content.
+- You do not need GraphQL to preserve the content itself; GraphQL is one read
+ interface over the repo-backed model.
+- You can keep raw files and migrate to another parser, static pipeline, or
+ database import script if Flatbread stops fitting the project.
+
+## Current limitations
+
+- JSON/CSV export is currently an API surface, not a first-class CLI command.
+- CSV is a flat view: nested object fields are omitted, and relation fields are
+ exported as reference IDs rather than expanded records.
+- Generated TypeScript read helpers execute through the GraphQL layer today.
+- Live content reload for the long-running `flatbread start` server remains a
+ separate watch-loop effort; see [local dev loop boundaries](./local-dev-loop.md).
diff --git a/docs/json-export.md b/docs/json-export.md
index d2f6c03c..c93c6966 100644
--- a/docs/json-export.md
+++ b/docs/json-export.md
@@ -1,5 +1,11 @@
# Snapshot export
+Snapshot exports are part of Flatbread's data ownership story: they turn the
+same repo-backed content graph into portable review artifacts. See
+[data ownership and exit story](./data-ownership.md) for how raw files, Git
+history, JSON/CSV exports, GraphQL introspection, and generated types fit
+together.
+
`@flatbread/core` exposes `exportCollectionsAsJson(configResult, options)` for
stable collection snapshots and `exportCollectionsAsCsv(configResult, options)`
for flat collection views. They are currently API surfaces rather than CLI
diff --git a/docs/positioning.md b/docs/positioning.md
index c54661a0..4512628a 100644
--- a/docs/positioning.md
+++ b/docs/positioning.md
@@ -1,6 +1,6 @@
# Flatbread positioning
-Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md). For **buyer-aware comparisons** (SQLite-style workflows, CMSs, Contentlayer-like stacks, agent artifact graphs) across setup time, typing, integrity, and related criteria—plus **go / no-go** guidance for an agent-artifact wedge—see the [PMF decision rubric](./pmf-decision-rubric.md).
+Flatbread positions itself the same way across the repo; this page is a stable link target. For install and usage, see the [main README](../README.md). For vocabulary used across docs and config—**collections**, **relations**, **IDs**, and how a **query interface** fits in—see the [glossary](./glossary.md). For **buyer-aware comparisons** (SQLite-style workflows, CMSs, Contentlayer-like stacks, agent artifact graphs) across setup time, typing, integrity, and related criteria—plus **go / no-go** guidance for an agent-artifact wedge—see the [PMF decision rubric](./pmf-decision-rubric.md). For portability and exit paths, see [data ownership](./data-ownership.md).
Turn flat files in Git into typed, relational content for your TypeScript app. The core artifact is an in-repo **content graph** (collections, records, **`refs`**). **Generated types plus [GraphQL](https://graphql.org/) operations** layer on top today as the most common **read interface** — they describe how many apps consume that graph at build/run time; they do not redefine what Flatbread **is**.
@@ -16,4 +16,6 @@ Turn flat files in Git into typed, relational content for your TypeScript app. T
**GraphQL:** In the default setup, GraphQL is a primary **interface** for reading an already-loaded content graph (`schema → operations → codegen`). Prefer thinking **files → model → typed read path** rather than treating GraphQL alone as Flatbread. For **traceability** from **backing files** (posts, authors, tag facets on posts) through **config** to generated schema and operation types—aligned with the [glossary](./glossary.md)—see the **Quickstart** and **Traceability** sections of [`packages/flatbread/README.md`](../packages/flatbread/README.md#quickstart-posts-authors-and-tags).
+**Portability and exit:** Raw files stay in Git, so content can be branched, reviewed, reverted, and migrated without asking a hosted CMS for a dump. JSON and CSV exports provide reviewable snapshots with normalized IDs and refs; GraphQL introspection and generated operation types preserve the read shapes your app used. The prototype generated read API is convenient inside Flatbread, while raw files, snapshots, GraphQL documents, and operation types are the durable exit surfaces.
+
**Skimming from GraphQL-first experience:** Jump to **`refs` + relations** in [glossary](./glossary.md), then codegen and your app’s **`flatbread codegen`** docs — the relational layer is upstream of the queries you write.
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index a6608c88..5d96dab6 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -36,6 +36,8 @@ Turn flat files in Git into typed, relational content for your TypeScript app. *
**Portability:** Stable JSON snapshot export is available as a core API and documented in [docs/json-export.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/json-export.md).
+**Ownership and exit:** Raw files, Git history, JSON/CSV exports, GraphQL introspection, and generated TypeScript all fit one portability story in [docs/data-ownership.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/data-ownership.md).
+
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
From 72c1cd1c0f5359f5f1c283db396702e28ca4ae19 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 14:43:58 +0000
Subject: [PATCH 23/37] docs: record relational starter benchmark
Summary of changes
- Add the relational starter benchmark report for the canonical posts/authors/tags path.
- Record a fresh-worktree install/build/codegen/demo query run completing in 49 seconds and a generated TypeScript read API build verification completing in 18 seconds.
- Capture observed friction and follow-up issue drafts for a network-cold benchmark and Next.js ESLint plugin warning.
Testing
- pnpm lint
- timed fresh-worktree benchmark: pnpm install, pnpm build, nextjs codegen, demo:watch-query
- timed read API verification: pnpm --filter nextjs build
- proof DAG: dag-flatbread-162-starter-benchmark-review-r4 completed 2/2 tasks
Closes #162
Change-Id: Ia44b7d6f5efcff6d303959acc0039a1c330d0972
---
.../issue-162-relational-starter-benchmark.md | 221 ++++++++++++++++++
1 file changed, 221 insertions(+)
create mode 100644 docs/experiments/issue-162-relational-starter-benchmark.md
diff --git a/docs/experiments/issue-162-relational-starter-benchmark.md b/docs/experiments/issue-162-relational-starter-benchmark.md
new file mode 100644
index 00000000..b8dccee0
--- /dev/null
+++ b/docs/experiments/issue-162-relational-starter-benchmark.md
@@ -0,0 +1,221 @@
+# Experiment: Issue #162 — relational starter benchmark
+
+## Question
+
+Can a developer start from the canonical Flatbread path, understand the
+`posts → authors + tags` model, and reach a typed read result in under 10
+minutes?
+
+## Benchmark path
+
+This uses the repo's canonical onboarding route:
+
+1. Read the root README quickstart:
+ [`packages/flatbread/README.md#quickstart-posts-authors-and-tags`](../../packages/flatbread/README.md#quickstart-posts-authors-and-tags).
+2. Inspect the backing files:
+ - `examples/content/markdown/posts/example-post.md`
+ - `examples/content/markdown/authors/tony.md`
+ - `examples/content/markdown/authors/eva.md`
+3. Inspect the relation config:
+ `examples/nextjs/flatbread.config.js`
+4. Generate typed artifacts from the example directory:
+ `cd examples/nextjs && pnpm exec flatbread codegen --clear-cache --verbose`
+5. Confirm the typed read surface:
+ - GraphQL operation types in `examples/nextjs/generated/graphql.ts`
+ - generated read API usage in `examples/nextjs/lib/read.ts`
+6. Verify the generated TypeScript read API path:
+ `pnpm --filter nextjs build`
+7. Optional raw query watch proof:
+ `pnpm --filter nextjs run demo:watch-query`
+
+## Fresh-worktree run
+
+Environment: detached Git worktree created from the current branch with empty
+workspace `node_modules` (pnpm reused the global package store).
+
+Command:
+
+```bash
+git worktree add --detach /tmp/flatbread-starter-benchmark-worktree HEAD
+cd /tmp/flatbread-starter-benchmark-worktree
+start=$(date +%s)
+corepack enable
+pnpm install
+pnpm build
+cd examples/nextjs
+pnpm exec flatbread codegen --clear-cache --verbose
+timeout 8s pnpm run demo:watch-query
+end=$(date +%s)
+echo "elapsed_seconds=$((end-start))"
+```
+
+Observed result:
+
+```text
+elapsed_seconds=49
+Done in 27.2s using pnpm v10.33.0
+✓ Generated TypeScript types: /tmp/flatbread-starter-benchmark-worktree/examples/nextjs/generated/graphql.ts
+```
+
+Relevant first-query output before the watcher timeout:
+
+```json
+{
+ "data": {
+ "allPosts": [
+ {
+ "id": "sdfsdf-23423-sdfsd-23444-dfghf",
+ "title": "The Art of Measuring Cats in Fruit Units",
+ "tags": ["cats", "measurements", "fruit-science", "important-research"],
+ "authors": [
+ { "id": "40s3", "name": "Eva" },
+ { "id": "2a3e", "name": "Tony" }
+ ]
+ }
+ ],
+ "allYamlAuthors": [
+ {
+ "id": "caffeine-researcher",
+ "name": "Dr. Maya Espresso",
+ "friend": { "id": "2a3e", "name": "Tony" }
+ }
+ ]
+ }
+}
+```
+
+The watcher is intentionally long-running, so the shell command used
+`timeout 8s`; the first render completed before timeout and no GraphQL `errors`
+field was present.
+
+This completes the canonical install → build → codegen → first demo query path
+in under 10 minutes.
+
+## Generated TypeScript read API verification
+
+The Next.js home page imports `getPostsAuthorsAndTagsViaReadApi()` and
+`getAuthorsViaReadApi()` from `examples/nextjs/lib/read.ts`. Static generation
+therefore exercises the generated TypeScript read API path.
+
+Command:
+
+```bash
+start=$(date +%s)
+pnpm --filter nextjs build
+end=$(date +%s)
+echo "elapsed_seconds=$((end-start))"
+```
+
+Observed result:
+
+```text
+elapsed_seconds=18
+✓ Compiled successfully in 3.0s
+✓ Generating static pages (5/5)
+Flatbread is done for now. Bye bye! 🥪
+```
+
+The build still emits the known `eslint-plugin-react-hooks` warning, but exits
+0 and renders the page path that calls the generated read API.
+
+## Warm-workspace rehearsal
+
+Environment: existing cloud workspace with dependencies already present. This
+is a **canonical-command rehearsal**, not a true empty-cache/fresh-clone
+measurement.
+
+Command:
+
+```bash
+start=$(date +%s)
+pnpm install
+pnpm build
+cd examples/nextjs
+pnpm exec flatbread codegen --clear-cache --verbose
+timeout 8s pnpm run demo:watch-query
+end=$(date +%s)
+echo "elapsed_seconds=$((end-start))"
+```
+
+Observed result:
+
+```text
+elapsed_seconds=23
+Done in 1.9s using pnpm v10.33.0
+✓ Generated TypeScript types: /workspace/examples/nextjs/generated/graphql.ts
+✓ TypeScript types generated successfully
+"title": "The Art of Measuring Cats in Fruit Units"
+"name": "Dr. Maya Espresso"
+```
+
+This is well under 10 minutes for the canonical command rehearsal in this
+workspace. The fresh-worktree run above is the primary timing evidence; this
+warm run remains useful for comparing maintainer-loop overhead.
+
+The docs now place the relation model before GraphQL, so the cognitive steps
+are:
+
+- `Post` files carry `authors` IDs and `tags` string facets.
+- `Author` files carry matching IDs.
+- `flatbread.config.js` declares `refs: { authors: 'Author' }`.
+- Codegen emits GraphQL operation types and Flatbread content-model/read helper
+ types.
+
+## Friction observed
+
+- The fresh-worktree benchmark reused the global pnpm store, so it is not a
+ network-cold install.
+- `pnpm --filter nextjs build` succeeds and exercises the generated read API
+ path, but still prints a known
+ `eslint-plugin-react-hooks` load warning from the Next.js ESLint stack.
+- `flatbread codegen --watch` is watch-only; docs must keep steering one-shot
+ benchmark users to `flatbread codegen --verbose`.
+- The generated TypeScript read API is still a prototype and executes through
+ GraphQL, so the current first typed read result is strongest when described
+ as "GraphQL operations plus generated read helpers over one content model."
+
+## Follow-up issue drafts
+
+### Follow-up: Network-cold benchmark on a fresh clone/container
+
+**Problem:** This benchmark used a fresh worktree but reused the global pnpm
+store.
+
+**Acceptance criteria:**
+
+- Run from a fresh clone/container with empty `node_modules` and cold pnpm
+ store/cache.
+- Record install, build, codegen, and first query time separately.
+- Note native dependency install warnings and remediation steps.
+
+### Follow-up: Turn friction notes into tracked issues
+
+**Problem:** This report can draft follow-up work, but the current automation
+cannot create/close GitHub issues.
+
+**Acceptance criteria:**
+
+- Create project notes or issues for the cold-start benchmark and Next.js ESLint
+ warning.
+- Link those issue URLs back into this report.
+
+This report is the current project note until GitHub-side follow-ups can be
+created by a maintainer.
+
+### Follow-up: Clean Next.js ESLint dependency warning
+
+**Problem:** `pnpm --filter nextjs build` exits 0 but reports a missing
+`eslint-plugin-react-hooks` plugin.
+
+**Acceptance criteria:**
+
+- Add or reconcile the missing plugin dependency.
+- `pnpm --filter nextjs build` runs without the plugin warning.
+
+## Decision
+
+**Iterate / keep.** The canonical starter path now makes the relation-first
+value legible and reaches typed output, generated TypeScript read API execution,
+and a demo query result comfortably under the 10-minute target in a fresh
+worktree / warm-store environment. A stricter network-cold benchmark should
+still be run before using the timing as external marketing evidence.
From 063d454b1ca5e99b07a14baf43caef7f8aeb2775 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 14:50:21 +0000
Subject: [PATCH 24/37] docs: record TypeScript safety experiment
Summary of changes
- Add a TypeScript safety experiment report for generated Flatbread model/read API types.
- Capture what works today, inference gaps, confusing helper names, nullability limitations, and PMF implications.
- Convert findings into follow-up issue drafts for typed selections, relation helper naming, and nullability alignment.
Testing
- pnpm --filter @flatbread/codegen build
- pnpm -F @flatbread/codegen exec vitest run
- pnpm --filter @flatbread/core build
- pnpm test:ava -- --match='*content types*'
- pnpm --filter nextjs build
- pnpm lint
- proof DAG: dag-flatbread-163-ts-safety-review completed 2/2 tasks
Closes #163
Change-Id: I9584a9ea1aece110cf32900dfd8afdcdddd8829b
---
.../issue-163-typescript-safety-test.md | 142 ++++++++++++++++++
1 file changed, 142 insertions(+)
create mode 100644 docs/experiments/issue-163-typescript-safety-test.md
diff --git a/docs/experiments/issue-163-typescript-safety-test.md b/docs/experiments/issue-163-typescript-safety-test.md
new file mode 100644
index 00000000..1c971989
--- /dev/null
+++ b/docs/experiments/issue-163-typescript-safety-test.md
@@ -0,0 +1,142 @@
+# Experiment: Issue #163 — TypeScript safety interview/test
+
+## Question
+
+Do generated Flatbread types and the prototype TypeScript read API make
+posts/authors/tags consumption materially safer than untyped flat-file reads or
+hand-written GraphQL strings?
+
+## Test surface
+
+Representative files:
+
+- `examples/nextjs/generated/graphql.ts`
+- `examples/nextjs/lib/read.ts`
+- `packages/codegen/src/__tests__/e2e.test.ts`
+- `packages/core/src/types.test.ts`
+
+## What works
+
+- `FlatbreadCollectionName` narrows collection names to configured literals.
+- `FlatbreadRecord<'Post'>` ties app code to generated record shape.
+- `FlatbreadRelationTarget<'Post', 'authors'>` ties relation traversal to the
+ configured `refs` target and cardinality.
+- `tags` on `Post` remains a string facet (`Post['tags']`), not a relation
+ helper, because the canonical example does not model `Tag` as a collection.
+- `FlatbreadRelationCardinality<'Post', 'authors'>` exposes whether a relation
+ is one or many.
+- `createFlatbreadReadApi()` lets the app read `Post` and `Author` through a
+ generated collection-shaped API while GraphQL remains the underlying
+ execution layer.
+- Core content/plugin types now use `unknown`, typed `ContentEntry.refs`, and
+ typed `Source.fetch` inputs instead of broad `any` surfaces.
+
+## Type-safety test run
+
+Commands:
+
+```bash
+pnpm --filter @flatbread/codegen build
+pnpm -F @flatbread/codegen exec vitest run
+pnpm --filter @flatbread/core build
+pnpm test:ava -- --match='*content types*'
+pnpm --filter nextjs build
+```
+
+Observed results in this workspace:
+
+```text
+@flatbread/codegen build: passed
+@flatbread/codegen vitest: 39 tests passed
+@flatbread/core build: passed
+AVA content type assertions: passed (the command currently runs the broader AVA suite)
+Next.js build: passed, with known eslint-plugin-react-hooks warning
+```
+
+## Inference gaps and confusing names
+
+- `createFlatbreadReadApi()` still accepts an optional GraphQL selection string
+ for advanced use. That selection is not type-checked, so the safest path is
+ the generated default selection.
+- `FlatbreadReadApi` returns `Partial>` because the selected
+ fields are a runtime concern. This is honest, but less precise than a typed
+ selection builder would be.
+- Generated relation helper names are verbose:
+ `FlatbreadRelationTargetCollection` versus `FlatbreadRelationTarget` can be
+ confusing without examples.
+- Nullable GraphQL results and generated helper types are not yet perfectly
+ aligned. The prototype errs toward safe optional/partial reads.
+- Flatbread metadata fields such as `_path` and `_slug` are still emitted as
+ nullable by GraphQL Code Generator even when Flatbread-managed records usually
+ provide them.
+- Core plugin author types are narrower, but `ContentEntry` still permits
+ arbitrary extra keys for plugin/config extensibility.
+
+## Verification transcript
+
+```text
+pnpm --filter @flatbread/codegen build
+exit 0
+
+pnpm -F @flatbread/codegen exec vitest run
+Test Files 5 passed (5)
+Tests 39 passed (39)
+
+pnpm --filter @flatbread/core build
+exit 0
+
+pnpm test:ava -- --match='*content types*'
+exit 0
+73 tests passed
+note: the match command currently runs the broader AVA suite because of the root script's argument forwarding
+
+pnpm --filter nextjs build
+exit 0
+note: build succeeds but prints the known eslint-plugin-react-hooks warning
+
+pnpm lint
+exit 0
+All matched files use Prettier code style!
+```
+
+## Follow-up issue drafts
+
+### Follow-up: Add typed selection builder for generated read API
+
+**Problem:** Selection strings are runtime GraphQL snippets, not typed
+TypeScript selections.
+
+**Acceptance criteria:**
+
+- Generate a selection builder or typed projection API for collection reads.
+- Compile-time tests reject unknown fields.
+- Existing string selection remains documented as an escape hatch or is removed.
+
+### Follow-up: Tighten relation helper naming and examples
+
+**Problem:** `FlatbreadRelationTarget` and
+`FlatbreadRelationTargetCollection` are useful but easy to confuse.
+
+**Acceptance criteria:**
+
+- Add generated JSDoc explaining each helper.
+- Add examples for one-to-one and one-to-many relations.
+- Ensure docs and generated names match the glossary.
+
+### Follow-up: Align nullability between GraphQL and read helper types
+
+**Problem:** GraphQL nullable list/member behavior is only approximately
+represented by the read helper types.
+
+**Acceptance criteria:**
+
+- Derive nullability from the GraphQL schema for relation helpers.
+- Add compile-time assertions for nullable singular, nullable list, and
+ non-null list relations.
+
+## Decision
+
+**Keep / iterate.** Type safety is a PMF-strengthening differentiator. The
+generated content-model helpers and read API remove several weakly typed paths,
+but the prototype still needs a typed selection story and sharper relation
+helper documentation before it can be marketed as a fully type-safe read layer.
From a619bd28b880babece71b8fc4c3a6ffcfdeaeb92 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 15:03:05 +0000
Subject: [PATCH 25/37] docs: record export trust experiment
Summary of changes
- Add an export trust experiment report with a JSON/CSV demo prompt for the posts/authors/tags ownership story.
- Include a product self-review table for raw files, JSON snapshots, CSV flat views, GraphQL introspection, generated types, and ownership docs.
- Add a verification transcript showing JSON and CSV export output from the Next.js example and follow-up issue drafts for CLI export, exit fixtures, and external interviews.
Testing
- pnpm lint
- Node export transcript using loadConfig and exportCollectionsAsJson/exportCollectionsAsCsv from the flatbread package
- proof DAG: dag-flatbread-164-export-trust-review-r2 completed 2/2 tasks
Closes #164
Change-Id: I60fd0a34d028a1e45f1d52adb78f013848097424
---
.../issue-164-export-trust-experiment.md | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 docs/experiments/issue-164-export-trust-experiment.md
diff --git a/docs/experiments/issue-164-export-trust-experiment.md b/docs/experiments/issue-164-export-trust-experiment.md
new file mode 100644
index 00000000..3df7f332
--- /dev/null
+++ b/docs/experiments/issue-164-export-trust-experiment.md
@@ -0,0 +1,156 @@
+# Experiment: Issue #164 — export trust experiment
+
+## Question
+
+Does an explicit ownership story plus JSON/CSV export behavior make Flatbread
+feel safer to adopt?
+
+## Demo prompt
+
+Use this prompt in interviews or demos after the posts/authors/tags quickstart.
+Run command examples from `examples/nextjs`, where `flatbread.config.js` lives:
+
+1. Show raw source files:
+ - `examples/content/markdown/posts/example-post.md`
+ - `examples/content/markdown/authors/tony.md`
+ - `examples/content/markdown/authors/eva.md`
+2. Show config-owned relations in `examples/nextjs/flatbread.config.js`.
+3. Show the [data ownership story](../data-ownership.md).
+4. Show the snapshot export APIs:
+
+ ```ts
+ import {
+ exportCollectionsAsCsv,
+ exportCollectionsAsJson,
+ loadConfig,
+ } from 'flatbread';
+
+ const configResult = await loadConfig({ cwd: process.cwd() });
+
+ const json = await exportCollectionsAsJson(configResult, {
+ collections: ['Post', 'Author'],
+ });
+
+ const csv = await exportCollectionsAsCsv(configResult, {
+ collections: ['Post'],
+ });
+ ```
+
+ See also:
+
+ - [Data ownership](../data-ownership.md)
+ - [Snapshot export docs](../json-export.md)
+
+5. Explain the exit path:
+ - raw files remain usable without Flatbread;
+ - JSON snapshots preserve normalized IDs and refs;
+ - CSV flat views are spreadsheet-friendly;
+ - GraphQL documents/types preserve the app's read shapes.
+
+## Product self-review notes
+
+No external participant interview was available in this execution environment,
+so these are product-review notes from the implemented demo path rather than
+human interview findings. Treat them as project notes, not external validation.
+
+## Verification transcript
+
+Command (run from `examples/nextjs`):
+
+```bash
+node --input-type=module - <<'NODE'
+import {
+ loadConfig,
+ exportCollectionsAsCsv,
+ exportCollectionsAsJson,
+} from 'flatbread';
+
+const configResult = await loadConfig({ cwd: process.cwd() });
+const json = await exportCollectionsAsJson(configResult, {
+ collections: ['Post'],
+ pathRoot: process.cwd(),
+});
+const csv = await exportCollectionsAsCsv(configResult, {
+ collections: ['Post'],
+ pathRoot: process.cwd(),
+});
+
+console.log(JSON.stringify(json.Post[0], null, 2).split('\n').slice(0, 12).join('\n'));
+console.log('---CSV---');
+console.log(csv.Post.split('\n').slice(0, 2).join('\n'));
+NODE
+```
+
+Trimmed output:
+
+```text
+{
+ "_content": {
+ "raw": "\nLorem ipsum\n"
+ },
+ "_filename": "b.md",
+ "_path": "content/markdown/posts/b.md",
+ "_slug": "b",
+ "authors": [
+ "1111",
+ "ab2c"
+ ],
+ "id": "2348fds-563fdh-59ddsd-3332-09876",
+---CSV---
+id,_filename,_path,_slug,authors,category,controversial_opinions,rating,research_duration,slurp_factor,soups_tested,tags,temperature_preference,title
+2348fds-563fdh-59ddsd-3332-09876,b.md,content/markdown/posts/b.md,b,1111;ab2c,,,44,,,,,,Test post B
+```
+
+| Prompt area | Trust signal | Remaining concern |
+| --------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
+| Raw Markdown/YAML files | Strong: source of truth is visible in Git | Derived fields / overrides require Flatbread to recompute |
+| JSON export API | Strong: preserves IDs/refs and validates graph first | API-only today; non-developers need a CLI |
+| CSV export API | Medium: useful for spreadsheet review | Nested fields are omitted and relation arrays are joined IDs |
+| GraphQL introspection/types | Medium: preserves app read contract | Requires a working Flatbread schema/server or generated artifacts |
+| Data ownership docs | Strong: clearly states non-goals and exit surfaces | Needs runnable CLI examples once export commands exist |
+
+## Keep / kill / iterate
+
+**Iterate based on product self-review.** Export behavior appears to improve the
+adoption-trust story because it turns "your files are yours" into concrete
+artifacts: raw files, JSON snapshots, CSV flat views, and generated read
+contracts.
+
+Do not market this as externally validated or as a complete non-developer export
+workflow yet. The trust story becomes materially stronger when JSON/CSV export
+has a first-class CLI and when docs include copy-paste commands that write files
+to disk.
+
+## Follow-up issue drafts
+
+### Follow-up: Add `flatbread export` CLI for JSON and CSV
+
+**Problem:** Export is currently an API, so adoption demos require a Node script.
+
+**Acceptance criteria:**
+
+- `flatbread export json --collections Post,Author --out snapshots/`
+- `flatbread export csv --collections Post --out snapshots/`
+- Commands fail with validation diagnostics for broken refs/duplicate IDs.
+- Docs use CLI first and API second.
+
+### Follow-up: Add exit-story fixture output
+
+**Problem:** Docs describe export behavior but do not check in example output.
+
+**Acceptance criteria:**
+
+- Add a small `examples/exit-story/` fixture or generated snapshot directory.
+- Include JSON and CSV outputs from the posts/authors/tags model.
+- Add a test that verifies snapshots are deterministic.
+
+### Follow-up: Interview with two target users
+
+**Problem:** This report contains product-review notes, not external user
+feedback.
+
+**Acceptance criteria:**
+
+- Run the demo prompt with at least two TypeScript/static-site developers.
+- Record whether JSON/CSV exports increase adoption trust.
+- Capture objections and update keep/kill/iterate decision.
From 16dcf05d785a01b780566cebf814417d8eba0a52 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 15:07:44 +0000
Subject: [PATCH 26/37] docs: benchmark agent artifact retrieval leverage
Summary of changes
- Add a cold-start versus Flatbread-mediated agent artifact retrieval benchmark for the Effort Graph validation track.
- Compare full-context artifact stuffing with filtered retrieval of blocking decision, plan, and session rows.
- Record payload size reduction, continuity tradeoffs, recommendation, and follow-up issue drafts for MCP query, broader benchmark, and expansion policy.
Testing
- wc -c over full artifact set and filtered Effort Graph rows
- pnpm lint
- proof DAG: dag-flatbread-169-artifact-retrieval-review completed 2/2 tasks
Closes #169
Change-Id: I8370996c72f1ccefd8829f7e59ca956baa399218
---
...-169-agent-artifact-retrieval-benchmark.md | 150 ++++++++++++++++++
1 file changed, 150 insertions(+)
create mode 100644 docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md
diff --git a/docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md b/docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md
new file mode 100644
index 00000000..82b24d19
--- /dev/null
+++ b/docs/experiments/issue-169-agent-artifact-retrieval-benchmark.md
@@ -0,0 +1,150 @@
+# Experiment: Issue #169 — cold-start vs Flatbread-mediated artifact retrieval
+
+## Question
+
+Does a Flatbread-style Effort Graph retrieval surface reduce prompt/context
+cost while preserving enough continuity to justify further MCP and agent-query
+investment?
+
+## Benchmark setup
+
+Representative artifact set:
+
+- `flatbread-agent-artifact-opportunity.md`
+- `docs/experiments/issue-167-effort-graph-layout-mapping.md`
+- `docs/experiments/issue-168-adversarial-multi-layout-schema.md`
+- Effort Graph fixture rows under
+ `docs/experiments/fixtures/cursor-proof-skill-effort-graph/`
+
+Task prompt:
+
+> For the PMF audit DAG effort, identify open blocking decisions and include
+> linked plan/session context.
+
+## Strategies compared
+
+### A. Cold-start context stuffing
+
+Stuff the full strategy/experiment history into context:
+
+```text
+flatbread-agent-artifact-opportunity.md
+issue-167-effort-graph-layout-mapping.md
+issue-168-adversarial-multi-layout-schema.md
+```
+
+Measured byte count:
+
+```text
+19,750 flatbread-agent-artifact-opportunity.md
+ 7,658 issue-167-effort-graph-layout-mapping.md
+ 9,693 issue-168-adversarial-multi-layout-schema.md
+37,101 total bytes
+```
+
+### B. Flatbread-mediated Effort Graph retrieval
+
+Retrieve only the blocking decision row plus linked plan/session rows:
+
+```text
+836 decisions/167-blocking-reference-layout.md
+771 plans/flatbread-flow-pmf-audit-dag.md
+618 sessions/proof-cli-session-20260508.md
+2,225 total bytes
+```
+
+Representative query shape from #167:
+
+```graphql
+query BlockingDecisionsForEffort {
+ allDecisions(
+ filter: { effort: { eq: "pmf-audit-dag" }, blocking: { eq: true } }
+ sortBy: "decided_at"
+ order: DESC
+ ) {
+ id
+ title
+ status
+ blocking
+ plan {
+ id
+ title
+ source_artifact
+ }
+ session {
+ id
+ runner
+ }
+ }
+}
+```
+
+## Result
+
+| Strategy | Approx. bytes retrieved | Continuity quality | Cost / noise |
+| ---------------------------- | ----------------------: | ------------------------------------------------------------------ | --------------------------------------------- |
+| Cold-start stuffing | 37,101 | High context recall, but requires rereading broad strategy docs | High: 16.7× larger than filtered rows |
+| Flatbread-mediated retrieval | 2,225 | Enough for the target question: blocking decision + plan + session | Low: focused payload, less repeated discovery |
+
+Filtered retrieval is roughly **94% smaller** for this task:
+
+```text
+1 - (2,225 / 37,101) ≈ 94.0%
+```
+
+## Continuity tradeoff
+
+Flatbread-mediated retrieval answers the target question directly:
+
+- **Decision:** issue #167 reference layout remains an open blocking gate.
+- **Plan context:** linked PMF audit DAG plan/source artifact.
+- **Session context:** proof CLI/session row describing the run surface.
+
+What it loses:
+
+- Broad market landscape and strategic rationale from the full artifact
+ opportunity memo.
+- Nuanced tensions from the adversarial schema report unless the query expands
+ to include related artifacts.
+
+That tradeoff is acceptable for "what is blocking this effort?" It is not
+enough for "should Flatbread become an agent memory company?" without an
+expanded query.
+
+## Recommendation
+
+**Keep / invest further.** The retrieval leverage is strong enough to justify
+the next MCP/agent-query slice. A 94% smaller context payload with preserved
+blocking decision continuity is exactly the kind of advantage the Effort Graph
+opportunity needs.
+
+## Follow-up issue drafts
+
+### Follow-up: MCP query for blocking decisions by effort
+
+**Acceptance criteria:**
+
+- Tool accepts `effortId`.
+- Returns blocking decisions with plan/session context.
+- Uses Flatbread filters internally.
+- Includes deterministic tests against the issue #167 fixture.
+
+### Follow-up: Expand artifact retrieval benchmark
+
+**Acceptance criteria:**
+
+- Use at least one multi-session real effort, not only representative fixtures.
+- Compare answer quality for at least three prompts:
+ - blocking decisions;
+ - why a product choice was made;
+ - what to do next.
+- Record token counts from an actual model/tool invocation.
+
+### Follow-up: Related-artifact expansion policy
+
+**Acceptance criteria:**
+
+- Define when a decision query should pull source artifacts, plan body, or full
+ strategy docs.
+- Add max-depth and max-byte guardrails.
+- Document recommended defaults for MCP calls.
From 15b55d0b03c4824cf6398493a879dd6afbda93b4 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 8 May 2026 15:19:25 +0000
Subject: [PATCH 27/37] docs: update roadmap from validation results
Summary of changes
- Add a validation-driven roadmap with keep/kill/iterate decisions across content layer, validation, TypeScript APIs, GraphQL, watch loop, exports, Effort Graph, CMS UI, and database-replacement scope.
- Explicitly decide Effort Graph should remain a secondary vertical with a path to primary wedge after MCP, token benchmark, and external validation gates.
- Add updated priorities, follow-up issue drafts, maintainer action checklist, and per-issue traceability for the project-board stack.
Testing
- pnpm lint
- proof DAG: dag-flatbread-165-roadmap-review-r2 completed 2/2 tasks
Closes #165
Change-Id: I6aa7d2cb865b8a54c261169b1c3db214c6b1b8a0
---
docs/roadmap.md | 151 +++++++++++++++++++++++++++++++++++
packages/flatbread/README.md | 2 +
2 files changed, 153 insertions(+)
create mode 100644 docs/roadmap.md
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 00000000..e10937f3
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,151 @@
+# Flatbread roadmap update from validation work
+
+This roadmap reflects the PMF audit, implementation work, and experiment
+reports completed through the current project-board sequence. All verdicts below
+rest primarily on internal evidence; external validation gates promotion past
+**Iterate** on user-facing claims.
+
+## Evidence inputs
+
+- [PMF decision rubric](./pmf-decision-rubric.md)
+- [PMF audit](../flatbread-flow-pmf-audit.md)
+- [Agent artifact opportunity](../flatbread-agent-artifact-opportunity.md)
+- [Positioning](./positioning.md)
+- [Relational starter benchmark](./experiments/issue-162-relational-starter-benchmark.md)
+- [TypeScript safety test](./experiments/issue-163-typescript-safety-test.md)
+- [Export trust experiment](./experiments/issue-164-export-trust-experiment.md)
+- [Effort Graph wire-up](./experiments/issue-167-effort-graph-layout-mapping.md)
+- [Adversarial Effort Graph schema test](./experiments/issue-168-adversarial-multi-layout-schema.md)
+- [Agent artifact retrieval benchmark](./experiments/issue-169-agent-artifact-retrieval-benchmark.md)
+
+## Keep / kill / iterate decisions
+
+| Initiative | Decision | Rationale | Next action |
+| ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
+| Relation-first content layer | **Keep** | Starter path reaches install/build/codegen/demo query under 10 minutes in a fresh worktree; docs now lead with files → model → typed reads. | Polish example content and resolve Next.js ESLint warning. |
+| ID/ref/cardinality validation | **Keep** | Normalized IDs, duplicate diagnostics, missing-ref validation, cardinality docs/tests, and snapshots now make integrity first-class. | Extract reusable validation API and add current/live server integration tests. |
+| Generated TypeScript model/read API | **Iterate** | Generated model helpers and read API prove typed consumption is plausible, but selection typing and nullability need hardening before stable positioning. | Build typed selection/projection API and refine relation helper docs. |
+| GraphQL interface | **Keep, repositioned** | GraphQL remains useful as schema/introspection/client interface, but docs now frame it as one read surface over the model. | Add schema/SDL export command or documented introspection artifact. |
+| Local dev loop/watch | **Iterate** | Current codegen watch is useful, but `flatbread start` still needs restart for live content/schema changes. | Implement `flatbread start --watch` after design/test seams are pinned. |
+| JSON/CSV portability exports | **Iterate** | Core APIs validate and export stable JSON/CSV views; trust story improves, but CLI and non-developer workflow are not complete. | Add `flatbread export json/csv` CLI and fixture outputs. |
+| Agent artifact / Effort Graph | **Iterate — strong candidate wedge** | #167/#168 show schema+mapping is viable; #169 shows large context reduction for blocking-decision retrieval. Evidence is promising but still fixture-driven. | Build MCP query for blocking decisions and run a multi-session real-effort benchmark before making it the primary wedge. |
+| Append/deposit write API | **Deferred** | Effort Graph may need append-oriented writes, but write scope is not validated enough to broaden beyond read/export surfaces. | Revisit only if Effort Graph moves toward primary wedge. |
+| Hosted CMS / authoring UI | **Kill for now** | No validation required a hosted dashboard; it conflicts with the ownership/local-first wedge. | Do not schedule until core filesystem workflow is excellent. |
+| General database replacement | **Kill for now** | Validation work strengthens content integrity but not transactions, auth, multi-writer, or operational DB semantics. | Keep non-goal language prominent. |
+
+## Updated priority order
+
+1. **Ship validation + type-safety foundation** — stabilize IDs, refs,
+ cardinality, snapshots, and generated model helpers.
+2. **Make the canonical example excellent** — keep posts/authors/tags as the
+ first-success path; resolve example lint noise; ensure docs and generated
+ artifacts never drift.
+3. **Add export CLI** — turn JSON/CSV APIs into copy-pasteable commands for the
+ ownership story.
+4. **Implement unified watch loop** — move from documented restart boundaries to
+ `flatbread start --watch` with tests; this is also a precondition for
+ promoting Effort Graph beyond secondary vertical because agent artifact
+ folders change continuously.
+5. **Prototype MCP / agent query surface** — start with blocking decisions by
+ effort ID and reuse the Effort Graph fixture.
+6. **Run external validation** — repeat starter, type-safety, export-trust, and
+ agent retrieval experiments with humans or real multi-session efforts.
+
+## Agent artifact opportunity status
+
+**Decision:** keep Effort Graph as a **secondary vertical with a path to primary
+wedge**.
+
+Reasoning:
+
+- The opportunity aligns with core Flatbread primitives instead of inventing a
+ separate product.
+- The adversarial schema test found fragmentation in mapping profiles, not in
+ the core nouns.
+- Filtered retrieval for blocking decisions was dramatically smaller than
+ context stuffing in the representative benchmark.
+- Evidence is not yet external or multi-session enough to displace the broader
+ TypeScript relational content wedge.
+
+Gate to primary wedge:
+
+- MCP blocking-decision query works against a real multi-session effort.
+- A token-based benchmark (not just bytes) confirms retrieval leverage.
+- MCP and generated-TypeScript read paths reach parity with the GraphQL filter
+ shape on the #167 fixture.
+- At least one external user/team records a saved rediscovery pass on a real
+ multi-session effort relative to its current vault/handoff/search workflow.
+
+## Follow-up issue drafts
+
+The current automation cannot create or close GitHub issues directly. These
+drafts should be turned into issues/project notes by a maintainer:
+
+1. **Add export CLI for JSON/CSV snapshots**
+ - `flatbread export json --collections Post,Author --out snapshots/`
+ - `flatbread export csv --collections Post --out snapshots/`
+2. **Implement `flatbread start --watch`**
+ - schema/content reload, codegen refresh, and failure semantics from
+ `docs/local-dev-loop.md`.
+3. **Add MCP Effort Graph query**
+ - `blockingDecisions(effortId)` returning decision + plan + session context.
+4. **Run network-cold starter benchmark**
+ - fresh clone/container with cold pnpm store.
+5. **Run external export trust interviews**
+ - at least two TypeScript/static-site developers.
+6. **Typed selection builder for generated read API**
+ - remove or isolate the string-selection escape hatch.
+7. **Schema/introspection export artifact**
+ - check in or command-print GraphQL SDL/introspection for exit workflows.
+8. **Harness mapping profiles**
+ - ship Claude-oriented, Cursor-oriented, and GCC-style Effort Graph mapping
+ profiles as configuration rather than separate schemas.
+9. **External validation interview set**
+ - starter, export trust, TypeScript safety, and Effort Graph retrieval runs
+ with non-maintainer users.
+
+## Maintainer action checklist
+
+1. Create follow-up issues/project notes from the drafts above.
+2. Close or split project-board issues according to the traceability table
+ below.
+3. Confirm whether any issue should remain open because acceptance requires
+ external validation this branch could only draft.
+4. Update project-board priority lanes to match the "Updated priority order"
+ section.
+
+## Closed / completed project-board issues in this stack
+
+| Issue | Evidence artifact / commit area | Proposed status |
+| ----- | ------------------------------------------------- | --------------------------------------------------------------- |
+| #142 | `docs/positioning.md` | Close |
+| #143 | `docs/glossary.md` | Close |
+| #144 | `docs/pmf-decision-rubric.md` | Close |
+| #145 | Root quickstart in `packages/flatbread/README.md` | Close |
+| #146 | README/command guidance updates | Close |
+| #147 | Relation-first traceability docs | Close |
+| #148 | ID normalization helpers/tests | Close |
+| #149 | Missing-reference validation/tests | Close |
+| #150 | Duplicate-ID diagnostics/tests | Close |
+| #151 | Cardinality docs/tests | Close |
+| #152 | Validation snapshot fixtures | Close |
+| #153 | Generated content-model types | Close |
+| #154 | Prototype generated TypeScript read API | Close as prototype; iterate follow-ups |
+| #155 | Read-interface docs | Close |
+| #156 | Narrowed core type surfaces | Close; iterate follow-ups |
+| #157 | `docs/local-dev-loop.md` design | Close design slice; implementation remains follow-up |
+| #158 | Edit/query demo docs/scripts | Close |
+| #159 | JSON export API/docs/tests | Close API slice; CLI remains follow-up |
+| #160 | CSV export API/docs/tests | Close API slice; CLI remains follow-up |
+| #161 | `docs/data-ownership.md` | Close |
+| #162 | Starter benchmark report | Close; network-cold benchmark remains follow-up |
+| #163 | TypeScript safety report | Close; external tests remain follow-up |
+| #164 | Export trust report | Close product self-review; external interviews remain follow-up |
+| #165 | This roadmap | Close after maintainer review |
+| #166 | Already merged separately | Excluded |
+| #167 | Effort Graph wire-up report/fixtures | Close |
+| #168 | Adversarial schema report/fixtures | Close |
+| #169 | Artifact retrieval benchmark | Close; token/multi-session benchmark remains follow-up |
+
+Human maintainers still need to confirm, split, or close issues in GitHub
+because this environment cannot mutate issues directly.
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index 5d96dab6..73fbfc36 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -38,6 +38,8 @@ Turn flat files in Git into typed, relational content for your TypeScript app. *
**Ownership and exit:** Raw files, Git history, JSON/CSV exports, GraphQL introspection, and generated TypeScript all fit one portability story in [docs/data-ownership.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/data-ownership.md).
+**Roadmap:** Current keep/kill/iterate decisions from validation work live in [docs/roadmap.md](https://github.com/FlatbreadLabs/flatbread/blob/main/docs/roadmap.md).
+
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
Born out of a desire to [Gridsome](https://gridsome.org/) (or [Gatsby](https://www.gatsbyjs.com/)) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
From 0fd4f9e6760b2255f76fbd05edae38c62374aa88 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 9 May 2026 21:20:48 +0000
Subject: [PATCH 28/37] docs: propose first-class bounded convergence loops in
@flatbread/proof
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Captures the design rationale for moving the existing
--converge-on/--max-iterations CLI singleton into a DAG-native
`loops` array. Decision recap:
- DAG `depends_on` edges stay acyclic (causality + parallelism).
- Bounded refinement (research → critique → refine, fix → test → fix)
becomes an explicit DAG.loops[] config instead of a back-edge.
- The CLI flag stays valid; loops just lift the same shape into the
JSON file so multiple convergence tasks can stack in one run and
reproducible runs do not depend on remembered flags.
Out of scope (this proposal): new stopWhen predicates beyond
'no-blockers', nested loops, cross-loop coordination.
Co-authored-by: Tony
---
.../proof-bounded-convergence-loops.md | 197 ++++++++++++++++++
1 file changed, 197 insertions(+)
create mode 100644 docs/proposals/proof-bounded-convergence-loops.md
diff --git a/docs/proposals/proof-bounded-convergence-loops.md b/docs/proposals/proof-bounded-convergence-loops.md
new file mode 100644
index 00000000..81bdb229
--- /dev/null
+++ b/docs/proposals/proof-bounded-convergence-loops.md
@@ -0,0 +1,197 @@
+# Proposal: First-class bounded convergence loops in `@flatbread/proof`
+
+Status: Implementation
+Tracking: branch `toeknee/proof-bounded-loop-cde0` stacked on PR #177
+
+## Why
+
+The earlier discussion on cyclic vs acyclic task graphs (cursor agent
+`bc-0ff9d782-…`, run `run-3cc886ad-…`) settled on the position:
+
+- The dependency graph should stay acyclic (DAG `depends_on` edges are
+ about static causality and parallelism — letting `depends_on` form a
+ cycle destroys readiness, skip, and rank semantics for no benefit).
+- "Cyclic flow" is real and useful — research → critique → refine, fix
+ → test → fix-until-oracle, write → review → patch — but it is
+ bounded refinement, not a back-edge in the dependency graph.
+
+`proof` already implements the right shape, just at the CLI:
+
+- `--converge-on ` + `--max-iterations ` re-executes the
+ named task plus its transitive ancestors with the previous result
+ stitched into ancestor prompts as `extraContext`.
+- The loop body parses `## Blockers` and `## High-severity findings`
+ and exits when both are empty, otherwise marks the convergence task
+ `BUDGET-EXCEEDED` after exhausting the iteration cap.
+
+Three real limitations:
+
+1. **Only one convergence task per run.** The CLI flag is a singleton;
+ you cannot stack a code-review loop and a docs-review loop in the
+ same DAG.
+2. **The "what to re-execute" set is hardcoded** to "all transitive
+ ancestors". For wide DAGs you often want to re-run only a focused
+ subset (say, the implementation task and the reviewer, not the
+ six independent research tasks at the root).
+3. **The convergence config lives outside the DAG JSON.** A DAG
+ author who wants reproducible convergence has to remember to pass
+ the right CLI flags every run, and tooling that emits DAGs has no
+ way to declare loop intent.
+
+This proposal adds a first-class, DAG-native bounded loop primitive
+that subsumes the CLI flag without breaking it.
+
+## What
+
+Add an optional top-level `DAG.loops` array. Each entry is a
+`DAGConvergenceLoop`:
+
+```jsonc
+{
+ "title": "implementation + adversarial review",
+ "loops": [
+ {
+ "id": "review-loop",
+ "convergeOn": "review",
+ "maxIterations": 3,
+ "reexecute": { "kind": "ancestors" },
+ "stopWhen": "no-blockers"
+ }
+ ],
+ "tasks": [
+ /* … */
+ ]
+}
+```
+
+### Schema
+
+```ts
+export type LoopReexecute =
+ | { kind: 'ancestors' }
+ | { kind: 'tasks'; tasks: string[] };
+
+export type LoopStopWhen = 'no-blockers'; // future: 'oracle-pass', etc.
+
+export interface DAGConvergenceLoop {
+ /** Stable id for canvas/log display. Defaults to `loop-${convergeOn}`. */
+ id?: string;
+ /** Task whose `## Blockers` / `## High-severity findings` drive the loop. */
+ convergeOn: string;
+ /** Iteration ceiling. Iteration 0 is the original main-rank run. */
+ maxIterations: number;
+ /** What to re-execute on each iteration. Defaults to `{ kind: 'ancestors' }`. */
+ reexecute?: LoopReexecute;
+ /** Stop predicate. Defaults to `'no-blockers'` (current behavior). */
+ stopWhen?: LoopStopWhen;
+}
+```
+
+### Validation rules
+
+- `convergeOn` must be a known task id.
+- `maxIterations` must be a positive integer.
+- For `reexecute.kind === 'tasks'`: every entry must be a known task
+ id; the set must be a subset of `transitiveAncestors(convergeOn) ∪ {convergeOn}`
+ (re-executing tasks outside the convergence ancestor cone breaks
+ topological re-execution order — explicit error rather than silent
+ divergence).
+- Two loops cannot share the same `convergeOn` (avoids ambiguous
+ iteration counter ownership).
+- `id` (when set) must be unique across loops.
+- The CLI `--converge-on` flag is mutually exclusive with `DAG.loops`
+ — supplying both is an error rather than a silent precedence rule.
+
+### Runner behavior
+
+The existing `runConvergenceLoop` function generalizes:
+
+- Caller supplies an explicit `reExecIds` set instead of computing
+ `transitiveAncestors(convergeOn) ∪ {convergeOn}` inside the loop.
+- The CLI flag synthesizes a single-element `loops` array so the same
+ code path covers both entry points.
+- Multiple loops run sequentially (in declaration order). Each loop's
+ `BUDGET-EXCEEDED` propagates to the run-level outcome the same way
+ the single CLI loop does today.
+- `dag.budget.maxIterations` continues to work and applies to each
+ loop independently — it is a hard cap on the per-loop iteration
+ counter, not a global counter.
+
+### What is intentionally out of scope (this PR)
+
+- New `stopWhen` predicates beyond `'no-blockers'`. The existing
+ parser (`extractConvergenceFindings` in `converge_loop.ts`) is
+ the only stop predicate; richer predicates (oracle-pass,
+ numeric thresholds) can land in follow-ups without further
+ schema churn.
+- Nested loops (loop inside loop). The flat array is enough for
+ every workflow we have today.
+- Cross-loop coordination (loop A waits on loop B's iteration N).
+ Same reasoning — no real demand and would force a bigger
+ scheduler rewrite.
+
+## Backward compatibility
+
+- DAG JSON without `loops` keeps parsing untouched.
+- The CLI flags `--converge-on` and `--max-iterations` keep working
+ end-to-end. Their behavior is reimplemented as a synthesized
+ single-element loops array.
+- `DAG.budget.maxIterations` keeps the same meaning (per-loop hard
+ cap) and the same `BUDGET-EXCEEDED` terminal status.
+- The `extractConvergenceFindings` parser, the `findings-dir`
+ sidecar contract, and the `extraContext` stitching format are
+ unchanged. Existing reviewer prompts keep working.
+
+## Test plan
+
+Unit tests (AVA, new `packages/proof/src/__tests__/loops.test.ts`):
+
+- `parseDAG` accepts `loops` with default `reexecute`/`stopWhen`.
+- `parseDAG` rejects `convergeOn` referencing an unknown task id.
+- `parseDAG` rejects two loops with the same `convergeOn`.
+- `parseDAG` rejects two loops with the same explicit `id`.
+- `parseDAG` rejects `reexecute.tasks` containing unknown ids or ids
+ outside the convergence ancestor cone.
+- `parseDAG` rejects non-positive `maxIterations`.
+- `resolveLoopReexecuteIds` returns the right id set for both
+ `'ancestors'` and explicit `tasks` modes.
+- Re-execution rank filtering preserves topological order for the
+ filtered subset.
+
+Backward-compat smoke:
+
+- A DAG with no `loops` and no CLI `--converge-on` runs zero
+ convergence iterations (existing behavior).
+- A DAG with no `loops` plus CLI `--converge-on` synthesizes one
+ loop and runs it.
+- A DAG with `loops` plus CLI `--converge-on` errors at startup.
+
+Self-review via `/proof` is the user-facing acceptance test; this
+PR's test plan above is what gates the merge.
+
+## Migration
+
+No code changes required for existing DAG JSON. Authors who want
+DAG-native convergence can move from:
+
+```bash
+proof --dag run.json --converge-on review --max-iterations 3
+```
+
+…to:
+
+```jsonc
+// run.json
+{
+ "loops": [{ "convergeOn": "review", "maxIterations": 3 }],
+ "tasks": [
+ /* … */
+ ]
+}
+```
+
+```bash
+proof --dag run.json
+```
+
+The CLI form stays valid for ad-hoc runs.
From c8cd76b6946e2f91231f8f1dc2d7f83d787b69ac Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 9 May 2026 21:21:10 +0000
Subject: [PATCH 29/37] feat(proof): first-class bounded convergence loops in
DAG schema
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds DAG.loops[] — an optional array of bounded convergence loops
that runs after the main rank loop completes. Generalizes the legacy
--converge-on/--max-iterations CLI singleton:
- Multiple loops per run (one per convergence task).
- Optional explicit `reexecute.tasks` allow-list, validated to lie
inside the convergence ancestor cone (off-cone tasks would break
filtered topological ordering).
- Default `reexecute: { kind: 'ancestors' }` matches the CLI
behavior bit-for-bit.
- `stopWhen: 'no-blockers'` is the only predicate today; the
schema is open for future predicates without further churn.
Schema, parsing, and re-execution id resolution all live in
dag.ts/converge_loop.ts; the runner consumes a single canonical
ResolvedConvergenceLoop list whether the user supplied DAG.loops or
the CLI flag (the two cannot be combined — the runner errors at
startup to avoid silent precedence rules).
DAG.budget.maxIterations continues to apply per-loop. Loops execute
sequentially in declaration order; each loop's BUDGET-EXCEEDED is
independent and surfaces via the existing per-task tally.
Tests (AVA, packages/proof/src/__tests__/loops.test.ts):
- 18 cases covering parser acceptance, validation rejections,
default-filling, ancestor-cone enforcement, multi-loop ids, and
reexecute selector resolution.
- Smoke verified end-to-end via the proof CLI: --init-only with a
loops-bearing DAG renders the canvas, and combining --converge-on
with DAG.loops fails fast with the expected message.
Co-authored-by: Tony
Change-Id: I403c6326709e508e20b776782e36e2debd462da5
---
packages/proof/src/__tests__/loops.test.ts | 344 +++++++++++++++++++++
packages/proof/src/converge_loop.ts | 29 +-
packages/proof/src/dag.ts | 288 ++++++++++++++++-
packages/proof/src/index.ts | 6 +
packages/proof/src/run_dag.ts | 65 +++-
5 files changed, 716 insertions(+), 16 deletions(-)
create mode 100644 packages/proof/src/__tests__/loops.test.ts
diff --git a/packages/proof/src/__tests__/loops.test.ts b/packages/proof/src/__tests__/loops.test.ts
new file mode 100644
index 00000000..ee7269a8
--- /dev/null
+++ b/packages/proof/src/__tests__/loops.test.ts
@@ -0,0 +1,344 @@
+import test from 'ava';
+import {
+ parseDAG,
+ resolveConvergenceLoops,
+ type DAG,
+ type DAGConvergenceLoop,
+ type RawTask,
+} from '../index.js';
+import { resolveLoopReexecuteIds } from '../converge_loop.js';
+
+const baseTasks: RawTask[] = [
+ {
+ id: 'research',
+ depends_on: [],
+ complexity: 'LOW',
+ subtask_prompt: 'research',
+ kind: 'task',
+ },
+ {
+ id: 'design',
+ depends_on: ['research'],
+ complexity: 'MED',
+ subtask_prompt: 'design',
+ kind: 'task',
+ },
+ {
+ id: 'implement',
+ depends_on: ['design'],
+ complexity: 'MED',
+ subtask_prompt: 'implement',
+ kind: 'task',
+ },
+ {
+ id: 'review',
+ depends_on: ['implement'],
+ complexity: 'HIGH',
+ subtask_prompt: 'review',
+ kind: 'task',
+ },
+];
+
+function dagWith(loops: unknown): unknown {
+ return {
+ title: 'loop-tests',
+ tasks: baseTasks.map((t) => ({
+ id: t.id,
+ depends_on: t.depends_on,
+ complexity: t.complexity,
+ subtask_prompt: t.subtask_prompt,
+ })),
+ loops,
+ };
+}
+
+test('parseDAG accepts a minimal loops entry with defaults', (t) => {
+ const dag = parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 2 }]));
+ t.truthy(dag.loops);
+ t.is(dag.loops!.length, 1);
+ t.is(dag.loops![0].convergeOn, 'review');
+ t.is(dag.loops![0].maxIterations, 2);
+});
+
+test('resolveConvergenceLoops fills defaults', (t) => {
+ const resolved = resolveConvergenceLoops([
+ { convergeOn: 'review', maxIterations: 2 },
+ ]);
+ t.is(resolved[0].id, 'loop-review');
+ t.deepEqual(resolved[0].reexecute, { kind: 'ancestors' });
+ t.is(resolved[0].stopWhen, 'no-blockers');
+});
+
+test('parseDAG rejects convergeOn referencing unknown task id', (t) => {
+ t.throws(
+ () => parseDAG(dagWith([{ convergeOn: 'nope', maxIterations: 2 }])),
+ { message: /not a task id/ }
+ );
+});
+
+test('parseDAG rejects non-positive maxIterations', (t) => {
+ t.throws(
+ () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 0 }])),
+ { message: /maxIterations must be a positive integer/ }
+ );
+ t.throws(
+ () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: -1 }])),
+ { message: /maxIterations must be a positive integer/ }
+ );
+ t.throws(
+ () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 1.5 }])),
+ { message: /maxIterations must be a positive integer/ }
+ );
+});
+
+test('parseDAG rejects two loops with the same convergeOn', (t) => {
+ t.throws(
+ () =>
+ parseDAG(
+ dagWith([
+ { convergeOn: 'review', maxIterations: 2 },
+ { convergeOn: 'review', maxIterations: 3 },
+ ])
+ ),
+ { message: /duplicate convergeOn/ }
+ );
+});
+
+test('parseDAG rejects two loops with the same explicit id', (t) => {
+ t.throws(
+ () =>
+ parseDAG(
+ dagWith([
+ { id: 'shared', convergeOn: 'review', maxIterations: 2 },
+ { id: 'shared', convergeOn: 'design', maxIterations: 2 },
+ ])
+ ),
+ { message: /duplicate loop id/ }
+ );
+});
+
+test('parseDAG accepts explicit reexecute.tasks inside the ancestor cone', (t) => {
+ const dag = parseDAG(
+ dagWith([
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ reexecute: { kind: 'tasks', tasks: ['implement'] },
+ },
+ ])
+ );
+ const reexec = dag.loops![0].reexecute!;
+ t.is(reexec.kind, 'tasks');
+ if (reexec.kind === 'tasks') {
+ t.true(reexec.tasks.includes('implement'));
+ // convergeOn is injected so the loop body always re-runs the
+ // convergence task itself after upstream re-execution.
+ t.true(reexec.tasks.includes('review'));
+ }
+});
+
+test('parseDAG rejects reexecute.tasks outside the ancestor cone', (t) => {
+ // 'review' depends on 'implement' which depends on 'design' which depends
+ // on 'research'. A task `unrelated` that is not in that cone should be
+ // rejected (we synthesize one off the side of the DAG).
+ const raw = {
+ title: 'cone-test',
+ tasks: [
+ ...baseTasks.map((t) => ({
+ id: t.id,
+ depends_on: t.depends_on,
+ complexity: t.complexity,
+ subtask_prompt: t.subtask_prompt,
+ })),
+ {
+ id: 'sibling',
+ depends_on: [],
+ complexity: 'LOW',
+ subtask_prompt: 'sibling',
+ },
+ ],
+ loops: [
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ reexecute: { kind: 'tasks', tasks: ['sibling'] },
+ },
+ ],
+ };
+ t.throws(() => parseDAG(raw), {
+ message: /not the convergeOn task and is not a transitive ancestor/,
+ });
+});
+
+test('parseDAG rejects reexecute.tasks containing unknown task ids', (t) => {
+ t.throws(
+ () =>
+ parseDAG(
+ dagWith([
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ reexecute: { kind: 'tasks', tasks: ['ghost'] },
+ },
+ ])
+ ),
+ { message: /unknown task id/ }
+ );
+});
+
+test('parseDAG rejects unknown reexecute.kind', (t) => {
+ t.throws(
+ () =>
+ parseDAG(
+ dagWith([
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ reexecute: { kind: 'all', tasks: [] },
+ },
+ ])
+ ),
+ { message: /reexecute\.kind must be one of/ }
+ );
+});
+
+test('parseDAG rejects unknown stopWhen', (t) => {
+ t.throws(
+ () =>
+ parseDAG(
+ dagWith([
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ stopWhen: 'oracle-pass',
+ },
+ ])
+ ),
+ { message: /stopWhen must be one of/ }
+ );
+});
+
+test('parseDAG with no loops still works', (t) => {
+ const dag = parseDAG({
+ title: 'no-loops',
+ tasks: [
+ {
+ id: 'only',
+ depends_on: [],
+ complexity: 'LOW',
+ subtask_prompt: 'x',
+ },
+ ],
+ });
+ t.is(dag.loops, undefined);
+});
+
+test('resolveLoopReexecuteIds with ancestors returns the full cone', (t) => {
+ const dag = parseDAG(
+ dagWith([{ convergeOn: 'review', maxIterations: 2 }])
+ ) as DAG;
+ const resolved = resolveConvergenceLoops(dag.loops!);
+ const ids = resolveLoopReexecuteIds(resolved[0], dag);
+ t.deepEqual([...ids].sort(), ['design', 'implement', 'research', 'review']);
+});
+
+test('resolveLoopReexecuteIds with explicit tasks honors the allow-list', (t) => {
+ const dag = parseDAG(
+ dagWith([
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ reexecute: { kind: 'tasks', tasks: ['implement'] },
+ },
+ ])
+ ) as DAG;
+ const resolved = resolveConvergenceLoops(dag.loops!);
+ const ids = resolveLoopReexecuteIds(resolved[0], dag);
+ // Only the explicit allow-list + convergence task itself — not 'design'
+ // or 'research' even though they are ancestors.
+ t.deepEqual([...ids].sort(), ['implement', 'review']);
+});
+
+test('resolveConvergenceLoops preserves user-provided id when set', (t) => {
+ const dag = parseDAG(
+ dagWith([{ id: 'review-loop', convergeOn: 'review', maxIterations: 3 }])
+ );
+ const resolved = resolveConvergenceLoops(dag.loops!);
+ t.is(resolved[0].id, 'review-loop');
+ t.is(resolved[0].maxIterations, 3);
+});
+
+test('parseDAG accepts multiple loops driving distinct tasks', (t) => {
+ const tasks = [
+ {
+ id: 'research',
+ depends_on: [],
+ complexity: 'LOW',
+ subtask_prompt: 'r',
+ },
+ {
+ id: 'docs',
+ depends_on: ['research'],
+ complexity: 'MED',
+ subtask_prompt: 'd',
+ },
+ {
+ id: 'docs-review',
+ depends_on: ['docs'],
+ complexity: 'HIGH',
+ subtask_prompt: 'dr',
+ },
+ {
+ id: 'impl',
+ depends_on: ['research'],
+ complexity: 'MED',
+ subtask_prompt: 'i',
+ },
+ {
+ id: 'impl-review',
+ depends_on: ['impl'],
+ complexity: 'HIGH',
+ subtask_prompt: 'ir',
+ },
+ ];
+ const dag = parseDAG({
+ title: 'multi-loop',
+ tasks,
+ loops: [
+ { convergeOn: 'docs-review', maxIterations: 2 },
+ { convergeOn: 'impl-review', maxIterations: 2 },
+ ],
+ });
+ t.is(dag.loops!.length, 2);
+ const resolved = resolveConvergenceLoops(dag.loops!);
+ t.deepEqual(
+ resolved.map((l) => l.id),
+ ['loop-docs-review', 'loop-impl-review']
+ );
+});
+
+test('parseDAG rejects non-array loops', (t) => {
+ t.throws(() => parseDAG(dagWith({ convergeOn: 'review' })), {
+ message: /must be an array/,
+ });
+});
+
+test('DAGConvergenceLoop type round-trips through resolveConvergenceLoops', (t) => {
+ const declared: DAGConvergenceLoop[] = [
+ {
+ id: 'r',
+ convergeOn: 'review',
+ maxIterations: 5,
+ reexecute: { kind: 'tasks', tasks: ['implement', 'review'] },
+ stopWhen: 'no-blockers',
+ },
+ ];
+ const resolved = resolveConvergenceLoops(declared);
+ t.deepEqual(resolved[0], {
+ id: 'r',
+ convergeOn: 'review',
+ maxIterations: 5,
+ reexecute: { kind: 'tasks', tasks: ['implement', 'review'] },
+ stopWhen: 'no-blockers',
+ });
+});
diff --git a/packages/proof/src/converge_loop.ts b/packages/proof/src/converge_loop.ts
index 4fd75166..6fa38b8b 100644
--- a/packages/proof/src/converge_loop.ts
+++ b/packages/proof/src/converge_loop.ts
@@ -22,7 +22,7 @@
* the same topological order as the original run.
*/
-import type { DAG } from './dag.js';
+import type { DAG, ResolvedConvergenceLoop } from './dag.js';
export interface ConvergenceFindings {
hasIssues: boolean;
@@ -133,6 +133,33 @@ export function transitiveAncestors(taskId: string, dag: DAG): Set {
return visited;
}
+/**
+ * Resolves a single loop's `reexecute` selector into the concrete set of
+ * task ids the runner re-executes per iteration. Always includes the
+ * convergence task itself so the loop body can re-run it after upstream
+ * re-execution. Pure function — does not mutate the DAG or the loop.
+ *
+ * - `{ kind: 'ancestors' }` → `transitiveAncestors(convergeOn) ∪ {convergeOn}`,
+ * matching the legacy `--converge-on` behavior.
+ * - `{ kind: 'tasks'; tasks: [...] }` → the validated allow-list (already
+ * guaranteed at parse time to lie inside the convergence ancestor cone).
+ * The convergence task id is added defensively even though `parseDAG`
+ * already injects it during validation.
+ */
+export function resolveLoopReexecuteIds(
+ loop: ResolvedConvergenceLoop,
+ dag: DAG
+): Set {
+ if (loop.reexecute.kind === 'ancestors') {
+ const ids = transitiveAncestors(loop.convergeOn, dag);
+ ids.add(loop.convergeOn);
+ return ids;
+ }
+ const ids = new Set(loop.reexecute.tasks);
+ ids.add(loop.convergeOn);
+ return ids;
+}
+
/**
* Renders the convergence task's `resultText` into the standard "extra
* upstream context" preamble we stitch into ancestor prompts on re-run. The
diff --git a/packages/proof/src/dag.ts b/packages/proof/src/dag.ts
index 77dc5472..897d167c 100644
--- a/packages/proof/src/dag.ts
+++ b/packages/proof/src/dag.ts
@@ -98,6 +98,17 @@ export interface DAG {
framing?: string;
budget?: DAGBudget;
tasks: RawTask[];
+ /**
+ * Optional first-class bounded convergence loops. Each entry generalizes
+ * the legacy CLI `--converge-on`/`--max-iterations` pair into a DAG-native
+ * declaration so the same JSON file is reproducibly runnable without
+ * remembering the right flags.
+ *
+ * Loops execute sequentially in declaration order after the main rank loop
+ * completes. `--converge-on` may not be combined with `loops`; the runner
+ * errors at startup if both are set.
+ */
+ loops?: DAGConvergenceLoop[];
}
export interface DAGBudget {
@@ -105,6 +116,66 @@ export interface DAGBudget {
maxTokensTotal?: number;
}
+/**
+ * Selector for which tasks a convergence loop re-executes per iteration.
+ *
+ * - `{ kind: 'ancestors' }` — default, mirrors the legacy CLI behavior:
+ * re-runs every transitive ancestor of `convergeOn` plus `convergeOn`
+ * itself.
+ * - `{ kind: 'tasks'; tasks: [...] }` — explicit allow-list. Every id must
+ * be a known task and must lie inside the convergence ancestor cone
+ * (`transitiveAncestors(convergeOn) ∪ {convergeOn}`); ids outside that
+ * cone are rejected at parse time because re-running them would break
+ * topological ordering of the filtered re-execution ranks.
+ */
+export type LoopReexecute =
+ | { kind: 'ancestors' }
+ | { kind: 'tasks'; tasks: string[] };
+
+/**
+ * Stop predicate for a convergence loop. Currently only `'no-blockers'` is
+ * supported — the convergence task's `## Blockers` and
+ * `## High-severity findings` sections must both be empty (per
+ * `extractConvergenceFindings`) for the loop to exit clean. Future
+ * predicates (e.g. `'oracle-pass'`, score thresholds) can be added here
+ * without further schema churn.
+ */
+export type LoopStopWhen = 'no-blockers';
+
+/**
+ * First-class bounded convergence loop. Generalizes the singleton CLI
+ * `--converge-on`/`--max-iterations` pair into a DAG-native config so a
+ * single run can stack multiple convergence tasks (e.g. one for the
+ * implementation reviewer, one for the docs reviewer) and so DAG-emitting
+ * tooling can declare loop intent reproducibly.
+ */
+export interface DAGConvergenceLoop {
+ /** Stable id for canvas/log display. Defaults to `loop-${convergeOn}` when omitted. */
+ id?: string;
+ /** Task whose `## Blockers` / `## High-severity findings` drive the loop. */
+ convergeOn: string;
+ /** Iteration ceiling. Iteration 0 is the original main-rank run. */
+ maxIterations: number;
+ /** What to re-execute per iteration. Defaults to `{ kind: 'ancestors' }`. */
+ reexecute?: LoopReexecute;
+ /** Stop predicate. Defaults to `'no-blockers'`. */
+ stopWhen?: LoopStopWhen;
+}
+
+/** Loop config with all defaults filled in — what the runner actually consumes. */
+export interface ResolvedConvergenceLoop {
+ id: string;
+ convergeOn: string;
+ maxIterations: number;
+ reexecute: LoopReexecute;
+ stopWhen: LoopStopWhen;
+}
+
+const LOOP_REEXECUTE_KINDS = new Set([
+ 'ancestors',
+ 'tasks',
+]);
+const LOOP_STOP_WHEN_VALUES = new Set(['no-blockers']);
const COMPLEXITY_VALUES = new Set(['HIGH', 'MED', 'LOW']);
export const COMPLEXITY_KEYS: readonly Complexity[] = [
'HIGH',
@@ -194,10 +265,225 @@ export function parseDAG(raw: unknown): DAG {
obj.framing === undefined ? undefined : validateFraming(obj.framing);
const budget =
obj.budget === undefined ? undefined : validateBudget(obj.budget);
+ const loops =
+ obj.loops === undefined ? undefined : validateLoops(obj.loops, tasks);
+
+ return { title: obj.title, models, framing, budget, tasks, loops };
+}
- return { title: obj.title, models, framing, budget, tasks };
+/**
+ * Returns the closed set of transitive ancestor ids for `taskId` in the
+ * given task list (the union of `depends_on` reached by repeated
+ * traversal). Mirrors `transitiveAncestors` in `converge_loop.ts` but is
+ * defined here so `parseDAG` can validate `loops.reexecute.tasks` without
+ * a circular module import.
+ */
+function transitiveAncestorIds(taskId: string, tasks: RawTask[]): Set {
+ const byId = new Map(tasks.map((t) => [t.id, t]));
+ const visited = new Set();
+ const start = byId.get(taskId);
+ if (!start) return visited;
+ const stack: string[] = [...start.depends_on];
+ while (stack.length > 0) {
+ const id = stack.pop()!;
+ if (visited.has(id)) continue;
+ visited.add(id);
+ const t = byId.get(id);
+ if (!t) continue;
+ for (const dep of t.depends_on) stack.push(dep);
+ }
+ return visited;
}
+function validateLoops(raw: unknown, tasks: RawTask[]): DAGConvergenceLoop[] {
+ if (!Array.isArray(raw)) {
+ throw new Error('DAG.loops must be an array of loop config objects.');
+ }
+ const taskIds = new Set(tasks.map((t) => t.id));
+ const loops: DAGConvergenceLoop[] = [];
+ const seenConvergeOn = new Set();
+ const seenIds = new Set();
+ for (let i = 0; i < raw.length; i++) {
+ const loop = validateLoop(raw[i], i, taskIds, tasks);
+ if (seenConvergeOn.has(loop.convergeOn)) {
+ throw new Error(
+ `DAG.loops[${i}]: duplicate convergeOn "${loop.convergeOn}" — each loop must drive a distinct task.`
+ );
+ }
+ seenConvergeOn.add(loop.convergeOn);
+ if (loop.id !== undefined) {
+ if (seenIds.has(loop.id)) {
+ throw new Error(`DAG.loops[${i}]: duplicate loop id "${loop.id}".`);
+ }
+ seenIds.add(loop.id);
+ }
+ loops.push(loop);
+ }
+ return loops;
+}
+
+function validateLoop(
+ raw: unknown,
+ index: number,
+ taskIds: Set,
+ tasks: RawTask[]
+): DAGConvergenceLoop {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ throw new Error(`DAG.loops[${index}] must be a JSON object.`);
+ }
+ const obj = raw as Record;
+ const convergeOn = obj.convergeOn;
+ if (typeof convergeOn !== 'string' || convergeOn.trim() === '') {
+ throw new Error(
+ `DAG.loops[${index}].convergeOn must be a non-empty string.`
+ );
+ }
+ if (!taskIds.has(convergeOn)) {
+ throw new Error(
+ `DAG.loops[${index}].convergeOn "${convergeOn}" is not a task id in this DAG.`
+ );
+ }
+ const maxIterations = obj.maxIterations;
+ if (
+ typeof maxIterations !== 'number' ||
+ !Number.isSafeInteger(maxIterations) ||
+ maxIterations <= 0
+ ) {
+ throw new Error(
+ `DAG.loops[${index}].maxIterations must be a positive integer.`
+ );
+ }
+ let id: string | undefined;
+ if (obj.id !== undefined) {
+ if (typeof obj.id !== 'string' || obj.id.trim() === '') {
+ throw new Error(
+ `DAG.loops[${index}].id must be a non-empty string when set.`
+ );
+ }
+ id = obj.id;
+ }
+ let stopWhen: LoopStopWhen | undefined;
+ if (obj.stopWhen !== undefined) {
+ if (
+ typeof obj.stopWhen !== 'string' ||
+ !LOOP_STOP_WHEN_VALUES.has(obj.stopWhen as LoopStopWhen)
+ ) {
+ throw new Error(
+ `DAG.loops[${index}].stopWhen must be one of: ${[
+ ...LOOP_STOP_WHEN_VALUES,
+ ].join(' | ')}.`
+ );
+ }
+ stopWhen = obj.stopWhen as LoopStopWhen;
+ }
+ let reexecute: LoopReexecute | undefined;
+ if (obj.reexecute !== undefined) {
+ reexecute = validateReexecute(
+ obj.reexecute,
+ index,
+ taskIds,
+ convergeOn,
+ tasks
+ );
+ }
+ const loop: DAGConvergenceLoop = { convergeOn, maxIterations };
+ if (id !== undefined) loop.id = id;
+ if (reexecute !== undefined) loop.reexecute = reexecute;
+ if (stopWhen !== undefined) loop.stopWhen = stopWhen;
+ return loop;
+}
+
+function validateReexecute(
+ raw: unknown,
+ loopIndex: number,
+ taskIds: Set,
+ convergeOn: string,
+ tasks: RawTask[]
+): LoopReexecute {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ throw new Error(
+ `DAG.loops[${loopIndex}].reexecute must be a JSON object when set.`
+ );
+ }
+ const obj = raw as Record;
+ const kind = obj.kind;
+ if (
+ typeof kind !== 'string' ||
+ !LOOP_REEXECUTE_KINDS.has(kind as LoopReexecute['kind'])
+ ) {
+ throw new Error(
+ `DAG.loops[${loopIndex}].reexecute.kind must be one of: ${[
+ ...LOOP_REEXECUTE_KINDS,
+ ].join(' | ')}.`
+ );
+ }
+ if (kind === 'ancestors') {
+ return { kind: 'ancestors' };
+ }
+ const list = obj.tasks;
+ if (
+ !Array.isArray(list) ||
+ list.length === 0 ||
+ list.some((t) => typeof t !== 'string' || t.trim() === '')
+ ) {
+ throw new Error(
+ `DAG.loops[${loopIndex}].reexecute.tasks must be a non-empty array of task id strings.`
+ );
+ }
+ const requested = list as string[];
+ for (const id of requested) {
+ if (!taskIds.has(id)) {
+ throw new Error(
+ `DAG.loops[${loopIndex}].reexecute.tasks contains unknown task id "${id}".`
+ );
+ }
+ }
+ // The re-execution set must be a subset of the convergence ancestor cone
+ // (ancestors of convergeOn ∪ convergeOn itself). Re-running a task that
+ // is not a transitive dependency of the convergence task would break the
+ // filtered topological order: the runner re-executes ranks in the
+ // convergence task's downward causal chain, so an unrelated task would
+ // either run out of order or not at all.
+ const cone = transitiveAncestorIds(convergeOn, tasks);
+ cone.add(convergeOn);
+ for (const id of requested) {
+ if (!cone.has(id)) {
+ throw new Error(
+ `DAG.loops[${loopIndex}].reexecute.tasks contains "${id}" which is not the convergeOn task and is not a transitive ancestor of "${convergeOn}".`
+ );
+ }
+ }
+ // Always include the convergence task itself so the loop body can re-run
+ // it after upstream re-execution. De-dupe while preserving caller order.
+ const seen = new Set();
+ const tasksOut: string[] = [];
+ for (const id of [...requested, convergeOn]) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ tasksOut.push(id);
+ }
+ return { kind: 'tasks', tasks: tasksOut };
+}
+
+/**
+ * Fills in defaults (`id`, `reexecute`, `stopWhen`) for each declared loop
+ * so the runner can consume a single canonical shape regardless of which
+ * fields the DAG author left implicit. Pure function — does not access the
+ * DAG task list. Defaults align with the legacy `--converge-on` behavior:
+ * re-execute the full ancestor cone and stop when the convergence task's
+ * `## Blockers` / `## High-severity findings` are both empty.
+ */
+export function resolveConvergenceLoops(
+ loops: readonly DAGConvergenceLoop[]
+): ResolvedConvergenceLoop[] {
+ return loops.map((loop) => ({
+ id: loop.id ?? `loop-${loop.convergeOn}`,
+ convergeOn: loop.convergeOn,
+ maxIterations: loop.maxIterations,
+ reexecute: loop.reexecute ?? { kind: 'ancestors' },
+ stopWhen: loop.stopWhen ?? 'no-blockers',
+ }));
+}
function validateFraming(raw: unknown): string {
if (typeof raw !== 'string') {
throw new Error('DAG.framing must be a string when set.');
diff --git a/packages/proof/src/index.ts b/packages/proof/src/index.ts
index 3458d7f5..b06952c5 100644
--- a/packages/proof/src/index.ts
+++ b/packages/proof/src/index.ts
@@ -18,6 +18,7 @@ export {
isPauseTask,
normalizeModelSelection,
parseDAG,
+ resolveConvergenceLoops,
resolveModelSelectionFromCatalog,
validateModelSelection,
validateModelMap,
@@ -26,6 +27,9 @@ export type {
Complexity,
DAG,
DAGBudget,
+ DAGConvergenceLoop,
+ LoopReexecute,
+ LoopStopWhen,
ModelCatalogItem,
ModelMap,
ModelMapOverride,
@@ -33,6 +37,7 @@ export type {
ModelSelection,
ModelSpec,
RawTask,
+ ResolvedConvergenceLoop,
ResolvedModelMap,
TaskKind,
} from './dag.js';
@@ -43,6 +48,7 @@ export type { RunState, TaskState, TaskStatus } from './canvas_writer.js';
export {
buildConvergenceContext,
extractConvergenceFindings,
+ resolveLoopReexecuteIds,
transitiveAncestors,
} from './converge_loop.js';
export type { ConvergenceFindings } from './converge_loop.js';
diff --git a/packages/proof/src/run_dag.ts b/packages/proof/src/run_dag.ts
index 0565dae9..5015f348 100644
--- a/packages/proof/src/run_dag.ts
+++ b/packages/proof/src/run_dag.ts
@@ -126,6 +126,7 @@ import {
import {
buildConvergenceContext,
extractConvergenceFindings,
+ resolveLoopReexecuteIds,
transitiveAncestors,
} from './converge_loop.js';
import {
@@ -594,6 +595,27 @@ async function main(): Promise {
`--converge-on "${args.convergeOn}" is not a task id in DAG "${dag.title}"`
);
}
+ // The CLI flag and the DAG-native `loops` config both produce convergence
+ // loops; combining them silently would force a precedence rule and make
+ // reproducible runs depend on whether someone remembered to pass the
+ // flag. Reject the combination outright.
+ if (args.convergeOn && dag.loops && dag.loops.length > 0) {
+ throw new Error(
+ `--converge-on cannot be combined with DAG.loops (DAG "${dag.title}" already declares ${dag.loops.length} loop(s)). Remove one.`
+ );
+ }
+ // Synthesize a single-element loop list from the CLI flag so the runner
+ // treats both entry points uniformly. `--max-iterations` (CLI) feeds the
+ // synthesized loop's `maxIterations`; `dag.budget.maxIterations`
+ // continues to apply on top per-loop via the existing budget check.
+ const resolvedLoops =
+ dag.loops !== undefined && dag.loops.length > 0
+ ? resolveConvergenceLoops(dag.loops)
+ : args.convergeOn !== undefined
+ ? resolveConvergenceLoops([
+ { convergeOn: args.convergeOn, maxIterations: args.maxIterations },
+ ])
+ : [];
const fullOutputAbsoluteDir: string | undefined = (() => {
if (args.noArtifacts || args.initOnly || args.dryCheckCmds)
@@ -911,10 +933,17 @@ async function main(): Promise {
}
await maybeRestartAfterRunnerChange('main ranks before convergence');
- if (args.convergeOn) {
+ // Loops execute sequentially in declaration order. A loop that hits
+ // BUDGET-EXCEEDED still lets later loops run — each loop's terminal
+ // state is independent and surfaces through the per-task status
+ // tally, the same way the legacy single-loop CLI worked.
+ for (const loop of resolvedLoops) {
+ const reExecIds = resolveLoopReexecuteIds(loop, dag);
await runConvergenceLoop({
- convergeOn: args.convergeOn,
- maxIterations: args.maxIterations,
+ loopId: loop.id,
+ convergeOn: loop.convergeOn,
+ maxIterations: loop.maxIterations,
+ reExecIds,
dag,
ranks,
stateById,
@@ -926,9 +955,9 @@ async function main(): Promise {
afterIteration: async (iteration: number) => {
writer.schedule(structuredCloneState(state));
await writer.flush();
- await persistState(`completed convergence iteration ${iteration}`);
+ await persistState(`completed ${loop.id} iteration ${iteration}`);
await maybeRestartAfterRunnerChange(
- `convergence iteration ${iteration}`
+ `${loop.id} iteration ${iteration}`
);
},
});
@@ -1553,8 +1582,17 @@ async function writeRunIndexMarkdown(
}
interface RunConvergenceLoopOptions {
+ /** Stable id used in canvas/log messages. Either the user-provided loop id or `loop-${convergeOn}`. */
+ loopId: string;
convergeOn: string;
maxIterations: number;
+ /**
+ * Precomputed re-execution id set. Always contains `convergeOn` itself so
+ * the loop body can re-run it after upstream re-execution completes. The
+ * caller computes this from the loop's `reexecute` selector via
+ * `resolveLoopReexecuteIds`.
+ */
+ reExecIds: Set;
dag: DAG;
ranks: RawTask[][];
stateById: Map;
@@ -1601,9 +1639,10 @@ async function runConvergenceLoop(
opts: RunConvergenceLoopOptions
): Promise {
const {
+ loopId,
convergeOn,
maxIterations,
- dag,
+ reExecIds,
ranks,
stateById,
dispatchTask,
@@ -1617,13 +1656,11 @@ async function runConvergenceLoop(
if (!convergeTs) {
// Defensive — main() already validates this, but the loop must not crash.
console.error(
- `[proof] --converge-on "${convergeOn}" not found in state; skipping convergence loop`
+ `[proof] ${loopId}: convergence task "${convergeOn}" not found in state; skipping`
);
return;
}
- const ancestorIds = transitiveAncestors(convergeOn, dag);
- const reExecIds = new Set([...ancestorIds, convergeOn]);
// Filter the original ranks to just the re-executed tasks. Drop empty
// ranks. Order is preserved → topological correctness is preserved.
const reExecRanks: RawTask[][] = ranks
@@ -1648,7 +1685,7 @@ async function runConvergenceLoop(
);
if (!findings.hasIssues) {
console.log(
- `[proof] converge-on ${convergeOn}: clean — no Blockers / High-severity findings after ${
+ `[proof] ${loopId} (converge-on ${convergeOn}): clean — no Blockers / High-severity findings after ${
iter - 1
} re-iteration(s)`
);
@@ -1677,13 +1714,13 @@ async function runConvergenceLoop(
convergeTs.errorMessage = `Convergence iteration ${iter} would exceed budget.maxIterations=${budget.maxIterations}`;
writer.schedule(structuredCloneState(state));
console.log(
- `[proof] converge-on ${convergeOn}: BUDGET-EXCEEDED — iteration ${iter} would exceed budget.maxIterations=${budget.maxIterations}`
+ `[proof] ${loopId} (converge-on ${convergeOn}): BUDGET-EXCEEDED — iteration ${iter} would exceed budget.maxIterations=${budget.maxIterations}`
);
return;
}
console.log(
- `[proof] converge iteration ${iter}/${maxIterations}: ${findings.blockerLines.length} blocker(s), ${findings.highSeverityLines.length} high-severity finding(s) — re-running ${reExecIds.size} task(s)`
+ `[proof] ${loopId} iteration ${iter}/${maxIterations}: ${findings.blockerLines.length} blocker(s), ${findings.highSeverityLines.length} high-severity finding(s) — re-running ${reExecIds.size} task(s)`
);
const convergenceContext = buildConvergenceContext(
@@ -1762,11 +1799,11 @@ async function runConvergenceLoop(
}
}
console.log(
- `[proof] converge-on ${convergeOn}: BUDGET-EXCEEDED — exhausted --max-iterations=${maxIterations} with ${finalFindings.blockerLines.length} blocker(s), ${finalFindings.highSeverityLines.length} high-severity finding(s)`
+ `[proof] ${loopId} (converge-on ${convergeOn}): BUDGET-EXCEEDED — exhausted maxIterations=${maxIterations} with ${finalFindings.blockerLines.length} blocker(s), ${finalFindings.highSeverityLines.length} high-severity finding(s)`
);
} else {
console.log(
- `[proof] converge-on ${convergeOn}: clean after ${maxIterations} re-iteration(s)`
+ `[proof] ${loopId} (converge-on ${convergeOn}): clean after ${maxIterations} re-iteration(s)`
);
}
}
From 23e5998c23dcce4cd214ae1db9d661928322db08 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 10 May 2026 23:48:19 +0000
Subject: [PATCH 30/37] fix(proof): resolve loop id collision, dedup
transitive-ancestor traversal, harden tests
validateLoops(): replace seenIds with seenResolvedIds so explicit ids cannot collide with another loop's default id (loop-${convergeOn}); detected case: { convergeOn: 'review' } + { id: 'loop-review', convergeOn: 'impl' }
Export transitiveAncestorIds from dag.ts; make transitiveAncestors in converge_loop.ts delegate to it, eliminating the duplicate DFS implementation
loops.test.ts: strengthen reexecute.tasks membership check to deepEqual; add collision regression test, dedup test, pause-as-convergeOn doc test
flatbread: bump @types/express to 5.x for Express 5 so AVA/ts-node can load graphql server tests without TS2769 overload errors
Co-authored-by: Cursor
Change-Id: I6b82b728f0427bd7ef6480ae5e7cdec97039faa3
---
packages/flatbread/package.json | 2 +-
packages/proof/src/__tests__/loops.test.ts | 52 ++++++++++++++++++++--
packages/proof/src/converge_loop.ts | 22 +++------
packages/proof/src/dag.ts | 27 ++++++-----
packages/proof/src/index.ts | 1 +
pnpm-lock.yaml | 37 ++++++++++-----
6 files changed, 101 insertions(+), 40 deletions(-)
diff --git a/packages/flatbread/package.json b/packages/flatbread/package.json
index 2670d164..ffc67209 100644
--- a/packages/flatbread/package.json
+++ b/packages/flatbread/package.json
@@ -55,7 +55,7 @@
},
"devDependencies": {
"@types/cors": "2.8.12",
- "@types/express": "4.17.13",
+ "@types/express": "5.0.6",
"@types/gradient-string": "1.1.2",
"@types/node": "16.11.47",
"@types/sade": "1.7.4",
diff --git a/packages/proof/src/__tests__/loops.test.ts b/packages/proof/src/__tests__/loops.test.ts
index ee7269a8..56e84320 100644
--- a/packages/proof/src/__tests__/loops.test.ts
+++ b/packages/proof/src/__tests__/loops.test.ts
@@ -113,7 +113,24 @@ test('parseDAG rejects two loops with the same explicit id', (t) => {
{ id: 'shared', convergeOn: 'design', maxIterations: 2 },
])
),
- { message: /duplicate loop id/ }
+ { message: /resolved loop id.*shared.*collides/ }
+ );
+});
+
+test('parseDAG rejects loops whose resolved ids collide (explicit id matches another loop\'s default)', (t) => {
+ // Loop 0 has no explicit id: resolves to 'loop-review' via default.
+ // Loop 1 explicitly sets id: 'loop-review', convergeOn a different task.
+ // Before the fix these two loops silently produced duplicate resolved ids;
+ // after the fix parseDAG must throw.
+ t.throws(
+ () =>
+ parseDAG(
+ dagWith([
+ { convergeOn: 'review', maxIterations: 2 },
+ { id: 'loop-review', convergeOn: 'implement', maxIterations: 2 },
+ ])
+ ),
+ { message: /resolved loop id.*loop-review.*collides/ }
);
});
@@ -130,13 +147,42 @@ test('parseDAG accepts explicit reexecute.tasks inside the ancestor cone', (t) =
const reexec = dag.loops![0].reexecute!;
t.is(reexec.kind, 'tasks');
if (reexec.kind === 'tasks') {
- t.true(reexec.tasks.includes('implement'));
// convergeOn is injected so the loop body always re-runs the
// convergence task itself after upstream re-execution.
- t.true(reexec.tasks.includes('review'));
+ t.deepEqual([...reexec.tasks].sort(), ['implement', 'review']);
+ }
+});
+
+test('parseDAG deduplicates convergeOn from reexecute.tasks when caller includes it explicitly', (t) => {
+ const dag = parseDAG(
+ dagWith([
+ {
+ convergeOn: 'review',
+ maxIterations: 2,
+ reexecute: { kind: 'tasks', tasks: ['implement', 'review'] }, // review = convergeOn
+ },
+ ])
+ );
+ const reexec = dag.loops![0].reexecute!;
+ t.is(reexec.kind, 'tasks');
+ if (reexec.kind === 'tasks') {
+ // 'review' must appear exactly once despite being both the convergeOn and explicit in the list
+ t.deepEqual([...reexec.tasks].sort(), ['implement', 'review']);
}
});
+test('parseDAG accepts a pause task as convergeOn (behavior: allowed, convergence semantics may be vacuous)', (t) => {
+ const raw = {
+ title: 'pause-convergeOn',
+ tasks: [
+ { id: 'gate', depends_on: [], subtask_prompt: 'wait', kind: 'pause' },
+ ],
+ loops: [{ convergeOn: 'gate', maxIterations: 1 }],
+ };
+ const dag = parseDAG(raw);
+ t.is(dag.loops![0].convergeOn, 'gate');
+});
+
test('parseDAG rejects reexecute.tasks outside the ancestor cone', (t) => {
// 'review' depends on 'implement' which depends on 'design' which depends
// on 'research'. A task `unrelated` that is not in that cone should be
diff --git a/packages/proof/src/converge_loop.ts b/packages/proof/src/converge_loop.ts
index 6fa38b8b..046e229b 100644
--- a/packages/proof/src/converge_loop.ts
+++ b/packages/proof/src/converge_loop.ts
@@ -22,7 +22,11 @@
* the same topological order as the original run.
*/
-import type { DAG, ResolvedConvergenceLoop } from './dag.js';
+import {
+ transitiveAncestorIds,
+ type DAG,
+ type ResolvedConvergenceLoop,
+} from './dag.js';
export interface ConvergenceFindings {
hasIssues: boolean;
@@ -116,21 +120,7 @@ const PLACEHOLDER_WORDS = new Set([
]);
export function transitiveAncestors(taskId: string, dag: DAG): Set {
- const byId = new Map(dag.tasks.map((t) => [t.id, t]));
- const visited = new Set();
- const start = byId.get(taskId);
- if (!start) return visited;
-
- const stack: string[] = [...start.depends_on];
- while (stack.length > 0) {
- const id = stack.pop()!;
- if (visited.has(id)) continue;
- visited.add(id);
- const t = byId.get(id);
- if (!t) continue;
- for (const dep of t.depends_on) stack.push(dep);
- }
- return visited;
+ return transitiveAncestorIds(taskId, dag.tasks);
}
/**
diff --git a/packages/proof/src/dag.ts b/packages/proof/src/dag.ts
index 897d167c..0a7824a4 100644
--- a/packages/proof/src/dag.ts
+++ b/packages/proof/src/dag.ts
@@ -274,11 +274,16 @@ export function parseDAG(raw: unknown): DAG {
/**
* Returns the closed set of transitive ancestor ids for `taskId` in the
* given task list (the union of `depends_on` reached by repeated
- * traversal). Mirrors `transitiveAncestors` in `converge_loop.ts` but is
- * defined here so `parseDAG` can validate `loops.reexecute.tasks` without
- * a circular module import.
+ * traversal). Canonical transitive-ancestor traversal shared with
+ * `converge_loop.ts`. Defined here (takes `RawTask[]` not a full `DAG`
+ * object) so `parseDAG` can validate `loops.reexecute.tasks` without a
+ * circular module import; `converge_loop.ts:transitiveAncestors` delegates
+ * to this function.
*/
-function transitiveAncestorIds(taskId: string, tasks: RawTask[]): Set