Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/objectql-cascade-autonumber.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
],
Expand Down
2 changes: 2 additions & 0 deletions examples/app-showcase/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions examples/app-showcase/src/pages/project-workspace.page.ts
Original file line number Diff line number Diff line change
@@ -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' },
],
},
],
},
},
],
},
],
};
152 changes: 152 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
execCtx?: ExecutionContext,
): Promise<void> {
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<number> {
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)
*
Expand Down Expand Up @@ -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<string, number>();

/**
* Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
Expand Down Expand Up @@ -1753,6 +1822,9 @@ export class ObjectQL implements IDataEngine {
const rows = (hookContext.input.data as any[]).map((row) =>
this.applyFieldDefaults(object, row as Record<string, unknown>, opCtx.context, nowSnap),
);
for (const r of rows) {
await this.applyAutonumbers(object, r as Record<string, unknown>, opCtx.context);
}
for (const r of rows) {
await this.encryptSecretFields(object, r, opCtx.context, hookContext.input.options);
}
Expand All @@ -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 });
Expand Down Expand Up @@ -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<void> {
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<string, any> | 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<any> {
object = this.resolveObjectName(object);
this.logger.debug('Delete operation starting', { object });
Expand Down Expand Up @@ -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 };
Expand Down
8 changes: 4 additions & 4 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down