first commit
This commit is contained in:
29
packages/core/src/schema/index.ts
Normal file
29
packages/core/src/schema/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export { SchemaRegistry, SchemaError } from "./registry.js";
|
||||
export type {
|
||||
FieldType,
|
||||
ColumnType,
|
||||
CollectionSupport,
|
||||
CollectionSource,
|
||||
FieldValidation,
|
||||
FieldWidgetOptions,
|
||||
Collection,
|
||||
Field,
|
||||
CreateCollectionInput,
|
||||
UpdateCollectionInput,
|
||||
CreateFieldInput,
|
||||
UpdateFieldInput,
|
||||
CollectionWithFields,
|
||||
} from "./types.js";
|
||||
export { FIELD_TYPE_TO_COLUMN, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS } from "./types.js";
|
||||
|
||||
export { getCollectionInfo, getCollectionInfoWithDb } from "./query.js";
|
||||
|
||||
export {
|
||||
generateZodSchema,
|
||||
generateFieldSchema,
|
||||
getCachedSchema,
|
||||
invalidateSchemaCache,
|
||||
clearSchemaCache,
|
||||
validateContent,
|
||||
generateTypeScript,
|
||||
} from "./zod-generator.js";
|
||||
44
packages/core/src/schema/query.ts
Normal file
44
packages/core/src/schema/query.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Collection info query for Astro templates.
|
||||
*
|
||||
* Same pattern as getMenu() / getComments() — uses getDb() for ambient DB access.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
|
||||
import type { Database } from "../database/types.js";
|
||||
import { getDb } from "../loader.js";
|
||||
import { SchemaRegistry } from "./registry.js";
|
||||
import type { Collection } from "./types.js";
|
||||
|
||||
/**
|
||||
* Get collection metadata by slug.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getCollectionInfo } from "emdash";
|
||||
*
|
||||
* const info = await getCollectionInfo("posts");
|
||||
* if (info?.commentsEnabled) {
|
||||
* // render comment UI
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getCollectionInfo(slug: string): Promise<Collection | null> {
|
||||
const db = await getDb();
|
||||
return getCollectionInfoWithDb(db, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection metadata with an explicit db handle.
|
||||
*
|
||||
* @internal Use `getCollectionInfo()` in templates. This variant is for
|
||||
* routes that already have a database handle.
|
||||
*/
|
||||
export async function getCollectionInfoWithDb(
|
||||
db: Kysely<Database>,
|
||||
slug: string,
|
||||
): Promise<Collection | null> {
|
||||
const registry = new SchemaRegistry(db);
|
||||
return registry.getCollection(slug);
|
||||
}
|
||||
965
packages/core/src/schema/registry.ts
Normal file
965
packages/core/src/schema/registry.ts
Normal file
@@ -0,0 +1,965 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import type { Selectable } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js";
|
||||
import { withTransaction } from "../database/transaction.js";
|
||||
import type { CollectionTable, Database, FieldTable } from "../database/types.js";
|
||||
import { FTSManager } from "../search/fts-manager.js";
|
||||
import {
|
||||
type Collection,
|
||||
type CollectionSource,
|
||||
type ColumnType,
|
||||
type Field,
|
||||
type CreateCollectionInput,
|
||||
type UpdateCollectionInput,
|
||||
type CreateFieldInput,
|
||||
type UpdateFieldInput,
|
||||
type CollectionWithFields,
|
||||
type FieldType,
|
||||
FIELD_TYPE_TO_COLUMN,
|
||||
RESERVED_FIELD_SLUGS,
|
||||
RESERVED_COLLECTION_SLUGS,
|
||||
} from "./types.js";
|
||||
|
||||
// Regex patterns for schema registry
|
||||
const SLUG_VALIDATION_PATTERN = /^[a-z][a-z0-9_]*$/;
|
||||
const EC_PREFIX_PATTERN = /^ec_/;
|
||||
const SINGLE_QUOTE_PATTERN = /'/g;
|
||||
const UNDERSCORE_PATTERN = /_/g;
|
||||
const WORD_BOUNDARY_PATTERN = /\b\w/g;
|
||||
|
||||
/** Valid column types for runtime validation */
|
||||
const COLUMN_TYPES: ReadonlySet<string> = new Set(["TEXT", "REAL", "INTEGER", "JSON"]);
|
||||
|
||||
/** Valid collection source prefixes/values */
|
||||
const VALID_SOURCES: ReadonlySet<string> = new Set(["manual", "discovered", "seed"]);
|
||||
|
||||
function isCollectionSource(value: string): value is CollectionSource {
|
||||
return VALID_SOURCES.has(value) || value.startsWith("template:") || value.startsWith("import:");
|
||||
}
|
||||
|
||||
function isFieldType(value: string): value is FieldType {
|
||||
return value in FIELD_TYPE_TO_COLUMN;
|
||||
}
|
||||
|
||||
function isColumnType(value: string): value is ColumnType {
|
||||
return COLUMN_TYPES.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a schema operation fails
|
||||
*/
|
||||
export class SchemaError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SchemaError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema Registry
|
||||
*
|
||||
* Manages collection and field definitions stored in D1.
|
||||
* Handles runtime DDL operations (CREATE TABLE, ALTER TABLE).
|
||||
*/
|
||||
export class SchemaRegistry {
|
||||
constructor(private db: Kysely<Database>) {}
|
||||
|
||||
// ============================================
|
||||
// Collection Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* List all collections
|
||||
*/
|
||||
async listCollections(): Promise<Collection[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom("_emdash_collections")
|
||||
.selectAll()
|
||||
.orderBy("slug", "asc")
|
||||
.execute();
|
||||
|
||||
return rows.map(this.mapCollectionRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection by slug
|
||||
*/
|
||||
async getCollection(slug: string): Promise<Collection | null> {
|
||||
const row = await this.db
|
||||
.selectFrom("_emdash_collections")
|
||||
.where("slug", "=", slug)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? this.mapCollectionRow(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection with all its fields
|
||||
*/
|
||||
async getCollectionWithFields(slug: string): Promise<CollectionWithFields | null> {
|
||||
const collection = await this.getCollection(slug);
|
||||
if (!collection) return null;
|
||||
|
||||
const fields = await this.listFields(collection.id);
|
||||
|
||||
return { ...collection, fields };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new collection
|
||||
*/
|
||||
async createCollection(input: CreateCollectionInput): Promise<Collection> {
|
||||
// Validate slug
|
||||
this.validateSlug(input.slug, "collection");
|
||||
if (RESERVED_COLLECTION_SLUGS.includes(input.slug)) {
|
||||
throw new SchemaError(`Collection slug "${input.slug}" is reserved`, "RESERVED_SLUG");
|
||||
}
|
||||
|
||||
// Check if collection already exists
|
||||
const existing = await this.getCollection(input.slug);
|
||||
if (existing) {
|
||||
throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS");
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
|
||||
// Insert collection record and create content table in a transaction
|
||||
// so a failure in table creation doesn't leave an orphaned row.
|
||||
// Uses withTransaction for D1 compatibility (no transaction support).
|
||||
// Derive hasSeo from supports array if not explicitly set
|
||||
const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false;
|
||||
|
||||
await withTransaction(this.db, async (trx) => {
|
||||
await trx
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id,
|
||||
slug: input.slug,
|
||||
label: input.label,
|
||||
label_singular: input.labelSingular ?? null,
|
||||
description: input.description ?? null,
|
||||
icon: input.icon ?? null,
|
||||
supports: input.supports ? JSON.stringify(input.supports) : null,
|
||||
source: input.source ?? "manual",
|
||||
has_seo: hasSeo ? 1 : 0,
|
||||
comments_enabled: input.commentsEnabled ? 1 : 0,
|
||||
url_pattern: input.urlPattern ?? null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create the content table for this collection
|
||||
await this.createContentTable(input.slug, trx);
|
||||
});
|
||||
|
||||
const collection = await this.getCollection(input.slug);
|
||||
if (!collection) {
|
||||
throw new SchemaError("Failed to create collection", "CREATE_FAILED");
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a collection
|
||||
*/
|
||||
async updateCollection(slug: string, input: UpdateCollectionInput): Promise<Collection> {
|
||||
const existing = await this.getCollection(slug);
|
||||
if (!existing) {
|
||||
throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Derive hasSeo from supports array if supports is being updated and hasSeo not explicitly set
|
||||
const supportsArray = input.supports ?? existing.supports;
|
||||
const hasSeo =
|
||||
input.hasSeo !== undefined
|
||||
? input.hasSeo
|
||||
: input.supports !== undefined
|
||||
? supportsArray.includes("seo")
|
||||
: existing.hasSeo;
|
||||
|
||||
await this.db
|
||||
.updateTable("_emdash_collections")
|
||||
.set({
|
||||
label: input.label ?? existing.label,
|
||||
label_singular: input.labelSingular ?? existing.labelSingular ?? null,
|
||||
description: input.description ?? existing.description ?? null,
|
||||
icon: input.icon ?? existing.icon ?? null,
|
||||
supports: input.supports
|
||||
? JSON.stringify(input.supports)
|
||||
: JSON.stringify(existing.supports),
|
||||
url_pattern:
|
||||
input.urlPattern !== undefined
|
||||
? (input.urlPattern ?? null)
|
||||
: (existing.urlPattern ?? null),
|
||||
has_seo: hasSeo ? 1 : 0,
|
||||
comments_enabled:
|
||||
input.commentsEnabled !== undefined
|
||||
? input.commentsEnabled
|
||||
? 1
|
||||
: 0
|
||||
: existing.commentsEnabled
|
||||
? 1
|
||||
: 0,
|
||||
comments_moderation: input.commentsModeration ?? existing.commentsModeration,
|
||||
comments_closed_after_days:
|
||||
input.commentsClosedAfterDays !== undefined
|
||||
? input.commentsClosedAfterDays
|
||||
: existing.commentsClosedAfterDays,
|
||||
comments_auto_approve_users:
|
||||
input.commentsAutoApproveUsers !== undefined
|
||||
? input.commentsAutoApproveUsers
|
||||
? 1
|
||||
: 0
|
||||
: existing.commentsAutoApproveUsers
|
||||
? 1
|
||||
: 0,
|
||||
updated_at: now,
|
||||
})
|
||||
.where("slug", "=", slug)
|
||||
.execute();
|
||||
|
||||
const updated = await this.getCollection(slug);
|
||||
if (!updated) {
|
||||
throw new SchemaError("Failed to update collection", "UPDATE_FAILED");
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a collection
|
||||
*/
|
||||
async deleteCollection(slug: string, options?: { force?: boolean }): Promise<void> {
|
||||
const existing = await this.getCollection(slug);
|
||||
if (!existing) {
|
||||
throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND");
|
||||
}
|
||||
|
||||
// Check if collection has content
|
||||
if (!options?.force) {
|
||||
const hasContent = await this.collectionHasContent(slug);
|
||||
if (hasContent) {
|
||||
throw new SchemaError(
|
||||
`Collection "${slug}" has content. Use force: true to delete.`,
|
||||
"COLLECTION_HAS_CONTENT",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the content table
|
||||
await this.dropContentTable(slug);
|
||||
|
||||
// Delete the collection record (fields will cascade)
|
||||
await this.db.deleteFrom("_emdash_collections").where("id", "=", existing.id).execute();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Field Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* List fields for a collection
|
||||
*/
|
||||
async listFields(collectionId: string): Promise<Field[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom("_emdash_fields")
|
||||
.where("collection_id", "=", collectionId)
|
||||
.selectAll()
|
||||
.orderBy("sort_order", "asc")
|
||||
.orderBy("created_at", "asc")
|
||||
.execute();
|
||||
|
||||
return rows.map(this.mapFieldRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a field by slug within a collection
|
||||
*/
|
||||
async getField(collectionSlug: string, fieldSlug: string): Promise<Field | null> {
|
||||
const collection = await this.getCollection(collectionSlug);
|
||||
if (!collection) return null;
|
||||
|
||||
const row = await this.db
|
||||
.selectFrom("_emdash_fields")
|
||||
.where("collection_id", "=", collection.id)
|
||||
.where("slug", "=", fieldSlug)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? this.mapFieldRow(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new field
|
||||
*/
|
||||
async createField(collectionSlug: string, input: CreateFieldInput): Promise<Field> {
|
||||
const collection = await this.getCollection(collectionSlug);
|
||||
if (!collection) {
|
||||
throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND");
|
||||
}
|
||||
|
||||
// Validate slug
|
||||
this.validateSlug(input.slug, "field");
|
||||
if (RESERVED_FIELD_SLUGS.includes(input.slug)) {
|
||||
throw new SchemaError(`Field slug "${input.slug}" is reserved`, "RESERVED_SLUG");
|
||||
}
|
||||
|
||||
// Check if field already exists
|
||||
const existing = await this.getField(collectionSlug, input.slug);
|
||||
if (existing) {
|
||||
throw new SchemaError(
|
||||
`Field "${input.slug}" already exists in collection "${collectionSlug}"`,
|
||||
"FIELD_EXISTS",
|
||||
);
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
const columnType = FIELD_TYPE_TO_COLUMN[input.type];
|
||||
|
||||
// Get max sort order
|
||||
const maxSort = await this.db
|
||||
.selectFrom("_emdash_fields")
|
||||
.where("collection_id", "=", collection.id)
|
||||
.select((eb) => eb.fn.max<number>("sort_order").as("max"))
|
||||
.executeTakeFirst();
|
||||
|
||||
const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1;
|
||||
|
||||
// Insert field record
|
||||
await this.db
|
||||
.insertInto("_emdash_fields")
|
||||
.values({
|
||||
id,
|
||||
collection_id: collection.id,
|
||||
slug: input.slug,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
column_type: columnType,
|
||||
required: input.required ? 1 : 0,
|
||||
unique: input.unique ? 1 : 0,
|
||||
default_value: input.defaultValue !== undefined ? JSON.stringify(input.defaultValue) : null,
|
||||
validation: input.validation ? JSON.stringify(input.validation) : null,
|
||||
widget: input.widget ?? null,
|
||||
options: input.options ? JSON.stringify(input.options) : null,
|
||||
sort_order: sortOrder,
|
||||
searchable: input.searchable ? 1 : 0,
|
||||
translatable: input.translatable === false ? 0 : 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Add column to content table
|
||||
await this.addColumn(collectionSlug, input.slug, input.type, {
|
||||
required: input.required,
|
||||
defaultValue: input.defaultValue,
|
||||
});
|
||||
|
||||
const field = await this.getField(collectionSlug, input.slug);
|
||||
if (!field) {
|
||||
throw new SchemaError("Failed to create field", "CREATE_FAILED");
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field
|
||||
*/
|
||||
async updateField(
|
||||
collectionSlug: string,
|
||||
fieldSlug: string,
|
||||
input: UpdateFieldInput,
|
||||
): Promise<Field> {
|
||||
const field = await this.getField(collectionSlug, fieldSlug);
|
||||
if (!field) {
|
||||
throw new SchemaError(
|
||||
`Field "${fieldSlug}" not found in collection "${collectionSlug}"`,
|
||||
"FIELD_NOT_FOUND",
|
||||
);
|
||||
}
|
||||
|
||||
await this.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({
|
||||
label: input.label ?? field.label,
|
||||
required: input.required !== undefined ? (input.required ? 1 : 0) : field.required ? 1 : 0,
|
||||
unique: input.unique !== undefined ? (input.unique ? 1 : 0) : field.unique ? 1 : 0,
|
||||
searchable:
|
||||
input.searchable !== undefined ? (input.searchable ? 1 : 0) : field.searchable ? 1 : 0,
|
||||
translatable:
|
||||
input.translatable !== undefined
|
||||
? input.translatable
|
||||
? 1
|
||||
: 0
|
||||
: field.translatable
|
||||
? 1
|
||||
: 0,
|
||||
default_value:
|
||||
input.defaultValue !== undefined
|
||||
? JSON.stringify(input.defaultValue)
|
||||
: field.defaultValue !== undefined
|
||||
? JSON.stringify(field.defaultValue)
|
||||
: null,
|
||||
validation: input.validation
|
||||
? JSON.stringify(input.validation)
|
||||
: field.validation
|
||||
? JSON.stringify(field.validation)
|
||||
: null,
|
||||
widget: input.widget ?? field.widget ?? null,
|
||||
options: input.options
|
||||
? JSON.stringify(input.options)
|
||||
: field.options
|
||||
? JSON.stringify(field.options)
|
||||
: null,
|
||||
sort_order: input.sortOrder ?? field.sortOrder,
|
||||
})
|
||||
.where("id", "=", field.id)
|
||||
.execute();
|
||||
|
||||
const updated = await this.getField(collectionSlug, fieldSlug);
|
||||
if (!updated) {
|
||||
throw new SchemaError("Failed to update field", "UPDATE_FAILED");
|
||||
}
|
||||
|
||||
// If searchable changed, rebuild the FTS index for this collection
|
||||
const searchableChanged =
|
||||
input.searchable !== undefined && input.searchable !== field.searchable;
|
||||
if (searchableChanged) {
|
||||
await this.rebuildSearchIndex(collectionSlug);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the search index for a collection
|
||||
*
|
||||
* Called when searchable fields change. If search is enabled for the collection,
|
||||
* this will rebuild the FTS table with the updated field list.
|
||||
*/
|
||||
private async rebuildSearchIndex(collectionSlug: string): Promise<void> {
|
||||
const ftsManager = new FTSManager(this.db);
|
||||
|
||||
// Check if search is enabled for this collection
|
||||
const config = await ftsManager.getSearchConfig(collectionSlug);
|
||||
if (!config?.enabled) {
|
||||
// Search not enabled, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current searchable fields
|
||||
const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
|
||||
|
||||
if (searchableFields.length === 0) {
|
||||
// No searchable fields left, disable search
|
||||
await ftsManager.disableSearch(collectionSlug);
|
||||
} else {
|
||||
// Rebuild the index with updated fields
|
||||
await ftsManager.rebuildIndex(collectionSlug, searchableFields, config.weights);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field
|
||||
*/
|
||||
async deleteField(collectionSlug: string, fieldSlug: string): Promise<void> {
|
||||
const field = await this.getField(collectionSlug, fieldSlug);
|
||||
if (!field) {
|
||||
throw new SchemaError(
|
||||
`Field "${fieldSlug}" not found in collection "${collectionSlug}"`,
|
||||
"FIELD_NOT_FOUND",
|
||||
);
|
||||
}
|
||||
|
||||
// Drop column from content table
|
||||
await this.dropColumn(collectionSlug, fieldSlug);
|
||||
|
||||
// Delete field record
|
||||
await this.db.deleteFrom("_emdash_fields").where("id", "=", field.id).execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder fields
|
||||
*/
|
||||
async reorderFields(collectionSlug: string, fieldSlugs: string[]): Promise<void> {
|
||||
const collection = await this.getCollection(collectionSlug);
|
||||
if (!collection) {
|
||||
throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND");
|
||||
}
|
||||
|
||||
// Update sort_order for each field
|
||||
for (let i = 0; i < fieldSlugs.length; i++) {
|
||||
await this.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({ sort_order: i })
|
||||
.where("collection_id", "=", collection.id)
|
||||
.where("slug", "=", fieldSlugs[i])
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DDL Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a content table for a collection
|
||||
*/
|
||||
private async createContentTable(slug: string, db?: Kysely<Database>): Promise<void> {
|
||||
const conn = db ?? this.db;
|
||||
const tableName = this.getTableName(slug);
|
||||
|
||||
await conn.schema
|
||||
.createTable(tableName)
|
||||
.addColumn("id", "text", (col) => col.primaryKey())
|
||||
.addColumn("slug", "text")
|
||||
.addColumn("status", "text", (col) => col.defaultTo("draft"))
|
||||
.addColumn("author_id", "text")
|
||||
.addColumn("primary_byline_id", "text")
|
||||
.addColumn("created_at", "text", (col) => col.defaultTo(currentTimestamp(conn)))
|
||||
.addColumn("updated_at", "text", (col) => col.defaultTo(currentTimestamp(conn)))
|
||||
.addColumn("published_at", "text")
|
||||
.addColumn("scheduled_at", "text")
|
||||
.addColumn("deleted_at", "text")
|
||||
.addColumn("version", "integer", (col) => col.defaultTo(1))
|
||||
.addColumn("live_revision_id", "text", (col) => col.references("revisions.id"))
|
||||
.addColumn("draft_revision_id", "text", (col) => col.references("revisions.id"))
|
||||
.addColumn("locale", "text", (col) => col.notNull().defaultTo("en"))
|
||||
.addColumn("translation_group", "text")
|
||||
.addUniqueConstraint(`${tableName}_slug_locale_unique`, ["slug", "locale"])
|
||||
.execute();
|
||||
|
||||
// Create standard indexes
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_status`)}
|
||||
ON ${sql.ref(tableName)} (status)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_slug`)}
|
||||
ON ${sql.ref(tableName)} (slug)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_created`)}
|
||||
ON ${sql.ref(tableName)} (created_at)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_deleted`)}
|
||||
ON ${sql.ref(tableName)} (deleted_at)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_scheduled`)}
|
||||
ON ${sql.ref(tableName)} (scheduled_at)
|
||||
WHERE scheduled_at IS NOT NULL
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_live_revision`)}
|
||||
ON ${sql.ref(tableName)} (live_revision_id)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_draft_revision`)}
|
||||
ON ${sql.ref(tableName)} (draft_revision_id)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_author`)}
|
||||
ON ${sql.ref(tableName)} (author_id)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_primary_byline`)}
|
||||
ON ${sql.ref(tableName)} (primary_byline_id)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_updated`)}
|
||||
ON ${sql.ref(tableName)} (updated_at)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_locale`)}
|
||||
ON ${sql.ref(tableName)} (locale)
|
||||
`.execute(conn);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX ${sql.ref(`idx_${tableName}_translation_group`)}
|
||||
ON ${sql.ref(tableName)} (translation_group)
|
||||
`.execute(conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a content table
|
||||
*/
|
||||
private async dropContentTable(slug: string): Promise<void> {
|
||||
const tableName = this.getTableName(slug);
|
||||
await sql`DROP TABLE IF EXISTS ${sql.ref(tableName)}`.execute(this.db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a column to a content table
|
||||
*/
|
||||
private async addColumn(
|
||||
collectionSlug: string,
|
||||
fieldSlug: string,
|
||||
fieldType: FieldType,
|
||||
options?: { required?: boolean; defaultValue?: unknown },
|
||||
): Promise<void> {
|
||||
const tableName = this.getTableName(collectionSlug);
|
||||
const columnType = FIELD_TYPE_TO_COLUMN[fieldType];
|
||||
const columnName = this.getColumnName(fieldSlug);
|
||||
|
||||
// Build ALTER TABLE statement
|
||||
// Note: SQLite requires DEFAULT for NOT NULL columns in ALTER TABLE
|
||||
if (options?.required && options?.defaultValue !== undefined) {
|
||||
const defaultVal = this.formatDefaultValue(options.defaultValue, fieldType);
|
||||
await sql`
|
||||
ALTER TABLE ${sql.ref(tableName)}
|
||||
ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)} NOT NULL DEFAULT ${sql.raw(defaultVal)}
|
||||
`.execute(this.db);
|
||||
} else if (options?.required) {
|
||||
// For required fields without default, use empty string/0 as default
|
||||
const defaultVal = this.getEmptyDefault(fieldType);
|
||||
await sql`
|
||||
ALTER TABLE ${sql.ref(tableName)}
|
||||
ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)} NOT NULL DEFAULT ${sql.raw(defaultVal)}
|
||||
`.execute(this.db);
|
||||
} else {
|
||||
await sql`
|
||||
ALTER TABLE ${sql.ref(tableName)}
|
||||
ADD COLUMN ${sql.ref(columnName)} ${sql.raw(columnType)}
|
||||
`.execute(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a column from a content table
|
||||
*/
|
||||
private async dropColumn(collectionSlug: string, fieldSlug: string): Promise<void> {
|
||||
const tableName = this.getTableName(collectionSlug);
|
||||
const columnName = this.getColumnName(fieldSlug);
|
||||
|
||||
await sql`
|
||||
ALTER TABLE ${sql.ref(tableName)}
|
||||
DROP COLUMN ${sql.ref(columnName)}
|
||||
`.execute(this.db);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if a collection has any content
|
||||
*/
|
||||
private async collectionHasContent(slug: string): Promise<boolean> {
|
||||
const tableName = this.getTableName(slug);
|
||||
try {
|
||||
const result = await sql<{ count: number }>`
|
||||
SELECT COUNT(*) as count FROM ${sql.ref(tableName)}
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(this.db);
|
||||
return (result.rows[0]?.count ?? 0) > 0;
|
||||
} catch {
|
||||
// Table might not exist
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name for a collection
|
||||
*/
|
||||
private getTableName(slug: string): string {
|
||||
return `ec_${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get column name for a field
|
||||
*/
|
||||
private getColumnName(slug: string): string {
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a slug
|
||||
*/
|
||||
private validateSlug(slug: string, type: "collection" | "field"): void {
|
||||
if (!slug || typeof slug !== "string") {
|
||||
throw new SchemaError(`${type} slug is required`, "INVALID_SLUG");
|
||||
}
|
||||
|
||||
if (!SLUG_VALIDATION_PATTERN.test(slug)) {
|
||||
throw new SchemaError(
|
||||
`${type} slug must start with a letter and contain only lowercase letters, numbers, and underscores`,
|
||||
"INVALID_SLUG",
|
||||
);
|
||||
}
|
||||
|
||||
if (slug.length > 63) {
|
||||
throw new SchemaError(`${type} slug must be 63 characters or less`, "INVALID_SLUG");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a default value for SQL.
|
||||
*
|
||||
* SQLite `ALTER TABLE ADD COLUMN ... DEFAULT` requires a literal constant
|
||||
* expression — parameterized values cannot be used here. We manually escape
|
||||
* single quotes and coerce types to ensure the output is safe.
|
||||
*
|
||||
* INTEGER/REAL values are coerced through `Number()` which can only produce
|
||||
* digits, `.`, `-`, `e`, `Infinity`, or `NaN` — all safe in SQL.
|
||||
* TEXT/JSON values have single quotes escaped via SQL standard doubling (`''`).
|
||||
*/
|
||||
private formatDefaultValue(value: unknown, fieldType: FieldType): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "NULL";
|
||||
}
|
||||
|
||||
const columnType = FIELD_TYPE_TO_COLUMN[fieldType];
|
||||
|
||||
if (columnType === "JSON") {
|
||||
// JSON.stringify produces valid JSON; escape single quotes for SQL literal
|
||||
const json = JSON.stringify(value);
|
||||
return `'${json.replace(SINGLE_QUOTE_PATTERN, "''")}'`;
|
||||
}
|
||||
|
||||
if (columnType === "INTEGER") {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return "0";
|
||||
}
|
||||
return String(Math.trunc(num));
|
||||
}
|
||||
|
||||
if (columnType === "REAL") {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return "0";
|
||||
}
|
||||
return String(num);
|
||||
}
|
||||
|
||||
// TEXT — escape single quotes via SQL standard doubling
|
||||
let text: string;
|
||||
if (typeof value === "string") {
|
||||
text = value;
|
||||
} else if (typeof value === "number" || typeof value === "boolean") {
|
||||
text = String(value);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
text = JSON.stringify(value);
|
||||
} else {
|
||||
text = "";
|
||||
}
|
||||
return `'${text.replace(SINGLE_QUOTE_PATTERN, "''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty default for a field type
|
||||
*/
|
||||
private getEmptyDefault(fieldType: FieldType): string {
|
||||
const columnType = FIELD_TYPE_TO_COLUMN[fieldType];
|
||||
|
||||
switch (columnType) {
|
||||
case "INTEGER":
|
||||
return "0";
|
||||
case "REAL":
|
||||
return "0.0";
|
||||
case "JSON":
|
||||
return "'null'";
|
||||
default:
|
||||
return "''";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a collection row to a Collection object
|
||||
*/
|
||||
private mapCollectionRow = (row: Selectable<CollectionTable>): Collection => {
|
||||
const moderation = row.comments_moderation;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
label: row.label,
|
||||
labelSingular: row.label_singular ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
icon: row.icon ?? undefined,
|
||||
supports: row.supports ? JSON.parse(row.supports) : [],
|
||||
source: row.source && isCollectionSource(row.source) ? row.source : undefined,
|
||||
hasSeo: row.has_seo === 1,
|
||||
urlPattern: row.url_pattern ?? undefined,
|
||||
commentsEnabled: row.comments_enabled === 1,
|
||||
commentsModeration:
|
||||
moderation === "all" || moderation === "first_time" || moderation === "none"
|
||||
? moderation
|
||||
: "first_time",
|
||||
commentsClosedAfterDays: row.comments_closed_after_days ?? 90,
|
||||
commentsAutoApproveUsers: row.comments_auto_approve_users === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a field row to a Field object
|
||||
*/
|
||||
private mapFieldRow = (row: Selectable<FieldTable>): Field => {
|
||||
return {
|
||||
id: row.id,
|
||||
collectionId: row.collection_id,
|
||||
slug: row.slug,
|
||||
label: row.label,
|
||||
type: isFieldType(row.type) ? row.type : "string",
|
||||
columnType: isColumnType(row.column_type) ? row.column_type : "TEXT",
|
||||
required: row.required === 1,
|
||||
unique: row.unique === 1,
|
||||
defaultValue: row.default_value ? JSON.parse(row.default_value) : undefined,
|
||||
validation: row.validation ? JSON.parse(row.validation) : undefined,
|
||||
widget: row.widget ?? undefined,
|
||||
options: row.options ? JSON.parse(row.options) : undefined,
|
||||
sortOrder: row.sort_order,
|
||||
searchable: row.searchable === 1,
|
||||
translatable: row.translatable !== 0,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Discovery
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Discover orphaned content tables
|
||||
*
|
||||
* Finds ec_* tables that exist in the database but don't have a
|
||||
* corresponding entry in _emdash_collections.
|
||||
*/
|
||||
async discoverOrphanedTables(): Promise<
|
||||
Array<{ slug: string; tableName: string; rowCount: number }>
|
||||
> {
|
||||
// Get all ec_* tables
|
||||
// Content tables are ec_* (e.g., ec_posts, ec_pages)
|
||||
// Internal tables are _emdash_* (e.g., _emdash_collections, _emdash_fts_posts)
|
||||
const allTables = await listTablesLike(this.db, "ec_%");
|
||||
|
||||
// Get registered collections
|
||||
const registered = await this.listCollections();
|
||||
const registeredSlugs = new Set(registered.map((c) => c.slug));
|
||||
|
||||
// Find orphans
|
||||
const orphans: Array<{
|
||||
slug: string;
|
||||
tableName: string;
|
||||
rowCount: number;
|
||||
}> = [];
|
||||
|
||||
for (const tableName of allTables) {
|
||||
const slug = tableName.replace(EC_PREFIX_PATTERN, "");
|
||||
|
||||
if (!registeredSlugs.has(slug)) {
|
||||
// Count rows in the orphaned table
|
||||
try {
|
||||
const countResult = await sql<{ count: number }>`
|
||||
SELECT COUNT(*) as count FROM ${sql.ref(tableName)}
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(this.db);
|
||||
|
||||
orphans.push({
|
||||
slug,
|
||||
tableName,
|
||||
rowCount: countResult.rows[0]?.count ?? 0,
|
||||
});
|
||||
} catch {
|
||||
// Table might have unexpected schema, still report it
|
||||
orphans.push({
|
||||
slug,
|
||||
tableName,
|
||||
rowCount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return orphans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an orphaned table as a collection
|
||||
*
|
||||
* Creates a _emdash_collections entry for an existing ec_* table.
|
||||
*/
|
||||
async registerOrphanedTable(
|
||||
slug: string,
|
||||
options?: {
|
||||
label?: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<Collection> {
|
||||
// Verify table exists
|
||||
const tableName = this.getTableName(slug);
|
||||
const exists = await tableExists(this.db, tableName);
|
||||
|
||||
if (!exists) {
|
||||
throw new SchemaError(`Table "${tableName}" does not exist`, "TABLE_NOT_FOUND");
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
const existing = await this.getCollection(slug);
|
||||
if (existing) {
|
||||
throw new SchemaError(`Collection "${slug}" is already registered`, "COLLECTION_EXISTS");
|
||||
}
|
||||
|
||||
// Create collection entry
|
||||
const id = ulid();
|
||||
const label = options?.label || this.slugToLabel(slug);
|
||||
|
||||
await this.db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id,
|
||||
slug,
|
||||
label,
|
||||
label_singular: options?.labelSingular ?? null,
|
||||
description: options?.description ?? null,
|
||||
icon: null,
|
||||
supports: JSON.stringify([]),
|
||||
source: "discovered",
|
||||
has_seo: 0,
|
||||
url_pattern: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const collection = await this.getCollection(slug);
|
||||
if (!collection) {
|
||||
throw new SchemaError("Failed to register orphaned table", "REGISTER_FAILED");
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert slug to human-readable label
|
||||
*/
|
||||
private slugToLabel(slug: string): string {
|
||||
return slug
|
||||
.replace(UNDERSCORE_PATTERN, " ")
|
||||
.replace(WORD_BOUNDARY_PATTERN, (c) => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
276
packages/core/src/schema/types.ts
Normal file
276
packages/core/src/schema/types.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Schema Registry Types
|
||||
*
|
||||
* These types represent the schema definitions stored in D1.
|
||||
* They are the source of truth for all collections and fields.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported field types
|
||||
*/
|
||||
export type FieldType =
|
||||
| "string"
|
||||
| "text"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "datetime"
|
||||
| "select"
|
||||
| "multiSelect"
|
||||
| "portableText"
|
||||
| "image"
|
||||
| "file"
|
||||
| "reference"
|
||||
| "json"
|
||||
| "slug";
|
||||
|
||||
/**
|
||||
* Array of all field types for validation
|
||||
*/
|
||||
export const FIELD_TYPES: readonly FieldType[] = [
|
||||
"string",
|
||||
"text",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"datetime",
|
||||
"select",
|
||||
"multiSelect",
|
||||
"portableText",
|
||||
"image",
|
||||
"file",
|
||||
"reference",
|
||||
"json",
|
||||
"slug",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* SQLite column types that map from field types
|
||||
*/
|
||||
export type ColumnType = "TEXT" | "REAL" | "INTEGER" | "JSON";
|
||||
|
||||
/**
|
||||
* Map field types to their SQLite column types
|
||||
*/
|
||||
export const FIELD_TYPE_TO_COLUMN: Record<FieldType, ColumnType> = {
|
||||
string: "TEXT",
|
||||
text: "TEXT",
|
||||
number: "REAL",
|
||||
integer: "INTEGER",
|
||||
boolean: "INTEGER",
|
||||
datetime: "TEXT",
|
||||
select: "TEXT",
|
||||
multiSelect: "JSON",
|
||||
portableText: "JSON",
|
||||
image: "TEXT",
|
||||
file: "TEXT",
|
||||
reference: "TEXT",
|
||||
json: "JSON",
|
||||
slug: "TEXT",
|
||||
};
|
||||
|
||||
/**
|
||||
* Features a collection can support
|
||||
*/
|
||||
export type CollectionSupport =
|
||||
| "drafts"
|
||||
| "revisions"
|
||||
| "preview"
|
||||
| "scheduling"
|
||||
| "search"
|
||||
| "seo";
|
||||
|
||||
/**
|
||||
* Sources for how a collection was created
|
||||
*/
|
||||
export type CollectionSource =
|
||||
| `template:${string}`
|
||||
| `import:${string}`
|
||||
| "manual"
|
||||
| "discovered"
|
||||
| "seed";
|
||||
|
||||
/**
|
||||
* Validation rules for a field
|
||||
*/
|
||||
export interface FieldValidation {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
options?: string[]; // For select/multiSelect
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget options for field rendering
|
||||
*/
|
||||
export interface FieldWidgetOptions {
|
||||
rows?: number; // For textarea
|
||||
showPreview?: boolean; // For image/file
|
||||
collection?: string; // For reference - which collection to reference
|
||||
allowMultiple?: boolean; // For reference
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection definition
|
||||
*/
|
||||
export interface Collection {
|
||||
id: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports: CollectionSupport[];
|
||||
source?: CollectionSource;
|
||||
/** Whether this collection has SEO metadata fields enabled */
|
||||
hasSeo: boolean;
|
||||
/** URL pattern with {slug} placeholder (e.g. "/{slug}", "/blog/{slug}") */
|
||||
urlPattern?: string;
|
||||
/** Whether comments are enabled for this collection */
|
||||
commentsEnabled: boolean;
|
||||
/** Moderation strategy: "all" | "first_time" | "none" */
|
||||
commentsModeration: "all" | "first_time" | "none";
|
||||
/** Auto-close comments after N days. 0 = never close. */
|
||||
commentsClosedAfterDays: number;
|
||||
/** Auto-approve comments from authenticated CMS users */
|
||||
commentsAutoApproveUsers: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A field definition
|
||||
*/
|
||||
export interface Field {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
columnType: ColumnType;
|
||||
required: boolean;
|
||||
unique: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: FieldValidation;
|
||||
widget?: string;
|
||||
options?: FieldWidgetOptions;
|
||||
sortOrder: number;
|
||||
searchable: boolean;
|
||||
/** Whether this field is translatable (default true). Non-translatable fields are synced across locales. */
|
||||
translatable: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a collection
|
||||
*/
|
||||
export interface CreateCollectionInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: CollectionSupport[];
|
||||
source?: CollectionSource;
|
||||
urlPattern?: string;
|
||||
hasSeo?: boolean;
|
||||
commentsEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a collection
|
||||
*/
|
||||
export interface UpdateCollectionInput {
|
||||
label?: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: CollectionSupport[];
|
||||
urlPattern?: string;
|
||||
hasSeo?: boolean;
|
||||
commentsEnabled?: boolean;
|
||||
commentsModeration?: "all" | "first_time" | "none";
|
||||
commentsClosedAfterDays?: number;
|
||||
commentsAutoApproveUsers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a field
|
||||
*/
|
||||
export interface CreateFieldInput {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: FieldValidation;
|
||||
widget?: string;
|
||||
options?: FieldWidgetOptions;
|
||||
sortOrder?: number;
|
||||
/** Whether this field should be indexed for search */
|
||||
searchable?: boolean;
|
||||
/** Whether this field is translatable (default true). Non-translatable fields are synced across locales. */
|
||||
translatable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a field
|
||||
*/
|
||||
export interface UpdateFieldInput {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: FieldValidation;
|
||||
widget?: string;
|
||||
options?: FieldWidgetOptions;
|
||||
sortOrder?: number;
|
||||
/** Whether this field should be indexed for search */
|
||||
searchable?: boolean;
|
||||
/** Whether this field is translatable (default true). Non-translatable fields are synced across locales. */
|
||||
translatable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection with its fields
|
||||
*/
|
||||
export interface CollectionWithFields extends Collection {
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved field slugs that cannot be used
|
||||
*/
|
||||
export const RESERVED_FIELD_SLUGS = [
|
||||
"id",
|
||||
"slug",
|
||||
"status",
|
||||
"author_id",
|
||||
"primary_byline_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"published_at",
|
||||
"scheduled_at",
|
||||
"deleted_at",
|
||||
"version",
|
||||
"live_revision_id",
|
||||
"draft_revision_id",
|
||||
];
|
||||
|
||||
/**
|
||||
* Reserved collection slugs that cannot be used
|
||||
*/
|
||||
export const RESERVED_COLLECTION_SLUGS = [
|
||||
"content",
|
||||
"media",
|
||||
"users",
|
||||
"revisions",
|
||||
"taxonomies",
|
||||
"options",
|
||||
"audit_logs",
|
||||
];
|
||||
413
packages/core/src/schema/zod-generator.ts
Normal file
413
packages/core/src/schema/zod-generator.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { z, type ZodTypeAny } from "zod";
|
||||
|
||||
import { hashString } from "../utils/hash.js";
|
||||
import type { Field, FieldType, CollectionWithFields } from "./types.js";
|
||||
|
||||
/** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */
|
||||
const PASCAL_CASE_SPLIT_PATTERN = /[_\-\s]+/;
|
||||
|
||||
/**
|
||||
* Generate a Zod schema from a collection's field definitions
|
||||
*
|
||||
* This allows runtime validation of content based on dynamically
|
||||
* defined schemas stored in D1.
|
||||
*/
|
||||
export function generateZodSchema(
|
||||
collection: CollectionWithFields,
|
||||
): z.ZodObject<Record<string, ZodTypeAny>> {
|
||||
const shape: Record<string, ZodTypeAny> = {};
|
||||
|
||||
for (const field of collection.fields) {
|
||||
shape[field.slug] = generateFieldSchema(field);
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Zod schema for a single field
|
||||
*/
|
||||
export function generateFieldSchema(field: Field): ZodTypeAny {
|
||||
let schema = getBaseSchema(field.type, field);
|
||||
|
||||
// Apply validation rules
|
||||
if (field.validation) {
|
||||
schema = applyValidation(schema, field);
|
||||
}
|
||||
|
||||
// Apply required/optional
|
||||
if (!field.required) {
|
||||
schema = schema.optional();
|
||||
}
|
||||
|
||||
// Apply default value
|
||||
if (field.defaultValue !== undefined) {
|
||||
schema = schema.default(field.defaultValue);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base Zod schema for a field type
|
||||
*/
|
||||
function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
|
||||
switch (type) {
|
||||
case "string":
|
||||
case "text":
|
||||
case "slug":
|
||||
return z.string();
|
||||
|
||||
case "number":
|
||||
return z.number();
|
||||
|
||||
case "integer":
|
||||
return z.number().int();
|
||||
|
||||
case "boolean":
|
||||
return z.boolean();
|
||||
|
||||
case "datetime":
|
||||
return z.string().datetime().or(z.string().date());
|
||||
|
||||
case "select": {
|
||||
const options = field.validation?.options;
|
||||
if (options && options.length > 0) {
|
||||
const [first, ...rest] = options;
|
||||
return z.enum([first, ...rest]);
|
||||
}
|
||||
return z.string();
|
||||
}
|
||||
|
||||
case "multiSelect": {
|
||||
const multiOptions = field.validation?.options;
|
||||
if (multiOptions && multiOptions.length > 0) {
|
||||
const [first, ...rest] = multiOptions;
|
||||
return z.array(z.enum([first, ...rest]));
|
||||
}
|
||||
return z.array(z.string());
|
||||
}
|
||||
|
||||
case "portableText":
|
||||
// Portable Text is an array of blocks
|
||||
return z.array(
|
||||
z
|
||||
.object({
|
||||
_type: z.string(),
|
||||
_key: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
);
|
||||
|
||||
case "image":
|
||||
return z.object({
|
||||
id: z.string(),
|
||||
src: z.string().optional(),
|
||||
alt: z.string().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
case "file":
|
||||
return z.object({
|
||||
id: z.string(),
|
||||
src: z.string().optional(),
|
||||
filename: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
});
|
||||
|
||||
case "reference":
|
||||
return z.string(); // Reference ID
|
||||
|
||||
case "json":
|
||||
return z.unknown();
|
||||
|
||||
default:
|
||||
return z.unknown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply validation rules to a schema
|
||||
*/
|
||||
function applyValidation(schema: ZodTypeAny, field: Field): ZodTypeAny {
|
||||
const validation = field.validation;
|
||||
if (!validation) return schema;
|
||||
|
||||
// String validations
|
||||
if (schema instanceof z.ZodString) {
|
||||
let strSchema = schema;
|
||||
if (validation.minLength !== undefined) {
|
||||
strSchema = strSchema.min(validation.minLength);
|
||||
}
|
||||
if (validation.maxLength !== undefined) {
|
||||
strSchema = strSchema.max(validation.maxLength);
|
||||
}
|
||||
if (validation.pattern) {
|
||||
strSchema = strSchema.regex(new RegExp(validation.pattern));
|
||||
}
|
||||
return strSchema;
|
||||
}
|
||||
|
||||
// Number validations
|
||||
if (schema instanceof z.ZodNumber) {
|
||||
let numSchema = schema;
|
||||
if (validation.min !== undefined) {
|
||||
numSchema = numSchema.min(validation.min);
|
||||
}
|
||||
if (validation.max !== undefined) {
|
||||
numSchema = numSchema.max(validation.max);
|
||||
}
|
||||
return numSchema;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema cache to avoid regenerating schemas on every request
|
||||
*/
|
||||
const schemaCache = new Map<string, { schema: z.ZodObject<any>; version: string }>();
|
||||
|
||||
/**
|
||||
* Get or generate a cached schema for a collection
|
||||
*/
|
||||
export function getCachedSchema(
|
||||
collection: CollectionWithFields,
|
||||
version?: string,
|
||||
): z.ZodObject<any> {
|
||||
const cacheKey = collection.slug;
|
||||
const cached = schemaCache.get(cacheKey);
|
||||
|
||||
// If version matches, return cached schema
|
||||
if (cached && (!version || cached.version === version)) {
|
||||
return cached.schema;
|
||||
}
|
||||
|
||||
// Generate new schema
|
||||
const schema = generateZodSchema(collection);
|
||||
|
||||
// Cache it
|
||||
schemaCache.set(cacheKey, {
|
||||
schema,
|
||||
version: version || collection.updatedAt,
|
||||
});
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached schema for a collection
|
||||
*/
|
||||
export function invalidateSchemaCache(slug: string): void {
|
||||
schemaCache.delete(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached schemas
|
||||
*/
|
||||
export function clearSchemaCache(): void {
|
||||
schemaCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate data against a collection's schema
|
||||
*/
|
||||
export function validateContent(
|
||||
collection: CollectionWithFields,
|
||||
data: unknown,
|
||||
): { success: true; data: unknown } | { success: false; errors: z.ZodError } {
|
||||
const schema = getCachedSchema(collection);
|
||||
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
return { success: false, errors: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TypeScript interface from field definitions
|
||||
* Used by CLI `emdash types` to generate types
|
||||
*/
|
||||
export function generateTypeScript(collection: CollectionWithFields): string {
|
||||
const interfaceName = getInterfaceName(collection);
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`export interface ${interfaceName} {`);
|
||||
lines.push(` id: string;`);
|
||||
lines.push(` slug: string | null;`);
|
||||
lines.push(` status: string;`);
|
||||
|
||||
for (const field of collection.fields) {
|
||||
const tsType = fieldTypeToTypeScript(field);
|
||||
const optional = field.required ? "" : "?";
|
||||
lines.push(` ${field.slug}${optional}: ${tsType};`);
|
||||
}
|
||||
|
||||
lines.push(` createdAt: Date;`);
|
||||
lines.push(` updatedAt: Date;`);
|
||||
lines.push(` publishedAt: Date | null;`);
|
||||
// Bylines are eagerly loaded by getEmDashCollection/getEmDashEntry
|
||||
lines.push(` bylines?: ContentBylineCredit[];`);
|
||||
lines.push(`}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete types file with module augmentation
|
||||
* This produces emdash-env.d.ts content that provides typed query functions
|
||||
*/
|
||||
export function generateTypesFile(collections: CollectionWithFields[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push(`// Generated by EmDash on dev server start`);
|
||||
lines.push(`// Do not edit manually`);
|
||||
lines.push(``);
|
||||
lines.push(`/// <reference types="emdash/locals" />`);
|
||||
lines.push(``);
|
||||
|
||||
// Check if we need PortableTextBlock import
|
||||
const needsPortableText = collections.some((c) =>
|
||||
c.fields.some((f) => f.type === "portableText"),
|
||||
);
|
||||
|
||||
// Build imports - ContentBylineCredit is always needed for bylines
|
||||
const imports = ["ContentBylineCredit"];
|
||||
if (needsPortableText) {
|
||||
imports.push("PortableTextBlock");
|
||||
}
|
||||
lines.push(`import type { ${imports.join(", ")} } from "emdash";`);
|
||||
lines.push(``);
|
||||
|
||||
// Generate individual interfaces
|
||||
for (const collection of collections) {
|
||||
lines.push(generateTypeScript(collection));
|
||||
lines.push(``);
|
||||
}
|
||||
|
||||
// Generate the Collections interface for module augmentation
|
||||
lines.push(`declare module "emdash" {`);
|
||||
lines.push(` interface EmDashCollections {`);
|
||||
for (const collection of collections) {
|
||||
const interfaceName = getInterfaceName(collection);
|
||||
lines.push(` ${collection.slug}: ${interfaceName};`);
|
||||
}
|
||||
lines.push(` }`);
|
||||
lines.push(`}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate schema hash for cache invalidation
|
||||
*/
|
||||
export async function generateSchemaHash(collections: CollectionWithFields[]): Promise<string> {
|
||||
const str = JSON.stringify(
|
||||
collections.map((c) => ({
|
||||
slug: c.slug,
|
||||
fields: c.fields.map((f) => ({
|
||||
slug: f.slug,
|
||||
type: f.type,
|
||||
required: f.required,
|
||||
validation: f.validation,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
return hashString(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map field type to TypeScript type
|
||||
*/
|
||||
function fieldTypeToTypeScript(field: Field): string {
|
||||
switch (field.type) {
|
||||
case "string":
|
||||
case "text":
|
||||
case "slug":
|
||||
case "datetime":
|
||||
return "string";
|
||||
|
||||
case "number":
|
||||
case "integer":
|
||||
return "number";
|
||||
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
|
||||
case "select":
|
||||
const options = field.validation?.options;
|
||||
if (options && options.length > 0) {
|
||||
return options.map((o) => `"${o}"`).join(" | ");
|
||||
}
|
||||
return "string";
|
||||
|
||||
case "multiSelect":
|
||||
const multiOptions = field.validation?.options;
|
||||
if (multiOptions && multiOptions.length > 0) {
|
||||
return `(${multiOptions.map((o) => `"${o}"`).join(" | ")})[]`;
|
||||
}
|
||||
return "string[]";
|
||||
|
||||
case "portableText":
|
||||
return "PortableTextBlock[]";
|
||||
|
||||
case "image":
|
||||
return "{ id: string; src?: string; alt?: string; width?: number; height?: number }";
|
||||
|
||||
case "file":
|
||||
return "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number }";
|
||||
|
||||
case "reference":
|
||||
// Could be enhanced to include the referenced collection type
|
||||
return "string";
|
||||
|
||||
case "json":
|
||||
return "unknown";
|
||||
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string to PascalCase (handles slugs, spaces, etc.)
|
||||
*/
|
||||
function pascalCase(str: string): string {
|
||||
return str
|
||||
.split(PASCAL_CASE_SPLIT_PATTERN)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple singularization - handles common cases
|
||||
*/
|
||||
function singularize(str: string): string {
|
||||
if (str.endsWith("ies")) {
|
||||
return str.slice(0, -3) + "y";
|
||||
}
|
||||
if (
|
||||
str.endsWith("es") &&
|
||||
(str.endsWith("sses") || str.endsWith("xes") || str.endsWith("ches") || str.endsWith("shes"))
|
||||
) {
|
||||
return str.slice(0, -2);
|
||||
}
|
||||
if (str.endsWith("s") && !str.endsWith("ss")) {
|
||||
return str.slice(0, -1);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the interface name for a collection
|
||||
*/
|
||||
function getInterfaceName(collection: CollectionWithFields): string {
|
||||
return pascalCase(collection.labelSingular || singularize(collection.slug));
|
||||
}
|
||||
Reference in New Issue
Block a user