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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 27 additions & 42 deletions docs/local-dev-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ Flatbread's local loop has four moving parts:
4. **Framework restart / refresh** — `flatbread start -- <framework command>`
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.
Today these pieces are automated by `flatbread start --watch`: content edits
incrementally reindex and hot-swap the GraphQL schema, config edits rebuild the
schema and watcher matchers, and document edits refresh codegen.

## Canonical Next.js happy path

Expand All @@ -39,31 +37,28 @@ 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
pnpm exec flatbread start --watch -- next dev --turbopack
```

Expected behavior:

- Editing a `.graphql` document or a content/config file triggers the codegen
watcher and updates `generated/graphql.ts`.
- Editing a `.graphql` document or a content/config file refreshes
`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.
- The running GraphQL endpoint at `http://localhost:5057/graphql` hot-swaps
valid content and config generations without restarting the framework.

## 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 |
| Change | Codegen watcher behavior | Running GraphQL server | Framework app | Action required today |
| --------------------------------------- | --------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------- |
| Markdown/YAML field value | Refreshes types if watched path matches | Hot-swaps after validation | Keeps rendering whatever the endpoint returns | None; framework refresh remains explicit |
| New/removed content file | Refreshes types if watched path matches | Hot-swaps after validation | Keeps rendering whatever the endpoint returns | None; framework refresh remains explicit |
| `.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 | Reloads config and refreshes types | Rebuilds and hot-swaps after validation | Keeps rendering whatever the endpoint returns | None; framework refresh remains explicit |
| 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

Expand All @@ -75,10 +70,10 @@ Expected behavior:
- 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.
- In unified watch mode, invalid candidates are rejected atomically: generated
artifacts and the live GraphQL server remain on the previous committed graph.

## Draft unified watch design (not implemented)
## Draft unified watch design (implemented)

The unified loop should eventually make this one command:

Expand Down Expand Up @@ -107,29 +102,19 @@ Design contract:

## Known limitations

- `flatbread start` does **not** currently hot-swap schema or content.
- `flatbread start --watch` hot-swaps valid content/config generations; invalid
candidates leave the prior schema active.
- `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.
- Codegen failures are logged and do not undo a committed schema generation.
- Watch mode requires a source plugin with `fetchPaths`; sources without it fail
fast at startup.
- `flatbread.config.*` watching is relative to the `flatbread start` cwd.
- 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.
Framework restarts remain explicit: Flatbread keeps the framework child process
running and does not attempt to restart or control its own refresh behavior.
94 changes: 94 additions & 0 deletions packages/codegen/src/__tests__/watch-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type { LoadedFlatbreadConfig } from '@flatbread/core';
import {
deriveFlatbreadWatchPatterns,
flattenFlatbreadWatchPatterns,
} from '../watchPatterns.js';

function makeConfig(
overrides: Partial<{
content: Array<Record<string, unknown>>;
extensions: string[];
}> = {}
): LoadedFlatbreadConfig {
return {
source: { fetch: async () => ({}) },
transformer: [],
fieldNameTransform: (field: string) => field,
content: overrides.content ?? [
{ path: 'content/posts', collection: 'Post' },
],
loaded: {
extensions: overrides.extensions ?? ['.md'],
},
} as unknown as LoadedFlatbreadConfig;
}

describe('deriveFlatbreadWatchPatterns', () => {
it('always includes the flatbread config glob', () => {
const patterns = deriveFlatbreadWatchPatterns(makeConfig(), {});
expect(patterns.config).toEqual(['flatbread.config.*']);
});

it('derives a content glob for a single extension', () => {
const patterns = deriveFlatbreadWatchPatterns(
makeConfig({ extensions: ['.md'] }),
{}
);
expect(patterns.content).toEqual(['content/posts/**/*.md']);
});

it('derives a brace-expansion content glob for multiple extensions and normalizes leading dots', () => {
const patterns = deriveFlatbreadWatchPatterns(
makeConfig({ extensions: ['.md', 'mdx', '.markdown'] }),
{}
);
expect(patterns.content).toEqual(['content/posts/**/*.{md,mdx,markdown}']);
});

it('derives one content glob per configured content entry and skips entries without a path', () => {
const patterns = deriveFlatbreadWatchPatterns(
makeConfig({
content: [
{ path: 'content/posts', collection: 'Post' },
{ path: 'content/authors', collection: 'Author' },
{ collection: 'Virtual' },
],
extensions: ['.md'],
}),
{}
);
expect(patterns.content).toEqual([
'content/posts/**/*.md',
'content/authors/**/*.md',
]);
});

it('passes documents through untouched and defaults to empty', () => {
const withDocuments = deriveFlatbreadWatchPatterns(makeConfig(), {
documents: ['src/**/*.graphql', 'queries/*.gql'],
});
expect(withDocuments.documents).toEqual([
'src/**/*.graphql',
'queries/*.gql',
]);

const withoutDocuments = deriveFlatbreadWatchPatterns(makeConfig(), {});
expect(withoutDocuments.documents).toEqual([]);
});
});

describe('flattenFlatbreadWatchPatterns', () => {
it('concatenates config, content, and documents patterns in order', () => {
const flattened = flattenFlatbreadWatchPatterns({
config: ['flatbread.config.*'],
content: ['content/posts/**/*.md'],
documents: ['src/**/*.graphql'],
});
expect(flattened).toEqual([
'flatbread.config.*',
'content/posts/**/*.md',
'src/**/*.graphql',
]);
});
});
58 changes: 7 additions & 51 deletions packages/codegen/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
checkPluginDependencies,
formatMissingDepsWarning,
} from './dependencyCheck.js';
import {
deriveFlatbreadWatchPatterns,
flattenFlatbreadWatchPatterns,
} from './watchPatterns.js';

function emitMissingDepsWarning(missingDeps: string[]) {
if (missingDeps.length === 0) return;
Expand Down Expand Up @@ -568,56 +572,6 @@ export async function generateTypesWithDocuments(
return generateTypes(schema, config, mergedOptions);
}

/**
* Get file patterns to watch based on the Flatbread configuration.
*
* Builds a list of glob patterns that includes:
* - Flatbread config files (flatbread.config.*)
* - Content directories with supported file extensions
* - GraphQL document files if specified in options
*
* @param config Loaded Flatbread configuration
* @param options Codegen options that may include document paths
* @returns Array of glob patterns to watch
*/
function getWatchPatterns(
config: LoadedFlatbreadConfig,
options: CodegenOptions
): string[] {
const patterns: string[] = [];

// Watch Flatbread config files
patterns.push('flatbread.config.*');

// Watch content directories from the configuration
if (config.content) {
for (const contentType of config.content) {
if (contentType.path) {
// Watch for all supported file extensions in content directories
const rawExtensions = config.loaded?.extensions || [
'.md',
'.mdx',
'.markdown',
];
// Remove dots from extensions since we'll add one in the pattern
const extensions = rawExtensions.map((ext) =>
ext.startsWith('.') ? ext.slice(1) : ext
);
const extensionPattern =
extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0];
patterns.push(`${contentType.path}/**/*.${extensionPattern}`);
}
}
}

// Watch GraphQL document files if provided
if (options.documents && options.documents.length > 0) {
patterns.push(...options.documents);
}

return patterns;
}

/**
* Watch for changes and regenerate types automatically.
*
Expand Down Expand Up @@ -673,7 +627,9 @@ export async function watchAndGenerate(
await generateTypes(schema, config, currentOptions);

// Set up file watchers
const patterns = getWatchPatterns(config, currentOptions);
const patterns = flattenFlatbreadWatchPatterns(
deriveFlatbreadWatchPatterns(config, currentOptions)
);
console.log(kleur.dim(`Watching patterns: ${patterns.join(', ')}`));

const ignored = [
Expand Down
6 changes: 6 additions & 0 deletions packages/codegen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ export {
export { loadCache, saveCache, isCacheValid, clearCache } from './cache.js';

export { createCodegenCommand } from './cli.js';

export {
deriveFlatbreadWatchPatterns,
flattenFlatbreadWatchPatterns,
} from './watchPatterns.js';
export type { FlatbreadWatchPatterns } from './watchPatterns.js';
37 changes: 37 additions & 0 deletions packages/codegen/src/watchPatterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { LoadedFlatbreadConfig } from '@flatbread/core';
import type { CodegenOptions } from './types.js';

export interface FlatbreadWatchPatterns {
config: readonly string[];
content: readonly string[];
documents: readonly string[];
}

export function deriveFlatbreadWatchPatterns(
config: LoadedFlatbreadConfig,
options: Pick<CodegenOptions, 'documents'>
): FlatbreadWatchPatterns {
const rawExtensions = config.loaded?.extensions || [
'.md',
'.mdx',
'.markdown',
];
const extensions = rawExtensions.map((ext) =>
ext.startsWith('.') ? ext.slice(1) : ext
);
const extensionPattern =
extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0];
return {
config: ['flatbread.config.*'],
content: config.content
.filter((entry) => Boolean(entry.path))
.map((entry) => `${entry.path}/**/*.${extensionPattern}`),
documents: options.documents ?? [],
};
}

export function flattenFlatbreadWatchPatterns(
patterns: FlatbreadWatchPatterns
): string[] {
return [...patterns.config, ...patterns.content, ...patterns.documents];
}
Loading
Loading