|
35 | 35 | * Refusals are asserted as the envelope an HTTP boundary answers with |
36 | 36 | * (`resolveThrownHttpError`). |
37 | 37 | */ |
38 | | -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 38 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
39 | 39 | import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; |
40 | 40 | import { resolveThrownHttpError } from '@objectstack/types'; |
41 | 41 | import { ObjectStackProtocolImplementation } from './protocol.js'; |
@@ -206,6 +206,7 @@ beforeEach(() => { |
206 | 206 | afterEach(() => { |
207 | 207 | ObjectStackProtocolImplementation.resetEnvWritableCache(); |
208 | 208 | resetEnvWritableMetadataTypes(); |
| 209 | + vi.useRealTimers(); |
209 | 210 | }); |
210 | 211 |
|
211 | 212 | describe('a write naming the read-only base itself is closed: its customization lands in the pair (ADR-0146 D1, D11)', () => { |
@@ -362,3 +363,115 @@ describe('CONTROLS: the writes the close leaves open, as on main', () => { |
362 | 363 | expect(rows(VIEW.type, VIEW.name)).toEqual([]); |
363 | 364 | }); |
364 | 365 | }); |
| 366 | + |
| 367 | +/** |
| 368 | + * [#29390] The commit doors. A commit the base recorded before the close (a |
| 369 | + * draft naming the base, published by `publishPackageDrafts` with the close |
| 370 | + * shadowed on this instance only, which is what a build before #28905 did) |
| 371 | + * can carry an item that EDITED the row bound to the base. Its revert restores |
| 372 | + * a stored version into that row, so it is refused per item, as the per-item |
| 373 | + * rollback of the same row is: the restore limb asks the same close on the |
| 374 | + * same key (the binding the restore writes) with the same sentence. An item |
| 375 | + * the commit CREATED is reverted by removing the row, which writes nothing and |
| 376 | + * stays open. |
| 377 | + */ |
| 378 | +describe('the commit doors: restoring a row bound to the base is closed, removing one stays open (#29390)', () => { |
| 379 | + /** Each commit a second apart, so the package rollback's "strictly newer" reads an order. */ |
| 380 | + let clock = Date.parse('2026-11-02T00:00:00.000Z'); |
| 381 | + const tick = () => { clock += 1000; vi.setSystemTime(clock); }; |
| 382 | + |
| 383 | + /** Publish `item` into `packageId` as one commit; `beforeClose` shadows the close on this instance only. */ |
| 384 | + async function publishCommit(protocol: any, packageId: string, item: Record<string, unknown>, beforeClose = false): Promise<string> { |
| 385 | + tick(); |
| 386 | + if (beforeClose) protocol.namedBaseOverlayWriteRefusal = () => null; |
| 387 | + try { |
| 388 | + const saved = await protocol.saveMetaItem({ type: VIEW.type, name: VIEW.name, item, packageId, mode: 'draft' }); |
| 389 | + expect(saved).toMatchObject({ success: true }); |
| 390 | + const published = await protocol.publishPackageDrafts({ packageId }); |
| 391 | + expect(published, JSON.stringify(published)).toMatchObject({ success: true }); |
| 392 | + expect(typeof published.commitId).toBe('string'); |
| 393 | + return published.commitId; |
| 394 | + } finally { |
| 395 | + if (beforeClose) delete protocol.namedBaseOverlayWriteRefusal; |
| 396 | + } |
| 397 | + } |
| 398 | + const intoBaseBeforeClose = (protocol: any, label: string) => publishCommit(protocol, BASE, VIEW.body(label), true); |
| 399 | + const declared = (label: string) => ({ ...VIEW.body(label), overrides: { package: BASE, basedOn: BASE_VERSION } }); |
| 400 | + |
| 401 | + beforeEach(() => { |
| 402 | + vi.useFakeTimers({ toFake: ['Date'] }); |
| 403 | + }); |
| 404 | + |
| 405 | + it('a revert of the base\'s commit that edited its row is refused per item naming the pair, and nothing is written', async () => { |
| 406 | + const { protocol, rows } = makeProtocol(); |
| 407 | + await intoBaseBeforeClose(protocol, 'First'); |
| 408 | + const edit = await intoBaseBeforeClose(protocol, 'Second'); |
| 409 | + // The close is live again on this instance: the save naming the base is refused. |
| 410 | + expectClosed(await thrown(() => protocol.saveMetaItem({ type: VIEW.type, name: VIEW.name, item: VIEW.body('After'), packageId: BASE })), VIEW.type, VIEW.name, 'save'); |
| 411 | + |
| 412 | + tick(); |
| 413 | + const result = await protocol.revertCommit({ commitId: edit, packageId: BASE, actor: 'admin' }); |
| 414 | + |
| 415 | + expect(result).toMatchObject({ success: false, revertedCount: 0, failedCount: 1, reverted: [] }); |
| 416 | + expect(result.failed).toEqual([expect.objectContaining({ type: VIEW.type, name: VIEW.name, code: 'NOT_OVERRIDABLE' })]); |
| 417 | + expectClosedSentence(result.failed[0].error, VIEW.type, VIEW.name, 'rollback'); |
| 418 | + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Second' }]); |
| 419 | + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('Second'); |
| 420 | + }); |
| 421 | + |
| 422 | + it('a package rollback through those commits reports each refused item and continues; the row is unchanged', async () => { |
| 423 | + const { protocol, rows } = makeProtocol(); |
| 424 | + const created = await intoBaseBeforeClose(protocol, 'First'); |
| 425 | + const second = await intoBaseBeforeClose(protocol, 'Second'); |
| 426 | + const third = await intoBaseBeforeClose(protocol, 'Third'); |
| 427 | + |
| 428 | + tick(); |
| 429 | + const result = await protocol.rollbackToPackageCommit({ commitId: created, packageId: BASE, actor: 'admin' }); |
| 430 | + |
| 431 | + expect(result.success).toBe(false); |
| 432 | + expect(result.revertedCommits).toEqual([third, second]); |
| 433 | + expect(result.failed.map((f: { commitId: string }) => f.commitId)).toEqual([third, second]); |
| 434 | + for (const f of result.failed) { |
| 435 | + expect(f.error).toContain(`Item '${VIEW.type}/${VIEW.name}' was not reverted: `); |
| 436 | + expectClosedSentence(f.error.slice(f.error.indexOf(': ') + 2), VIEW.type, VIEW.name, 'rollback'); |
| 437 | + } |
| 438 | + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Third' }]); |
| 439 | + }); |
| 440 | + |
| 441 | + it('CONTROL: a revert of the base\'s commit that created its row removes it (the delete stays open); the base is served again', async () => { |
| 442 | + const { protocol, rows } = makeProtocol(); |
| 443 | + const created = await intoBaseBeforeClose(protocol, 'First'); |
| 444 | + |
| 445 | + tick(); |
| 446 | + const result = await protocol.revertCommit({ commitId: created, packageId: BASE, actor: 'admin' }); |
| 447 | + |
| 448 | + expect(result).toMatchObject({ success: true, revertedCount: 1, failed: [], reverted: [{ type: VIEW.type, name: VIEW.name, action: 'removed' }] }); |
| 449 | + expect(rows(VIEW.type, VIEW.name)).toEqual([]); |
| 450 | + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe(PACKAGED); |
| 451 | + }); |
| 452 | + |
| 453 | + it('CONTROL: a writable package\'s commit of the same item reverts its row in place', async () => { |
| 454 | + const { protocol, rows } = makeProtocol(); |
| 455 | + await publishCommit(protocol, WRITABLE, VIEW.body('First')); |
| 456 | + const edit = await publishCommit(protocol, WRITABLE, VIEW.body('Second')); |
| 457 | + |
| 458 | + tick(); |
| 459 | + const result = await protocol.revertCommit({ commitId: edit, packageId: WRITABLE, actor: 'admin' }); |
| 460 | + |
| 461 | + expect(result).toMatchObject({ success: true, revertedCount: 1, failed: [], reverted: [{ type: VIEW.type, name: VIEW.name, action: 'restored' }] }); |
| 462 | + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: WRITABLE, state: 'active', label: 'First' }]); |
| 463 | + }); |
| 464 | + |
| 465 | + it('CONTROL: the pair\'s own commit (no row bound to the base) reverts its row in place', async () => { |
| 466 | + const { protocol, rows } = makeProtocol(); |
| 467 | + await publishCommit(protocol, PAIR, declared('PairFirst')); |
| 468 | + const edit = await publishCommit(protocol, PAIR, declared('PairSecond')); |
| 469 | + |
| 470 | + tick(); |
| 471 | + const result = await protocol.revertCommit({ commitId: edit, packageId: PAIR, actor: 'admin' }); |
| 472 | + |
| 473 | + expect(result).toMatchObject({ success: true, revertedCount: 1, failed: [], reverted: [{ type: VIEW.type, name: VIEW.name, action: 'restored' }] }); |
| 474 | + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: PAIR, state: 'active', label: 'PairFirst' }]); |
| 475 | + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('PairFirst'); |
| 476 | + }); |
| 477 | +}); |
0 commit comments