From 860d908892532d680b4e5f4545d47781a1a299ee Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Mon, 11 May 2026 22:15:47 -0700 Subject: [PATCH] Add schema manifest output and CLI Introduce committed JSON schema manifest generation for model-driven admin/CMS UIs. Adds a new generator (src/schema-manifest.js) and exports generateSchemaManifest/renderSchemaManifest from the public API and CLI. New config options schemaOutFile and schemaManifest (customizeField hook) are supported, and schemaOutFile is resolved in loadConfig. The sync flow writes the manifest when configured, and a new CLI subcommand jsondb schema manifest [--out ] is implemented. Type definitions, Vite plugin options, README, SPEC, and example config are updated, and comprehensive tests were added to verify UI inference, nested/array/relation handling, customizeField overrides/omissions, serialization diagnostics, and CLI output. --- README.md | 70 ++++++++ SPEC.md | 105 ++++++++++- jsondb.config.example.mjs | 11 ++ src/cli.js | 19 ++ src/config.js | 6 + src/index.d.ts | 22 +++ src/index.js | 1 + src/schema-manifest.js | 370 ++++++++++++++++++++++++++++++++++++++ src/sync.js | 8 + src/vite.d.ts | 2 +- test/jsondb.test.js | 183 +++++++++++++++++++ 11 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 src/schema-manifest.js diff --git a/README.md b/README.md index 865cdf7..199a89f 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ The defaults are designed to be useful without `jsondb.config.mjs`. When you do | Fixture folder | `./db` | [Different fixture folder](#different-fixture-folder) | Keep fixtures in a package, example, or app-specific folder. | | Runtime state behavior | `.jsondb`, mirror mode | [Mirror vs source mode](#mirror-vs-source-mode) | Keep source fixtures clean, or intentionally write generated ids back. | | Importable generated types | `.jsondb/types/index.ts` | [Committed generated types](#committed-generated-types) | Let TypeScript imports work in CI and fresh checkouts before sync runs. | +| Importable schema manifest | Off | [Schema manifest output](#schema-manifest-output) | Generate runtime field metadata for model-driven admin/CMS forms. | | Unknown fields in schema-backed data | Warn | [Schema strictness](#schema-strictness) | Move from permissive local data to stricter contracts. | | Schema-only mock records | Off | [Generated schema seed data](#generated-schema-seed-data) | Create local records from schema when no fixture data exists yet. | | Local latency | 30-100ms | [Mock delay and errors](#mock-delay-and-errors) | Disable it, use a fixed delay, or choose a different range. | @@ -346,6 +347,7 @@ npm run db -- types --watch npm run db -- types --out ./src/generated/jsondb.types.ts npm run db -- schema npm run db -- schema users +npm run db -- schema manifest --out ./src/generated/jsondb.schema.json npm run db -- schema validate npm run db -- create users '{"id":"u_2","name":"Grace Hopper","email":"grace@example.com"}' npm run db -- serve @@ -362,6 +364,7 @@ jsondb types --watch jsondb types --out ./src/generated/jsondb.types.ts jsondb schema jsondb schema users +jsondb schema manifest --out ./src/generated/jsondb.schema.json jsondb schema validate jsondb create users '{"id":"u_2","name":"Grace Hopper","email":"grace@example.com"}' jsondb serve @@ -800,6 +803,23 @@ export default defineConfig({ emitComments: true, }, + schemaOutFile: './src/generated/jsondb.schema.json', + schemaManifest: { + customizeField({ fieldName, defaultManifest }) { + if (fieldName.endsWith('Markdown')) { + return { + ...defaultManifest, + ui: { + ...defaultManifest.ui, + component: 'markdown', + }, + }; + } + + return defaultManifest; + }, + }, + schema: { unknownFields: 'warn', // "allow" | "warn" | "error" }, @@ -866,6 +886,56 @@ export default defineConfig({ }); ``` +### Schema Manifest Output + +Use `schemaOutFile` when a local admin or CMS UI needs runtime schema metadata instead of hand-coded forms: + +```js +import { defineConfig } from 'jsondb/config'; + +export default defineConfig({ + schemaOutFile: './src/generated/jsondb.schema.json', +}); +``` + +`jsondb sync` writes the manifest when `schemaOutFile` is set. You can also generate it directly: + +```bash +jsondb schema manifest --out ./src/generated/jsondb.schema.json +``` + +The JSON manifest groups `collections` and `documents`, includes normalized field metadata such as `type`, `required`, `nullable`, `default`, `values`, nested `fields`, array `items`, and `relation`, and adds inferred `ui` defaults. Defaults are metadata only; they do not change fixtures, seed data, runtime state, REST, or GraphQL behavior. + +Override or omit field output with a visitor hook: + +```js +import { defineConfig } from 'jsondb/config'; + +export default defineConfig({ + schemaManifest: { + customizeField({ fieldName, resourceName, file, path, defaultManifest }) { + if (resourceName === 'users' && fieldName === 'passwordHash') { + return null; + } + + if (fieldName.endsWith('Markdown')) { + return { + ...defaultManifest, + ui: { + ...defaultManifest.ui, + component: 'markdown', + section: file, + orderKey: path, + }, + }; + } + + return defaultManifest; + }, + }, +}); +``` + ### Schema Strictness Use strict unknown-field checks when schema-backed fixtures should reject drift: diff --git a/SPEC.md b/SPEC.md index 61ba19d..7bd5f03 100644 --- a/SPEC.md +++ b/SPEC.md @@ -192,6 +192,23 @@ Projects can customize the output location: export default { dbDir: './db', stateDir: './.jsondb', + schemaOutFile: './src/generated/jsondb.schema.json', + + schemaManifest: { + customizeField({ fieldName, defaultManifest }) { + if (fieldName.endsWith('Markdown')) { + return { + ...defaultManifest, + ui: { + ...defaultManifest.ui, + component: 'markdown', + }, + }; + } + + return defaultManifest; + }, + }, types: { enabled: true, @@ -615,10 +632,11 @@ Add schema commands: ```bash jsondb schema jsondb schema users +jsondb schema manifest --out ./src/generated/jsondb.schema.json jsondb schema validate ``` -`jsondb sync` should also regenerate types. +`jsondb sync` should also regenerate types and should write the committed schema manifest when `schemaOutFile` is configured. Expected output: @@ -628,6 +646,7 @@ Loaded db/posts.json Generated .jsondb/schema.generated.json Generated .jsondb/types/index.ts Generated src/generated/jsondb.types.ts +Generated src/generated/jsondb.schema.json Synced runtime mirror ``` @@ -997,6 +1016,90 @@ export type User = { Use schema field descriptions to emit JSDoc comments. +## Schema manifest output and model-driven admin UIs + +Add optional JSON schema manifest generation for local-first admin/CMS UIs that render forms from JSONDB models instead of duplicating per-resource form configuration. + +This is separate from `.jsondb/schema.generated.json`. The existing generated schema file remains runtime/server metadata and may include diagnostics, source paths, seeds, REST route lists, and GraphQL SDL. The committed manifest is a small importable artifact for applications. + +Configure it with: + +```js +export default { + schemaOutFile: './src/generated/jsondb.schema.json', +}; +``` + +When `schemaOutFile` is set, `jsondb sync` writes the manifest. The CLI can also write one directly: + +```bash +jsondb schema manifest --out ./src/generated/jsondb.schema.json +``` + +The manifest should have this top-level shape: + +```json +{ + "version": 1, + "collections": {}, + "documents": {} +} +``` + +Each resource entry should include `kind`, `name`, `idField` for collections, optional `description`, and `fields`. Each field should include normalized field metadata such as `type`, `required`, `nullable`, `default`, `values`, nested object `fields`, array `items`, `relation`, constraints, and inferred `ui` defaults. + +The manifest must not include seed records, source hashes, source paths, runtime state, diagnostics, REST route lists, or GraphQL SDL. + +Default UI inference should be deterministic and safe: + +```txt +boolean -> toggle +small enum -> radio +larger enum -> select +email-like field name -> email +url-like field name -> url +image/avatar/photo-like field name -> image +description/body/content/notes/bio/markdown-like field name -> textarea +array -> tags +array -> multiSelect +object with declared fields -> fieldset +open object or unknown field -> json +relation field -> relationSelect with optionsFrom +collection id field -> readonly +``` + +Manifest defaults are metadata only. They must not change fixtures, seed data, runtime state, validation, REST, or GraphQL behavior. + +Apps can customize or omit field entries with a visitor hook: + +```js +export default { + schemaManifest: { + customizeField({ field, fieldName, resource, resourceName, path, file, sourceFile, defaultManifest }) { + if (resourceName === 'users' && fieldName === 'passwordHash') { + return null; + } + + if (fieldName.endsWith('Markdown')) { + return { + ...defaultManifest, + ui: { + ...defaultManifest.ui, + component: 'markdown', + }, + }; + } + + return defaultManifest; + }, + }, +}; +``` + +The visitor return value must be JSON-serializable. Functions, classes, symbols, bigint values, non-finite numbers, and non-plain objects should fail generation with a diagnostic that includes resource and field path. Returning `null` omits the field from the manifest. + +The intended first use is permissioned admin CRUD for resources such as dashboards, users, and permission policies. Admin screens can map manifest field metadata to reusable create/edit/view components while policy checks decide whether fields are hidden, readonly, or editable for a given session. + Support schema-only fixtures. The package should accept these source formats: diff --git a/jsondb.config.example.mjs b/jsondb.config.example.mjs index eebf625..d948e8b 100644 --- a/jsondb.config.example.mjs +++ b/jsondb.config.example.mjs @@ -8,6 +8,17 @@ export default defineConfig({ // Runtime output folder. Defaults to './.jsondb'. stateDir: './.jsondb', + // Optional committed JSON schema manifest for model-driven admin/CMS UIs. + // Generated during `jsondb sync` when set. + schemaOutFile: null, + + // Optional visitor hook for changing or omitting generated manifest fields. + schemaManifest: { + customizeField({ defaultManifest }) { + return defaultManifest; + }, + }, + // mirror: keep source fixtures unchanged and write app edits to .jsondb/state. // source: write generated ids back to plain .json fixtures when needed. mode: 'mirror', diff --git a/src/cli.js b/src/cli.js index 62fec9b..5d9d0fc 100755 --- a/src/cli.js +++ b/src/cli.js @@ -6,6 +6,7 @@ import { runJsonDbDoctor } from './doctor.js'; import { openJsonFixtureDb } from './db.js'; import { generateHonoStarter } from './generate/hono.js'; import { loadProjectSchema } from './schema.js'; +import { generateSchemaManifest } from './schema-manifest.js'; import { startJsonDbServer } from './server.js'; import { syncJsonFixtureDb } from './sync.js'; import { generateTypes } from './types.js'; @@ -109,6 +110,23 @@ async function runTypesOnce(config, args) { async function runSchema(config, args) { const project = await loadProjectSchema(config); + if (args[0] === 'manifest') { + const result = await generateSchemaManifest(config, { + project, + outFile: valueAfter(args, '--out'), + }); + + if (result.outFiles.length === 0) { + console.log(result.content); + return; + } + + for (const filePath of result.outFiles) { + console.log(`Generated ${path.relative(config.cwd, filePath)}`); + } + return; + } + if (args[0] === 'validate') { for (const diagnostic of project.diagnostics) { printDiagnostic(diagnostic); @@ -240,6 +258,7 @@ Usage: jsondb sync jsondb types [--watch] [--out ] jsondb schema [resource] + jsondb schema manifest [--out ] jsondb schema validate jsondb doctor [--strict] [--json] jsondb check [--strict] [--json] diff --git a/src/config.js b/src/config.js index f53c0c4..f9ad151 100644 --- a/src/config.js +++ b/src/config.js @@ -8,6 +8,8 @@ export const DEFAULT_CONFIG = { dbDir: './db', sourceDir: './db', stateDir: './.jsondb', + schemaOutFile: null, + schemaManifest: {}, mode: 'mirror', types: { enabled: true, @@ -99,6 +101,10 @@ export async function loadConfig(options = {}) { merged.types.commitOutFile = resolveFrom(cwd, merged.types.commitOutFile); } + if (merged.schemaOutFile) { + merged.schemaOutFile = resolveFrom(cwd, merged.schemaOutFile); + } + merged.forks = normalizeForks(merged, merged.forks); return merged; diff --git a/src/index.d.ts b/src/index.d.ts index 0f8f506..013fd54 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -20,6 +20,22 @@ export type JsonDbGeneratedTypesOptions = { exportRuntimeHelpers?: boolean; }; +export type JsonDbSchemaManifestFieldContext = { + field: Record; + fieldName: string; + resource: Record; + resourceName: string; + path: string; + file: string | null; + sourceFile: string | null; + defaultManifest: Record; +}; + +export type JsonDbSchemaManifestOptions = { + /** Customize or omit generated field manifest entries. Return null to omit a field. */ + customizeField?: (context: JsonDbSchemaManifestFieldContext) => Record | null; +}; + export type JsonDbOptions = { /** Project root used to resolve relative config paths. Defaults to process.cwd(). */ cwd?: string; @@ -31,6 +47,10 @@ export type JsonDbOptions = { sourceDir?: string; /** Generated runtime output folder. Defaults to "./.jsondb". */ stateDir?: string; + /** Optional committed generated JSON schema manifest for admin/CMS UI generation. */ + schemaOutFile?: string | null; + /** Optional visitor hooks for customizing generated schema manifest output. */ + schemaManifest?: JsonDbSchemaManifestOptions; /** "mirror" keeps source fixtures unchanged; "source" may write generated ids back to plain .json fixtures. */ mode?: 'mirror' | 'source'; /** Run sync automatically when opening the package API. */ @@ -261,6 +281,8 @@ export function runJsonDbDoctor(config: JsonDbOptions): Promise; export function syncJsonFixtureDb(config: JsonDbOptions, options?: { allowErrors?: boolean }): Promise; export function generateTypes(config: JsonDbOptions, options?: { outFile?: string }): Promise<{ content: string; outFiles: string[] }>; +export function generateSchemaManifest(config: JsonDbOptions, options?: { outFile?: string }): Promise<{ manifest: unknown; content: string; outFiles: string[] }>; +export function renderSchemaManifest(resources: unknown[], config?: JsonDbOptions): unknown; export function generateHonoStarter( config: JsonDbOptions, options?: { diff --git a/src/index.js b/src/index.js index e7913d7..9ef5821 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ export { runJsonDbDoctor } from './doctor.js'; export { executeGraphql, executeGraphqlBatch, parseGraphql } from './graphql/index.js'; export { generateHonoStarter, renderHonoStarter } from './generate/hono.js'; export { loadProjectSchema, makeGeneratedSchema } from './schema.js'; +export { generateSchemaManifest, renderSchemaManifest } from './schema-manifest.js'; export { createJsonDbRequestHandler, startJsonDbServer } from './server.js'; export { syncJsonFixtureDb } from './sync.js'; export { generateTypes, renderTypes } from './types.js'; diff --git a/src/schema-manifest.js b/src/schema-manifest.js new file mode 100644 index 0000000..15c7df5 --- /dev/null +++ b/src/schema-manifest.js @@ -0,0 +1,370 @@ +import path from 'node:path'; +import { resolveFrom, writeText } from './fs-utils.js'; +import { loadProjectSchema } from './schema.js'; + +export async function generateSchemaManifest(config, options = {}) { + const project = options.project ?? await loadProjectSchema(config); + const manifest = renderSchemaManifest(project.resources, config); + const content = `${JSON.stringify(manifest, null, 2)}\n`; + const outFiles = outputFiles(config, options); + + for (const outFile of outFiles) { + await writeText(outFile, content); + } + + return { + manifest, + content, + outFiles, + diagnostics: project.diagnostics, + }; +} + +export function renderSchemaManifest(resources, config = {}) { + const diagnostics = []; + const manifest = { + version: 1, + collections: {}, + documents: {}, + }; + + for (const resource of resources) { + const bucket = resource.kind === 'document' ? manifest.documents : manifest.collections; + bucket[resource.name] = resourceManifest(resource, config, diagnostics); + } + + if (diagnostics.length > 0) { + throw manifestDiagnosticsError(diagnostics); + } + + return manifest; +} + +function outputFiles(config, options) { + const outFile = options.outFile + ? resolveFrom(config.cwd, options.outFile) + : config.schemaOutFile; + return outFile ? [outFile] : []; +} + +function resourceManifest(resource, config, diagnostics) { + const manifest = { + kind: resource.kind, + name: resource.name, + fields: renderFieldMap(resource.fields ?? {}, resource, config, diagnostics, ''), + }; + + if (resource.description) { + manifest.description = resource.description; + } + + if (resource.kind === 'collection') { + manifest.idField = resource.idField; + } + + return manifest; +} + +function renderFieldMap(fields, resource, config, diagnostics, parentPath) { + const output = {}; + for (const [fieldName, field] of Object.entries(fields)) { + const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName; + const fieldManifest = renderFieldManifest(fieldName, field, resource, config, diagnostics, fieldPath); + if (fieldManifest !== null) { + output[fieldName] = fieldManifest; + } + } + return output; +} + +function renderFieldManifest(fieldName, field, resource, config, diagnostics, fieldPath) { + const defaultManifest = defaultFieldManifest(fieldName, field, resource, config, diagnostics, fieldPath); + const customizeField = config.schemaManifest?.customizeField; + const sourceFile = resource.schemaPath ?? resource.dataPath ?? null; + + if (typeof customizeField !== 'function') { + return defaultManifest; + } + + let customized; + try { + customized = customizeField({ + field, + fieldName, + resource, + resourceName: resource.name, + path: fieldPath, + file: sourceFile ? path.relative(config.cwd, sourceFile) : null, + sourceFile, + defaultManifest: structuredClone(defaultManifest), + }); + } catch (error) { + diagnostics.push({ + code: 'SCHEMA_MANIFEST_FIELD_CUSTOMIZE_FAILED', + severity: 'error', + resource: resource.name, + field: fieldPath, + message: `Could not customize schema manifest field "${resource.name}.${fieldPath}": ${error.message}`, + hint: 'Update schemaManifest.customizeField so it returns a JSON-serializable field manifest or null.', + details: { + resource: resource.name, + field: fieldPath, + }, + }); + return defaultManifest; + } + + if (customized === null) { + return null; + } + + const serializablePath = firstNonSerializablePath(customized); + if (serializablePath) { + diagnostics.push(nonSerializableDiagnostic(resource, fieldPath, serializablePath)); + return defaultManifest; + } + + return customized; +} + +function defaultFieldManifest(fieldName, field, resource, config, diagnostics, fieldPath) { + const manifest = { + type: field.type ?? 'unknown', + required: Boolean(field.required), + nullable: Boolean(field.nullable), + }; + + for (const property of [ + 'description', + 'default', + 'values', + 'relation', + 'unique', + 'min', + 'max', + 'minLength', + 'maxLength', + 'pattern', + 'additionalProperties', + ]) { + if (property in field) { + manifest[property] = structuredClone(field[property]); + } + } + + if (field.type === 'array') { + manifest.items = itemManifest(field.items ?? { type: 'unknown' }); + } + + if (field.type === 'object' && field.fields && typeof field.fields === 'object') { + manifest.fields = renderFieldMap(field.fields, resource, config, diagnostics, fieldPath); + } + + manifest.ui = inferFieldUi(fieldName, field, resource, fieldPath); + return manifest; +} + +function itemManifest(field) { + const manifest = { + type: field.type ?? 'unknown', + required: Boolean(field.required), + nullable: Boolean(field.nullable), + }; + + for (const property of [ + 'description', + 'default', + 'values', + 'relation', + 'unique', + 'min', + 'max', + 'minLength', + 'maxLength', + 'pattern', + 'additionalProperties', + ]) { + if (property in field) { + manifest[property] = structuredClone(field[property]); + } + } + + if (field.type === 'array') { + manifest.items = itemManifest(field.items ?? { type: 'unknown' }); + } + + if (field.type === 'object' && field.fields && typeof field.fields === 'object') { + manifest.fields = Object.fromEntries( + Object.entries(field.fields).map(([childName, childField]) => [childName, itemManifest(childField)]), + ); + } + + return manifest; +} + +function inferFieldUi(fieldName, field, resource, fieldPath) { + const ui = { + label: labelFromFieldName(fieldName), + component: componentForField(fieldName, field), + }; + + if (resource.kind === 'collection' && fieldPath === resource.idField) { + ui.readonly = true; + } + + if (field.relation?.to) { + ui.optionsFrom = field.relation.to; + } + + return ui; +} + +function componentForField(fieldName, field) { + if (field.relation) { + return 'relationSelect'; + } + + switch (field.type) { + case 'boolean': + return 'toggle'; + case 'enum': + return Array.isArray(field.values) && field.values.length > 0 && field.values.length <= 3 + ? 'radio' + : 'select'; + case 'datetime': + return 'datetime'; + case 'number': + return 'number'; + case 'array': + return componentForArray(field.items); + case 'object': + return field.fields && Object.keys(field.fields).length > 0 ? 'fieldset' : 'json'; + case 'string': + return componentForString(fieldName, field); + default: + return 'json'; + } +} + +function componentForArray(itemField = { type: 'unknown' }) { + if (itemField.type === 'enum') { + return 'multiSelect'; + } + + if (itemField.type === 'string') { + return 'tags'; + } + + return 'list'; +} + +function componentForString(fieldName, field) { + const normalized = normalizeName(fieldName); + + if (/(^|[^a-z])email([^a-z]|$)/.test(normalized) || normalized.endsWith('email')) { + return 'email'; + } + + if (/(image|avatar|photo|picture|thumbnail|logo|icon)/.test(normalized)) { + return 'image'; + } + + if (/(^|[^a-z])(url|uri|website|link)([^a-z]|$)/.test(normalized) || normalized.endsWith('url')) { + return 'url'; + } + + if (/(description|body|content|notes|note|bio|summary|markdown)/.test(normalized)) { + return 'textarea'; + } + + if (Number.isFinite(field.maxLength) && field.maxLength >= 240) { + return 'textarea'; + } + + return 'text'; +} + +function normalizeName(fieldName) { + return String(fieldName) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .toLowerCase(); +} + +function labelFromFieldName(fieldName) { + const words = normalizeName(fieldName) + .split(/\s+/) + .filter(Boolean); + if (words.length === 0) { + return String(fieldName); + } + + return words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(' '); +} + +function firstNonSerializablePath(value, currentPath = '') { + if (value === null) { + return null; + } + + const valueType = typeof value; + if (valueType === 'string' || valueType === 'boolean') { + return null; + } + + if (valueType === 'number') { + return Number.isFinite(value) ? null : currentPath || ''; + } + + if (valueType === 'undefined' || valueType === 'function' || valueType === 'symbol' || valueType === 'bigint') { + return currentPath || ''; + } + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const childPath = firstNonSerializablePath(value[index], `${currentPath}[${index}]`); + if (childPath) { + return childPath; + } + } + return null; + } + + if (typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return currentPath || ''; + } + + for (const [key, childValue] of Object.entries(value)) { + const childPath = firstNonSerializablePath(childValue, currentPath ? `${currentPath}.${key}` : key); + if (childPath) { + return childPath; + } + } + } + + return null; +} + +function nonSerializableDiagnostic(resource, fieldPath, serializablePath) { + return { + code: 'SCHEMA_MANIFEST_FIELD_NOT_SERIALIZABLE', + severity: 'error', + resource: resource.name, + field: fieldPath, + message: `schemaManifest.customizeField returned non-serializable output for "${resource.name}.${fieldPath}" at "${serializablePath}".`, + hint: 'Return JSON-serializable values such as strings, numbers, booleans, arrays, plain objects, null, or return null to omit the field.', + details: { + resource: resource.name, + field: fieldPath, + path: serializablePath, + }, + }; +} + +function manifestDiagnosticsError(diagnostics) { + const error = new Error(diagnostics.map((diagnostic) => diagnostic.message).join('\n')); + error.diagnostics = diagnostics; + return error; +} diff --git a/src/sync.js b/src/sync.js index 2f0b779..9ee2858 100644 --- a/src/sync.js +++ b/src/sync.js @@ -2,6 +2,7 @@ import { mkdir } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { loadProjectSchema, makeGeneratedSchema } from './schema.js'; +import { generateSchemaManifest } from './schema-manifest.js'; import { generateTypes } from './types.js'; import { readJsonState, statePathForResource, writeJsonState } from './state.js'; import { writeText } from './fs-utils.js'; @@ -37,6 +38,13 @@ export async function syncJsonFixtureDb(config, options = {}) { } } + if (config.schemaOutFile) { + const result = await generateSchemaManifest(config, { project }); + for (const outFile of result.outFiles) { + logs.push(`Generated ${path.relative(config.cwd, outFile)}`); + } + } + const sourceMetadataPath = path.join(config.stateDir, 'state', '.sources.json'); const sourceMetadata = await readJsonState(sourceMetadataPath, { resources: {} }); sourceMetadata.resources ??= {}; diff --git a/src/vite.d.ts b/src/vite.d.ts index 12d0aa7..294928e 100644 --- a/src/vite.d.ts +++ b/src/vite.d.ts @@ -5,7 +5,7 @@ export type JsonDbVirtualClient = JsonDbClient & { fork(name: string): JsonDbClient; }; -export type JsonDbVitePluginOptions = Pick & { +export type JsonDbVitePluginOptions = Pick & { /** Scoped base for jsondb dev tools. Defaults to "/__jsondb". */ apiBase?: string; /** Serve root REST routes such as "/users" during Vite dev. Defaults to false. */ diff --git a/test/jsondb.test.js b/test/jsondb.test.js index 8c667bb..f1a0f93 100644 --- a/test/jsondb.test.js +++ b/test/jsondb.test.js @@ -876,6 +876,189 @@ test('types.commitOutFile writes a committed type copy', async () => { assert.match(committedTypes, /users: User;/); }); +test('schemaOutFile writes a committed manifest with inferred UI defaults without changing fixtures', async () => { + const cwd = await makeProject(); + const usersFixture = JSON.stringify([ + { + id: 'u_1', + email: 'ada@example.com', + active: true, + avatarUrl: 'https://example.com/ada.png', + body: 'First local admin user.', + }, + ]); + await writeConfig(cwd, `export default { + schemaOutFile: './src/generated/jsondb.schema.json' + };`); + await writeFixture(cwd, 'users.json', usersFixture); + + const config = await loadConfig({ cwd }); + await syncJsonFixtureDb(config); + + const manifest = JSON.parse(await readFile(path.join(cwd, 'src/generated/jsondb.schema.json'), 'utf8')); + const sourceAfterSync = await readFile(path.join(cwd, 'db/users.json'), 'utf8'); + + assert.equal(sourceAfterSync, `${usersFixture}\n`); + assert.equal(manifest.version, 1); + assert.deepEqual(Object.keys(manifest.documents), []); + assert.equal(manifest.collections.users.kind, 'collection'); + assert.equal(manifest.collections.users.name, 'users'); + assert.equal(manifest.collections.users.idField, 'id'); + assert.equal(manifest.collections.users.fields.id.ui.readonly, true); + assert.equal(manifest.collections.users.fields.email.ui.component, 'email'); + assert.equal(manifest.collections.users.fields.active.ui.component, 'toggle'); + assert.equal(manifest.collections.users.fields.avatarUrl.ui.component, 'image'); + assert.equal(manifest.collections.users.fields.body.ui.component, 'textarea'); + assert.equal(manifest.collections.users.fields.email.required, true); + assert.equal('seed' in manifest.collections.users, false); + assert.equal('source' in manifest.collections.users, false); + assert.equal('diagnostics' in manifest, false); + assert.equal('graphql' in manifest, false); + assert.equal('rest' in manifest, false); +}); + +test('schema manifest includes schema defaults, nested fields, arrays, relations, and enum UI defaults', async () => { + const cwd = await makeProject(); + await writeConfig(cwd, `export default { + schemaOutFile: './src/generated/jsondb.schema.json' + };`); + await writeFixture(cwd, 'groups.schema.jsonc', `{ + "kind": "collection", + "fields": { + "id": { "type": "string", "required": true }, + "name": { "type": "string", "required": true } + } + }`); + await writeFixture(cwd, 'users.schema.jsonc', `{ + "kind": "collection", + "idField": "id", + "fields": { + "id": { "type": "string", "required": true }, + "role": { "type": "enum", "values": ["admin", "user"], "default": "user" }, + "status": { "type": "enum", "values": ["draft", "review", "published", "archived"] }, + "groupId": { "type": "string", "relation": { "to": "groups" } }, + "tags": { "type": "array", "items": { "type": "string" } }, + "profile": { + "type": "object", + "fields": { + "bio": { "type": "string" } + } + } + } + }`); + + const config = await loadConfig({ cwd }); + await syncJsonFixtureDb(config); + + const manifest = JSON.parse(await readFile(path.join(cwd, 'src/generated/jsondb.schema.json'), 'utf8')); + const users = manifest.collections.users; + + assert.equal(users.fields.role.default, 'user'); + assert.deepEqual(users.fields.role.values, ['admin', 'user']); + assert.equal(users.fields.role.ui.component, 'radio'); + assert.equal(users.fields.status.ui.component, 'select'); + assert.equal(users.fields.groupId.ui.component, 'relationSelect'); + assert.equal(users.fields.groupId.ui.optionsFrom, 'groups'); + assert.equal(users.fields.tags.ui.component, 'tags'); + assert.equal(users.fields.tags.items.type, 'string'); + assert.equal(users.fields.profile.ui.component, 'fieldset'); + assert.equal(users.fields.profile.fields.bio.ui.component, 'textarea'); +}); + +test('schema manifest customizeField can override and omit field output', async () => { + const cwd = await makeProject(); + await writeConfig(cwd, `export default { + schemaOutFile: './src/generated/jsondb.schema.json', + schemaManifest: { + customizeField({ fieldName, resourceName, path, file, defaultManifest }) { + if (fieldName === 'secret') { + return null; + } + + if (resourceName === 'users' && fieldName.endsWith('Markdown')) { + return { + ...defaultManifest, + ui: { + ...defaultManifest.ui, + component: 'markdown', + section: \`\${file}:\${path}\` + } + }; + } + + return defaultManifest; + } + } + };`); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + bioMarkdown: '# Ada', + secret: 'hidden', + }, + ])); + + const config = await loadConfig({ cwd }); + await syncJsonFixtureDb(config); + + const manifest = JSON.parse(await readFile(path.join(cwd, 'src/generated/jsondb.schema.json'), 'utf8')); + + assert.equal(manifest.collections.users.fields.bioMarkdown.ui.component, 'markdown'); + assert.equal(manifest.collections.users.fields.bioMarkdown.ui.section, 'db/users.json:bioMarkdown'); + assert.equal('secret' in manifest.collections.users.fields, false); +}); + +test('schema manifest rejects non-serializable customizeField output with diagnostics', async () => { + const cwd = await makeProject(); + await writeConfig(cwd, `export default { + schemaOutFile: './src/generated/jsondb.schema.json', + schemaManifest: { + customizeField({ defaultManifest }) { + return { + ...defaultManifest, + ui: { + ...defaultManifest.ui, + render: () => 'nope' + } + }; + } + } + };`); + await writeFixture(cwd, 'users.json', JSON.stringify([{ id: 'u_1', name: 'Ada' }])); + + const config = await loadConfig({ cwd }); + + await assert.rejects( + () => syncJsonFixtureDb(config), + (error) => { + assert.equal(error.diagnostics?.[0]?.code, 'SCHEMA_MANIFEST_FIELD_NOT_SERIALIZABLE'); + assert.match(error.diagnostics[0].message, /users\.id/); + assert.match(error.diagnostics[0].hint, /JSON-serializable/); + return true; + }, + ); +}); + +test('CLI schema manifest --out writes relative to --cwd', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([{ id: 'u_1', email: 'ada@example.com' }])); + + const { stdout } = await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), + 'schema', + 'manifest', + '--cwd', + cwd, + '--out', + './src/generated/jsondb.schema.json', + ]); + + const manifest = JSON.parse(await readFile(path.join(cwd, 'src/generated/jsondb.schema.json'), 'utf8')); + + assert.match(stdout, /Generated src\/generated\/jsondb\.schema\.json/); + assert.equal(manifest.collections.users.fields.email.ui.component, 'email'); +}); + test('strict unknown fields fail sync in mixed mode', async () => { const cwd = await makeProject(); await writeConfig(cwd, `export default {