Skip to content

Commit 713beba

Browse files
fix(metadata-protocol): the per-item rollback restores the row the read serves, so a .custom pair's override rolls back beside a base-bound row (#29848) (#30268)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgHf6TbfHAaaj7ZxnraVYYjb)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - **改了什么:** 单项回滚(`POST /api/v1/meta/:type/:name/rollback`)现在回滚「用户看到的那一行」:定制包(`BASE.custom`)里声明了覆盖的行,即使旁边还躺着一条关停前写进只读基包的旧行,也能正常回滚,旧行不动。 - **为什么改:** 旧逻辑按「存储里第一条同名行」取包,常常取到基包旧行,于是被「点名基包写入已关闭」拒绝(403),报错还指向用户根本没打算碰的行;在 `os migrate packageless-overlays` 跑之前,这一项的历史回滚等于废了。 - **风险与回滚:** 不加参数、不改错误码与状态;只有同名多行时取行结果会变(取被服务的那一行)。只有基包旧行被服务时仍照旧拒绝。回滚即撤 PR。 - **先看:** `packages/metadata-protocol/src/protocol-package-commits.ts`(`rollbackMetaItemUnaudited` 里取包的那一段与 `restoreVersion` 的 `packageId`),`packages/objectql/src/protocol-packageless-overlay-migration.test.ts`(S5 pin 按新寻址重判)。 - **两分钟验证:** `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.named-base-overlay-write.test.ts -t 29848`:三条全绿;定制行回滚成功、基包行原样,删掉定制行后回滚基包行仍是 403 `NOT_OVERRIDABLE`。 - **需要你做的:** 无,席位落地。 Fixes #29848 Clause-②: no — the door's accept set does not change (no new parameter, no new value); the package is derived from the row the by-name read serves. ADR-0146 D2: an item of the pair that declares `overrides` "replaces the base's item **whole** for every read that serves the base's name", and its revert ("Restore package original" = delete the overriding item from `.custom`, so the base's document is served again) is the pair's own row's; neither ever addressed the base row. The 403 refused the pair's own row because the binding read named the base row, a refusal those texts already deny. Metadata-protocol: none ## What changes `packages/metadata-protocol/src/protocol-package-commits.ts` (`rollbackMetaItemUnaudited`), two lines of behaviour: 1. **The row the rollback addresses is the row the read serves.** The package is now `findServedOverlayRow` at `packagelessReadAddress` with `servedOverride: true`: the same resolution `getMetaItem` naming no package uses (ADR-0146 D8 item 2, the pair's declared override first, `isServedOverrideRowOf` the one predicate). Before, `resolveOverlayPackageBinding` answered the first active row of the name in store order. When no row is served (a row only another package binds under the name), the binding read answers as before. 2. **`restoreVersion` restores into that row** (`packageId: rollbackPackageId`, the parameter PR #29797 added for the commit doors). Before, the restore's active-row read was package-agnostic too, so even with the right binding the parent hash and the write could land on the base row. Every gate after the binding read is unchanged and now reads the served row's package: the package-less close, the named-base close, `restoreWriteFor` (the pair's row is a runtime-only write judged by the override write gate, ADR-0146 D2), the registry write-through. `protocol.ts` gains two docblock lines on `rollbackMetaItem`. No new parameter, no new error code, no spec change. ## Tests `packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts`, new top-level describe `[#29848]` over the file's existing harness: - **Red first, the measured two-row state.** A row bound to the base stored first, then the pair's declared override saved twice. On `5fa9375e` the rollback to the pair's first version answered `403 NOT_OVERRIDABLE` with the named-base sentence ("a rollback of its row bound to the read-only base is closed"), the card's reading. Now: success, the pair row holds `PairFirst` with its `overrides` declaration, the base row is untouched, and both the read naming no package and the read naming the base serve `PairFirst`. - The pair's rollback to the base row's version (a body that declares no override) is the override write gate's `403 NOT_OVERRIDABLE`, not the named-base close's; nothing is written. - **The named-base close still holds.** Once the pair's row is deleted, the read serves the base row again, and its rollback is refused with the close's envelope (`status` 403, `code` `NOT_OVERRIDABLE`, the sentence naming the pair); the row is unchanged. `packages/objectql/src/protocol-packageless-overlay-migration.test.ts`, the shadowed (S5) case re-judged: it pinned "the rollback's row is the first active row of the name in store order", the package-less row, while the read served the pair's declared row beside it. That is this card's defect on a package-less row instead of a base-bound one. The rollback now addresses the served pair row; its version 1 is the package-less row's undeclared body, so the D2 gate refuses it and both rows stay as they were. The save and DELETE halves of the pin are unchanged; the final read now serves the package-less row's current body, since it is no longer rolled back. ### Ablations `scripts/ablation-replace.mjs` (wrap mode), each leg restored with blob == HEAD (`35ffb243`) and `git status --porcelain` empty. | # | Mutation | Result | |:--|:--|:--| | A1 | the served-row derivation off (`rollbackPackageId` back to the binding read) | 2 / 34 red: the two-row rollback, the D2-gate pin | | A2 | `restoreVersion` without `packageId` | 1 / 34 red: the two-row rollback | ## Acceptance notes - **S5 behaviour change, named.** With a package-less row and the pair's declared row both stored (the census's `shadowed`), the per-item rollback used to restore the package-less row; it now restores the pair's row, which is the one every read serves. A rollback of the package-less row itself is not reachable through this door any more; its remedies are unchanged (a DELETE of the pair's row, after which the row is `movable` and `os migrate packageless-overlays` moves it). - PR #30226 (#30062) is in flight in the same area (`protocol-packaged-base-refusal.ts`, `protocol-package-bindings.ts`); this PR touches neither file. The served-row resolution it reads (`findServedOverlayRow`, `isServedOverrideRowOf`) is not in #30226's diff. - `content/docs/concepts/metadata-lifecycle.mdx:171` says a write naming no package (a rollback included) onto a packaged item is refused; it speaks of package-less rows, and a rollback of a served pair row already succeeded on `main` when the pair row was the only one. Left as is. ## Verification Head `abd5d69f` (base `5fa9375e`, `origin/main` `771b2db2` merged in once, no conflict, no rebase). The merge brought 8 commits (core dispatch loop, objectql lifecycle service, the spec i18n resolver, ui); none touches this diff's files. The affected closure was rebuilt after the merge under the shared lock. - `pnpm --filter @objectstack/metadata-protocol test` (pre-merge head `2d469b09`): 312 files passed (4 skipped), 22 357 tests passed (26 skipped). `typecheck`: exit 0 pre- and post-merge (`--listFiles` includes the new test file). - `pnpm --filter @objectstack/objectql test` (pre-merge head `2d469b09`): 548 / 548 files, 10 863 / 10 863 tests. - Post-merge, scoped (§10): `protocol.named-base-overlay-write.test.ts` 34 / 34; objectql `protocol-packageless-overlay-migration.test.ts` + `protocol-publish-rollback.test.ts` 40 / 40; `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date. - `pnpm lint`: exit 0 (pre-merge head `2d469b09`; the merge adds no lintable change of this PR's). - Red on `5fa9375e`: the two-row pin answered the named-base sentence, `403 NOT_OVERRIDABLE`; the D2-gate pin read that same sentence. <details><summary>Gates: dispatch-gates --commands, each exit code captured before any pipe</summary> 61 derived families (head `2d469b09`; the list derived at `abd5d69f` is byte-identical): 60 exit 0, 1 NOT MEASURED. `dispatch-gates --ran`: exit 0, "61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED". - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3, "Run `pnpm build` first. This is NOT a pass: nothing was measured" (70 packages outside this diff's closure have no `dist/`); left to CI. - `pnpm check:lean-entry-closure` first exited 3 (no `packages/objectql/dist`); after `turbo run build --filter=@objectstack/objectql` it exits 0 ("Admitted set held exactly (15 packages)"). - `node scripts/check-plugin-teardown-shape.mjs --self-test` ran after `git fetch origin 621a487…` (the pinned fixture commit): exit 0. - Re-run on the merged head `abd5d69f`: `check:type-check-coverage`, `check:type-check-debt`, `check:durability-log-level`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check-empty-changeset`, `check-adr-0087-registration`, `check-changeset-no-major`: all exit 0. </details> Change set: 5 paths, +134 / −8 against `5fa9375e` (two source files +33 / −2, two test files +93 / −6, one changeset +8). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is Chinese by the seat's convention). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CXdBvpJrhnHyW825Hb --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXdBvpJrhnHyW825Hb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 343ddb2 commit 713beba

5 files changed

Lines changed: 134 additions & 8 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
fix(metadata-protocol): the per-item rollback (`POST /api/v1/meta/:type/:name/rollback`) restores into the row the by-name read naming no package serves. A `.custom` pair's declared override stored beside a row bound to its read-only base (a row saved before the named-base close and not yet moved) is now rolled back in place, and the base row is left untouched; before, the door read a package-agnostic binding, addressed the base row, and was refused `403 NOT_OVERRIDABLE` naming a row the caller never meant. The door takes no new parameter. A rollback whose served row is the base's own is still refused by the named-base close.

‎packages/metadata-protocol/src/protocol-package-commits.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,7 +1906,7 @@ export class ProtocolPackageCommitsFacet {
19061906
}
19071907

19081908
/** [#28355] The body of {@link rollbackMetaItem} before its refusal audit; that method's docblock is the contract. */
1909-
static async rollbackMetaItemUnaudited(host: Pick<ProtocolHost, 'applyRegistryWriteThrough' | 'assertLockAllowsWrite' | 'emitMetadataMutation' | 'ensureOverlayIndex' | 'getOverlayRepo' | 'isArtifactBacked' | 'isOverlayAllowed' | 'isRuntimeCreateAllowed' | 'metadataConflictRefusal' | 'namedBaseOverlayWriteRefusal' | 'packagelessOverlayWriteRefusal' | 'receiptVersion' | 'recordMetadataAudit' | 'recordOptimisticConflictAudit' | 'refuseOrganizationScopedWrite' | 'resolveOverlayPackageBinding' | 'restoreWriteFor' | 'runMutationProjector'>, request: RollbackMetaItemRequest): Promise<{
1909+
static async rollbackMetaItemUnaudited(host: Pick<ProtocolHost, 'applyRegistryWriteThrough' | 'assertLockAllowsWrite' | 'emitMetadataMutation' | 'ensureOverlayIndex' | 'findServedOverlayRow' | 'getOverlayRepo' | 'isArtifactBacked' | 'isOverlayAllowed' | 'isRuntimeCreateAllowed' | 'metadataConflictRefusal' | 'namedBaseOverlayWriteRefusal' | 'packagelessOverlayWriteRefusal' | 'packagelessReadAddress' | 'receiptVersion' | 'recordMetadataAudit' | 'recordOptimisticConflictAudit' | 'refuseOrganizationScopedWrite' | 'resolveOverlayPackageBinding' | 'restoreWriteFor' | 'runMutationProjector'>, request: RollbackMetaItemRequest): Promise<{
19101910
success: boolean;
19111911
version: string;
19121912
seq: number;
@@ -2025,7 +2025,31 @@ export class ProtocolPackageCommitsFacet {
20252025
// it afterwards would put a fallible query downstream of a write that
20262026
// already succeeded — the shape that ends in a `catch {}` swallowing a
20272027
// real outage (#4867).
2028-
const rollbackPackageId = await host.resolveOverlayPackageBinding(singularType, request.name, member);
2028+
//
2029+
// [#29848, ADR-0146 D8 item 2] The row is the one the by-name read
2030+
// naming no package SERVES ({@link findServedOverlayRow} at
2031+
// {@link packagelessReadAddress}, its declared override first), so a
2032+
// rollback restores the item the caller sees. Beside a row bound to the
2033+
// read-only base (stored before #28905's close), the base's paired
2034+
// `.custom` row that declares the override is served, and its rollback
2035+
// restores it; the package-agnostic binding read answered whichever
2036+
// active row of the name the store returned first, so the pair's
2037+
// rollback addressed the base row and the named-base close below
2038+
// refused it, naming a row the caller never meant. No row served (a
2039+
// row only another package binds under the name): that row's binding,
2040+
// as before.
2041+
const servedPackage = await host.packagelessReadAddress(singularType, request.name, member);
2042+
const served = await host.findServedOverlayRow({
2043+
type: singularType,
2044+
name: request.name,
2045+
state: 'active',
2046+
...(servedPackage !== undefined ? { packageId: servedPackage } : {}),
2047+
...memberOption(member),
2048+
servedOverride: true,
2049+
});
2050+
const rollbackPackageId = served !== undefined
2051+
? ((served.row as { package_id?: string | null } | null)?.package_id ?? null)
2052+
: await host.resolveOverlayPackageBinding(singularType, request.name, member);
20292053
// [ADR-0146 D11, D1] A rollback writes the row its binding names, so
20302054
// a rollback of a packaged item's package-less row (an overlay type's
20312055
// row the ceremony has not moved, or none stored, whose restore writes
@@ -2081,6 +2105,9 @@ export class ProtocolPackageCommitsFacet {
20812105
deriveRestoredBody,
20822106
// [#22877] The member whose lineage `toVersion` is taken from.
20832107
...memberOption(member),
2108+
// [#29848, #29414] The row the binding above names, and no other
2109+
// package's row of the name: its parent hash, and the write.
2110+
packageId: rollbackPackageId,
20842111
});
20852112
// #4521 — a rollback is a live write like any other: the restored
20862113
// body must be the one the runtime dispatches on immediately, not

‎packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,3 +648,84 @@ describe('the commit doors: restoring a row bound to the base is closed, removin
648648
expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: WRITABLE, state: 'active', label: 'First' }]);
649649
});
650650
});
651+
652+
/**
653+
* [#29848, ADR-0146 D2, D8 item 2, D11] The per-item rollback
654+
* (`POST /api/v1/meta/:type/:name/rollback`) beside a row bound to the base.
655+
* The door names no package: it restores into the row the by-name read naming
656+
* no package SERVES (the pair's declared override over a row bound to the
657+
* base, `findServedOverlayRow`), never whichever active row of the name the
658+
* store returns first. Before, the binding read and the restore's active-row
659+
* read were both package-agnostic, so with the base row stored first the
660+
* rollback of the pair's override addressed the base row and was refused by
661+
* the named-base close, naming a row the caller never meant.
662+
*/
663+
describe('the per-item rollback addresses the row the read serves: the pair\'s declared override beside a row bound to the base (#29848)', () => {
664+
const declared = (label: string) => ({ ...VIEW.body(label), overrides: { package: BASE, basedOn: BASE_VERSION } });
665+
666+
/** The history version whose body carries `label`. */
667+
async function versionOf(protocol: any, label: string): Promise<number> {
668+
const history = (await protocol.engine.find('sys_metadata_history', { where: { type: VIEW.type, name: VIEW.name } })) as Row[];
669+
const hit = history.filter((h) => h.metadata != null && JSON.parse(h.metadata).label === label);
670+
expect(hit, `one history row labelled ${label}`).toHaveLength(1);
671+
return hit[0]!.version as number;
672+
}
673+
674+
async function savePair(protocol: any, label: string) {
675+
const saved = await protocol.saveMetaItem({ type: VIEW.type, name: VIEW.name, packageId: PAIR, item: declared(label) });
676+
expect(saved, JSON.stringify(saved)).toMatchObject({ success: true });
677+
}
678+
679+
it('the measured two-row state (the base row stored first): the pair\'s row rolls back, the base row is untouched', async () => {
680+
const { protocol, rows, storeBaseRow } = makeProtocol();
681+
await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('BaseRow'));
682+
await savePair(protocol, 'PairFirst');
683+
await savePair(protocol, 'PairSecond');
684+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('PairSecond');
685+
686+
const receipt = await protocol.rollbackMetaItem({
687+
type: VIEW.type, name: VIEW.name, toVersion: await versionOf(protocol, 'PairFirst'), actor: 'admin',
688+
});
689+
690+
expect(receipt).toMatchObject({ success: true });
691+
expect(rows(VIEW.type, VIEW.name)).toEqual([
692+
{ package_id: BASE, state: 'active', label: 'BaseRow' },
693+
{ package_id: PAIR, state: 'active', label: 'PairFirst' },
694+
]);
695+
const pairRow = await protocol.engine.findOne('sys_metadata', { where: { type: VIEW.type, name: VIEW.name, state: 'active', package_id: PAIR } });
696+
expect(JSON.parse(pairRow.metadata).overrides).toEqual({ package: BASE, basedOn: BASE_VERSION });
697+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('PairFirst');
698+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name, packageId: BASE })).item.label).toBe('PairFirst');
699+
});
700+
701+
it('the pair\'s rollback to the base row\'s version is the override write gate\'s refusal, not the named-base close; nothing is written', async () => {
702+
const { protocol, rows, storeBaseRow } = makeProtocol();
703+
await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('BaseRow'));
704+
await savePair(protocol, 'PairFirst');
705+
706+
const toVersion = await versionOf(protocol, 'BaseRow');
707+
const refused = await thrown(() => protocol.rollbackMetaItem({ type: VIEW.type, name: VIEW.name, toVersion, actor: 'admin' }));
708+
709+
expect(resolveThrownHttpError(refused)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' });
710+
expect(String(refused.message)).not.toContain('a rollback of its row bound to the read-only base is closed');
711+
expect(rows(VIEW.type, VIEW.name)).toEqual([
712+
{ package_id: BASE, state: 'active', label: 'BaseRow' },
713+
{ package_id: PAIR, state: 'active', label: 'PairFirst' },
714+
]);
715+
});
716+
717+
it('once the pair\'s row is deleted, the read serves the base row again and its rollback is refused by the named-base close', async () => {
718+
const { protocol, rows, storeBaseRow } = makeProtocol();
719+
await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('BaseFirst'));
720+
await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('BaseSecond'));
721+
await savePair(protocol, 'PairFirst');
722+
expect(await protocol.deleteMetaItem({ type: VIEW.type, name: VIEW.name, packageId: PAIR })).toMatchObject({ success: true });
723+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('BaseSecond');
724+
725+
const toVersion = await versionOf(protocol, 'BaseFirst');
726+
const refused = await thrown(() => protocol.rollbackMetaItem({ type: VIEW.type, name: VIEW.name, toVersion, actor: 'admin' }));
727+
728+
expectClosed(refused, VIEW.type, VIEW.name, 'rollback');
729+
expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'BaseSecond' }]);
730+
});
731+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13088,6 +13088,10 @@ export class ObjectStackProtocolImplementation implements
1308813088
* (`VERSION_NOT_FOUND`) when the target version doesn't exist;
1308913089
* 409 (`VERSION_NOT_RESTORABLE`) when the target is a delete
1309013090
* tombstone (no body to bring back).
13091+
*
13092+
* [#29848, ADR-0146 D8 item 2] The door names no package: it restores
13093+
* into the row the by-name read naming no package serves, a `.custom`
13094+
* pair's declared override over a row bound to its read-only base.
1309113095
*/
1309213096
async rollbackMetaItem(request: RollbackMetaItemRequest) {
1309313097
// [#28355] A refusal that carries no row of its own leaves its `denied` row here.

‎packages/objectql/src/protocol-packageless-overlay-migration.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -612,11 +612,17 @@ describe('[#28576] the close reaches only what the ceremony moves: every row it
612612
const pairRow = rowOf(VIEW, PAIR);
613613
expect(columnsOf(await read(protocol))).toEqual(['title']);
614614

615-
// The rollback's row is the first active row of the name in store
616-
// order (`resolveOverlayPackageBinding`): here the package-less one,
617-
// which is rolled back to its version 1.
618-
expect(await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 }))).toBeUndefined();
619-
expect(columnsOf(JSON.parse(String(rowOf(VIEW, null)!.metadata)))).toEqual(UNBOUND_COLUMNS);
615+
// [#29848] The rollback's row is the one the read serves, the pair's
616+
// declared row, never the first active row of the name in store order:
617+
// version 1 is the package-less row's undeclared body, so the override
618+
// write gate answers it there (D2), not the close, and nothing is written.
619+
const packagelessRow = rowOf(VIEW, null);
620+
const rolledBack = await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 }));
621+
expect(rolledBack).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' });
622+
expect(rolledBack?.message).toContain('declares no override');
623+
expect(rolledBack?.message).not.toContain('os migrate packageless-overlays');
624+
expect(rowOf(VIEW, PAIR)).toEqual(pairRow);
625+
expect(rowOf(VIEW, null)).toEqual(packagelessRow);
620626
// The package-less save and DELETE are not the close's to refuse:
621627
// they are addressed as before the close (`packagelessAddress`: the
622628
// one bound row of the name, the pair's), so the override write gate
@@ -630,7 +636,7 @@ describe('[#28576] the close reaches only what the ceremony moves: every row it
630636
expect(rows(VIEW)).toEqual([[null, 'active']]);
631637
// …which leaves the package-less row movable: the ceremony's remedy.
632638
expect(dispositions(await census(protocol))).toEqual([`${VIEW}@active movable`]);
633-
expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS);
639+
expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS);
634640
});
635641

636642
it('CONTROL movable: the same stored row with no pair row beside it — its package-less rollback, save and DELETE are refused', async () => {

0 commit comments

Comments
 (0)