Skip to content

Commit fdc244e

Browse files
os-zhuangclaude
andauthored
fix(dev-dx): hot-reload new objects end to end, persistent dev-admin hint, doctor container/nav refs (#3108)
Three P2s from the 15.1 third-party evaluation, all reproduced against a real create-objectstack scaffold and re-verified fixed on the same project with this branch's runtime. 1. Hot-added objects (os dev watch) were dead until restart. The artifact reload path calls MetadataManager.register(), which fires no subscribe() watchers — ObjectQL's registry bridge never saw the new object, tables only ever came from the boot-time sync, and seeds only from the boot-time bundle. Now: - metadata: the metadata:reloaded payload carries the freshly parsed artifact collections (seeds have no name and never enter the manager — the payload is the only channel) - objectql: the reload hook ingests payload object definitions into the SchemaRegistry (mirroring the subscribe handler, provenance preserved) and re-runs the idempotent schema sync, serialized, honoring skipSchemaSync - runtime: a dev-gated, single-tenant hot-reload seeder loads seeds for first-seen objects only — an already-loaded (possibly user-edited) dataset is never re-upserted mid-run - cli: the watch recompile line calls out new objects explicitly 2. Dev admin credentials vanished after the first boot. os dev uses a persistent DB, so the seed (and the banner hint keyed off it) only fired on the seeding boot, and the login page had no signal at all. maybeSeedDevAdmin now re-arms the hint whenever the seed account still verifies against the default password (better-auth native password.verify), and getPublicConfig() exposes a dev-only devSeedAdmin field for the Console login page (objectui side ships separately). Hint disappears the boot after the password changes — verified live in all three states. 3. os doctor reported objects bound via defineView containers (list/listViews/form/formViews data.object, subform childObject, lookup form fields) and app navigation (objectName, nested children, areas) as 'defined but not referenced'. The collector now walks the canonical shapes plus flow node config.object/objectName; the dead agent.objects read is gone and findOrphanViews descends into containers. Stock CLI on the probe project flags 3/3 objects; patched flags only the genuinely unreferenced one. Tests: cli 524, plugin-auth 460, objectql 905, runtime 544, metadata 260 — all green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 878c1ed commit fdc244e

12 files changed

Lines changed: 636 additions & 26 deletions

File tree

.changeset/dev-loop-dx-p2.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/metadata": patch
4+
"@objectstack/runtime": patch
5+
"@objectstack/plugin-auth": patch
6+
"@objectstack/cli": patch
7+
---
8+
9+
Dev-loop DX fixes from the 15.1 third-party evaluation (P2 batch):
10+
11+
- **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.
12+
- **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.
13+
- **`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.

packages/cli/src/commands/dev.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,26 @@ export default class Dev extends Command {
392392
let inFlight = false;
393393
let queued = false;
394394

395+
// Object-name inventory of the artifact, diffed across recompiles so a
396+
// newly added *.object.ts is called out explicitly (15.1 third-party
397+
// eval: "recompiled" alone read as all-green while the new object's
398+
// table/seed sync was invisible to the user).
399+
const readArtifactObjects = (): Set<string> | null => {
400+
try {
401+
const raw = JSON.parse(fs.readFileSync(opts.artifactPath, 'utf8'));
402+
const meta = raw?.metadata ?? raw?.data?.metadata ?? raw;
403+
const objects = Array.isArray(meta?.objects) ? meta.objects : [];
404+
return new Set(
405+
objects
406+
.map((o: any) => o?.name)
407+
.filter((n: any): n is string => typeof n === 'string'),
408+
);
409+
} catch {
410+
return null;
411+
}
412+
};
413+
let knownObjects = readArtifactObjects();
414+
395415
const compileAndPing = async () => {
396416
if (inFlight) { queued = true; return; }
397417
inFlight = true;
@@ -419,6 +439,19 @@ export default class Dev extends Command {
419439
// available for external trigger sources (cloud webhooks,
420440
// git hooks, ad-hoc curl).
421441
console.log(chalk.green(` ✓ recompiled in ${dt}ms — server will auto-reload`));
442+
const objectsNow = readArtifactObjects();
443+
if (objectsNow) {
444+
const prior = knownObjects;
445+
const fresh = prior ? [...objectsNow].filter((n) => !prior.has(n)) : [];
446+
knownObjects = objectsNow;
447+
if (fresh.length > 0) {
448+
console.log(
449+
chalk.cyan(
450+
` ✚ new object(s): ${fresh.join(', ')} — table & seeds sync on reload`,
451+
),
452+
);
453+
}
454+
}
422455
}
423456
inFlight = false;
424457
if (queued) { queued = false; setTimeout(compileAndPing, 50); }

packages/cli/src/commands/doctor.ts

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,79 @@ function detectCircularDependencies(objects: any[]): string[] {
7171
return issues;
7272
}
7373

74-
function findOrphanViews(config: any): string[] {
74+
// ── Object-reference walking ────────────────────────────────────────
75+
// A `config.views` entry is a ViewSchema CONTAINER (`{ list, form, listViews,
76+
// formViews }` — the defineView() shape): the object binding lives on each
77+
// sub-view at `data.object` (provider 'object'), never on a top-level
78+
// `view.object`. Legacy flat ViewItems (top-level `object`) are still read.
79+
80+
function* subViewsOf(view: any): Generator<[slot: string, subView: any]> {
81+
if (!view || typeof view !== 'object') return;
82+
if (view.list) yield ['list', view.list];
83+
if (view.form) yield ['form', view.form];
84+
for (const [key, sub] of Object.entries<any>(
85+
view.listViews && typeof view.listViews === 'object' ? view.listViews : {},
86+
)) {
87+
yield [`listViews.${key}`, sub];
88+
}
89+
for (const [key, sub] of Object.entries<any>(
90+
view.formViews && typeof view.formViews === 'object' ? view.formViews : {},
91+
)) {
92+
yield [`formViews.${key}`, sub];
93+
}
94+
}
95+
96+
function subViewObject(sub: any): string | undefined {
97+
if (typeof sub?.data?.object === 'string') return sub.data.object;
98+
if (typeof sub?.objectName === 'string') return sub.objectName;
99+
return undefined;
100+
}
101+
102+
/** Every object name a view (container or legacy flat item) references. */
103+
function collectViewObjectRefs(view: any): string[] {
104+
const refs: string[] = [];
105+
if (typeof view?.object === 'string') refs.push(view.object);
106+
for (const [, sub] of subViewsOf(view)) {
107+
const bound = subViewObject(sub);
108+
if (bound) refs.push(bound);
109+
// Inline master-detail children and lookup form fields are references too.
110+
for (const subform of Array.isArray(sub?.subforms) ? sub.subforms : []) {
111+
if (typeof subform?.childObject === 'string') refs.push(subform.childObject);
112+
}
113+
for (const section of Array.isArray(sub?.sections) ? sub.sections : []) {
114+
for (const field of Array.isArray(section?.fields) ? section.fields : []) {
115+
if (typeof field?.reference === 'string') refs.push(field.reference);
116+
}
117+
}
118+
}
119+
return refs;
120+
}
121+
122+
/**
123+
* Every object name an app's navigation references. Object nav items carry
124+
* `objectName` (AppSchema `ObjectNavItemSchema`), nest under `children`, and
125+
* may live in `areas[*].navigation` instead of the top-level `navigation`.
126+
*/
127+
function collectAppObjectRefs(app: any): string[] {
128+
const refs: string[] = [];
129+
const walk = (items: any): void => {
130+
if (!Array.isArray(items)) return;
131+
for (const item of items) {
132+
if (!item || typeof item !== 'object') continue;
133+
if (typeof item.objectName === 'string') refs.push(item.objectName);
134+
if (typeof item.object === 'string') refs.push(item.object);
135+
if (typeof item.requiresObject === 'string') refs.push(item.requiresObject);
136+
walk(item.children);
137+
}
138+
};
139+
walk(app?.navigation);
140+
for (const area of Array.isArray(app?.areas) ? app.areas : []) {
141+
walk(area?.navigation);
142+
}
143+
return refs;
144+
}
145+
146+
export function findOrphanViews(config: any): string[] {
75147
const objectNames = new Set<string>();
76148
if (Array.isArray(config.objects)) {
77149
for (const obj of config.objects) {
@@ -82,15 +154,23 @@ function findOrphanViews(config: any): string[] {
82154
const orphans: string[] = [];
83155
if (Array.isArray(config.views)) {
84156
for (const view of config.views) {
85-
if (view.object && !objectNames.has(view.object)) {
157+
if (typeof view?.object === 'string' && !objectNames.has(view.object)) {
86158
orphans.push(`View "${view.name || '?'}" references non-existent object "${view.object}"`);
87159
}
160+
for (const [slot, sub] of subViewsOf(view)) {
161+
const bound = subViewObject(sub);
162+
if (bound && !objectNames.has(bound)) {
163+
orphans.push(
164+
`View "${view?.name || sub?.label || slot}" (${slot}) references non-existent object "${bound}"`,
165+
);
166+
}
167+
}
88168
}
89169
}
90170
return orphans;
91171
}
92172

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

101181
const referencedObjects = new Set<string>();
102182

103-
// Views reference objects
183+
// Views — container sub-views (data.object), subforms, lookup form fields.
104184
if (Array.isArray(config.views)) {
105185
for (const view of config.views) {
106-
if (view.object) referencedObjects.add(view.object);
186+
for (const ref of collectViewObjectRefs(view)) referencedObjects.add(ref);
107187
}
108188
}
109189

110-
// Flows may reference objects via trigger
190+
// Flows — the bound object lives inside node config (FlowNodeSchema.config
191+
// is unstructured; `object`/`objectName` is the canonical alias pair used
192+
// by record_change triggers and CRUD nodes).
111193
if (Array.isArray(config.flows)) {
112194
for (const flow of config.flows) {
113195
if (flow.trigger?.object) referencedObjects.add(flow.trigger.object);
114196
if (flow.object) referencedObjects.add(flow.object);
197+
for (const node of Array.isArray(flow?.nodes) ? flow.nodes : []) {
198+
const cfg = node?.config;
199+
if (typeof cfg?.object === 'string') referencedObjects.add(cfg.object);
200+
if (typeof cfg?.objectName === 'string') referencedObjects.add(cfg.objectName);
201+
}
115202
}
116203
}
117204

118-
// Apps may reference objects via navigation
205+
// Apps — navigation (top-level, nested children, and areas).
119206
if (Array.isArray(config.apps)) {
120207
for (const app of config.apps) {
121-
if (Array.isArray(app.navigation)) {
122-
for (const nav of app.navigation) {
123-
if (nav.object) referencedObjects.add(nav.object);
124-
}
125-
}
126-
}
127-
}
128-
129-
// Agents may reference objects
130-
if (Array.isArray(config.agents)) {
131-
for (const agent of config.agents) {
132-
if (Array.isArray(agent.objects)) {
133-
for (const o of agent.objects) referencedObjects.add(o);
134-
}
208+
for (const ref of collectAppObjectRefs(app)) referencedObjects.add(ref);
135209
}
136210
}
137211

@@ -151,7 +225,7 @@ function findUnusedObjects(config: any): string[] {
151225
const unused: string[] = [];
152226
for (const name of objectNames) {
153227
if (!referencedObjects.has(name)) {
154-
unused.push(`Object "${name}" is defined but not referenced by any view, flow, app, or agent`);
228+
unused.push(`Object "${name}" is defined but not referenced by any view, flow, app, or lookup field`);
155229
}
156230
}
157231
return unused;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// `os doctor` reference analysis (15.1 third-party eval): the collector only
4+
// read the legacy flat `view.object` / `nav.object` shapes, so an object
5+
// bound through a defineView container (`list.data.object`, `listViews[*]`)
6+
// and hung on app navigation (`objectName`) was still reported as
7+
// "defined but not referenced". These fixtures pin the canonical shapes.
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { findUnusedObjects, findOrphanViews } from '../src/commands/doctor';
11+
12+
const obj = (name: string, fields: Record<string, unknown> = { title: { type: 'text', label: 'T' } }) => ({
13+
name,
14+
label: name,
15+
fields,
16+
});
17+
18+
describe('findUnusedObjects', () => {
19+
it('sees objects bound through defineView containers (list / listViews / form)', () => {
20+
const config = {
21+
objects: [obj('crm_account'), obj('crm_contact'), obj('crm_lead')],
22+
views: [
23+
{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } },
24+
{
25+
listViews: {
26+
recent: { type: 'grid', data: { provider: 'object', object: 'crm_contact' } },
27+
},
28+
},
29+
{ form: { data: { provider: 'object', object: 'crm_lead' } } },
30+
],
31+
};
32+
expect(findUnusedObjects(config)).toEqual([]);
33+
});
34+
35+
it('sees app navigation objectName, nested children, and areas', () => {
36+
const config = {
37+
objects: [obj('crm_account'), obj('crm_contact'), obj('crm_case')],
38+
apps: [
39+
{
40+
name: 'crm',
41+
navigation: [
42+
{ type: 'object', objectName: 'crm_account' },
43+
{
44+
type: 'group',
45+
label: 'More',
46+
children: [{ type: 'object', objectName: 'crm_contact' }],
47+
},
48+
],
49+
areas: [
50+
{ name: 'service', navigation: [{ type: 'object', objectName: 'crm_case' }] },
51+
],
52+
},
53+
],
54+
};
55+
expect(findUnusedObjects(config)).toEqual([]);
56+
});
57+
58+
it('sees the object inside flow node config (record_change trigger / CRUD nodes)', () => {
59+
const config = {
60+
objects: [obj('crm_order')],
61+
flows: [
62+
{
63+
name: 'on_order_change',
64+
nodes: [{ id: 't1', type: 'record_change', config: { object: 'crm_order' } }],
65+
},
66+
],
67+
};
68+
expect(findUnusedObjects(config)).toEqual([]);
69+
});
70+
71+
it('sees subform childObject and lookup form-field references', () => {
72+
const config = {
73+
objects: [obj('crm_order'), obj('crm_order_line'), obj('crm_account')],
74+
views: [
75+
{
76+
list: { type: 'grid', data: { provider: 'object', object: 'crm_order' } },
77+
form: {
78+
data: { provider: 'object', object: 'crm_order' },
79+
subforms: [{ field: 'lines', childObject: 'crm_order_line' }],
80+
sections: [
81+
{ fields: [{ name: 'account', type: 'lookup', reference: 'crm_account' }] },
82+
],
83+
},
84+
},
85+
],
86+
};
87+
expect(findUnusedObjects(config)).toEqual([]);
88+
});
89+
90+
it('still reads legacy flat ViewItems and lookup fields', () => {
91+
const config = {
92+
objects: [
93+
obj('crm_account'),
94+
obj('crm_contact', {
95+
account: { type: 'lookup', reference: 'crm_account', label: 'Account' },
96+
}),
97+
],
98+
views: [{ name: 'contacts', object: 'crm_contact' }],
99+
};
100+
expect(findUnusedObjects(config)).toEqual([]);
101+
});
102+
103+
it('still reports a genuinely unreferenced object', () => {
104+
const config = {
105+
objects: [obj('crm_account'), obj('crm_orphan')],
106+
views: [{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } }],
107+
};
108+
const unused = findUnusedObjects(config);
109+
expect(unused).toHaveLength(1);
110+
expect(unused[0]).toContain('"crm_orphan"');
111+
});
112+
});
113+
114+
describe('findOrphanViews', () => {
115+
it('reports container sub-views bound to non-existent objects', () => {
116+
const config = {
117+
objects: [obj('crm_account')],
118+
views: [
119+
{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } },
120+
{
121+
listViews: {
122+
ghosts: { type: 'grid', data: { provider: 'object', object: 'crm_ghost' } },
123+
},
124+
},
125+
],
126+
};
127+
const orphans = findOrphanViews(config);
128+
expect(orphans).toHaveLength(1);
129+
expect(orphans[0]).toContain('"crm_ghost"');
130+
expect(orphans[0]).toContain('listViews.ghosts');
131+
});
132+
133+
it('passes healthy containers and non-object providers', () => {
134+
const config = {
135+
objects: [obj('crm_account')],
136+
views: [
137+
{
138+
list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } },
139+
form: { data: { provider: 'schema', schemaId: 'report' } },
140+
},
141+
],
142+
};
143+
expect(findOrphanViews(config)).toEqual([]);
144+
});
145+
});

0 commit comments

Comments
 (0)