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
13 changes: 13 additions & 0 deletions .changeset/dev-loop-dx-p2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/objectql": patch
"@objectstack/metadata": patch
"@objectstack/runtime": patch
"@objectstack/plugin-auth": patch
"@objectstack/cli": patch
---

Dev-loop DX fixes from the 15.1 third-party evaluation (P2 batch):

- **Hot-added objects are now queryable without a restart.** Adding a `*.object.ts` under `os dev` used to recompile "green" while every query answered `no such table` (or `not registered`) until a manual restart: the artifact reload never notified the ObjectQL registry, tables were only created at boot, and seeds only loaded from the boot-time bundle. The `metadata:reloaded` payload now carries the parsed artifact; ObjectQL ingests the object definitions and re-runs the idempotent schema sync (same `skipSchemaSync` opt-out as boot), and the runtime loads seeds for first-seen objects (dev, single-tenant). `os dev` also prints `✚ new object(s): …` on recompile.
- **Dev admin credentials stay visible.** The `os dev` startup banner only showed `admin@objectos.ai / admin123` on the boot that actually seeded it; with the persistent default DB every later boot hid it, and the Console login page never knew it existed. The hint now re-arms on every dev boot for as long as the account still verifies against the default password, and `GET /api/v1/auth/config` exposes a dev-gated `devSeedAdmin` field (never present outside `NODE_ENV=development`) so the login page can show it.
- **`os doctor` reference analysis understands current metadata shapes.** Objects bound through `defineView` containers (`list`/`listViews`/`form`/`formViews` → `data.object`, subform `childObject`, lookup form fields) and app navigation (`objectName`, nested `children`, `areas`) were reported as "defined but not referenced". The collector now walks the canonical shapes (plus flow node `config.object`/`objectName`) and the orphan-view check descends into containers.
33 changes: 33 additions & 0 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,26 @@ export default class Dev extends Command {
let inFlight = false;
let queued = false;

// Object-name inventory of the artifact, diffed across recompiles so a
// newly added *.object.ts is called out explicitly (15.1 third-party
// eval: "recompiled" alone read as all-green while the new object's
// table/seed sync was invisible to the user).
const readArtifactObjects = (): Set<string> | null => {
try {
const raw = JSON.parse(fs.readFileSync(opts.artifactPath, 'utf8'));
const meta = raw?.metadata ?? raw?.data?.metadata ?? raw;
const objects = Array.isArray(meta?.objects) ? meta.objects : [];
return new Set(
objects
.map((o: any) => o?.name)
.filter((n: any): n is string => typeof n === 'string'),
);
} catch {
return null;
}
};
let knownObjects = readArtifactObjects();

const compileAndPing = async () => {
if (inFlight) { queued = true; return; }
inFlight = true;
Expand Down Expand Up @@ -419,6 +439,19 @@ export default class Dev extends Command {
// available for external trigger sources (cloud webhooks,
// git hooks, ad-hoc curl).
console.log(chalk.green(` ✓ recompiled in ${dt}ms — server will auto-reload`));
const objectsNow = readArtifactObjects();
if (objectsNow) {
const prior = knownObjects;
const fresh = prior ? [...objectsNow].filter((n) => !prior.has(n)) : [];
knownObjects = objectsNow;
if (fresh.length > 0) {
console.log(
chalk.cyan(
` ✚ new object(s): ${fresh.join(', ')} — table & seeds sync on reload`,
),
);
}
}
}
inFlight = false;
if (queued) { queued = false; setTimeout(compileAndPing, 50); }
Expand Down
118 changes: 96 additions & 22 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,79 @@ function detectCircularDependencies(objects: any[]): string[] {
return issues;
}

function findOrphanViews(config: any): string[] {
// ── Object-reference walking ────────────────────────────────────────
// A `config.views` entry is a ViewSchema CONTAINER (`{ list, form, listViews,
// formViews }` — the defineView() shape): the object binding lives on each
// sub-view at `data.object` (provider 'object'), never on a top-level
// `view.object`. Legacy flat ViewItems (top-level `object`) are still read.

function* subViewsOf(view: any): Generator<[slot: string, subView: any]> {
if (!view || typeof view !== 'object') return;
if (view.list) yield ['list', view.list];
if (view.form) yield ['form', view.form];
for (const [key, sub] of Object.entries<any>(
view.listViews && typeof view.listViews === 'object' ? view.listViews : {},
)) {
yield [`listViews.${key}`, sub];
}
for (const [key, sub] of Object.entries<any>(
view.formViews && typeof view.formViews === 'object' ? view.formViews : {},
)) {
yield [`formViews.${key}`, sub];
}
}

function subViewObject(sub: any): string | undefined {
if (typeof sub?.data?.object === 'string') return sub.data.object;
if (typeof sub?.objectName === 'string') return sub.objectName;
return undefined;
}

/** Every object name a view (container or legacy flat item) references. */
function collectViewObjectRefs(view: any): string[] {
const refs: string[] = [];
if (typeof view?.object === 'string') refs.push(view.object);
for (const [, sub] of subViewsOf(view)) {
const bound = subViewObject(sub);
if (bound) refs.push(bound);
// Inline master-detail children and lookup form fields are references too.
for (const subform of Array.isArray(sub?.subforms) ? sub.subforms : []) {
if (typeof subform?.childObject === 'string') refs.push(subform.childObject);
}
for (const section of Array.isArray(sub?.sections) ? sub.sections : []) {
for (const field of Array.isArray(section?.fields) ? section.fields : []) {
if (typeof field?.reference === 'string') refs.push(field.reference);
}
}
}
return refs;
}

/**
* Every object name an app's navigation references. Object nav items carry
* `objectName` (AppSchema `ObjectNavItemSchema`), nest under `children`, and
* may live in `areas[*].navigation` instead of the top-level `navigation`.
*/
function collectAppObjectRefs(app: any): string[] {
const refs: string[] = [];
const walk = (items: any): void => {
if (!Array.isArray(items)) return;
for (const item of items) {
if (!item || typeof item !== 'object') continue;
if (typeof item.objectName === 'string') refs.push(item.objectName);
if (typeof item.object === 'string') refs.push(item.object);
if (typeof item.requiresObject === 'string') refs.push(item.requiresObject);
walk(item.children);
}
};
walk(app?.navigation);
for (const area of Array.isArray(app?.areas) ? app.areas : []) {
walk(area?.navigation);
}
return refs;
}

export function findOrphanViews(config: any): string[] {
const objectNames = new Set<string>();
if (Array.isArray(config.objects)) {
for (const obj of config.objects) {
Expand All @@ -82,15 +154,23 @@ function findOrphanViews(config: any): string[] {
const orphans: string[] = [];
if (Array.isArray(config.views)) {
for (const view of config.views) {
if (view.object && !objectNames.has(view.object)) {
if (typeof view?.object === 'string' && !objectNames.has(view.object)) {
orphans.push(`View "${view.name || '?'}" references non-existent object "${view.object}"`);
}
for (const [slot, sub] of subViewsOf(view)) {
const bound = subViewObject(sub);
if (bound && !objectNames.has(bound)) {
orphans.push(
`View "${view?.name || sub?.label || slot}" (${slot}) references non-existent object "${bound}"`,
);
}
}
}
}
return orphans;
}

function findUnusedObjects(config: any): string[] {
export function findUnusedObjects(config: any): string[] {
const objectNames = new Set<string>();
if (Array.isArray(config.objects)) {
for (const obj of config.objects) {
Expand All @@ -100,38 +180,32 @@ function findUnusedObjects(config: any): string[] {

const referencedObjects = new Set<string>();

// Views reference objects
// Views — container sub-views (data.object), subforms, lookup form fields.
if (Array.isArray(config.views)) {
for (const view of config.views) {
if (view.object) referencedObjects.add(view.object);
for (const ref of collectViewObjectRefs(view)) referencedObjects.add(ref);
}
}

// Flows may reference objects via trigger
// Flows — the bound object lives inside node config (FlowNodeSchema.config
// is unstructured; `object`/`objectName` is the canonical alias pair used
// by record_change triggers and CRUD nodes).
if (Array.isArray(config.flows)) {
for (const flow of config.flows) {
if (flow.trigger?.object) referencedObjects.add(flow.trigger.object);
if (flow.object) referencedObjects.add(flow.object);
for (const node of Array.isArray(flow?.nodes) ? flow.nodes : []) {
const cfg = node?.config;
if (typeof cfg?.object === 'string') referencedObjects.add(cfg.object);
if (typeof cfg?.objectName === 'string') referencedObjects.add(cfg.objectName);
}
}
}

// Apps may reference objects via navigation
// Apps — navigation (top-level, nested children, and areas).
if (Array.isArray(config.apps)) {
for (const app of config.apps) {
if (Array.isArray(app.navigation)) {
for (const nav of app.navigation) {
if (nav.object) referencedObjects.add(nav.object);
}
}
}
}

// Agents may reference objects
if (Array.isArray(config.agents)) {
for (const agent of config.agents) {
if (Array.isArray(agent.objects)) {
for (const o of agent.objects) referencedObjects.add(o);
}
for (const ref of collectAppObjectRefs(app)) referencedObjects.add(ref);
}
}

Expand All @@ -151,7 +225,7 @@ function findUnusedObjects(config: any): string[] {
const unused: string[] = [];
for (const name of objectNames) {
if (!referencedObjects.has(name)) {
unused.push(`Object "${name}" is defined but not referenced by any view, flow, app, or agent`);
unused.push(`Object "${name}" is defined but not referenced by any view, flow, app, or lookup field`);
}
}
return unused;
Expand Down
145 changes: 145 additions & 0 deletions packages/cli/test/doctor-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// `os doctor` reference analysis (15.1 third-party eval): the collector only
// read the legacy flat `view.object` / `nav.object` shapes, so an object
// bound through a defineView container (`list.data.object`, `listViews[*]`)
// and hung on app navigation (`objectName`) was still reported as
// "defined but not referenced". These fixtures pin the canonical shapes.

import { describe, it, expect } from 'vitest';
import { findUnusedObjects, findOrphanViews } from '../src/commands/doctor';

const obj = (name: string, fields: Record<string, unknown> = { title: { type: 'text', label: 'T' } }) => ({
name,
label: name,
fields,
});

describe('findUnusedObjects', () => {
it('sees objects bound through defineView containers (list / listViews / form)', () => {
const config = {
objects: [obj('crm_account'), obj('crm_contact'), obj('crm_lead')],
views: [
{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } },
{
listViews: {
recent: { type: 'grid', data: { provider: 'object', object: 'crm_contact' } },
},
},
{ form: { data: { provider: 'object', object: 'crm_lead' } } },
],
};
expect(findUnusedObjects(config)).toEqual([]);
});

it('sees app navigation objectName, nested children, and areas', () => {
const config = {
objects: [obj('crm_account'), obj('crm_contact'), obj('crm_case')],
apps: [
{
name: 'crm',
navigation: [
{ type: 'object', objectName: 'crm_account' },
{
type: 'group',
label: 'More',
children: [{ type: 'object', objectName: 'crm_contact' }],
},
],
areas: [
{ name: 'service', navigation: [{ type: 'object', objectName: 'crm_case' }] },
],
},
],
};
expect(findUnusedObjects(config)).toEqual([]);
});

it('sees the object inside flow node config (record_change trigger / CRUD nodes)', () => {
const config = {
objects: [obj('crm_order')],
flows: [
{
name: 'on_order_change',
nodes: [{ id: 't1', type: 'record_change', config: { object: 'crm_order' } }],
},
],
};
expect(findUnusedObjects(config)).toEqual([]);
});

it('sees subform childObject and lookup form-field references', () => {
const config = {
objects: [obj('crm_order'), obj('crm_order_line'), obj('crm_account')],
views: [
{
list: { type: 'grid', data: { provider: 'object', object: 'crm_order' } },
form: {
data: { provider: 'object', object: 'crm_order' },
subforms: [{ field: 'lines', childObject: 'crm_order_line' }],
sections: [
{ fields: [{ name: 'account', type: 'lookup', reference: 'crm_account' }] },
],
},
},
],
};
expect(findUnusedObjects(config)).toEqual([]);
});

it('still reads legacy flat ViewItems and lookup fields', () => {
const config = {
objects: [
obj('crm_account'),
obj('crm_contact', {
account: { type: 'lookup', reference: 'crm_account', label: 'Account' },
}),
],
views: [{ name: 'contacts', object: 'crm_contact' }],
};
expect(findUnusedObjects(config)).toEqual([]);
});

it('still reports a genuinely unreferenced object', () => {
const config = {
objects: [obj('crm_account'), obj('crm_orphan')],
views: [{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } }],
};
const unused = findUnusedObjects(config);
expect(unused).toHaveLength(1);
expect(unused[0]).toContain('"crm_orphan"');
});
});

describe('findOrphanViews', () => {
it('reports container sub-views bound to non-existent objects', () => {
const config = {
objects: [obj('crm_account')],
views: [
{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } },
{
listViews: {
ghosts: { type: 'grid', data: { provider: 'object', object: 'crm_ghost' } },
},
},
],
};
const orphans = findOrphanViews(config);
expect(orphans).toHaveLength(1);
expect(orphans[0]).toContain('"crm_ghost"');
expect(orphans[0]).toContain('listViews.ghosts');
});

it('passes healthy containers and non-object providers', () => {
const config = {
objects: [obj('crm_account')],
views: [
{
list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } },
form: { data: { provider: 'schema', schemaId: 'report' } },
},
],
};
expect(findOrphanViews(config)).toEqual([]);
});
});
Loading