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
13 changes: 13 additions & 0 deletions .changeset/22090-metadata-protocol-revert-stored-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/metadata-protocol": minor
---

`ObjectStackProtocolImplementation.revertStoredPackage({ packageId, organizationId?, actor? })` reverts a package's stored members to their published version

Clause-②: yes (widening)

- A Studio-authored package's members are `sys_metadata` rows bound by `package_id`. The method reads them through the same predicate `listDrafts` uses, which now has one statement shared by both reads.
- No stored row bound to the package: it answers `{ stored: false, discarded: [] }` and touches nothing. The package's members, if it has any, belong to the metadata service. An unprovisioned `sys_metadata` answers the same way.
- Stored rows, none of them published: it refuses with `RESOURCE_CONFLICT` / 409, "Package '…' has never been published, so there is no published version to revert to." Nothing is touched.
- Otherwise it removes every draft of the package, each in the scope it lives in, through the same per-draft step `discardPackageDrafts` takes. Each item then serves its published (active) row again. An item created after the last publish has no published row and is removed. A refused draft fails the call with that refusal's own code and status.
- The only change to the public surface is this one added method. `discardPackageDrafts` and `listDrafts` answer as before.
12 changes: 12 additions & 0 deletions .changeset/22090-runtime-revert-studio-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/runtime": patch
---

`POST /packages/:id/revert` reverts a Studio-authored package instead of answering 404 "No metadata items found"

Clause-②: no

- The door asked only the metadata service, whose in-memory registry never holds a Studio package's stored rows. So a package with published items and a pending draft answered `404 RESOURCE_NOT_FOUND`, and the draft stayed.
- The door now asks the protocol's `revertStoredPackage` first, with the caller's active organization. A package with stored rows answers `200 { success: true }` with its drafts removed, or `409 RESOURCE_CONFLICT` when it has never been published.
- A package with no stored row is answered by the metadata service's `revertPackage`, exactly as before. That covers a code-shipped package and an unknown id (`404`). It is also what happens when the protocol does not provide `revertStoredPackage`.
- The request and the response shape are unchanged.
142 changes: 125 additions & 17 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js';
import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js';
import { DraftConflictError, SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js';
import { DraftConflictError, SysMetadataRepository, packageScopedRowWhere, type SysMetadataEngine } from './sys-metadata-repository.js';
import { isOriginGatedType, packagedBaseRegimeSentence } from './packaged-base-regime.js';
import {
resolveArtifactLockLayer,
Expand Down Expand Up @@ -23711,7 +23711,8 @@ export class ObjectStackProtocolImplementation implements
*
* Use case: "I edited this app for a while and it turned out worse than
* before — abandon all my changes." Routes through the sys_metadata path
* (no metadata-service dependency, unlike `POST /packages/:id/revert`).
* (no metadata-service dependency). {@link revertStoredPackage} takes the
* same per-draft step for `POST /packages/:id/revert`.
*/
async discardPackageDrafts(request: {
packageId: string;
Expand All @@ -23734,25 +23735,14 @@ export class ObjectStackProtocolImplementation implements

for (const d of drafts) {
try {
// Discard the draft in the scope it lives in (#3115). Like
// publish, `listDrafts` surfaces env-wide drafts to a non-null
// active org via `$or`; deleting under the request's active org
// would silently no-op on those env-wide rows.
const draftOrgId = d.organizationId ?? null;
await this.deleteMetaItem({
type: d.type,
name: d.name,
state: 'draft',
...(draftOrgId ? { organizationId: draftOrgId } : {}),
...(request.actor ? { actor: request.actor } : {}),
});
await this.discardDraftInItsScope(d, request.actor);
discarded.push({ type: d.type, name: d.name });
} catch (e: any) {
// [#8136] Same source, same reasoning as `deletePackage`'s
// `failed[]` collector below: this `try` wraps only
// `deleteMetaItem`, whose exits all now either declare a
// refusal or withhold at the source. Clean derivatively; no
// filter of its own.
// `deleteMetaItem` (through `discardDraftInItsScope`), whose
// exits all now either declare a refusal or withhold at the
// source. Clean derivatively; no filter of its own.
failed.push({
type: d.type,
name: d.name,
Expand All @@ -23771,6 +23761,124 @@ export class ObjectStackProtocolImplementation implements
};
}

/**
* Discard ONE pending draft in the scope it lives in — the per-draft step
* {@link discardPackageDrafts} and {@link revertStoredPackage} share, so
* the scope rule below has one home.
*/
private async discardDraftInItsScope(
draft: { type: string; name: string; organizationId: string | null },
actor: string | undefined,
): Promise<void> {
// Discard the draft in the scope it lives in (#3115). Like
// publish, `listDrafts` surfaces env-wide drafts to a non-null
// active org via `$or`; deleting under the request's active org
// would silently no-op on those env-wide rows.
const draftOrgId = draft.organizationId ?? null;
await this.deleteMetaItem({
type: draft.type,
name: draft.name,
state: 'draft',
...(draftOrgId ? { organizationId: draftOrgId } : {}),
...(actor ? { actor } : {}),
});
}

/**
* [#22090] Revert a package's STORED members to their published version —
* the half of `POST /packages/:id/revert` a Studio-authored package needs.
*
* ## Why the metadata service could not do this
*
* `MetadataManager.revertPackage` collects members from its in-memory
* registry, by an item's own `packageId` / `package` key. A Studio-authored
* package lives in `sys_metadata`, bound by each row's `package_id`
* column, and none of its rows is in that registry: a real boot gives the
* manager no `sys_metadata` loader, and `@objectstack/metadata` cannot
* reach this package (the dependency runs the other way). So the revert of
* a Studio package answered 404 "No metadata items found" while the
* package held published items and a pending draft.
*
* ## What "revert" means for a stored member
*
* A stored item's published version is its ACTIVE row, and a pending
* change is a separate DRAFT row beside it. Reverting the package to its
* published state is removing its drafts, through the per-draft step
* {@link discardPackageDrafts} takes ({@link discardDraftInItsScope}) —
* not a second revert mechanism. A draft with no active row (an item
* created after the last publish) is removed too: it was not part of the
* published state.
*
* Membership is the package's rows as {@link SysMetadataRepository.listDrafts}
* reads them — `package_id`, the caller's organization plus env-wide — and
* the active rows are read through the same predicate,
* {@link packageScopedRowWhere}, never a `where` of this method's own.
*
* ## Answers
*
* - No stored row bound to the package: `{ stored: false }`, and nothing
* is touched. Its members, if it has any, are the metadata service's;
* the caller asks that service's `revertPackage` next, so a code-shipped
* package reverts exactly as it did before. An unprovisioned
* `sys_metadata` answers the same way: there are genuinely no rows.
* - Stored rows, none of them active: `RESOURCE_CONFLICT` / 409. The
* package has never been published, so there is no published version to
* revert to. Nothing is touched.
* - Otherwise every draft is discarded. A published package with no draft
* is already at its published version, which is a success too.
*
* A draft whose discard is refused fails the call with that refusal's own
* `code` and `status` (ADR-0112). {@link discardPackageDrafts} answers a
* per-item outcome report; this verb's door answers `{ success: true }` or
* an error, so a refusal collected into a list would reach the caller as a
* success. Drafts discarded before the refusal stay discarded, as they do
* in {@link discardPackageDrafts}.
*/
async revertStoredPackage(request: {
packageId: string;
organizationId?: string;
actor?: string;
}): Promise<{
stored: boolean;
discarded: Array<{ type: string; name: string }>;
}> {
await this.ensureOverlayIndex();
const orgId = request.organizationId ?? null;
let drafts: Awaited<ReturnType<SysMetadataRepository['listDrafts']>>;
let publishedRows: unknown[];
try {
drafts = await this.getOverlayRepo(orgId).listDrafts({ packageId: request.packageId });
// [#21911] The explicit system opt-in — see findServedOverlayRow.
publishedRows = (await this.engine.find('sys_metadata', {
where: packageScopedRowWhere(orgId, 'active', { packageId: request.packageId }),
limit: 1,
context: { isSystem: true },
})) as unknown[];
} catch (error) {
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
return { stored: false, discarded: [] };
}

if (drafts.length === 0 && publishedRows.length === 0) return { stored: false, discarded: [] };

if (publishedRows.length === 0) {
const err = new Error(
`Package '${request.packageId}' has never been published, so there is no published version to `
+ 'revert to. Publish the package first, or discard its drafts to drop them.',
) as Error & { code?: string; status?: number };
err.code = 'RESOURCE_CONFLICT';
err.status = 409;
throw err;
}

const discarded: Array<{ type: string; name: string }> = [];
for (const d of drafts) {
await this.discardDraftInItsScope(d, request.actor);
discarded.push({ type: d.type, name: d.name });
}
return { stored: true, discarded };
}

/**
* Delete an ENTIRE package: every `sys_metadata` row bound to it (active
* AND draft) and — by default — the physical table of each object it
Expand Down
64 changes: 45 additions & 19 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,50 @@ export function resetEnvWritableMetadataTypes(): void {
_envWritableMetadataTypes = null;
}

/**
* [#22090] The `sys_metadata` predicate for "the rows of one state that a
* caller in `organizationId` sees, optionally narrowed to a `type` and a
* package" — the ONE statement of that rule, which
* {@link SysMetadataRepository.listDrafts} asks for `'draft'` and the
* protocol's package revert asks for `'active'`.
*
* A package's membership in storage is its rows' `package_id` column, the
* same column the read path decorates as `_packageId`. The revert has to know
* which of a package's rows are published as well as which are drafts, and a
* second spelling of the org half below is exactly the drift #3115 / #7705 /
* #7779 each had to repair on one copy at a time — so the revert asks this
* function, never a `where` of its own.
*
* Deliberately NOT exported from the package entry: it is a shared predicate
* between this module and `protocol.ts`, not a public read.
*/
export function packageScopedRowWhere(
organizationId: string | null,
state: 'draft' | 'active',
filter?: { type?: string; packageId?: string },
): Record<string, unknown> {
const where: Record<string, unknown> = { state };
// Surface BOTH org-scoped drafts and env-wide (`organization_id IS NULL`)
// drafts. Env-wide drafts are real pending changes — `getMetaItems`/preview
// overlays them and `publish-drafts` promotes them — but a strict
// `organization_id = this.organizationId` equality silently dropped them
// whenever the active org was non-null. AI-authored metadata is written
// env-wide, so its drafts vanished from the pending-changes list and the
// Publish CTA never appeared: the change showed in preview yet could not be
// published from the UI (the "orphaned draft" bug).
if (organizationId != null) {
where.$or = [
{ organization_id: organizationId },
{ organization_id: null },
];
} else {
where.organization_id = null;
}
if (filter?.type) where.type = filter.type;
if (filter?.packageId) where.package_id = filter.packageId;
return where;
}

/**
* One live `watch()` subscription, as a record rather than a bare listener.
*
Expand Down Expand Up @@ -1238,25 +1282,7 @@ export class SysMetadataRepository implements MetadataRepository {
}>
> {
this.assertOpen();
const where: Record<string, unknown> = { state: 'draft' };
// Surface BOTH org-scoped drafts and env-wide (`organization_id IS NULL`)
// drafts. Env-wide drafts are real pending changes — `getMetaItems`/preview
// overlays them and `publish-drafts` promotes them — but a strict
// `organization_id = this.organizationId` equality silently dropped them
// whenever the active org was non-null. AI-authored metadata is written
// env-wide, so its drafts vanished from the pending-changes list and the
// Publish CTA never appeared: the change showed in preview yet could not be
// published from the UI (the "orphaned draft" bug).
if (this.organizationId != null) {
where.$or = [
{ organization_id: this.organizationId },
{ organization_id: null },
];
} else {
where.organization_id = null;
}
if (filter?.type) where.type = filter.type;
if (filter?.packageId) where.package_id = filter.packageId;
const where = packageScopedRowWhere(this.organizationId, 'draft', filter);
const rows = await this.engine.find('sys_metadata', { where, context: { isSystem: true } });
return (rows as any[]).map((row) => ({
type: row.type,
Expand Down
11 changes: 10 additions & 1 deletion packages/runtime/src/domains/packages-vetted-org-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ function protocolDouble() {
const discarded = record('discardPackageDrafts', req).drafts.splice(0);
return { success: true, discardedCount: discarded.length, discarded };
},
// [#22090] The stored-member half of `POST /packages/:id/revert`: the
// partition's drafts are its stored rows, so it answers `stored: true`.
revertStoredPackage: async (req: any) => {
const discarded = record('revertStoredPackage', req).drafts.splice(0);
return { stored: true, discarded: discarded.map((name) => ({ type: 'view', name })) };
},
listCommits: async (req: any) =>
record('listCommits', req).commits.map((id) => ({ id, packageId: req.packageId })),
revertCommit: async (req: any) => {
Expand Down Expand Up @@ -321,13 +327,16 @@ beforeEach(() => {
});
afterEach(() => { warnSpy.mockRestore(); });

// ── The nine doors that read the organization source ──────────────────────────
// ── The doors that read the organization source ───────────────────────────────
// The nine of the defect, and `POST /packages/:id/revert`, which reads it since
// #22090 asks the protocol for the package's stored members.

interface Door { name: string; method: string; path: string; body?: unknown; verb: string }

const DOORS: Door[] = [
{ name: 'POST /packages/:id/publish-drafts', method: 'POST', path: `/packages/${PKG}/publish-drafts`, body: {}, verb: 'publishPackageDrafts' },
{ name: 'POST /packages/:id/discard-drafts', method: 'POST', path: `/packages/${PKG}/discard-drafts`, body: {}, verb: 'discardPackageDrafts' },
{ name: 'POST /packages/:id/revert', method: 'POST', path: `/packages/${PKG}/revert`, body: {}, verb: 'revertStoredPackage' },
{ name: 'GET /packages/:id/commits', method: 'GET', path: `/packages/${PKG}/commits`, verb: 'listCommits' },
{ name: 'POST /packages/:id/commits/:commitId/revert', method: 'POST', path: `/packages/${PKG}/commits/cmt_alpha/revert`, body: {}, verb: 'revertCommit' },
{ name: 'POST /packages/:id/rollback', method: 'POST', path: `/packages/${PKG}/rollback`, body: { commitId: 'cmt_alpha' }, verb: 'rollbackToPackageCommit' },
Expand Down
Loading
Loading