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
4 changes: 4 additions & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,3 +80,7 @@ export type {
IntrospectedTable,
IntrospectedSchema,
} from './util.js';

// Seed loader — materializes `seed` metadata into rows (used by publishMetaItem
// and the runtime dispatcher/app plugins).
export { SeedLoaderService } from './seed-loader.js';
137 changes: 137 additions & 0 deletions packages/objectql/src/protocol-publish-package-drafts.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,4 +64,141 @@ describe('protocol.publishPackageDrafts (ADR-0033)', () => {
expect(publishMetaItem).not.toHaveBeenCalled();
expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 0 });
});

it('publishes seeds LAST and batch-applies their rows in ONE pass (seedApplied)', async () => {
// listDrafts order puts the seed FIRST — the partition must still publish
// the object before it (its table must exist before rows land).
const drafts = [
{ type: 'seed', name: 'project_sample' },
{ type: 'object', name: 'project' },
{ type: 'seed', name: 'task_sample' },
];
const protocol = new ObjectStackProtocolImplementation({} as never);
(protocol as any).ensureOverlayIndex = async () => {};
const seedBodyByName: Record<string, unknown> = {
project_sample: { object: 'project', records: [{ name: 'Apollo' }] },
task_sample: { object: 'task', records: [{ name: 'Design' }] },
};
(protocol as any).getOverlayRepo = () => ({
listDrafts: async () => drafts,
get: async (ref: any, opts: any) =>
opts?.state === 'draft' && seedBodyByName[ref.name]
? { body: seedBodyByName[ref.name], hash: 'h' }
: null,
});
const publishMetaItem = vi.spyOn(protocol, 'publishMetaItem' as never);
publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never);
const applySeedBodies = vi
.spyOn(protocol as any, 'applySeedBodies')
.mockResolvedValue({ success: true, inserted: 2, updated: 0 });

const res = await protocol.publishPackageDrafts({ packageId: 'app.pm' });

// Object published BEFORE the seeds, and every publish suppressed per-item apply.
expect((publishMetaItem.mock.calls[0][0] as any)).toMatchObject({ type: 'object', name: 'project' });
for (const call of publishMetaItem.mock.calls) {
expect((call[0] as any)._skipSeedApply).toBe(true);
}
// ONE batch apply with BOTH seed bodies (cross-seed refs need a single pass).
expect(applySeedBodies).toHaveBeenCalledTimes(1);
expect(applySeedBodies.mock.calls[0][0]).toEqual([
seedBodyByName.project_sample,
seedBodyByName.task_sample,
]);
expect(res.seedApplied).toEqual({ success: true, inserted: 2, updated: 0 });
});

it('omits seedApplied when the package has no seed drafts', async () => {
const { protocol, publishMetaItem } = makeProtocol([{ type: 'object', name: 'course' }]);
publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never);
const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' });
expect(res.seedApplied).toBeUndefined();
});
});

/**
* Publishing a single `seed` draft (the per-ref path: POST /meta/seed/:name/publish,
* used by the home banner) must materialize its rows too — not only the package
* route. The publish itself NEVER fails on a seed problem; it reports under
* `seedApplied`.
*/
describe('protocol.publishMetaItem — seed self-apply', () => {
function makePublishable(body: unknown) {
const protocol = new ObjectStackProtocolImplementation({} as never);
(protocol as any).ensureOverlayIndex = async () => {};
(protocol as any).assertLockAllowsWrite = async () => null;
(protocol as any).isArtifactBacked = () => false;
(protocol as any).applyObjectRegistryMutation = () => {};
(protocol as any).ensureObjectStorage = async () => {};
(protocol as any).getOverlayRepo = () => ({
promoteDraft: async () => ({ version: 'sha256:x', seq: 7, item: { body } }),
});
const applySeedBodies = vi
.spyOn(protocol as any, 'applySeedBodies')
.mockResolvedValue({ success: true, inserted: 3, updated: 0 });
return { protocol, applySeedBodies };
}

it('applies the seed body on publish and reports seedApplied', async () => {
const body = { object: 'project', records: [{ name: 'Apollo' }] };
const { protocol, applySeedBodies } = makePublishable(body);
const res = await protocol.publishMetaItem({ type: 'seed', name: 'project_sample' });
expect(applySeedBodies).toHaveBeenCalledWith([body], null);
expect(res.seedApplied).toEqual({ success: true, inserted: 3, updated: 0 });
expect(res.success).toBe(true);
});

it('suppresses the self-apply when _skipSeedApply is set (package batch path)', async () => {
const { protocol, applySeedBodies } = makePublishable({ object: 'p', records: [] });
const res = await protocol.publishMetaItem({ type: 'seed', name: 'p_sample', _skipSeedApply: true });
expect(applySeedBodies).not.toHaveBeenCalled();
expect(res.seedApplied).toBeUndefined();
});

it('does not touch the loader for non-seed publishes', async () => {
const { protocol, applySeedBodies } = makePublishable({ name: 'overview' });
const res = await protocol.publishMetaItem({ type: 'dashboard', name: 'overview' });
expect(applySeedBodies).not.toHaveBeenCalled();
expect(res.seedApplied).toBeUndefined();
});
});

/**
* applySeedBodies wires the real SeedLoaderService: externalId('name')-keyed
* upsert against the engine, object metadata read through the protocol's own
* getMetaItem. A smoke test with a fake engine proves rows actually land and
* the result mapping is faithful.
*/
describe('protocol.applySeedBodies — real loader smoke test', () => {
it('inserts seed records via the engine and reports counts', async () => {
const protocol = new ObjectStackProtocolImplementation({} as never);
const inserted: Array<{ object: string; record: any }> = [];
(protocol as any).engine = {
find: async () => [],
insert: async (object: string, record: any) => {
inserted.push({ object, record });
return { id: `${object}_${inserted.length}` };
},
update: async () => ({}),
};
(protocol as any).getMetaItem = async ({ name }: any) => ({
item: { name, fields: { name: { type: 'text' } } },
});

const res = await (protocol as any).applySeedBodies(
[{ object: 'project', records: [{ name: 'Apollo' }, { name: 'Gemini' }] }],
null,
);

expect(inserted.map((i) => i.record.name)).toEqual(['Apollo', 'Gemini']);
expect(res.success).toBe(true);
expect(res.inserted).toBe(2);
});

it('returns a loud failure (never throws) for an unreadable body', async () => {
const protocol = new ObjectStackProtocolImplementation({} as never);
const res = await (protocol as any).applySeedBodies([{ nope: true }], null);
expect(res.success).toBe(false);
expect(res.error).toMatch(/no readable seed bodies/);
});
});
121 changes: 119 additions & 2 deletions packages/objectql/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3785,11 +3785,30 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
organizationId?: string;
actor?: string;
message?: string;
/**
* INTERNAL — `publishPackageDrafts` publishes many drafts and batch-applies
* every seed body in ONE loader pass afterwards (cross-seed references need
* multi-pass over the whole set), so it suppresses the per-item apply here.
*/
_skipSeedApply?: boolean;
}): Promise<{
success: boolean;
version: string;
seq: number;
message?: string;
/**
* Present when a `seed` draft was published: the result of materializing
* its rows. Publishing the metadata ALWAYS succeeds independently — a
* seed-load problem is surfaced here, never thrown, so callers (and UIs)
* must check `seedApplied.success` instead of assuming data went live.
*/
seedApplied?: {
success: boolean;
inserted: number;
updated: number;
error?: string;
errors?: unknown[];
};
}> {
const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType)
Expand DownExpand Up@@ -3840,12 +3859,27 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
});
// Create the object's table now so it's CRUD-able without a restart.
await this.ensureObjectStorage(request.type, request.name);
return {
const response: {
success: boolean;
version: string;
seq: number;
message?: string;
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
} = {
success: true,
version: result.version,
seq: result.seq,
message: `Published draft — type=${request.type}, name=${request.name} [seq=${result.seq}]`,
};
// Publishing a `seed` is what makes its rows live — materialize them
// NOW (best-effort, never fails the publish) so every publish path
// (per-ref REST publish, the home banner, package publish-drafts)
// lands data, not just metadata. The body is already in hand from
// the promote — no read-back, so no org-scope resolution pitfalls.
if (singularType === 'seed' && !request._skipSeedApply) {
response.seedApplied = await this.applySeedBodies([result.item.body], orgId);
}
return response;
} catch (err: any) {
if (err instanceof ConflictError) {
const conflict: any = new Error(
Expand All@@ -3862,6 +3896,65 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
}
}

/**
* Materialize published `seed` bodies into data rows via the SeedLoaderService
* (externalId-keyed upsert, multi-pass for cross-seed references). Passing ALL
* of a publish's seed bodies in ONE call lets a child seed reference a parent
* seed's rows regardless of publish order. Best-effort: any failure is
* returned, never thrown — publishing metadata must not be blocked by a data
* problem, but the caller surfaces `seedApplied` so the failure is LOUD.
*/
private async applySeedBodies(
bodies: unknown[],
organizationId: string | null,
): Promise<{ success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }> {
try {
const seeds = bodies.filter(
(b: any) => b && typeof b.object === 'string' && Array.isArray(b.records),
);
if (seeds.length === 0) {
return { success: false, inserted: 0, updated: 0, error: 'seed apply: no readable seed bodies' };
}
const { SeedLoaderService } = await import('./seed-loader.js');
const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data');
// The loader only needs `getObject` from IMetadataService (dependency
// graph + field introspection); satisfy it from the protocol's own
// metadata reads so no kernel service lookup is required.
const metadataAdapter = {
getObject: async (name: string) => {
const wrapper: any = await (this as any).getMetaItem({
type: 'object',
name,
...(organizationId ? { organizationId } : {}),
});
return wrapper?.item ?? wrapper ?? null;
},
};
const loader = new SeedLoaderService(
this.engine as any,
metadataAdapter as any,
console as any,
);
const request = SeedLoaderRequestSchema.parse({
seeds,
config: {
defaultMode: 'upsert',
multiPass: true,
...(organizationId ? { organizationId } : {}),
},
});
const r = await loader.load(request);
return {
success: r.success,
inserted: r.summary.totalInserted,
updated: r.summary.totalUpdated,
...(r.errors?.length ? { errors: r.errors } : {}),
};
} catch (e: any) {
return { success: false, inserted: 0, updated: 0, error: e?.message ?? 'seed apply failed' };
}
}

/**
* List pending DRAFT metadata (ADR-0033) for the org, optionally narrowed
* by `packageId` and/or `type`. The list reads of `getMetaItems` only see
Expand DownExpand Up@@ -3911,6 +4004,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
failedCount: number;
published: Array<{ type: string; name: string; version: string }>;
failed: Array<{ type: string; name: string; error: string; code?: string }>;
/** Aggregate result of materializing every published `seed` (absent when no seeds). */
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
}> {
await this.ensureOverlayIndex();
const orgId = request.organizationId ?? null;
Expand All@@ -3920,14 +4015,33 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
const published: Array<{ type: string; name: string; version: string }> = [];
const failed: Array<{ type: string; name: string; error: string; code?: string }> = [];

for (const d of drafts) {
// Structure first, seeds LAST — a seed's rows can only land after its
// object's table exists (publishMetaItem creates it). Within the seeds we
// batch-apply every body in ONE loader pass below (multi-pass reference
// resolution across the whole set), so per-item apply is suppressed.
const ordered = [
...drafts.filter((d) => d.type !== 'seed'),
...drafts.filter((d) => d.type === 'seed'),
];
const seedBodies: unknown[] = [];

for (const d of ordered) {
try {
if (d.type === 'seed') {
// Capture the body BEFORE promote (the draft row is deleted by
// the promote, and a post-publish read-back has org-scope
// resolution pitfalls — reading the draft is unambiguous).
const ref = { type: d.type, name: d.name, org: orgId ?? 'env' } as unknown as Parameters<typeof repo.get>[0];
const draft = await repo.get(ref, { state: 'draft' });
if (draft?.body) seedBodies.push(draft.body);
}
const r = await this.publishMetaItem({
type: d.type,
name: d.name,
...(request.organizationId ? { organizationId: request.organizationId } : {}),
...(request.actor ? { actor: request.actor } : {}),
message: `publish app package '${request.packageId}'`,
_skipSeedApply: true,
});
published.push({ type: d.type, name: d.name, version: r.version });
} catch (e: any) {
Expand All@@ -3946,6 +4060,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
failedCount: failed.length,
published,
failed,
...(seedBodies.length > 0
? { seedApplied: await this.applySeedBodies(seedBodies, orgId) }
: {}),
};
}

Expand Down
Loading
Loading