From 54703adf123904b1944f8ed2b87d4368ce852d18 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:56:57 +0800 Subject: [PATCH 1/3] feat(app-showcase): master-detail showcase page (New Project + Tasks) Adds showcase_project_workspace page rendering object-master-detail-form for showcase_project + showcase_task (master_detail), plus a nav entry. Verifies ObjectUI ADR-0001 master-detail subform end-to-end in the showcase. Co-Authored-By: Claude Opus 4.8 --- examples/app-showcase/objectstack.config.ts | 4 +- examples/app-showcase/src/apps/index.ts | 1 + examples/app-showcase/src/pages/index.ts | 2 + .../src/pages/project-workspace.page.ts | 89 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 examples/app-showcase/src/pages/project-workspace.page.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 743a0cbd7..60fecc2fb 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -10,7 +10,7 @@ import { ShowcaseApp } from './src/apps/index.js'; import { ChartGalleryDashboard } from './src/dashboards/index.js'; import { allReports } from './src/reports/index.js'; import { allActions } from './src/actions/index.js'; -import { ComponentGalleryPage } from './src/pages/index.js'; +import { ComponentGalleryPage, ProjectWorkspacePage } from './src/pages/index.js'; import { allFlows } from './src/flows/index.js'; import { allWebhooks } from './src/webhooks/index.js'; import { allJobs } from './src/jobs/index.js'; @@ -113,7 +113,7 @@ export default defineStack({ apps: [ShowcaseApp], portals: allPortals, views: [TaskViews, ProjectViews], - pages: [ComponentGalleryPage], + pages: [ComponentGalleryPage, ProjectWorkspacePage], dashboards: [ChartGalleryDashboard], reports: allReports, actions: allActions, diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index 0f40bd732..81a13d99c 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -48,6 +48,7 @@ export const ShowcaseApp = App.create({ icon: 'layout', children: [ { id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' }, + { id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' }, ], }, ], diff --git a/examples/app-showcase/src/pages/index.ts b/examples/app-showcase/src/pages/index.ts index 089f6432b..6a9a0c6e9 100644 --- a/examples/app-showcase/src/pages/index.ts +++ b/examples/app-showcase/src/pages/index.ts @@ -2,6 +2,8 @@ import type { Page } from '@objectstack/spec/ui'; +export { ProjectWorkspacePage } from './project-workspace.page.js'; + /** * Component Gallery — a custom page that places a spread of standard page * components (header, card, tabs, text/number/image/divider/button elements, diff --git a/examples/app-showcase/src/pages/project-workspace.page.ts b/examples/app-showcase/src/pages/project-workspace.page.ts new file mode 100644 index 000000000..4a8502e88 --- /dev/null +++ b/examples/app-showcase/src/pages/project-workspace.page.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Page } from '@objectstack/spec/ui'; + +/** + * Project Workspace — a master-detail (header + line items) entry scenario. + * + * Demonstrates the `object-master-detail-form` renderer (ObjectUI ADR-0001): + * create a Project (parent) together with its Tasks (children) in one screen. + * `showcase_task.project` is a `master_detail` field, so the children are + * created with the parent FK set in a single client-orchestrated transaction. + */ +export const ProjectWorkspacePage: Page = { + name: 'showcase_project_workspace', + label: 'New Project + Tasks', + type: 'app', + template: 'default', + kind: 'full', + regions: [ + { + name: 'header', + width: 'full', + components: [ + { + type: 'page:header', + properties: { + title: 'New Project + Tasks', + subtitle: + 'Master-detail entry — fill the project, add its tasks inline, and save them together.', + icon: 'folder-plus', + }, + }, + ], + }, + { + name: 'main', + width: 'large', + components: [ + { + type: 'object-master-detail-form', + properties: { + objectName: 'showcase_project', + mode: 'create', + formType: 'simple', + submitText: 'Create Project + Tasks', + fields: ['name', 'account', 'status', 'health', 'budget', 'end_date'], + details: [ + { + title: 'Tasks', + childObject: 'showcase_task', + relationshipField: 'project', + amountField: 'estimate_hours', + addLabel: 'Add task', + columns: [ + { field: 'title', label: 'Title', type: 'text', required: true }, + { + field: 'status', + label: 'Status', + type: 'select', + options: [ + { label: 'Backlog', value: 'backlog' }, + { label: 'To Do', value: 'todo' }, + { label: 'In Progress', value: 'in_progress' }, + { label: 'In Review', value: 'in_review' }, + { label: 'Done', value: 'done' }, + ], + }, + { + field: 'priority', + label: 'Priority', + type: 'select', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + { label: 'Urgent', value: 'urgent' }, + ], + }, + { field: 'estimate_hours', label: 'Estimate (h)', type: 'number' }, + { field: 'due_date', label: 'Due Date', type: 'date' }, + ], + }, + ], + }, + }, + ], + }, + ], +}; From 6a6c7df32ca0ce6c47deffc3f7640366074a4b75 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:33:18 +0800 Subject: [PATCH 2/3] fix(objectql): master_detail cascade delete + autonumber generation - delete now applies referential delete behavior for incoming relations: master_detail cascades (parent owns children; only explicit 'restrict' deviates), lookup honors deleteBehavior (default set_null). Recurses for grandchildren; depth-guarded. - insert now generates values for empty autonumber fields BEFORE required validation (max+1, seeded per object.field, honors autonumberFormat), so a required autonumber is never rejected as 'missing'. Verified end-to-end on app-showcase: showcase_project delete cascades to showcase_task; showcase_field_zoo.f_autonumber auto-populates. Co-Authored-By: Claude Opus 4.8 --- packages/objectql/src/engine.ts | 152 ++++++++++++++++++++++++++++ packages/spec/src/data/field.zod.ts | 8 +- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0a6ddf8c9..771dae3ca 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -701,6 +701,71 @@ export class ObjectQL implements IDataEngine { return out; } + /** + * Generate values for empty `autonumber` fields on insert. Runs BEFORE + * required-validation so a `required` autonumber (e.g. a record number) is + * never rejected for "missing" — the runtime owns the value, not the client. + * + * The next value is `max(existing) + 1`, seeded once per `object.field` from + * the store then incremented in memory (monotonic within the process, + * resilient to deletions). `autonumberFormat` is honored, e.g. + * `CASE-{0000}` → `CASE-0042`. NOTE: in-memory seeding is single-instance; + * a persistent sequence store is a follow-up for multi-instance setups. + */ + private async applyAutonumbers( + object: string, + record: Record, + execCtx?: ExecutionContext, + ): Promise { + const fields = (this.getSchema(object) as any)?.fields; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return; + for (const [name, def] of Object.entries(fields)) { + if ((def as any)?.type !== 'autonumber') continue; + const current = record[name]; + if (current != null && current !== '') continue; // respect explicit value + const key = `${object}.${name}`; + let next = this.autonumberCounters.get(key); + if (next == null) next = await this.seedAutonumber(object, name, execCtx); + next += 1; + this.autonumberCounters.set(key, next); + record[name] = this.formatAutonumber((def as any).autonumberFormat, next); + } + } + + /** Seed the autonumber counter from the current max numeric value in store. */ + private async seedAutonumber( + object: string, + field: string, + execCtx?: ExecutionContext, + ): Promise { + try { + const rows = await this.find(object, { + select: ['id', field], + limit: 5000, + context: execCtx, + } as any); + let max = 0; + for (const r of rows || []) { + const v = r?.[field]; + if (v == null) continue; + const m = String(v).match(/(\d+)(?!.*\d)/); // last run of digits + if (m) max = Math.max(max, parseInt(m[1], 10) || 0); + } + return max; + } catch { + return 0; + } + } + + /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */ + private formatAutonumber(format: string | undefined, value: number): string { + if (!format) return String(value); + const m = format.match(/\{(0+)\}/); + if (!m) return format.includes('{0}') ? format.replace('{0}', String(value)) : `${format}${value}`; + const padded = String(value).padStart(m[1].length, '0'); + return format.replace(m[0], padded); + } + /** * Register contribution (Manifest) * @@ -1431,6 +1496,10 @@ export class ObjectQL implements IDataEngine { /** Maximum depth for recursive expand to prevent infinite loops */ private static readonly MAX_EXPAND_DEPTH = 3; + private static readonly MAX_CASCADE_DEPTH = 10; + /** In-memory next-value cache per `object.field` for autonumber generation, + * lazily seeded from the current max in the store. */ + private readonly autonumberCounters = new Map(); /** * Post-process expand: resolve lookup/master_detail fields by batch-loading related records. @@ -1753,6 +1822,9 @@ export class ObjectQL implements IDataEngine { const rows = (hookContext.input.data as any[]).map((row) => this.applyFieldDefaults(object, row as Record, opCtx.context, nowSnap), ); + for (const r of rows) { + await this.applyAutonumbers(object, r as Record, opCtx.context); + } for (const r of rows) { await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options); } @@ -1773,6 +1845,7 @@ export class ObjectQL implements IDataEngine { opCtx.context, nowSnap, ); + await this.applyAutonumbers(object, row, opCtx.context); await this.encryptSecretFields(object, row, opCtx.context, hookContext.input.options); validateRecord(schemaForValidation, row, 'insert'); evaluateValidationRules(schemaForValidation as any, row, 'insert', { logger: this.logger }); @@ -1946,6 +2019,82 @@ export class ObjectQL implements IDataEngine { return opCtx.result; } + /** + * Apply referential delete behavior for relations pointing AT this record, + * before it is removed. For every registered object with a `master_detail` + * or `lookup` field referencing `object`, honor the field's `deleteBehavior`: + * - `cascade` → delete the dependent rows (recursively, so grandchildren + * are handled by each child's own delete), + * - `set_null` → clear the foreign key, + * - `restrict` → refuse the delete when dependents exist. + * `master_detail` defaults to `cascade` (the parent owns the child + * lifecycle); `lookup` defaults to `set_null`. Only runs for single-id + * deletes — multi/predicate deletes skip cascade (logged). + */ + private async cascadeDeleteRelations( + object: string, + id: string | number, + context?: ExecutionContext, + depth = 0, + ): Promise { + if (id == null || depth >= ObjectQL.MAX_CASCADE_DEPTH) return; + let objects: ServiceObject[]; + try { + objects = this._registry.getAllObjects(); + } catch { + return; + } + for (const child of objects) { + const childName = (child as any)?.name as string | undefined; + const fields = (child as any)?.fields as Record | undefined; + if (!childName || !fields) continue; + for (const [fieldName, fdef] of Object.entries(fields)) { + if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue; + const ref = fdef.reference; + if (!ref) continue; + // Match the target object by raw or resolved name. + let resolvedRef: string | undefined; + try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } + if (ref !== object && resolvedRef !== object) continue; + + // A master-detail parent owns its children: cascade by default (the + // child FK is typically required, so set_null would be invalid). Only + // an explicit `restrict` deviates. A plain lookup honors its + // configured deleteBehavior (default set_null). + const behavior: string = + fdef.type === 'master_detail' + ? (fdef.deleteBehavior === 'restrict' ? 'restrict' : 'cascade') + : (fdef.deleteBehavior || 'set_null'); + + let dependents: any[]; + try { + dependents = await this.find(childName, { where: { [fieldName]: id }, context } as any); + } catch { + continue; + } + if (!dependents || dependents.length === 0) continue; + + if (behavior === 'restrict') { + throw new Error( + `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) via ${fieldName}`, + ); + } + + for (const dep of dependents) { + const depId = dep?.id; + if (depId == null) continue; + if (behavior === 'cascade') { + // Recurse via the public delete so the child's own cascade, + // hooks and events fire. + await this.delete(childName, { where: { id: depId }, context } as any); + } else { + await this.update(childName, { id: depId, [fieldName]: null }, { context } as any); + } + } + } + } + } + async delete(object: string, options?: EngineDeleteOptions): Promise { object = this.resolveObjectName(object); this.logger.debug('Delete operation starting', { object }); @@ -1981,6 +2130,9 @@ export class ObjectQL implements IDataEngine { try { let result; if (hookContext.input.id) { + // Honor referential delete behavior (cascade/set_null/restrict) + // for relations pointing at this record before removing it. + await this.cascadeDeleteRelations(object, hookContext.input.id as string | number, opCtx.context); result = await driver.delete(object, hookContext.input.id as string, hookContext.input.options as any); } else if (options?.multi && driver.deleteMany) { const ast: QueryAST = { object, where: options.where }; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d023bbc98..423b9c192 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -608,10 +608,10 @@ export const Field = { ...config } as const), - masterDetail: (reference: string, config: FieldInput = {}) => ({ - type: 'master_detail', - reference, - ...config + masterDetail: (reference: string, config: FieldInput = {}) => ({ + type: 'master_detail', + reference, + ...config } as const), // Enhanced Field Type Helpers From a7319ed34292cc88a7fa980dbacf4aa995baf078 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:50:43 +0800 Subject: [PATCH 3/3] chore: changeset for objectql cascade-delete + autonumber Co-Authored-By: Claude Opus 4.8 --- .changeset/objectql-cascade-autonumber.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/objectql-cascade-autonumber.md diff --git a/.changeset/objectql-cascade-autonumber.md b/.changeset/objectql-cascade-autonumber.md new file mode 100644 index 000000000..d1a91ec1d --- /dev/null +++ b/.changeset/objectql-cascade-autonumber.md @@ -0,0 +1,8 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): master_detail cascade delete + autonumber generation + +- `delete` now applies referential delete behavior for incoming relations: `master_detail` cascades to children (the parent owns the child lifecycle; only an explicit `restrict` deviates), `lookup` honors its `deleteBehavior` (default `set_null`). Recurses for grandchildren, depth-guarded, single-id deletes. Previously deleting a parent left its children orphaned. +- `insert` now generates values for empty `autonumber` fields before required-validation (`max+1`, seeded per `object.field`, honors `autonumberFormat`). Previously a required autonumber was rejected as "missing" and autonumber fields were never populated.