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
9 changes: 9 additions & 0 deletions docs/isolated-schema-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Isolated schema factory

`generateSchema` now creates a new GraphQL composer and schema for every build. A returned schema therefore owns the resolver closures for that build’s content snapshot; a later build cannot replace its types, fields, or reads.

We removed the config-keyed schema cache after applying the deletion test. Deleting it removed more complexity than it exposed: the cache keyed schemas from configuration while resolver closures captured content from the first build, producing stale reads after content changed. It also required validation-before-cache ordering and forced the Next.js watch demo to add a `__demoCacheBust` field solely to avoid a cache hit.

The process-global composer was a separate shared-state issue. Per-build composers isolate type registration and make schemas independently usable; this is not evidence that a schema cache is needed. Because `graphql-compose-json` registers nested object types on the global composer even when given a composer instance, core now owns a small JSON→type parser that threads the per-build composer through every recursion while reproducing the upstream semantics and type naming exactly. AVA remains configured with concurrency `1` until a follow-up validates parallel safety across the complete test suite.

We did not re-key the cache by content-snapshot identity. There is no profiling evidence that schema construction is the dominant cost in a relevant workload, while a content-keyed cache would add identity, eviction, and lifecycle complexity without demonstrated leverage. If profiling later proves a need, introduce a measured cache behind an explicit seam with correctness tests for changing content.
13 changes: 1 addition & 12 deletions examples/nextjs/scripts/watch-content-query.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,7 @@ async function loadFreshProvider() {
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);
return new FlatbreadProvider(result.config);
}

async function render() {
Expand Down
51 changes: 0 additions & 51 deletions packages/core/src/cache/cache.ts

This file was deleted.

85 changes: 85 additions & 0 deletions packages/core/src/generators/composeCollection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
isComposeOutputType,
ObjectTypeComposer,
SchemaComposer,
upperFirst,
} from 'graphql-compose';
import type { EntryNode } from '../types';

type FieldConfig = Parameters<
ObjectTypeComposer<unknown, unknown>['setField']
>[1];

export function composeCollectionTC(
composer: SchemaComposer<unknown>,
typeName: string,
reducedNode: EntryNode
): ObjectTypeComposer<unknown, unknown> {
if (!reducedNode || typeof reducedNode !== 'object') {
throw new Error(
'You provide empty object in second arg for `createTC` method.'
);
}

const tc = composer.createObjectTC(typeName);
Object.keys(reducedNode).forEach((fieldName) => {
const fieldConfig = getFieldConfig(reducedNode[fieldName], {
typeName,
fieldName,
composer,
});
tc.setField(fieldName, fieldConfig);
});
return tc;
}

interface FieldConfigOptions {
composer: SchemaComposer<unknown>;
fieldName?: string;
typeName?: string;
}

function getFieldConfig(
value: unknown,
options: FieldConfigOptions
): FieldConfig {
const typeOf = typeof value;
if (typeOf === 'number') return 'Float';
if (typeOf === 'string') return 'String';
if (typeOf === 'boolean') return 'Boolean';
if (value instanceof Date) return 'Date';
if (isComposeOutputType(value)) return value;

if (typeOf === 'object') {
if (value === null) return 'JSON';
if (Array.isArray(value)) {
if (Array.isArray(value[0])) return ['JSON'];
const firstValue = value[0];
const mergedValue =
typeof firstValue === 'object' && firstValue !== null
? Object.assign({}, ...value)
: firstValue;
const nestedOptions =
options.typeName && options.fieldName
? {
...options,
typeName: options.typeName,
fieldName: options.fieldName,
}
: { composer: options.composer };
return [getFieldConfig(mergedValue, nestedOptions)] as FieldConfig;
}
if (options.typeName && options.fieldName) {
return composeCollectionTC(
options.composer,
`${options.typeName}_${upperFirst(options.fieldName)}`,
value as EntryNode
);
}
}

if (typeOf === 'function') {
return (value as () => FieldConfig)();
}
return 'JSON';
}
40 changes: 10 additions & 30 deletions packages/core/src/generators/schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { schemaComposer } from 'graphql-compose';
import { composeWithJson } from 'graphql-compose-json';
import { SchemaComposer } from 'graphql-compose';
import { merge } from 'lodash-es';
import plur from 'plur';
import { cacheSchema, checkCacheForSchema } from '../cache/cache';
import {
generateArgsForAllItemQuery,
generateArgsForManyItemQuery,
Expand All @@ -17,6 +15,7 @@ import {
LoadedFlatbreadConfig,
} from '../types';
import { produceRecords, validateRecords } from '../records';
import { composeCollectionTC } from './composeCollection';
import { generateCollection } from './generateCollection';

interface RootQueries {
Expand All @@ -37,7 +36,6 @@ interface ResolverPayload {
export async function generateSchema(
configResult: ConfigResult<LoadedFlatbreadConfig> & {
contentGraph?: ContentGraphSnapshot;
useSchemaCache?: boolean;
}
) {
const { config } = configResult;
Expand All @@ -58,21 +56,7 @@ export async function generateSchema(
contentNodesByCollection = validateRecords(allContentNodesJSON, config);
}

// 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 =
configResult.useSchemaCache === false
? undefined
: 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 composer = new SchemaComposer();

const preknownSchemaFragments = fetchPreknownSchemaFragments(config);
const executor = createQueryExecutor({
Expand All @@ -96,15 +80,15 @@ export async function generateSchema(
const schemaArray = Object.fromEntries(
Object.entries(allContentNodesJSON).map(([collection, nodes]) => [
collection,
composeWithJson(
composeCollectionTC(
composer,
collection,
generateCollection({
collection,
nodes,
config,
preknownSchemaFragments,
}),
{ schemaComposer }
})
),
])
);
Expand Down Expand Up @@ -167,7 +151,7 @@ export async function generateSchema(
executor.all({ name: type, refs }, rp.args),
});

schemaComposer.Query.addFields({
composer.Query.addFields({
/**
* Add find by ID to each content type
*/
Expand All @@ -188,12 +172,12 @@ export async function generateSchema(

// Create map of references on each content node
for (const { collection, refs } of config.content) {
const typeTC = schemaComposer.getOTC(collection);
const typeTC = composer.getOTC(collection);

if (!refs) continue;

Object.entries(refs).forEach(([refField, refType]) => {
const refTypeTC = schemaComposer.getOTC(refType);
const refTypeTC = composer.getOTC(refType);

// If the current content type has this valid reference field as declared in the config, we'll add a resolver for this reference
if (!typeTC.hasField(refField)) return;
Expand Down Expand Up @@ -226,11 +210,7 @@ export async function generateSchema(
});
}

const schema = schemaComposer.buildSchema();

if (configResult.useSchemaCache !== false) cacheSchema(config, schema);

return schema;
return composer.buildSchema();
}

/**
Expand Down
Loading
Loading