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
16 changes: 16 additions & 0 deletions .changeset/33005-object-registry-refused-advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): a publish whose object the runtime registry refused says so on its answer, under the stable advisory rule `object-registry-refused`, and logs it at error once

Clause-②: no
Metadata-protocol: none

The registry write-through runs after the metadata commit (ADR-0067 D2) and used to keep a refused `registerObject` to a `warn` line: `publishPackageDrafts` answered `published` with nothing naming the object, while every data read, write and seed load against it answered `OBJECT_NOT_FOUND` until something registered it.

- `publishPackageDrafts`: the refused object's `published[]` element carries one `warning` advisory, rule `object-registry-refused`, `where: object "NAME"`, in the `RuntimeAuthoringIssue` shape the other advisories use. The outcome and `success` are unchanged; a clean publish's answer is byte-unchanged.
- `publishMetaItem` and a publish-mode `saveMetaItem` put the same advisory on their own `advisories`.
- The server logs one `error` line per refused object of a publish, naming the consequence and the fix (resolve the refusal, then publish the object again, or restart), with the registry's reason as a separate argument. An undeclared failure's text stays out of the 2xx body, as on the `object-storage-sync-refused` advisory.

The rule id is stable across releases: a client can key on it now.
20 changes: 19 additions & 1 deletion packages/metadata-protocol/src/protocol-history-and-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { isBatchObjectType, judgeBatchObjectRegistrations, registerAdmittedBatch
import { engineCanRollBack } from '@objectstack/core';
import type { SysMetadataRepository } from './sys-metadata-repository.js';
import { captureStoredExpressionChange, fillStoredExpressionChange, type StoredExpressionFillCapture } from './protocol-stored-expression-fill.js';
import { reportObjectRegistryRefusal } from './protocol-registry-hydration.js';

/**
* [#32396] A promotion a package publish wrote: what {@link undoLandedPromotions}
Expand Down Expand Up @@ -581,6 +582,9 @@ export class ProtocolHistoryAndPublishFacet {
if (effects.projectionApplied) response.projectionApplied = effects.projectionApplied;
// [#30635] A refused storage sync rides the same omitted-when-empty
// `advisories`, after the gate's findings; the publish is not failed.
// [#33005] …and so does a registry that refused the object, ahead of
// the storage notice: the object is published, not served.
if (effects.registryRefused) response.advisories = [...(response.advisories ?? []), effects.registryRefused];
if (effects.storageRefused) response.advisories = [...(response.advisories ?? []), effects.storageRefused];
// [#10219] LAST, and awaited: tell the host that ONE item went live, so
// it can announce `metadata:reloaded` and boot-cached consumers re-sync
Expand Down Expand Up @@ -1335,26 +1339,34 @@ export class ProtocolHistoryAndPublishFacet {
materializeApplied?: PublishMaterializeResult;
projectionApplied?: MutationProjectionOutcome;
storageRefused?: RuntimeAuthoringIssue;
registryRefused?: RuntimeAuthoringIssue;
}> {
const out: {
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] };
materializeApplied?: PublishMaterializeResult;
projectionApplied?: MutationProjectionOutcome;
storageRefused?: RuntimeAuthoringIssue;
registryRefused?: RuntimeAuthoringIssue;
} = {};
// Drafts skipped the registry mutation; on publish we now refresh the
// runtime registry so live behaviour catches up immediately (matches
// saveMetaItem's post-persistence registry update path — #4521 makes
// that path cover every overlay type, so promoting a drafted action
// makes it dispatchable at once instead of at the next listing).
await host.applyRegistryWriteThrough({
const registryRefusal = await host.applyRegistryWriteThrough({
type: args.singularType,
name: args.name,
item: args.body,
packageId: args.packageId,
// [#6602] The promoted draft is environment-wide (ADR-0131 D6).
organizationId: null,
});
// [#33005] A registry that refused the promoted object is reported
// once, at `error`, and handed back as the advisory each door puts on
// the success it answers: the definition is live and the publish is
// not failed (ADR-0067 D2), but the object is not served until
// something registers it.
if (registryRefusal) out.registryRefused = reportObjectRegistryRefusal(args.name, registryRefusal);
// Create the object's table now so it's CRUD-able without a restart.
//
// [#8820] Reads the FOLDED `singularType`, like every other consumer in
Expand Down Expand Up @@ -2493,6 +2505,12 @@ export class ProtocolHistoryAndPublishFacet {
// this, only the server log said the table was not created or
// altered. Appended after the gate's findings, still
// omitted-when-empty: a clean sync adds no key.
// [#33005] A registry that refused the object rides the same
// element, ahead of the storage notice, under the stable rule
// `object-registry-refused`: the batch still answers
// `published` (outcome and `success` unchanged), and this is
// the element a caller reads to learn the object is not served.
if (eff.registryRefused) element.advisories = [...(element.advisories ?? []), eff.registryRefused];
if (eff.storageRefused) {
element.advisories = [...(element.advisories ?? []), eff.storageRefused];
storageRefusedObjects.add(p.d.name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';
// Pay @objectstack/lint's first transform at collection, not inside a clocked case.
import '@objectstack/lint';

interface Row {
id: string;
Expand Down Expand Up @@ -466,3 +468,95 @@ describe('[#30693] publishPackageDrafts reports a refused object-storage sync on
expect(JSON.stringify(res.published)).not.toContain('advisories');
});
});

// ---------------------------------------------------------------------------
// [#33005] A registry that refused a promoted object rides that object's element.
// ---------------------------------------------------------------------------
// The registry write-through runs after the metadata commit (ADR-0067 D2) and
// used to keep a refused `registerObject` to a `warn` line: the batch answered
// `published` with nothing naming the object, and a seed load right after it
// answered OBJECT_NOT_FOUND. The ruled interim answer (option D): outcome and
// `success` unchanged, one `error` line for the operator, and one warning under
// the stable rule `object-registry-refused` on the refused object's element.

const REGISTRY_REFUSED_RULE = 'object-registry-refused';

/** The registry refusing exactly the named objects, with an undeclared (no 4xx `status`) failure. */
function refuseRegistration(engine: any, refused: ReadonlySet<string>): void {
engine.registry.registerObject = (obj: { name?: string }) => {
if (obj?.name !== undefined && refused.has(obj.name)) {
throw new Error(`object ${obj.name} collides with a registered field index`);
}
};
}

/** The operator lines the refusal logged at `error`, by the object they name. */
const registryRefusedLines = (error: { mock: { calls: unknown[][] } }, name: string) =>
error.mock.calls.filter((args) => String(args[0]).includes(`object '${name}' was published but the runtime registry REFUSED it`));

describe('[#33005] publishPackageDrafts reports a registry-refused object on that object\'s element', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('the batch still answers published; exactly the refused element carries the rule, logged at error once', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const { engine } = makeStubEngine();
refuseRegistration(engine, new Set(['ticket']));
const protocol = new ObjectStackProtocolImplementation(engine);
await stageObjectDraft(protocol, 'ticket');
await stageObjectDraft(protocol, 'invoice');

const res = await protocol.publishPackageDrafts({ packageId: PKG });

// Option D changes neither the outcome nor `success`.
expect(res.failed).toEqual([]);
expect(res).toMatchObject({ success: true, outcome: 'published', publishedCount: 2, failedCount: 0 });

const byName = new Map(res.published.map((p) => [p.name, p]));
const refused = byName.get('ticket')!;
expect(refused.advisories).toHaveLength(1);
expect(refused.advisories![0]).toMatchObject({
rule: REGISTRY_REFUSED_RULE, severity: 'warning', path: '', where: 'object "ticket"',
});
expect(refused.advisories![0]!.message).toContain("'ticket'");
expect(Object.keys(refused.advisories![0]!).sort())
.toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']);
// An undeclared registry failure is withheld from the 2xx body…
expect(refused.advisories![0]!.message).not.toContain('collides with a registered field index');

// …and reaches the operator once, at error, with the reason whole.
const lines = registryRefusedLines(error, 'ticket');
expect(lines).toHaveLength(1);
expect(lines[0]![1]).toBe('object ticket collides with a registered field index');
expect(registryRefusedLines(error, 'invoice')).toEqual([]);

const sibling = byName.get('invoice')!;
expect('advisories' in sibling).toBe(false);
expect(Object.keys(sibling).sort()).toEqual(['name', 'type', 'version']);
});

it('publishMetaItem and a publish-mode save answer the same rule on their advisories', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const { engine } = makeStubEngine();
refuseRegistration(engine, new Set(['ticket', 'invoice']));
const protocol = new ObjectStackProtocolImplementation(engine);
await stageObjectDraft(protocol, 'ticket');

const published: any = await protocol.publishMetaItem({ type: 'object', name: 'ticket', packageId: PKG });
const saved: any = await (protocol as any).saveMetaItem({
type: 'object', name: 'invoice', item: objectBody('invoice'), packageId: PKG, mode: 'publish',
});

expect(published.success).toBe(true);
expect(saved.success).toBe(true);
expect((published.advisories ?? []).map((a: any) => [a.rule, a.where]))
.toEqual([[REGISTRY_REFUSED_RULE, 'object "ticket"']]);
expect((saved.advisories ?? []).map((a: any) => [a.rule, a.where]))
.toEqual([[REGISTRY_REFUSED_RULE, 'object "invoice"']]);
expect(registryRefusedLines(error, 'ticket')).toHaveLength(1);
expect(registryRefusedLines(error, 'invoice')).toHaveLength(1);
});
});
75 changes: 69 additions & 6 deletions packages/metadata-protocol/src/protocol-registry-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ export class ProtocolRegistryHydrationFacet {
* rather than reading it back from the caller. Same sentence the boot
* re-hydration already writes for the same rows.
*/
static async applyObjectRegistryMutation(host: Pick<ProtocolHost, 'classifyObjectContribution' | 'engine' | 'overlayPackageMismatchError' | 'packagelessAddress'>, request: { type: string; name: string; item?: any; packageId?: string | null }): Promise<void> {
static async applyObjectRegistryMutation(host: Pick<ProtocolHost, 'classifyObjectContribution' | 'engine' | 'overlayPackageMismatchError' | 'packagelessAddress'>, request: { type: string; name: string; item?: any; packageId?: string | null }): Promise<ObjectRegistryRefusal | undefined> {
// [#8862] The `&& request.type !== 'objects'` limb that stood here is
// GONE. It did not merely admit a plural, it registered under one —
// `registerItem` below mints the entry under the spelling that arrived
Expand Down Expand Up @@ -485,7 +485,13 @@ export class ProtocolRegistryHydrationFacet {
console.warn(
`[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`,
);
// [#33005] …and handed back, so a door that answers success over
// this write can say the object is not served
// ({@link reportObjectRegistryRefusal}). Callers that answer no
// receipt ignore it and keep the line above as their whole report.
return { error: err };
}
return undefined;
}

/**
Expand Down Expand Up @@ -1429,7 +1435,7 @@ export class ProtocolRegistryHydrationFacet {
* {@link HydrationOptions}.
*/
reboundFrom?: { readonly packageId: string | null };
}): Promise<void> {
}): Promise<ObjectRegistryRefusal | undefined> {
// [#8862] The `|| request.type === 'objects'` limb is GONE — this
// branch admitted the plural that {@link applyObjectRegistryMutation}
// then consumed. All four call sites of this method fold at their
Expand Down Expand Up @@ -1468,8 +1474,9 @@ export class ProtocolRegistryHydrationFacet {
// would make a runtime-created object unreachable for data CRUD
// rather than merely un-listed. This branch has never carried the
// kernel-scope gate below either, for the same reason.
await host.applyObjectRegistryMutation(request);
return;
// [#33005] The refusal, when the registry refused the object, is
// this method's answer; every other branch answers `undefined`.
return host.applyObjectRegistryMutation(request);
}
// [#28574] An `object_extension` row is its package's `'extend'`
// contributor of the object it names: not org-gated and not
Expand All @@ -1484,10 +1491,10 @@ export class ProtocolRegistryHydrationFacet {
`[Protocol] registry write-through failed for ${request.type}/${request.name}: ${err?.message ?? err}`,
);
}
return;
return undefined;
}
// [#23298] Whose registry this is — never `environmentId` alone.
if (!host.registryTakesStoredRows()) return;
if (!host.registryTakesStoredRows()) return undefined;
try {
await host.hydrateOverlayIntoRegistry(request.type, request.item, {
packageId: request.packageId ?? undefined,
Expand All @@ -1503,6 +1510,7 @@ export class ProtocolRegistryHydrationFacet {
`[Protocol] registry write-through failed for ${request.type}/${request.name}: ${err?.message ?? err}`,
);
}
return undefined;
}

/**
Expand Down Expand Up @@ -2395,3 +2403,58 @@ export function objectStorageSyncRefusedNotice(name: string, err: unknown): Runt
+ 'or restart the server, which syncs the storage of every registered object.',
};
}

/**
* [#33005] What {@link ProtocolRegistryHydrationFacet.applyObjectRegistryMutation}
* answers when the registry refused the object it was asked to register: the
* thrown value, whole. The row is already persisted; this is the one fact the
* write-through used to keep to a `warn` line.
*/
export interface ObjectRegistryRefusal {
readonly error: unknown;
}

/**
* [#33005] The rule id of the advisory a publish answers with when the runtime
* registry refused an object the publish made live. Stable across releases,
* like {@link OBJECT_STORAGE_SYNC_REFUSED}: the key a client groups, suppresses
* or acts on the notice by.
*/
export const OBJECT_REGISTRY_REFUSED = 'object-registry-refused';

/**
* [#33005] A door that answers success over a registry write-through the
* registry refused: one `error` line for the operator, then one `warning` for
* the caller, on the doors and in the shape of
* {@link objectStorageSyncRefusedNotice}. `error` by AGENTS.md "Degradation log
* levels": the publish answers success and its definition is stored, while the
* object is not served, so a seed load or data read against it answers
* `OBJECT_NOT_FOUND` until something registers it. Said once per refused object
* of a write; the write is deliberately NOT failed (ADR-0067 D2: the registry
* mutation runs after the metadata commit). The thrown text is a separate
* argument, and reaches the caller only under {@link clientFacingFailureText}.
*/
export function reportObjectRegistryRefusal(name: string, refusal: ObjectRegistryRefusal): RuntimeAuthoringIssue {
const err = refusal.error as { message?: unknown } | undefined;
console.error(
`[Protocol] object '${name}' was published but the runtime registry REFUSED it — its definition is stored `
+ 'and the publish answered success, so it looks usable while every data read, write and seed load against '
+ 'it answers OBJECT_NOT_FOUND until it is registered. Fix: resolve the registry\'s refusal (logged after '
+ 'this line), then publish the object again, which re-runs the registration, or restart, since the boot '
+ 'registers every stored object.',
err?.message ?? refusal.error,
);
return {
rule: OBJECT_REGISTRY_REFUSED,
severity: 'warning',
where: `object "${name}"`,
path: '',
message:
`object '${name}' was published, but the runtime registry refused it `
+ `(${clientFacingFailureText(refusal.error, 'the reason is in the server log')}), so it is NOT served: `
+ 'until it is registered, data reads, writes and seed loads against it answer OBJECT_NOT_FOUND.',
hint:
"Resolve the registry's refusal, then publish the object again (which re-runs the registration), "
+ 'or restart the server, which registers every stored object.',
};
}
Loading
Loading