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
12 changes: 12 additions & 0 deletions .changeset/33152-batch-extension-drafts-closure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): a package publish judges each draft with the batch's own extension drafts folded onto their targets

Clause-②: no — this removes a false refusal the published contract already denies: the `@objectstack/metadata-protocol` changelog entry 0c24898 states "a package publishes as a self-consistent unit — `publishPackageDrafts` judges each draft against the batch's own pending declarations", and the batch's `object_extension` drafts were the pending declarations left out. No exported type or signature changes.
Metadata-protocol: none — no key, value or metadata type changes; `os build` already accepts these packages, and the batch door now gives the same verdict.

`publishPackageDrafts` (and the draft read's publish verdict, which uses the same collection) now carries the package's `object_extension` drafts in the batch's pending set, and the runtime authoring gate adds each draft's `fields` to its target in the object universe, the way the registry serves a published extension. Before, a draft whose formula or lookup hop read a field that a sibling extension draft adds (for example `{ extend: 'crm_region', fields: { tier__c } }` beside a formula `record.region.tier__c`) was refused with `INVALID_METADATA` (`unknown field tier__c on crm_region`), and the whole batch rolled back. The workaround was to publish the extension first through the per-item door; it is no longer needed.

A hop into a field that neither the batch nor the live object has is still refused, and the batch still rolls back.
8 changes: 4 additions & 4 deletions packages/metadata-protocol/src/protocol-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,10 @@ export async function runAuthoringGate(
* are not being written and stay served. `undefined` (the ordinary fold) for
* every other write type, and for a write naming no package or no target.
*
* A package-wide draft publish (`publishPackageDrafts`) whose drafts depend on
* a sibling extension draft is refused, because that batch's pending set
* carries no extension drafts (#33152); publishing the extension first through
* the per-item door works.
* [#33152] A package-wide draft publish (`publishPackageDrafts`) carries its
* other extension drafts in its pending set, and the gate folds them onto
* their targets (`foldPendingObjectExtensions`), so a draft that depends on a
* sibling extension draft is judged against the target as the batch serves it.
*/
export function objectExtensionWriteFold(
singularType: string,
Expand Down
11 changes: 9 additions & 2 deletions packages/metadata-protocol/src/protocol-publish-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
* re-indented one level and otherwise unedited: the same reads, in the same
* order, answering with the same receipts and the same degradations.
* Edited since the move: `applySeedBodies`' failure receipt counts the rows the
* loader wrote before it threw and names the seed that stopped it.
* loader wrote before it threw and names the seed that stopped it; and
* [#33152] `collectBatchPendingDeclarations` collects the batch's
* `object_extension` drafts too.
*
* ## How the moved bodies reach the class
*
Expand Down Expand Up @@ -212,8 +214,13 @@ export async function collectBatchPendingDeclarations(
// at this line instead — and [#17063]'s NARROWING was one too, which is
// how `pages` left this literal in the same edit it left
// `RuntimeStackContext`.
//
// [#33152] `objectExtensions` is the batch's `object_extension` drafts,
// which the gate folds onto their targets in `objects` rather than joining
// a collection: without them a sibling's formula or hop into a field the
// batch's own extension adds read as unknown and rolled the batch back.
const pending: { [K in keyof RuntimePendingDeclarations]-?: unknown[] } = {
objects: [], permissions: [], books: [], datasets: [], flows: [],
objects: [], permissions: [], books: [], datasets: [], flows: [], objectExtensions: [],
};
let any = false;
// [#32666] A flow draft is read only to subtract it from the live flows an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
// never from `@objectstack/objectql` — that import would close a cycle.
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';
import { evaluateRuntimeAuthoringGate, foldPendingObjectExtensions } from './runtime-authoring-gate.js';

interface Row {
id: string;
Expand Down Expand Up @@ -872,3 +873,56 @@ describe('[#32666] an object write that makes a field readonly is judged against
expect(res).toMatchObject({ success: true, outcome: 'published', publishedCount: 1 });
});
});

// [#33152] The batch's `object_extension` drafts join the closure by FOLDING
// onto their targets, never by replacing them: the live objects are served
// (every published extension already folded on), and an extension draft
// carries only `extend` and the fields it adds. The door-level pins — a batch
// whose formula hops into a sibling extension draft's field publishes, and a
// hop into a field no sibling adds still rolls the batch back — run on the
// real store in `packages/runtime` (`meta-object-extension-item.test.ts`);
// these pin the fold and the gate's use of it.
describe('[#33152] a batch\'s extension drafts fold onto their targets', () => {
const regionLive = { name: 'crm_region', label: 'Region', fields: { code: { type: 'text', label: 'Code' } } };
const account = { name: 'crm_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } };
const tierDraft = { extend: 'crm_region', fields: { tier__c: { type: 'text', label: 'Tier' } } };

it('adds the draft\'s fields to its target and keeps every field the target already serves', () => {
const folded = foldPendingObjectExtensions([regionLive, account], [tierDraft]) as any[];
expect(Object.keys(folded[0].fields).sort()).toEqual(['code', 'tier__c']);
expect(folded[0].label).toBe('Region');
// An entry nothing extends is the same reference, and the live entry is not mutated.
expect(folded[1]).toBe(account);
expect(Object.keys(regionLive.fields)).toEqual(['code']);
});

it('a draft whose target is in no universe folds onto nothing', () => {
expect(foldPendingObjectExtensions([account], [tierDraft])).toEqual([account]);
});

it('`withoutTarget` leaves an extension write\'s own draft to the rules that fold the written entry', () => {
const folded = foldPendingObjectExtensions([regionLive], [tierDraft], 'crm_region') as any[];
expect(Object.keys(folded[0].fields)).toEqual(['code']);
});

it('the gate judges a batch member against the target with the sibling extension draft folded on', () => {
const deal = {
name: 'shyx_deal', label: 'Deal', sharingModel: 'private',
fields: {
region: { type: 'lookup', label: 'Region', reference: 'crm_region' },
tier_label: { type: 'formula', label: 'Tier label', expression: "record.region.tier__c + '!'" },
},
};
const judge = (objectExtensions?: unknown[]) => evaluateRuntimeAuthoringGate({
type: 'object', name: deal.name, state: 'active', body: deal,
objects: [regionLive],
pending: { objects: [deal], ...(objectExtensions ? { objectExtensions } : {}) },
});
// Without the sibling extension draft the hop is refused…
const refused = judge().error as any;
expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
expect(JSON.stringify(refused.issues)).toContain('tier__c');
// …and with it, it resolves.
expect(judge([tierDraft]).error).toBeNull();
});
});
97 changes: 92 additions & 5 deletions packages/metadata-protocol/src/runtime-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,8 +707,20 @@ export function findHierarchyScopeCapabilityGaps(args: {
* the snapshot is a one-key edit" note is next acted on: a key added there
* arrives here typed, and {@link CLOSURE_CONTEXT_KEY_BY_TYPE} below is the
* only thing that then needs a decision.
*
* ## `objectExtensions` — the one key that is NOT a context collection
*
* [#33152] The batch's `object_extension` drafts. They join no collection of
* their own: the live `objects` the gate reads are already SERVED (the
* registry folds every published extension onto its target), so a pending
* extension is folded onto its target in `objects` the same way
* ({@link foldPendingObjectExtensions}). Without it a formula or a hop into a
* field a sibling extension draft adds read as `unknown field`, and the whole
* batch rolled back, while `os build` accepted the same package.
*/
export type RuntimePendingDeclarations = RuntimeStackContext;
export type RuntimePendingDeclarations = RuntimeStackContext & {
objectExtensions?: readonly unknown[];
};

/**
* Which context collection a pending draft of a given metadata type joins.
Expand Down Expand Up @@ -743,7 +755,11 @@ export const CLOSURE_CONTEXT_KEY_BY_TYPE = {
// batch's objects ({@link objectWriteFlowSubjects}). They are not folded in
// as subjects themselves, and no other write type reads them.
flow: 'flows',
} as const satisfies Readonly<Record<string, keyof RuntimeStackContext>>;
// [#33152] A batch's extension drafts, folded onto their targets in
// `objects` ({@link foldPendingObjectExtensions}), never a context
// collection of their own (see {@link RuntimePendingDeclarations}).
object_extension: 'objectExtensions',
} as const satisfies Readonly<Record<string, keyof RuntimePendingDeclarations>>;

/**
* Every context collection some row above routes into.
Expand Down Expand Up @@ -860,6 +876,65 @@ export function mergePendingDeclarations(
return [...kept, ...pending];
}

/**
* [#33152] `objects` with this batch's pending `object_extension` drafts folded
* onto their targets: each target is a copy whose `fields` are its own with
* the extension's `fields` merged in by name, a later writer replacing the
* whole field — the registry's `mergeObjectDefinitions` field merge, and the
* one part of it `objectsAsServed` in `@objectstack/lint` folds for the build.
*
* Why a fold and not a merge by name: the live objects are SERVED (every
* published extension already folded on), and an extension draft carries
* `extend` and the fields it adds, never its target's other fields. Folded, a
* sibling's formula or hop into a field the batch's extension adds resolves as
* it will once the batch has published; replacing the target by name would
* drop every field the extension does not repeat.
*
* `withoutTarget` leaves out the drafts extending that one target — an
* `object_extension` write's own: the gate's rules fold the written entry
* themselves, onto its target as it stands without the written package's
* extension (#33072), and a package holds one extension per target.
*
* Additive, as {@link mergePendingDeclarations} is: a draft can only make more
* fields resolvable. A draft whose target is in neither the batch nor the live
* universe folds onto nothing, so a hop into it still reads as unknown. Pure
* and total: an entry that is not an object, names no `extend`, or carries no
* `fields` record is skipped; a target whose `fields` is not a record is kept
* as it is.
*/
export function foldPendingObjectExtensions(
objects: readonly unknown[],
pendingExtensions: readonly unknown[] | undefined,
withoutTarget?: string,
): readonly unknown[] {
if (!pendingExtensions || pendingExtensions.length === 0) return objects;
const fieldsByTarget = new Map<string, Record<string, unknown>>();
for (const ext of pendingExtensions) {
if (!isRec(ext) || typeof ext.extend !== 'string' || ext.extend === '') continue;
if (ext.extend === withoutTarget || !isRec(ext.fields)) continue;
fieldsByTarget.set(ext.extend, { ...fieldsByTarget.get(ext.extend), ...ext.fields });
}
if (fieldsByTarget.size === 0) return objects;
return objects.map((obj) => {
if (!isRec(obj) || typeof obj.name !== 'string') return obj;
const added = fieldsByTarget.get(obj.name);
if (added === undefined) return obj;
if (obj.fields !== undefined && !isRec(obj.fields)) return obj;
return { ...obj, fields: { ...obj.fields, ...added } };
});
}

/**
* [#33152] The target an `object_extension` write extends — the body's
* `extend`, else the row's name (the item gate holds the two equal) — and
* `undefined` for every other write type.
*/
function objectExtensionWriteTarget(type: string, name: string, body: unknown): string | undefined {
if (type !== 'object_extension') return undefined;
const extend = isRec(body) ? body.extend : undefined;
return typeof extend === 'string' && extend !== '' ? extend : name;
}

/**
* [#32666] The flows an OBJECT write is judged against as subjects
* (`RuntimeStackContext.flows`): the written package's live flows, minus every
Expand Down Expand Up @@ -1203,7 +1278,9 @@ export function evaluateRuntimeAuthoringGate(args: {
/**
* [#10377] The declarations this write's own BATCH is publishing alongside
* it — folded into the five collections above by
* {@link mergePendingDeclarations} before any rule runs.
* {@link mergePendingDeclarations} before any rule runs, and [#33152] its
* `objectExtensions` onto their targets in `objects` by
* {@link foldPendingObjectExtensions}.
*
* Stated by the batch door (`publishPackageDrafts`), which is the only
* caller that HAS a batch; the single-item door publishes one item, so its
Expand Down Expand Up @@ -1344,7 +1421,14 @@ export function evaluateRuntimeAuthoringGate(args: {

// The object universe, folded once: the shared rules resolve names against
// it and the #21476 rule reads a form's target object out of it.
const objects = mergePendingDeclarations(args.objects ?? [], args.pending?.objects);
// [#33152] …with the batch's extension drafts folded onto their targets,
// bar an extension write's own (its rules fold the written entry).
const extensionWriteTarget = objectExtensionWriteTarget(args.type, args.name, args.body);
const objects = foldPendingObjectExtensions(
mergePendingDeclarations(args.objects ?? [], args.pending?.objects),
args.pending?.objectExtensions,
extensionWriteTarget,
);
// [#32666] The flows an object write judges as subjects; none on any other type.
const objectWriteFlows = objectWriteFlowSubjects(args.type, args.flows, args.pending?.flows);
// [#32666] …and, on that write, the objects the SUBJECT differential judges
Expand All @@ -1358,7 +1442,10 @@ export function evaluateRuntimeAuthoringGate(args: {
// rule judges exactly what it judged before (a draft that breaks a live
// sibling under them still cancels, as it always has).
const subjectObjects = objectWriteFlows !== undefined && objectWriteFlows.length > 0
? mergePendingDeclarations(args.objects ?? [], withoutWrittenItem(args.pending?.objects, args.name))
? foldPendingObjectExtensions(
mergePendingDeclarations(args.objects ?? [], withoutWrittenItem(args.pending?.objects, args.name)),
args.pending?.objectExtensions,
)
: undefined;

const result = runRuntimeAuthoringRules({
Expand Down
Loading
Loading