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
69 changes: 69 additions & 0 deletions packages/metadata-protocol/src/protocol-loud-degradations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 33] Two degradations said out loud, from
* `ObjectStackProtocolImplementation`, moved out of `protocol.ts` whole:
*
* - `throwMetadataServiceUnavailable` — a degraded MetadataService read
* turned into the same 503 the overlay half of the reads throws;
* - `warnClosureReadUnavailable` — why the batch closure degraded, once per
* process.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps each method — the published `dist/*.d.ts` lists its name,
* docblock and declared signature — and its body is one literal of exactly
* the {@link LoudDegradationsHost} members it reads, plus a `.call`; a body
* that reads no member is called as a plain function. The warned-once flag is
* handed over as a getter and a setter, so the read and the write reach the
* class field as it stands at call time. Every caller keeps calling the class
* methods.
*
* What the bodies read from elsewhere, they import from where `protocol.ts`
* imports it; `metadataStoreUnavailableError` is `protocol.ts`'s own and is imported from there. Nothing re-exports this module, so the published surface does
* not move.
*/

import type { ObjectStackProtocolImplementation } from './protocol.js';
import { metadataStoreUnavailableError } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of
* the members it reads. Each is typed AS the class member (an indexed access
* on the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
*/
export interface LoudDegradationsHost {
closureReadWarned: ObjectStackProtocolImplementation['closureReadWarned'];
}

/** The body of `ObjectStackProtocolImplementation#throwMetadataServiceUnavailable`; the method's docblock there is its contract. */
export function throwMetadataServiceUnavailable(errors: string[]): never {
throw metadataStoreUnavailableError(
new Error(
`The metadata service could not read every loader: ${
errors.length > 0 ? errors.join('; ') : 'no loader detail reported'
}`,
),
);
}

/** The body of `ObjectStackProtocolImplementation#warnClosureReadUnavailable`; the method's docblock there is its contract. */
export function warnClosureReadUnavailable(this: Pick<LoudDegradationsHost, 'closureReadWarned'>, type: string, name: string): void {
if (this.closureReadWarned) return;
this.closureReadWarned = true;
console.warn(
`[Protocol] publishPackageDrafts: this overlay repository declares no 'get', so the batch's own `
+ `pending drafts cannot be read (first reached at ${type}/${name}). Author-time validation falls `
+ `back to the LIVE declarations only, without this batch's own drafts in the closure, so a draft that references a `
+ `sibling drafted in the SAME batch may be refused as unresolved. Nothing is published unchecked: `
+ `the gate still runs, with strictly less resolution context.`,
);
}
95 changes: 95 additions & 0 deletions packages/metadata-protocol/src/protocol-overlay-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 33] `sys_metadata`'s overlay-uniqueness indexes and the
* bundle-member backfill they need first, each run once per process, from
* `ObjectStackProtocolImplementation`, moved out of `protocol.ts` whole:
* `ensureOverlayIndex` and `ensureOverlayMembersBackfilled`.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps each method — the published `dist/*.d.ts` lists its name,
* docblock and declared signature — and its body is one literal of exactly
* the {@link OverlayIndexHost} members it reads, plus a `.call`; a body that
* reads no member is called as a plain function. The `engine` field and the
* two run-once flags are handed over as getters (and the flags as setters),
* so each read and write reaches the class field as it stands at call time;
* the call to `ensureOverlayMembersBackfilled` is a closure, so it still
* reaches the class method. Every caller keeps calling the class methods.
*
* What the bodies read from elsewhere, they import from where `protocol.ts`
* imports it. Nothing re-exports this module, so the published surface does
* not move.
*/

import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js';
import { backfillOverlayBundleMembers, ensureMetadataOverlayIndexes } from './migrations/overlay-index.js';
import type { ObjectStackProtocolImplementation } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of
* the members it reads. Each is typed AS the class member (an indexed access
* on the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
*/
export interface OverlayIndexHost {
readonly engine: ObjectStackProtocolImplementation['engine'];
readonly ensureOverlayMembersBackfilled: ObjectStackProtocolImplementation['ensureOverlayMembersBackfilled'];
overlayIndexEnsured: ObjectStackProtocolImplementation['overlayIndexEnsured'];
overlayMembersBackfilled: ObjectStackProtocolImplementation['overlayMembersBackfilled'];
}

/** The body of `ObjectStackProtocolImplementation#ensureOverlayIndex`. */
export async function ensureOverlayIndex(this: Pick<OverlayIndexHost, 'engine' | 'ensureOverlayMembersBackfilled' | 'overlayIndexEnsured'>): Promise<void> {
if (this.overlayIndexEnsured) return;
this.overlayIndexEnsured = true;
// [#22877] Rows stored before the member column existed get their
// body's member before the first write can address them.
await this.ensureOverlayMembersBackfilled();
try {
const engineAny = this.engine as any;
let driver: any = engineAny?.driver ?? engineAny?.getDriver?.();
if (!driver && engineAny?.drivers instanceof Map) {
for (const candidate of engineAny.drivers.values()) {
if (driverCanRunSql(candidate)) {
driver = candidate;
break;
}
}
}
if (!driver) return;
const resolved = resolveDriverExec(driver);
// The refusal stays INSIDE the seam rather than becoming an early
// return: a driver taken from `engineAny?.driver` is not
// capability-checked above, and when it offers neither surface it is
// the migration that classifies and reports the failure — an early
// return here would put that back into the silent `catch` below.
const exec = async (sql: string): Promise<void> => {
if (!resolved) throw new Error('driver has neither raw nor execute');
await resolved(sql);
};
// `console` satisfies the logger surface structurally; this class
// carries no injected logger, and its own diagnostics go to
// `console.warn` throughout (see `emitMetadataMutation`).
await ensureMetadataOverlayIndexes(exec, console);
} catch {
// A boot must never fail over an index. Note this arm is now only
// reachable for driver RESOLUTION failures: every DDL failure past
// this point is classified and reported by the migration itself,
// instead of vanishing into an empty catch (#6418).
}
}

/** The body of `ObjectStackProtocolImplementation#ensureOverlayMembersBackfilled`. */
export async function ensureOverlayMembersBackfilled(this: Pick<OverlayIndexHost, 'engine' | 'overlayMembersBackfilled'>): Promise<void> {
if (this.overlayMembersBackfilled) return;
this.overlayMembersBackfilled = true;
await backfillOverlayBundleMembers(this.engine as any, console);
}
137 changes: 137 additions & 0 deletions packages/metadata-protocol/src/protocol-package-commit-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 33] The ADR-0067 commit row that groups a turn's published
* artifacts, from `ObjectStackProtocolImplementation`, moved out of
* `protocol.ts` whole: `recordPackageCommit` and the named INSERT it calls,
* `persistPackageCommitRow` (named so the durability log-level rule can
* declare it).
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps each method — the published `dist/*.d.ts` lists its name,
* docblock and declared signature — and its body is one literal of exactly
* the {@link PackageCommitRecordHost} members it reads, plus a `.call`; a
* body that reads no member is called as a plain function. The `engine` field
* and the noted-once flag are handed over as getters (and the flag as a
* setter), so each read and write reaches the class field as it stands at
* call time; the call to `persistPackageCommitRow` is a closure, so it still
* reaches the class method. Every caller keeps calling the class methods.
*
* What the bodies read from elsewhere, they import from where `protocol.ts`
* imports it. Nothing re-exports this module, so the published surface does
* not move.
*/

import { isMissingTableError } from '@objectstack/metadata/errors';
import type { CommitPlanItem, ObjectStackProtocolImplementation } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of
* the members it reads. Each is typed AS the class member (an indexed access
* on the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
*/
export interface PackageCommitRecordHost {
commitStoreUnprovisionedNoted: ObjectStackProtocolImplementation['commitStoreUnprovisionedNoted'];
readonly engine: ObjectStackProtocolImplementation['engine'];
readonly persistPackageCommitRow: ObjectStackProtocolImplementation['persistPackageCommitRow'];
}

/** The body of `ObjectStackProtocolImplementation#persistPackageCommitRow`; the method's docblock there is its contract. */
export async function persistPackageCommitRow(this: Pick<PackageCommitRecordHost, 'engine'>, row: Record<string, unknown>): Promise<void> {
// [#21911] The explicit system opt-in — see recordMetadataAudit.
await this.engine.insert('sys_metadata_commit', row, { context: { isSystem: true } });
}

/** The body of `ObjectStackProtocolImplementation#recordPackageCommit`; the method's docblock there is its contract. */
export async function recordPackageCommit(this: Pick<PackageCommitRecordHost, 'commitStoreUnprovisionedNoted' | 'persistPackageCommitRow'>, args: {
packageId: string;
operation: 'apply' | 'revert';
message?: string;
actor?: string;
aiModel?: string;
parentCommitId?: string;
items: CommitPlanItem[];
eventSeqStart?: number;
eventSeqEnd?: number;
}): Promise<{ commitId: string } | null> {
try {
const commitId = 'cmt_' + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${args.eventSeqEnd ?? 0}-${args.items.length}-${args.packageId}`);
await this.persistPackageCommitRow({
id: commitId,
package_id: args.packageId,
operation: args.operation,
...(args.message ? { message: args.message } : {}),
...(args.actor ? { actor: args.actor } : {}),
...(args.aiModel ? { ai_model: args.aiModel } : {}),
...(args.parentCommitId ? { parent_commit_id: args.parentCommitId } : {}),
...(args.eventSeqStart !== undefined ? { event_seq_start: args.eventSeqStart } : {}),
...(args.eventSeqEnd !== undefined ? { event_seq_end: args.eventSeqEnd } : {}),
items: JSON.stringify(args.items),
item_count: args.items.length,
created_at: new Date().toISOString(),
});
return { commitId };
} catch (error) {
// [#9066] The publish keeps its outcome — `null` still comes back
// and the caller still reports success — but the silence is over.
//
// BENIGN: `sys_metadata_commit` was never provisioned. That is a
// real deployment state (a first boot, or an env kernel composed
// without the commit log — the exact case
// `@objectstack/metadata`'s plugin note records), and it is a
// CONFIGURATION fact, not a store that broke: it is the same on
// every publish and it is fixed in one place. Stated once per
// instance, at `info`, per this card's ruling that an unprovisioned
// commit store stays silent or informational.
//
// EVERYTHING ELSE — a connection drop, a timeout, a permission
// denial, schema drift on that one table — is a write that was
// supposed to land and did not, while the publish it describes DID.
// `error`, per AGENTS.md "Degradation log levels", and the line
// owes the two things that rule requires: the CONSEQUENCE (this
// turn is not revertible, and the system will keep looking healthy)
// and the FIX (repair write access to the commit store).
//
// NOT rethrown, and not surfaced to the caller: the artifacts are
// already live and unwinding them over a missing history row would
// be strictly worse than losing the row. Telling the CALLER that
// the turn is unrevertible is a separate question (a response-field
// change the #8896 ruling forbids for this family) and deliberately
// NOT decided here.
if (isMissingTableError(error, 'sys_metadata_commit')) {
if (!this.commitStoreUnprovisionedNoted) {
this.commitStoreUnprovisionedNoted = true;
console.info(
'[Protocol] sys_metadata_commit is not provisioned — publishes and reverts '
+ 'succeed but record no ADR-0067 commit row, so no turn can be reverted '
+ '(the revert plan of every turn is dropped). Fix: provision the commit '
+ 'store — it is registered alongside sys_metadata_history by '
+ "@objectstack/metadata's plugin; run schema sync. Said once per protocol instance.",
);
}
return null;
}
const reason = (error as { message?: string } | undefined)?.message ?? String(error);
console.error(
`[Protocol] sys_metadata_commit write FAILED for package '${args.packageId}' `
+ `(${args.operation}, ${args.items.length} item(s)): ${reason}. `
+ 'The publish itself SUCCEEDED and reports success, so nothing looks broken — but '
+ "this turn's revert plan was not persisted: the turn is absent from the package "
+ 'commit timeline and can never be reverted, and every further publish loses its '
+ 'own the same way until this is repaired. Fix: restore write access to '
+ 'sys_metadata_commit (connectivity, permissions, or schema drift on that table).',
);
return null;
}
}
Loading
Loading