/**
* Sync Schema Tool
*
* Creates collections and/or fields in Fast Mode.
* Requires authentication. Will skip duplicates.
*/
import {
apiRequest,
isApiError,
needsAuthentication,
} from '../lib/api-client';
import { ensureAuthenticated } from '../lib/device-flow';
import { AVAILABLE_FIELD_TYPES } from './get-field-types';
// ============ Types ============
interface FieldToCreate {
slug: string;
name: string;
type: string;
description?: string;
isRequired?: boolean;
options?: string; // For select/multiSelect
referenceCollection?: string; // For relation type
}
interface CollectionToCreate {
slug: string;
name: string;
nameSingular: string;
description?: string;
hasSlug?: boolean;
fields?: FieldToCreate[];
}
interface FieldsToAdd {
collectionSlug: string;
isBuiltin?: boolean; // Deprecated - kept for backwards compatibility but ignored
fields: FieldToCreate[];
}
interface SyncSchemaInput {
projectId: string;
collections?: CollectionToCreate[];
fieldsToAdd?: FieldsToAdd[];
}
// ============ API Response Types ============
interface Collection {
id: string;
slug: string;
name: string;
nameSingular: string;
fields: Array<{ id: string; slug: string; name: string; type: string }>;
}
interface TenantWithRole {
id: string;
name: string;
subdomain: string;
role: string;
}
// ============ Constants ============
const VALID_FIELD_TYPES = AVAILABLE_FIELD_TYPES.map(ft => ft.value);
const AUTH_REQUIRED_MESSAGE = `# Authentication Required
This tool requires authentication to create collections and fields.
**To authenticate:**
1. Set the FASTMODE_AUTH_TOKEN environment variable, OR
2. Run this tool again and follow the browser-based login flow
Use \`list_projects\` to verify your authentication status.
`;
// ============ Helper Functions ============
/**
* Normalize select/multiSelect options to array format before sending to API.
* Handles comma-separated strings and converts to JSON array.
*/
function normalizeOptionsForApi(options: string | undefined, fieldType: string): string | undefined {
if (!options) return undefined;
// Only process select/multiSelect fields
if (fieldType !== 'select' && fieldType !== 'multiSelect') {
return options;
}
// If it already looks like a JSON array, validate and return
if (options.startsWith('[')) {
try {
const parsed = JSON.parse(options);
if (Array.isArray(parsed)) return options;
} catch {
// Invalid JSON, fall through to comma-separated handling
}
}
// Convert comma-separated string to JSON array
const arr = options.split(',').map(s => s.trim()).filter(Boolean);
return JSON.stringify(arr);
}
/**
* Validate a field type against available types
*/
function validateFieldType(type: string): { valid: boolean; error?: string } {
if (!type) {
return { valid: false, error: 'Field type is required' };
}
if (!VALID_FIELD_TYPES.includes(type)) {
return {
valid: false,
error: `Invalid field type "${type}". Valid types: ${VALID_FIELD_TYPES.join(', ')}`
};
}
return { valid: true };
}
/**
* Validate all fields in input
*/
function validateFields(fields: FieldToCreate[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
for (const field of fields) {
if (!field.slug) {
errors.push(`Field missing slug`);
continue;
}
if (!field.name) {
errors.push(`Field "${field.slug}" missing name`);
}
if (!field.type) {
errors.push(`Field "${field.slug}" missing type. Use get_field_types to see available types.`);
continue;
}
const typeValidation = validateFieldType(field.type);
if (!typeValidation.valid) {
errors.push(`Field "${field.slug}": ${typeValidation.error}`);
}
// Check for required options/referenceCollection
if ((field.type === 'select' || field.type === 'multiSelect') && !field.options) {
errors.push(`Field "${field.slug}" (${field.type}) requires "options" parameter with comma-separated values`);
}
if (field.type === 'relation' && !field.referenceCollection) {
errors.push(`Field "${field.slug}" (relation) requires "referenceCollection" parameter`);
}
}
return { valid: errors.length === 0, errors };
}
/**
* Resolve project identifier to tenant ID
*/
async function resolveProjectId(projectIdentifier: string): Promise<{ tenantId: string } | { error: string }> {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidPattern.test(projectIdentifier)) {
return { tenantId: projectIdentifier };
}
const response = await apiRequest<TenantWithRole[]>('/api/tenants');
if (isApiError(response)) {
return { error: `Failed to look up project: ${response.error}` };
}
const match = response.data.find(
p => p.name.toLowerCase() === projectIdentifier.toLowerCase()
);
if (match) {
return { tenantId: match.id };
}
const partialMatch = response.data.find(
p => p.name.toLowerCase().includes(projectIdentifier.toLowerCase())
);
if (partialMatch) {
return { tenantId: partialMatch.id };
}
return {
error: `Project "${projectIdentifier}" not found. Use list_projects to see available projects.`
};
}
/**
* Fetch existing collections for a project
*/
async function fetchExistingCollections(tenantId: string): Promise<Collection[] | { error: string }> {
const collectionsRes = await apiRequest<Collection[]>('/api/collections', { tenantId });
if (isApiError(collectionsRes)) {
return { error: `Failed to fetch collections: ${collectionsRes.error}` };
}
return collectionsRes.data;
}
// ============ Main Function ============
/**
* Sync schema - create collections and/or fields
*
* @param input - The sync schema input with projectId, collections, and/or fieldsToAdd
*/
export async function syncSchema(input: SyncSchemaInput): Promise<string> {
// Check authentication
if (await needsAuthentication()) {
const authResult = await ensureAuthenticated();
if (!authResult.authenticated) {
return AUTH_REQUIRED_MESSAGE;
}
}
const { projectId, collections, fieldsToAdd } = input;
// Validate input
if (!projectId) {
return `# Error: Missing projectId
Please provide a projectId. Use \`list_projects\` to see your available projects.
`;
}
if ((!collections || collections.length === 0) && (!fieldsToAdd || fieldsToAdd.length === 0)) {
return `# Error: Nothing to sync
Please provide either:
- \`collections\`: Array of new collections to create
- \`fieldsToAdd\`: Array of fields to add to existing collections
Use \`get_field_types\` to see available field types.
`;
}
// Validate all field types before making any API calls
const allValidationErrors: string[] = [];
const fieldTypeTips: string[] = [];
// Field type hints based on common naming patterns
const fieldTypeHints: Record<string, { suggestedType: string; tip: string }> = {
'video': { suggestedType: 'videoEmbed', tip: 'videoEmbed provides responsive iframe helpers for YouTube/Vimeo' },
'youtube': { suggestedType: 'videoEmbed', tip: 'videoEmbed handles YouTube embeds with correct settings' },
'vimeo': { suggestedType: 'videoEmbed', tip: 'videoEmbed handles Vimeo embeds properly' },
'loom': { suggestedType: 'videoEmbed', tip: 'videoEmbed supports Loom video URLs' },
'wistia': { suggestedType: 'videoEmbed', tip: 'videoEmbed supports Wistia video URLs' },
'author': { suggestedType: 'relation', tip: 'relation links to an authors collection' },
'category': { suggestedType: 'relation', tip: 'relation links to a categories collection' },
'categories': { suggestedType: 'relation', tip: 'relation links to a categories collection' },
'tag': { suggestedType: 'relation', tip: 'relation links to a tags collection' },
'tags': { suggestedType: 'relation', tip: 'relation links to a tags collection' },
'parent': { suggestedType: 'relation', tip: 'relation links to a parent collection' },
'related': { suggestedType: 'relation', tip: 'relation links to related items' },
};
// Helper to check field type hints
const checkFieldTypeHint = (field: FieldToCreate) => {
const hint = fieldTypeHints[field.slug.toLowerCase()];
if (hint && field.type !== hint.suggestedType) {
// Only add tip if type is a "close but not optimal" choice
if ((field.type === 'url' || field.type === 'text') && hint.suggestedType === 'videoEmbed') {
fieldTypeTips.push(`💡 "${field.slug}": Consider using "${hint.suggestedType}" type - ${hint.tip}`);
} else if (field.type === 'text' && hint.suggestedType === 'relation') {
fieldTypeTips.push(`💡 "${field.slug}": Consider using "${hint.suggestedType}" type - ${hint.tip}`);
}
}
};
if (collections) {
for (const col of collections) {
if (!col.slug) allValidationErrors.push(`Collection missing slug`);
if (!col.name) allValidationErrors.push(`Collection "${col.slug || 'unknown'}" missing name`);
if (!col.nameSingular) allValidationErrors.push(`Collection "${col.slug || 'unknown'}" missing nameSingular`);
if (col.fields && col.fields.length > 0) {
const fieldValidation = validateFields(col.fields);
if (!fieldValidation.valid) {
allValidationErrors.push(...fieldValidation.errors.map(e => `Collection "${col.slug}": ${e}`));
}
// Check for field type hints
for (const field of col.fields) {
checkFieldTypeHint(field);
}
}
}
}
if (fieldsToAdd) {
for (const group of fieldsToAdd) {
if (!group.collectionSlug) {
allValidationErrors.push(`fieldsToAdd entry missing collectionSlug`);
continue;
}
// Note: isBuiltin is deprecated and ignored - all collections are now custom
if (group.isBuiltin) {
allValidationErrors.push(`isBuiltin is no longer supported. All collections are custom collections. Use the collection slug directly.`);
}
if (!group.fields || group.fields.length === 0) {
allValidationErrors.push(`fieldsToAdd for "${group.collectionSlug}" has no fields`);
continue;
}
const fieldValidation = validateFields(group.fields);
if (!fieldValidation.valid) {
allValidationErrors.push(...fieldValidation.errors.map(e => `${group.collectionSlug}: ${e}`));
}
// Check for field type hints
for (const field of group.fields) {
checkFieldTypeHint(field);
}
}
}
if (allValidationErrors.length > 0) {
return `# Validation Errors
Please fix the following errors before syncing:
${allValidationErrors.map(e => `- ${e}`).join('\n')}
**Tip:** Use \`get_field_types\` to see available field types and their requirements.
`;
}
// Resolve project ID
const resolved = await resolveProjectId(projectId);
if ('error' in resolved) {
return `# Project Not Found
${resolved.error}
`;
}
const { tenantId } = resolved;
// Fetch existing collections
const existingResult = await fetchExistingCollections(tenantId);
if ('error' in existingResult) {
// Check if auth error
if (existingResult.error.includes('401') || existingResult.error.includes('auth')) {
const authResult = await ensureAuthenticated();
if (!authResult.authenticated) {
return AUTH_REQUIRED_MESSAGE;
}
// Retry
const retry = await fetchExistingCollections(tenantId);
if ('error' in retry) {
return `# Error\n\n${retry.error}`;
}
} else {
return `# Error\n\n${existingResult.error}`;
}
}
let existingCollections = Array.isArray(existingResult) ? existingResult : [];
// Track results
const collectionResults: string[] = [];
const fieldResults: string[] = [];
const created = { collections: 0, fields: 0 };
const skipped = { collections: 0, fields: 0 };
const failed = { collections: 0, fields: 0 };
// Build a map of collection slug -> ID (for both existing and newly created)
const collectionIdMap: Map<string, string> = new Map();
const collectionFieldsMap: Map<string, Array<{ slug: string }>> = new Map();
// Initialize with existing collections
for (const col of existingCollections) {
collectionIdMap.set(col.slug.toLowerCase(), col.id);
collectionFieldsMap.set(col.slug.toLowerCase(), col.fields);
}
// ============ PHASE 1: Create/Resolve ALL Collections ============
if (collections && collections.length > 0) {
collectionResults.push('### Phase 1: Collections\n');
for (const col of collections) {
const slugLower = col.slug.toLowerCase();
// Check if collection already exists
if (collectionIdMap.has(slugLower)) {
collectionResults.push(`| ${col.slug} | Skipped | Already exists |`);
skipped.collections++;
continue;
}
// Create new collection (WITHOUT fields - those come in Phase 2)
let createRes = await apiRequest<{ id: string }>('/api/collections', {
tenantId,
method: 'POST',
body: {
slug: col.slug,
name: col.name,
nameSingular: col.nameSingular,
description: col.description,
hasSlug: col.hasSlug ?? true,
},
});
// Retry once if failed
if (isApiError(createRes)) {
await new Promise(resolve => setTimeout(resolve, 500));
createRes = await apiRequest<{ id: string }>('/api/collections', {
tenantId,
method: 'POST',
body: {
slug: col.slug,
name: col.name,
nameSingular: col.nameSingular,
description: col.description,
hasSlug: col.hasSlug ?? true,
},
});
}
if (isApiError(createRes)) {
collectionResults.push(`| ${col.slug} | FAILED | ${createRes.error} |`);
failed.collections++;
continue;
}
// Add to our maps
collectionIdMap.set(slugLower, createRes.data.id);
collectionFieldsMap.set(slugLower, []); // New collection has no fields yet
collectionResults.push(`| ${col.slug} | Created | ${col.name} |`);
created.collections++;
}
}
// ============ PHASE 2: Add ALL Fields ============
// Gather all fields to add (from both collections and fieldsToAdd)
interface FieldJob {
collectionSlug: string;
field: FieldToCreate;
}
const fieldJobs: FieldJob[] = [];
// Fields from new collections
if (collections) {
for (const col of collections) {
if (col.fields && col.fields.length > 0) {
for (const field of col.fields) {
fieldJobs.push({ collectionSlug: col.slug, field });
}
}
}
}
// Fields from fieldsToAdd
if (fieldsToAdd) {
for (const group of fieldsToAdd) {
for (const field of group.fields) {
fieldJobs.push({ collectionSlug: group.collectionSlug, field });
}
}
}
if (fieldJobs.length > 0) {
fieldResults.push('### Phase 2: Fields\n');
fieldResults.push('| Collection | Field | Type | Status |');
fieldResults.push('|------------|-------|------|--------|');
for (const job of fieldJobs) {
const slugLower = job.collectionSlug.toLowerCase();
const collectionId = collectionIdMap.get(slugLower);
if (!collectionId) {
fieldResults.push(`| ${job.collectionSlug} | ${job.field.slug} | ${job.field.type} | FAILED: Collection not found |`);
failed.fields++;
continue;
}
// Check if field already exists
const existingFields = collectionFieldsMap.get(slugLower) || [];
const fieldExists = existingFields.some(
f => f.slug.toLowerCase() === job.field.slug.toLowerCase()
);
if (fieldExists) {
fieldResults.push(`| ${job.collectionSlug} | ${job.field.slug} | ${job.field.type} | Skipped (exists) |`);
skipped.fields++;
continue;
}
// Normalize options for select/multiSelect fields before sending
const normalizedOptions = normalizeOptionsForApi(job.field.options, job.field.type);
// Create the field with retry logic
let fieldRes = await apiRequest<unknown>(`/api/collections/${collectionId}/fields`, {
tenantId,
method: 'POST',
body: {
slug: job.field.slug,
name: job.field.name,
type: job.field.type,
description: job.field.description,
isRequired: job.field.isRequired,
options: normalizedOptions,
referenceCollection: job.field.referenceCollection,
},
});
// Retry once if failed (network issues, temporary errors)
if (isApiError(fieldRes)) {
// Wait a moment and retry
await new Promise(resolve => setTimeout(resolve, 500));
fieldRes = await apiRequest<unknown>(`/api/collections/${collectionId}/fields`, {
tenantId,
method: 'POST',
body: {
slug: job.field.slug,
name: job.field.name,
type: job.field.type,
description: job.field.description,
isRequired: job.field.isRequired,
options: normalizedOptions,
referenceCollection: job.field.referenceCollection,
},
});
}
if (isApiError(fieldRes)) {
fieldResults.push(`| ${job.collectionSlug} | ${job.field.slug} | ${job.field.type} | FAILED: ${fieldRes.error} |`);
failed.fields++;
} else {
fieldResults.push(`| ${job.collectionSlug} | ${job.field.slug} | ${job.field.type} | Created |`);
created.fields++;
// Update the fields map so subsequent checks know this field exists
const fields = collectionFieldsMap.get(slugLower) || [];
fields.push({ slug: job.field.slug });
collectionFieldsMap.set(slugLower, fields);
}
}
}
// ============ Build Summary ============
const hasFailures = failed.collections > 0 || failed.fields > 0;
let output = `# Schema Sync ${hasFailures ? 'Completed with Errors' : 'Complete'}
**Project ID:** \`${tenantId}\`
## Summary
| Metric | Created | Skipped | Failed |
|--------|---------|---------|--------|
| Collections | ${created.collections} | ${skipped.collections} | ${failed.collections} |
| Fields | ${created.fields} | ${skipped.fields} | ${failed.fields} |
`;
if (collectionResults.length > 1) {
output += `## Collections
| Slug | Status | Details |
|------|--------|---------|
${collectionResults.slice(1).join('\n')}
`;
}
if (fieldResults.length > 0) {
output += `## Fields
${fieldResults.join('\n')}
`;
}
if (hasFailures) {
output += `---
## ACTION REQUIRED
Some items failed to create. Please review the errors above and:
1. Fix any issues with field types or parameters
2. Run sync_schema again - it will skip already-created items and retry failed ones
`;
}
// Add field type tips if any were collected
if (fieldTypeTips.length > 0) {
output += `---
## Tips
The following suggestions may help improve your schema:
${fieldTypeTips.join('\n')}
These are suggestions only - your current field types will still work.
`;
}
return output;
}