cogsbox-shape
Version:
A TypeScript library for creating type-safe database schemas with Zod validation, SQL type definitions, and automatic client/server transformations. Unifies client, server, and database types through a single schema definition, with built-in support for r
819 lines (818 loc) • 30.1 kB
JavaScript
import { createPluginContext, getGlobalStore } from "cogsbox-state";
import { z } from "zod";
function pathKey(path) {
return path.join("\0");
}
function getValueAtPath(value, path) {
let cursor = value;
for (const segment of path) {
if (cursor === null || typeof cursor !== "object")
return undefined;
cursor = cursor[segment];
}
return cursor;
}
function setValueAtPath(value, path, nextValue) {
if (path.length === 0)
return nextValue;
if (value === null || typeof value !== "object")
return value;
const root = Array.isArray(value)
? [...value]
: { ...value };
let cursor = root;
for (let index = 0; index < path.length - 1; index++) {
const segment = path[index];
const current = cursor[segment];
cursor[segment] =
current && typeof current === "object"
? Array.isArray(current)
? [...current]
: { ...current }
: {};
cursor = cursor[segment];
}
cursor[path[path.length - 1]] = nextValue;
return root;
}
function valuesEqual(left, right) {
if (Object.is(left, right))
return true;
try {
return JSON.stringify(left) === JSON.stringify(right);
}
catch {
return false;
}
}
function cacheKeyFor(stateKey, adapter, shape) {
const key = adapter?.key?.({ shape: shape, stateKey });
if (key === undefined || key === null || key === "")
return stateKey;
return `${stateKey}:${String(key)}`;
}
function dirtyPathKey(path) {
return path.length === 0 ? "$" : path.join(".");
}
function shapeStatusDependencyPath(path) {
return [...path, "$pluginMeta", "shape", "status"];
}
function notifyShapeStatus(stateKey, path = []) {
const store = getGlobalStore.getState();
const rootMeta = store.getShadowMetadata(stateKey, []);
const statusMeta = store.getShadowMetadata(stateKey, shapeStatusDependencyPath(path));
statusMeta?.pathComponents?.forEach((componentId) => {
rootMeta?.components?.get(componentId)?.forceUpdate();
});
}
function sameStatus(left, right) {
return (left.cacheKey === right.cacheKey &&
left.isDirty === right.isDirty &&
left.isLoading === right.isLoading &&
left.isSaving === right.isSaving &&
left.loadStatus === right.loadStatus &&
left.saveStatus === right.saveStatus &&
left.error === right.error &&
left.lastLoadedAt === right.lastLoadedAt &&
left.lastSavedAt === right.lastSavedAt &&
valuesEqual(left.dirtyPaths, right.dirtyPaths));
}
function statusFromMeta(stateKey, cacheKey, meta) {
const dirtyPaths = meta?.dirtyPaths ?? [];
const loadStatus = meta?.loadStatus ?? "idle";
const saveStatus = meta?.saveStatus ?? "idle";
return {
cacheKey: meta?.cacheKey ?? cacheKey,
dirtyPaths,
isDirty: dirtyPaths.length > 0,
isLoading: loadStatus === "loading",
isSaving: saveStatus === "saving",
loadStatus,
saveStatus,
error: meta?.error,
lastLoadedAt: meta?.lastLoadedAt,
lastSavedAt: meta?.lastSavedAt,
};
}
function normaliseFromServer(entry, data) {
const clientSchema = entry.validators?.client ?? entry.schemas.client;
const parsed = clientSchema.safeParse(data);
if (parsed.success)
return parsed.data;
if (entry.transforms?.parseFromDb) {
try {
return entry.transforms.parseFromDb(data);
}
catch {
// Fall through to the raw data. The caller's save/load handler may
// already return client-shaped data with server-side metadata attached.
}
}
if (entry.transforms?.toClient) {
try {
return entry.transforms.toClient(data);
}
catch {
// Same fallback as parseFromDb.
}
}
return data;
}
function identityFor(entry, value) {
if (value === null || typeof value !== "object")
return undefined;
const source = value;
const keys = [...(entry.pk ?? []), ...(entry.clientPk ?? [])];
if (keys.length === 0)
return undefined;
return Object.fromEntries(keys
.filter((key) => Object.prototype.hasOwnProperty.call(source, key))
.map((key) => [key, source[key]]));
}
function resolveRelatedPaths(blurPath, relatedFields) {
const parent = blurPath.slice(0, -1);
return [...relatedFields].map((field) => [...parent, field]);
}
function mapZodIssues(issues) {
return issues.map((issue) => ({
path: issue.path.map(String),
message: issue.message,
code: issue.code,
}));
}
function cloneStateForInputEvent(state, path, value) {
if (path.length === 0)
return value;
if (state === null || typeof state !== "object")
return state;
const root = Array.isArray(state)
? [...state]
: { ...state };
let cursor = root;
for (let index = 0; index < path.length - 1; index++) {
const segment = path[index];
if (Array.isArray(cursor)) {
const arrayIndex = Number(segment);
if (!Number.isInteger(arrayIndex))
return root;
const next = cursor[arrayIndex];
const cloned = next && typeof next === "object"
? Array.isArray(next)
? [...next]
: { ...next }
: {};
cursor[arrayIndex] = cloned;
cursor = cloned;
}
else {
const next = cursor[segment];
const cloned = next && typeof next === "object"
? Array.isArray(next)
? [...next]
: { ...next }
: {};
cursor[segment] = cloned;
cursor = cloned;
}
}
const leaf = path[path.length - 1];
if (Array.isArray(cursor)) {
const arrayIndex = Number(leaf);
if (Number.isInteger(arrayIndex))
cursor[arrayIndex] = value;
}
else {
cursor[leaf] = value;
}
return root;
}
function getStateForValidation(params) {
const state = params.getState();
if (params.event.activityType !== "input" ||
!("details" in params.event) ||
!params.event.details ||
typeof params.event.details !== "object" ||
!("value" in params.event.details)) {
return state;
}
return cloneStateForInputEvent(state, params.path, params.event.details.value);
}
function notifyValidationPaths(stateKey, paths) {
const store = getGlobalStore.getState();
for (const path of paths) {
store.notifyPathSubscribers([stateKey, ...path].join("."), {
type: "VALIDATION_UPDATE",
});
}
}
function clearValidationPaths(params, paths) {
if (paths.length === 0)
return;
if (params.clearZodErrors) {
params.clearZodErrors(paths);
notifyValidationPaths(params.stateKey, paths);
return;
}
const store = getGlobalStore.getState();
for (const path of paths) {
const currentMeta = store.getShadowMetadata(params.stateKey, path) || {};
store.setShadowMetadata(params.stateKey, path, {
...currentMeta,
validation: {
status: "NOT_VALIDATED",
errors: [],
lastValidated: Date.now(),
validatedValue: undefined,
},
});
}
notifyValidationPaths(params.stateKey, paths);
}
function addValidationIssues(params, issues) {
if (issues.length === 0)
return;
params.addZodErrors(issues);
notifyValidationPaths(params.stateKey, issues.map((issue) => issue.path));
}
function createShadowValidationBridge(stateKey) {
const store = getGlobalStore.getState();
return {
stateKey,
getState: () => store.getShadowValue(stateKey, []),
addZodErrors: (issues) => {
for (const error of issues) {
const errorPath = error.path;
const currentMeta = store.getShadowMetadata(stateKey, errorPath) || {};
store.setShadowMetadata(stateKey, errorPath, {
...currentMeta,
validation: {
status: "INVALID",
errors: [
{
source: "client",
message: error.message,
severity: "error",
code: error.code,
},
],
lastValidated: Date.now(),
validatedValue: undefined,
},
});
}
},
clearZodErrors: (paths) => {
for (const path of paths) {
const currentMeta = store.getShadowMetadata(stateKey, path) || {};
store.setShadowMetadata(stateKey, path, {
...currentMeta,
validation: {
status: "NOT_VALIDATED",
errors: [],
lastValidated: Date.now(),
validatedValue: undefined,
},
});
}
},
};
}
function notifyStateComponents(stateKey) {
const store = getGlobalStore.getState();
const stateEntry = store.getShadowMetadata(stateKey, []);
if (!stateEntry?.components)
return;
const updates = new Set();
stateEntry.components.forEach((component) => {
const reactiveTypes = component
? Array.isArray(component.reactiveType)
? component.reactiveType
: [component.reactiveType || "component"]
: null;
if (!reactiveTypes?.includes("none")) {
updates.add(() => component.forceUpdate());
}
});
queueMicrotask(() => {
updates.forEach((update) => update());
});
}
function persistValidateGroupResults(params, keys, mapped) {
const validationParams = createShadowValidationBridge(params.stateKey);
if (params.clearOutsideKeys === true) {
clearOutsideGroupValidation(params, keys, validationParams);
}
const keyPaths = keys.map((key) => [...params.path, key]);
const activeKeys = new Set(mapped.map((issue) => pathKey(issue.path)));
const stalePaths = keyPaths.filter((targetPath) => !activeKeys.has(pathKey(targetPath)));
clearValidationPaths(validationParams, stalePaths);
if (mapped.length > 0) {
addValidationIssues(validationParams, mapped);
}
notifyStateComponents(params.stateKey);
}
function clearOutsideGroupValidation(params, keys, validationParams) {
const store = getGlobalStore.getState();
const parentNode = store.getShadowNode(params.stateKey, params.path);
if (!parentNode)
return;
const keySet = new Set(keys);
const outsidePaths = [];
for (const childKey of Object.keys(parentNode)) {
if (childKey === "_meta" || keySet.has(childKey))
continue;
const fieldPath = [...params.path, childKey];
const status = store.getShadowMetadata(params.stateKey, fieldPath)
?.validation?.status;
if (status === "INVALID") {
outsidePaths.push(fieldPath);
}
}
clearValidationPaths(validationParams, outsidePaths);
}
function issueMatchesSelectedKeys(issue, parentPath, selectedKeys) {
const issuePath = issue.path.map(String);
if (issuePath.length <= parentPath.length)
return false;
for (let index = 0; index < parentPath.length; index++) {
if (issuePath[index] !== parentPath[index])
return false;
}
return selectedKeys.has(issuePath[parentPath.length]);
}
function getRelatedFields(entry, field) {
const groupIndexes = entry.refineInfo?.fieldToGroup[field];
if (!groupIndexes?.length)
return null;
const related = new Set([field]);
for (const index of groupIndexes) {
const deps = entry.refineInfo?.groups[index]?.deps;
if (!deps)
continue;
for (const dep of deps)
related.add(dep);
}
return related;
}
function issueMatchesRelatedFields(issue, relatedFields) {
const leaf = String(issue.path.at(-1) ?? "");
return relatedFields.has(leaf);
}
function getChangedObjectFields(oldValue, newValue) {
if (oldValue === null ||
newValue === null ||
typeof oldValue !== "object" ||
typeof newValue !== "object" ||
Array.isArray(oldValue) ||
Array.isArray(newValue)) {
return null;
}
const fields = new Set();
const oldRecord = oldValue;
const newRecord = newValue;
for (const key of new Set([
...Object.keys(oldRecord),
...Object.keys(newRecord),
])) {
if (!Object.is(oldRecord[key], newRecord[key]))
fields.add(key);
}
return fields;
}
function resolveUpdateRefineTarget(entry, updatePath, oldValue, newValue) {
const changedObjectFields = getChangedObjectFields(oldValue, newValue);
if (changedObjectFields) {
const parentPath = updatePath;
const relatedFields = new Set();
for (const field of changedObjectFields) {
const groupFields = getRelatedFields(entry, field);
if (!groupFields)
continue;
for (const related of groupFields)
relatedFields.add(related);
}
if (relatedFields.size === 0)
return null;
return {
relatedFields,
relatedPaths: [...relatedFields].map((field) => [...parentPath, field]),
};
}
const field = updatePath.at(-1);
if (!field)
return null;
const relatedFields = getRelatedFields(entry, field);
if (!relatedFields)
return null;
return {
relatedFields,
relatedPaths: resolveRelatedPaths(updatePath, relatedFields),
};
}
function clearStaleRefineValidation(box, params, target, state) {
const entry = box[params.stateKey];
const clientSchema = entry?.validators?.client ?? entry?.schemas.client;
if (!entry || !clientSchema)
return;
const result = clientSchema.safeParse(state);
if (result.success) {
clearValidationPaths(params, target.relatedPaths);
return;
}
const issues = result.error.issues.filter((issue) => issueMatchesRelatedFields(issue, target.relatedFields));
const mapped = mapZodIssues(issues);
const activeKeys = new Set(mapped.map((entry) => pathKey(entry.path)));
const stalePaths = target.relatedPaths.filter((targetPath) => !activeKeys.has(pathKey(targetPath)));
clearValidationPaths(params, stalePaths);
}
function applyRefineValidation(box, params, target, state) {
const entry = box[params.stateKey];
const clientSchema = entry?.validators?.client ?? entry?.schemas.client;
if (!entry || !clientSchema)
return;
const result = clientSchema.safeParse(state);
if (result.success) {
clearValidationPaths(params, target.relatedPaths);
return;
}
const issues = result.error.issues.filter((issue) => issueMatchesRelatedFields(issue, target.relatedFields));
const mapped = mapZodIssues(issues);
const activeKeys = new Set(mapped.map((entry) => pathKey(entry.path)));
const stalePaths = target.relatedPaths.filter((targetPath) => !activeKeys.has(pathKey(targetPath)));
clearValidationPaths(params, stalePaths);
addValidationIssues(params, mapped);
}
export function wireShapeValidationOptions(box, params) {
const entry = box[params.stateKey];
if (!entry)
return;
params.setOptions({
validation: {
zodSchemaV4: entry.validators?.client ?? entry.schemas.client,
onBlur: "error",
},
});
}
/** Cross-field refine errors only — field rules are handled by state via setOptions. */
export function validateShapeRefines(box, params) {
if (params.event.activityType !== "blur" &&
params.event.activityType !== "input") {
return;
}
const entry = box[params.stateKey];
if (!entry)
return;
const field = params.path.at(-1);
if (!field)
return;
const relatedFields = getRelatedFields(entry, field);
if (!relatedFields)
return;
const target = {
relatedFields,
relatedPaths: resolveRelatedPaths(params.path, relatedFields),
};
const state = getStateForValidation(params);
if (params.event.activityType === "input") {
clearStaleRefineValidation(box, params, target, state);
return;
}
applyRefineValidation(box, params, target, state);
}
export function validateShapeRefinesOnUpdate(box, params) {
if (params.update.updateType !== "update")
return;
const entry = box[params.stateKey];
if (!entry)
return;
const target = resolveUpdateRefineTarget(entry, params.update.path, params.update.oldValue, params.update.newValue);
if (!target)
return;
clearStaleRefineValidation(box, params, target, params.getState());
}
export function validateShapeKeys(box, params) {
const entry = box[params.stateKey];
const clientSchema = entry?.validators?.client ?? entry?.schemas.client;
if (!entry || !clientSchema)
return { success: true, results: [] };
const rootState = params.getState?.() ?? getGlobalStore.getState().getShadowValue(params.stateKey, []);
const result = clientSchema.safeParse(rootState);
const selectedKeys = params.keys ? new Set(params.keys) : null;
const shouldPersist = params.persist !== false && !!params.keys?.length;
if (result.success) {
if (shouldPersist) {
persistValidateGroupResults(params, params.keys, []);
}
return {
success: true,
results: params.keys?.map((key) => ({
key,
path: [...params.path, key],
success: true,
data: getValueAtPath(rootState, [...params.path, key]),
})) ?? [],
};
}
const issues = selectedKeys
? result.error.issues.filter((issue) => issueMatchesSelectedKeys(issue, params.path, selectedKeys))
: result.error.issues;
const mapped = mapZodIssues(issues);
if (shouldPersist) {
persistValidateGroupResults(params, params.keys, mapped);
}
return {
success: mapped.length === 0,
results: params.keys?.map((key) => {
const keyPath = [...params.path, key];
const keyIssues = mapped.filter((issue) => issue.path[params.path.length] === key);
return {
key,
path: keyPath,
success: keyIssues.length === 0,
data: keyIssues.length === 0
? getValueAtPath(rootState, keyPath)
: undefined,
error: keyIssues.length === 0 ? undefined : { issues: keyIssues },
};
}) ?? [],
};
}
function buildInitialState(entries) {
const state = {};
for (const key of Object.keys(entries)) {
const entry = entries[key];
if (!entry)
continue;
state[key] = entry.generateDefaults();
}
return state;
}
function normalizeViewEntry(view) {
return {
stateType: {},
generateDefaults: view.defaults,
schemas: view.schemas,
validators: view.validators,
transforms: view.transforms,
pk: view.pk,
clientPk: view.clientPk,
isClientRecord: () => false,
};
}
const { createPlugin } = createPluginContext({
options: z.object({
logs: z.boolean().optional(),
}),
pluginMetaData: z.object({
cacheKey: z.string().optional(),
baseline: z.any().optional(),
dirtyPaths: z.array(z.string()).optional(),
isDirty: z.boolean().optional(),
isLoading: z.boolean().optional(),
isSaving: z.boolean().optional(),
loadStatus: z
.enum(["idle", "loading", "success", "error"])
.optional(),
saveStatus: z
.enum(["idle", "saving", "success", "error"])
.optional(),
error: z.any().optional(),
lastLoadedAt: z.number().optional(),
lastSavedAt: z.number().optional(),
suppressDirtyOnce: z.boolean().optional(),
}),
});
export function createShapePlugin(box, config = {}) {
const entries = {};
for (const [key, value] of Object.entries(box)) {
if (!value)
continue;
if (typeof value === "object" && "isView" in value && value.isView) {
entries[key] = normalizeViewEntry(value);
}
else {
entries[key] = value;
}
}
const stateConfig = config.state ?? {};
for (const [stateKey, rawStateEntry] of Object.entries(stateConfig)) {
const stateEntry = rawStateEntry;
if (!stateEntry || typeof stateEntry !== "object" || !("from" in stateEntry)) {
continue;
}
const base = box[stateEntry.from];
const view = base?.createView?.(stateEntry.with);
if (!view) {
throw new Error(`No shape view could be created for state key "${stateKey}"`);
}
entries[stateKey] = normalizeViewEntry(view);
}
const getAdapter = (stateKey) => (config.state?.[stateKey] ??
config.server?.[stateKey]);
const getCacheKey = (stateKey, shape) => cacheKeyFor(stateKey, getAdapter(stateKey), shape);
return createPlugin("shape")
.initialState(() => buildInitialState(entries))
.transformState((params) => {
wireShapeValidationOptions(entries, params);
const meta = (params.getPluginMetaData?.() ?? {});
const cacheKey = getCacheKey(params.stateKey, params.getState());
if (!meta.baseline || meta.cacheKey !== cacheKey) {
params.setPluginMetaData({
cacheKey,
baseline: params.getState(),
dirtyPaths: [],
isDirty: false,
loadStatus: meta.loadStatus ?? "idle",
saveStatus: meta.saveStatus ?? "idle",
});
}
})
.onFormUpdate((params) => {
if (params.options?.logs) {
console.log("[shape]", params.stateKey, params.path, params.event.activityType);
}
validateShapeRefines(entries, params);
})
.onUpdate((params) => {
validateShapeRefinesOnUpdate(entries, params);
if (params.update.updateType !== "update")
return;
const meta = (params.getPluginMetaData?.() ?? {});
if (meta.suppressDirtyOnce) {
params.setPluginMetaData({ suppressDirtyOnce: false });
return;
}
const cacheKey = getCacheKey(params.stateKey, params.getState());
if (meta.cacheKey && meta.cacheKey !== cacheKey) {
const nextMeta = {
...meta,
cacheKey,
baseline: params.getState(),
dirtyPaths: [],
isDirty: false,
};
params.setPluginMetaData(nextMeta);
notifyShapeStatus(params.stateKey, []);
return;
}
const beforeStatus = statusFromMeta(params.stateKey, cacheKey, meta);
const baseline = meta.baseline ?? entries[params.stateKey]?.generateDefaults?.();
const dirty = new Set(meta.dirtyPaths ?? []);
const key = dirtyPathKey(params.update.path);
const baselineValue = getValueAtPath(baseline, params.update.path);
if (valuesEqual(baselineValue, params.update.newValue)) {
dirty.delete(key);
}
else {
dirty.add(key);
}
const nextMeta = {
...meta,
cacheKey,
dirtyPaths: [...dirty],
isDirty: dirty.size > 0,
};
params.setPluginMetaData(nextMeta);
if (!sameStatus(beforeStatus, statusFromMeta(params.stateKey, cacheKey, nextMeta))) {
notifyShapeStatus(params.stateKey, []);
}
})
.methods((m) => ({
validateGroup: m.object((ctx, keys) => validateShapeKeys(entries, {
stateKey: ctx.stateKey,
path: ctx.path,
keys,
getState: ctx.$get,
})),
status: m.object((ctx) => {
ctx.watchPluginMeta?.("status");
const cacheKey = getCacheKey(ctx.stateKey, ctx.$get());
return statusFromMeta(ctx.stateKey, cacheKey, ctx.getFieldMetaData());
}),
load: m.object(async (ctx) => {
const entry = entries[ctx.stateKey];
const adapter = getAdapter(ctx.stateKey);
if (!entry || !adapter?.load) {
throw new Error(`No shape load adapter registered for ${ctx.stateKey}`);
}
const value = ctx.$get();
const meta = (ctx.getFieldMetaData() ?? {});
const cacheKey = getCacheKey(ctx.stateKey, value);
const status = statusFromMeta(ctx.stateKey, cacheKey, meta);
ctx.setFieldMetaData({
cacheKey,
loadStatus: "loading",
error: undefined,
});
ctx.notifyPluginMeta?.("status");
try {
const serverData = await adapter.load({
stateKey: ctx.stateKey,
cacheKey,
path: ctx.path,
value,
entry,
id: identityFor(entry, value),
options: ctx.options,
status,
});
const nextState = normaliseFromServer(entry, serverData);
ctx.setFieldMetaData({
suppressDirtyOnce: true,
baseline: nextState,
dirtyPaths: [],
isDirty: false,
loadStatus: "success",
lastLoadedAt: Date.now(),
error: undefined,
});
ctx.$update(nextState);
ctx.notifyPluginMeta?.("status");
return { success: true, data: nextState, cacheKey };
}
catch (error) {
ctx.setFieldMetaData({ loadStatus: "error", error });
ctx.notifyPluginMeta?.("status");
return { success: false, error, cacheKey };
}
}),
save: m.object(async (ctx) => {
const entry = entries[ctx.stateKey];
const adapter = getAdapter(ctx.stateKey);
if (!entry || !adapter) {
throw new Error(`No shape save adapter registered for ${ctx.stateKey}`);
}
const value = ctx.$get();
const meta = (ctx.getFieldMetaData() ?? {});
const cacheKey = getCacheKey(ctx.stateKey, value);
const operation = entry.isClientRecord?.(value) ? "insert" : "update";
const data = operation === "insert"
? entry.transforms?.parseForDb?.(value) ?? value
: entry.transforms?.parsePatchForDb?.(value) ??
entry.transforms?.parseForDb?.(value) ??
value;
const status = statusFromMeta(ctx.stateKey, cacheKey, meta);
ctx.setFieldMetaData({
cacheKey,
saveStatus: "saving",
error: undefined,
});
ctx.notifyPluginMeta?.("status");
try {
const baseCtx = {
stateKey: ctx.stateKey,
cacheKey,
path: ctx.path,
value,
data,
entry,
id: identityFor(entry, value),
operation,
options: ctx.options,
status,
};
const saved = adapter.save !== undefined
? await adapter.save(baseCtx)
: operation === "insert"
? await adapter.insert?.(baseCtx)
: await adapter.update?.(baseCtx);
if (saved === undefined) {
throw new Error(`No ${operation} handler returned data for ${ctx.stateKey}`);
}
const nextState = normaliseFromServer(entry, saved);
ctx.setFieldMetaData({
suppressDirtyOnce: true,
baseline: nextState,
dirtyPaths: [],
isDirty: false,
saveStatus: "success",
lastSavedAt: Date.now(),
error: undefined,
});
ctx.$update(nextState);
ctx.notifyPluginMeta?.("status");
return { success: true, data: nextState, operation, cacheKey };
}
catch (error) {
ctx.setFieldMetaData({ saveStatus: "error", error });
ctx.notifyPluginMeta?.("status");
return { success: false, error, operation, cacheKey };
}
}),
revert: m.object((ctx) => {
const meta = (ctx.getFieldMetaData() ?? {});
const baseline = meta.baseline;
if (baseline === undefined) {
return { success: false, error: "No shape baseline to revert to" };
}
ctx.setFieldMetaData({
suppressDirtyOnce: true,
dirtyPaths: [],
isDirty: false,
});
ctx.$update(baseline);
ctx.notifyPluginMeta?.("status");
return { success: true, data: baseline };
}),
}));
}