|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | | -import { describe, expect, it } from 'vitest'; |
| 3 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
4 | 4 | // [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / |
5 | 5 | // #5480 update), so the fake engine below cannot accept a call ObjectQL |
6 | 6 | // refuses. Imported from `@objectstack/metadata-core` and not from |
7 | 7 | // `@objectstack/objectql`: objectql DEPENDS ON this package, so that import |
8 | 8 | // would close a dependency cycle turbo rejects outright. |
9 | | -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; |
| 9 | +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, hashSpec } from '@objectstack/metadata-core'; |
10 | 10 | import { ObjectStackProtocolImplementation } from './protocol.js'; |
11 | 11 |
|
12 | 12 | /** |
@@ -393,3 +393,282 @@ describe('publishMetaItem — the per-item door names a package too (#10350)', ( |
393 | 393 | expect(labelOf(active[0])).toBe('FROM_OTHER'); |
394 | 394 | }); |
395 | 395 | }); |
| 396 | + |
| 397 | +/** |
| 398 | + * [#11003] The ORG-SCOPE probes' half of the same ADR-0048 key — maintainer |
| 399 | + * ruling 2026-08-22, option A (recorded on the issue): the scope probes ask |
| 400 | + * the promote's question, i.e. `resolveDraftOrgScopeForPublish` threads the |
| 401 | + * stated `packageId` into BOTH of its `sys_metadata` probes. |
| 402 | + * |
| 403 | + * ## The defect these cases reproduce |
| 404 | + * |
| 405 | + * With two packages holding drafts for ONE `(type, name)` in DIFFERENT org |
| 406 | + * scopes, a package-stating publish resolved the wrong scope: probe 1 was |
| 407 | + * package-agnostic, matched the OTHER package's row in the caller's org, |
| 408 | + * named that org as the scope — and the promote (whose `whereFor` IS |
| 409 | + * package-exact since #8907/#10350) then found nothing there and answered |
| 410 | + * `404 [no_draft]` over a draft sitting env-wide, publishable, and named by |
| 411 | + * the caller. |
| 412 | + * |
| 413 | + * Unlike the #8907 cases above, NO insertion-order rigging is needed for the |
| 414 | + * wrong row to win: the two drafts live in different org partitions, so probe |
| 415 | + * 1's `organization_id` filter alone selects the foreign package's row — the |
| 416 | + * pre-fix failure is deterministic, not a driver-order coin toss. |
| 417 | + * |
| 418 | + * ## Why type `object` and the `OS_METADATA_WRITABLE` hatch |
| 419 | + * |
| 420 | + * The card's scenario is `(object, shared_ticket)`. `object` is |
| 421 | + * `allowOrgOverride: false` in the static registry, so an org-scoped object |
| 422 | + * draft exists only where the operator hatch (`OS_METADATA_WRITABLE=object` |
| 423 | + * — the Studio-side editing escape, #6190 R7) is open; with the hatch closed |
| 424 | + * the promote's own #6190 gate would answer `403 [not_overridable]` before |
| 425 | + * the probes' answer mattered, and the card's measured `404 [no_draft]` |
| 426 | + * could not be reproduced as filed. The hatch is scoped to this describe |
| 427 | + * (`beforeAll`/`afterAll` + cache reset), the same pattern |
| 428 | + * `protocol.org-scoped-write-refused.test.ts` R7 uses. |
| 429 | + * |
| 430 | + * ## Accepted cost, pinned on purpose |
| 431 | + * |
| 432 | + * The ruling's own words: a caller stating a package no longer discovers a |
| 433 | + * no-package draft of the same `(type, name)` — it 404s and the caller |
| 434 | + * retries without `?package=`; that narrowing is the ruling, not a side |
| 435 | + * effect. The last case pins BOTH halves of that sentence. The package-less |
| 436 | + * draft row is seeded by DIRECT `engine.insert`, not through |
| 437 | + * `saveMetaItem(mode:'draft')` — PR #11139 is changing how a package-less |
| 438 | + * draft save resolves its binding (inheriting the overlaid active row's |
| 439 | + * `package_id`), so a fixture seeded through that save path would stop |
| 440 | + * meaning "a package-less draft exists" the day it lands. |
| 441 | + */ |
| 442 | +describe('publishMetaItem — the scope probes ask the promote\'s question (#11003)', () => { |
| 443 | + beforeAll(() => { |
| 444 | + process.env.OS_METADATA_WRITABLE = 'object'; |
| 445 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 446 | + }); |
| 447 | + afterAll(() => { |
| 448 | + delete process.env.OS_METADATA_WRITABLE; |
| 449 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 450 | + }); |
| 451 | + |
| 452 | + /** |
| 453 | + * The card's coexistence arrangement: `app.other` holds the caller's-org |
| 454 | + * (`org1`) draft, `app.demo` holds the env-wide one. Distinct ADR-0048 |
| 455 | + * rows — different `(org, package)` pairings, one `(type, name)`. |
| 456 | + */ |
| 457 | + async function seedCrossScopeDrafts(protocol: ObjectStackProtocolImplementation) { |
| 458 | + await protocol.saveMetaItem({ |
| 459 | + type: 'object', |
| 460 | + name: 'shared_ticket', |
| 461 | + item: objectBody('shared_ticket', 'FROM_OTHER_ORG1'), |
| 462 | + packageId: 'app.other', |
| 463 | + organizationId: 'org1', |
| 464 | + mode: 'draft', |
| 465 | + }); |
| 466 | + await protocol.saveMetaItem({ |
| 467 | + type: 'object', |
| 468 | + name: 'shared_ticket', |
| 469 | + item: objectBody('shared_ticket', 'FROM_DEMO_ENV'), |
| 470 | + packageId: 'app.demo', |
| 471 | + mode: 'draft', |
| 472 | + }); |
| 473 | + } |
| 474 | + |
| 475 | + it('finds the draft the caller NAMED: publishing app.demo succeeds over app.other\'s same-org row', async () => { |
| 476 | + const { engine, rows } = makeStubEngine(); |
| 477 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 478 | + |
| 479 | + await seedCrossScopeDrafts(protocol); |
| 480 | + |
| 481 | + // The ADR-0048 coexistence precondition, asserted so a future change |
| 482 | + // to the seeding cannot silently drain these cases' discriminating |
| 483 | + // power: two draft rows, the foreign package's in the CALLER'S org, |
| 484 | + // the named package's env-wide. |
| 485 | + const drafts = draftRowsOf(rows); |
| 486 | + expect(drafts.map((r) => [r.package_id, r.organization_id])).toEqual([ |
| 487 | + ['app.other', 'org1'], |
| 488 | + ['app.demo', null], |
| 489 | + ]); |
| 490 | + |
| 491 | + // Pre-fix this REJECTED with `404 [no_draft]`: probe 1, package- |
| 492 | + // agnostic, matched app.other's org1 row and answered `org1`; the |
| 493 | + // package-exact promote then looked in org1 WITH |
| 494 | + // `package_id = 'app.demo'` and found nothing — while app.demo's |
| 495 | + // draft sat env-wide, publishable, and was the row the caller named. |
| 496 | + const res = await protocol.publishMetaItem({ |
| 497 | + type: 'object', |
| 498 | + name: 'shared_ticket', |
| 499 | + packageId: 'app.demo', |
| 500 | + organizationId: 'org1', |
| 501 | + }); |
| 502 | + |
| 503 | + expect(res).toMatchObject({ success: true }); |
| 504 | + const active = activeRowsOf(rows); |
| 505 | + expect(active).toHaveLength(1); |
| 506 | + expect(labelOf(active[0])).toBe('FROM_DEMO_ENV'); |
| 507 | + }); |
| 508 | + |
| 509 | + it('lands env-wide: the caller\'s org row belongs to another package, and the promotion never touches that partition', async () => { |
| 510 | + const { engine, rows } = makeStubEngine(); |
| 511 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 512 | + |
| 513 | + await seedCrossScopeDrafts(protocol); |
| 514 | + await protocol.publishMetaItem({ |
| 515 | + type: 'object', |
| 516 | + name: 'shared_ticket', |
| 517 | + packageId: 'app.demo', |
| 518 | + organizationId: 'org1', |
| 519 | + }); |
| 520 | + |
| 521 | + // The resolution's landing, pinned row-by-row: the probe fell through |
| 522 | + // to env-wide BECAUSE the caller's own org row belongs to another |
| 523 | + // package, so the active row is ENV-WIDE under the named package — |
| 524 | + // not an org1 row minted from a partition holding nothing of |
| 525 | + // app.demo's. |
| 526 | + const active = activeRowsOf(rows); |
| 527 | + expect(active).toHaveLength(1); |
| 528 | + expect(active[0].organization_id).toBeNull(); |
| 529 | + expect(active[0].package_id).toBe('app.demo'); |
| 530 | + // …and app.other's org1 draft is untouched — pending, undrained, in |
| 531 | + // its own partition. Pre-fix there was nothing to assert here: the |
| 532 | + // door had already refused. |
| 533 | + const drafts = draftRowsOf(rows); |
| 534 | + expect(drafts).toHaveLength(1); |
| 535 | + expect(drafts[0].package_id).toBe('app.other'); |
| 536 | + expect(drafts[0].organization_id).toBe('org1'); |
| 537 | + expect(labelOf(drafts[0])).toBe('FROM_OTHER_ORG1'); |
| 538 | + }); |
| 539 | + |
| 540 | + it('still resolves the caller\'s own org when THAT is where the named package\'s draft lives (no overshoot)', async () => { |
| 541 | + const { engine, rows } = makeStubEngine(); |
| 542 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 543 | + |
| 544 | + // Mirrored arrangement: app.other env-wide (seeded FIRST, so any |
| 545 | + // regression back toward package-agnostic env probing has a wrong row |
| 546 | + // to find), app.demo in the caller's org. |
| 547 | + await protocol.saveMetaItem({ |
| 548 | + type: 'object', |
| 549 | + name: 'shared_ticket', |
| 550 | + item: objectBody('shared_ticket', 'FROM_OTHER_ENV'), |
| 551 | + packageId: 'app.other', |
| 552 | + mode: 'draft', |
| 553 | + }); |
| 554 | + await protocol.saveMetaItem({ |
| 555 | + type: 'object', |
| 556 | + name: 'shared_ticket', |
| 557 | + item: objectBody('shared_ticket', 'FROM_DEMO_ORG1'), |
| 558 | + packageId: 'app.demo', |
| 559 | + organizationId: 'org1', |
| 560 | + mode: 'draft', |
| 561 | + }); |
| 562 | + |
| 563 | + const res = await protocol.publishMetaItem({ |
| 564 | + type: 'object', |
| 565 | + name: 'shared_ticket', |
| 566 | + packageId: 'app.demo', |
| 567 | + organizationId: 'org1', |
| 568 | + }); |
| 569 | + |
| 570 | + // GREEN BEFORE THE FIX TOO, and stated so nobody reads a repro into |
| 571 | + // it: pre-fix probe 1 happened to answer `org1` because the only org1 |
| 572 | + // row WAS app.demo's. What this case bounds is the fix itself — the |
| 573 | + // ADR-0005 precedence (own org shadows env-wide) must survive the |
| 574 | + // package narrowing, so a "package-exact means env-first/env-only" |
| 575 | + // mis-fix fails here loudly. |
| 576 | + expect(res).toMatchObject({ success: true }); |
| 577 | + const active = activeRowsOf(rows); |
| 578 | + expect(active).toHaveLength(1); |
| 579 | + expect(active[0].organization_id).toBe('org1'); |
| 580 | + expect(active[0].package_id).toBe('app.demo'); |
| 581 | + expect(labelOf(active[0])).toBe('FROM_DEMO_ORG1'); |
| 582 | + // app.other's env-wide draft: pending, undrained. |
| 583 | + const drafts = draftRowsOf(rows); |
| 584 | + expect(drafts).toHaveLength(1); |
| 585 | + expect(drafts[0].package_id).toBe('app.other'); |
| 586 | + expect(drafts[0].organization_id).toBeNull(); |
| 587 | + }); |
| 588 | + |
| 589 | + it('keeps the historical match-any probes when the caller states NO package (cross-scope fixture)', async () => { |
| 590 | + const { engine, rows } = makeStubEngine(); |
| 591 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 592 | + |
| 593 | + await seedCrossScopeDrafts(protocol); |
| 594 | + |
| 595 | + // No `packageId` key at all: the probes stay package-agnostic, the |
| 596 | + // promote matches any package, and the ADR-0005 precedence picks the |
| 597 | + // caller's own org row — app.other's, whatever package it belongs to. |
| 598 | + // This is the same absent-key contract the #10350 case above pins |
| 599 | + // env-wide, exercised HERE because these probes only run for an |
| 600 | + // org-scoped caller (`requestOrgId === null` returns early). |
| 601 | + const res = await protocol.publishMetaItem({ |
| 602 | + type: 'object', |
| 603 | + name: 'shared_ticket', |
| 604 | + organizationId: 'org1', |
| 605 | + }); |
| 606 | + |
| 607 | + expect(res).toMatchObject({ success: true }); |
| 608 | + const active = activeRowsOf(rows); |
| 609 | + expect(active).toHaveLength(1); |
| 610 | + expect(active[0].organization_id).toBe('org1'); |
| 611 | + expect(active[0].package_id).toBe('app.other'); |
| 612 | + expect(labelOf(active[0])).toBe('FROM_OTHER_ORG1'); |
| 613 | + }); |
| 614 | + |
| 615 | + it('accepted cost (the ruling, not a side effect): a package-stating caller 404s over a package-less draft, and retrying without ?package= publishes it', async () => { |
| 616 | + const { engine, rows } = makeStubEngine(); |
| 617 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 618 | + |
| 619 | + // Seeded by DIRECT insert — see the describe header for why this row |
| 620 | + // must not come from `saveMetaItem(mode:'draft')` while PR #11139 is |
| 621 | + // changing that path's binding resolution. The shape mirrors what the |
| 622 | + // repository's `put` writes for a package-less org draft — `checksum` |
| 623 | + // included: the post-promotion drain is an optimistic-lock delete |
| 624 | + // keyed on it, and a checksum-less row makes the drain read as the |
| 625 | + // benign "newer draft saved" race and survive (measured on this |
| 626 | + // fixture's first run). |
| 627 | + const noPackageBody = objectBody('shared_ticket', 'NO_PACKAGE'); |
| 628 | + await engine.insert('sys_metadata', { |
| 629 | + type: 'object', |
| 630 | + name: 'shared_ticket', |
| 631 | + organization_id: 'org1', |
| 632 | + package_id: null, |
| 633 | + state: 'draft', |
| 634 | + metadata: JSON.stringify(noPackageBody), |
| 635 | + checksum: hashSpec(noPackageBody), |
| 636 | + version: 1, |
| 637 | + created_at: new Date().toISOString(), |
| 638 | + updated_at: new Date().toISOString(), |
| 639 | + }); |
| 640 | + |
| 641 | + // Half 1 — the narrowing: the caller stated `app.demo`, so neither |
| 642 | + // probe nor promote may discover the unbound row. ADR-0112 envelope, |
| 643 | + // not a bare `toThrow`. (No pre-fix red here, stated plainly: the |
| 644 | + // package-exact PROMOTE already answered `no_draft` for this |
| 645 | + // arrangement; what this pins is that the ruling's cost sentence |
| 646 | + // holds end-to-end and stays held.) |
| 647 | + await expect( |
| 648 | + protocol.publishMetaItem({ |
| 649 | + type: 'object', |
| 650 | + name: 'shared_ticket', |
| 651 | + packageId: 'app.demo', |
| 652 | + organizationId: 'org1', |
| 653 | + }), |
| 654 | + ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); |
| 655 | + // …and the refusal touched nothing: the package-less draft is intact. |
| 656 | + expect(draftRowsOf(rows)).toHaveLength(1); |
| 657 | + |
| 658 | + // Half 2 — the documented remedy: retry WITHOUT `?package=`. The |
| 659 | + // absent key restores the match-any resolution and the unbound draft |
| 660 | + // publishes. |
| 661 | + const res = await protocol.publishMetaItem({ |
| 662 | + type: 'object', |
| 663 | + name: 'shared_ticket', |
| 664 | + organizationId: 'org1', |
| 665 | + }); |
| 666 | + expect(res).toMatchObject({ success: true }); |
| 667 | + const active = activeRowsOf(rows); |
| 668 | + expect(active).toHaveLength(1); |
| 669 | + expect(active[0].package_id).toBeNull(); |
| 670 | + expect(active[0].organization_id).toBe('org1'); |
| 671 | + expect(labelOf(active[0])).toBe('NO_PACKAGE'); |
| 672 | + expect(draftRowsOf(rows)).toHaveLength(0); |
| 673 | + }); |
| 674 | +}); |
0 commit comments