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 {