Skip to content

Commit 7cacad5

Browse files
feat(plugin-sharing,plugin-approvals,metadata-protocol)!: 18.x step 7 part 7a-3 — the organization-less readers of sharing rules, approval units and the seed loader retire (#24828)
Part of #24583 — step 7, part 7a-3 (claim 6285134534; governing text: execution record 6285122667, ADR-0131 D2 and D8). The other step-7 parts (7a-1 driver, 7a-2 authz, 7b-*, 7c webhooks) are not in this PR, and #24583 remains open. Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused. ## What changes **plugin-sharing `sharing-rule-service.ts`** - A declared rule's `single` projection carries a typed marker, `deploymentWide: true` (`DeploymentWideSharingRule`, `isDeploymentWideSharingRule`), and no `organization_id` key. This is the shape of the declared webhooks' `deploymentWide` (ruling A, 6278609968). The scope list holds a `DEPLOYMENT_WIDE_SCOPE` symbol in place of `null`. - `criteriaContext` loses its NULL branch. It answers one of three named cases: - the rule's organization, which is the tenant predicate; - a deployment-wide projection, which carries no tenant (D8 computes no predicate under `single`); - a composition with no organization object (the lean embedding, `no-organization-object`), which also carries no tenant. - Otherwise it answers `null`: a stored row with no organization sweeps nothing and grants nothing, and the service logs it once at `error` with the remedy. - **How the sweep runs (decided and pinned):** - Under `single`: one deployment-wide pass, and each grant is stamped with its record's organization. - Under a wall: one projection per organization, each inside that organization's one predicate, with grants stamped with it. No projection carries the marker under a wall, so Layer 0's predicate is never widened. - `adminOrgScope` and `findRuleRowByName` read the caller's own organization only. - `assertCanDeletePlatformGlobalRule` is deleted, together with its call. - `sweepOrphanedRuleGrants` and `ruleGrantScope` follow the marker. **plugin-approvals `ApprovalService.businessUnitOrgScope`**: a strict equality. The ceremony attributes `sys_business_unit`, and a fresh install stamps it. **metadata-protocol `seed-loader.ts`**: the `unownedHolders` read is deleted, together with its `refuse` decision and the refusal text #24681 had rewritten. A scoped load issues no `organization_id IS NULL` read on any path. **Pins rewritten to the new rule** (no assertion weakened): - `rule-criteria-org-scope.test.ts` (real SqlDriver): - declared rule under `single`: deployment-wide sweep; - declared rule under `isolated`: per-organization sweep; - stored org-less row: refused, logged once; - lean embedding: the control case. - `sharing-rule.test.ts`: - #7676 / #7761 / #8158 blocks: own-organization only; - the #7795 platform-global delete block is removed with its guard; - declared single projection: carries the marker. - `approval-service.test.ts` and `business-unit-member-org-screen.test.ts` (fixtures re-stamped to the request's organization). - `seed-loader-null-organization-holder.test.ts` and its runtime real-driver twin (no NULL read on any of the three readers). - The two sharing dogfood files. ## Premise measured first (step 7's "after certification") Every reader retired here was dead on all three trees: | tree | tenant-column tables | rows with NULL `organization_id` | |---|---|---| | certified database (`ceremoniedFixture` over the 18.0 fixture), after the ceremony and again after a HEAD boot | 66 | 0 | | fresh birth-marked `single` install (showcase `--fresh`) | all | 0 (`sys_business_unit` 5 rows, all stamped) | | fresh `isolated` install, real organizations runtime | all | 0 | One live path remains. It is kept, not deleted: the lean embedding with no organization object, which still writes NULL rows by design. It keeps the deployment-wide sweep, keyed on the composition fact (`sys_organization` not registered), never on a row's NULL. The production `os serve` boot without `OS_AUTH_SECRET` is that same composition today; 7b-3 makes it refuse. ## Proof Final head `357025dc97`. The boots, harness and suites ran on `62227f5632`, which differs from it only by `main`'s README commit. - **R1:** a fresh showcase boot on a random port, run on the pre-merge head and again on the final head: - health 200, seeded-admin sign-in 200, 0 error lines; - 0 NULL `organization_id` rows in any tenant table; - the declared rule `share_key_account_qualified_contacts_with_managers` materialised a stamped grant. - **R2 + R3:** `upgrade-18x-harness.dogfood.test.ts` + `organization-ownership-boot-refusal-order.dogfood.test.ts` passed, together with the two sharing dogfood files: 33/33. This run includes the ceremonied fixture, and the v17 refusal fired before the ceremony. - **R4:** - An `isolated` `os serve` with `@objectstack/organizations` (invite-only membership, owner email set), on both heads: health 200, sign-in 200, 0 error lines, 0 NULL rows. `GET /sharing/rules` serves both declared rules as per-organization projections, with no marker. The grant is stamped. - Step 4's 7 walled e2e cases (`serve-organizations-host-resolution` + `serve-organizations-mount-failure`, `OS_TEST_TIERS=nightly`): 7/7. - **Suites:** - plugin-sharing 51 files / 1181 tests; - plugin-approvals 86 / 1147; - metadata-protocol 251 / 20815; - runtime 431 / 6781; - typecheck of plugin-sharing, plugin-approvals, metadata-protocol, runtime and dogfood: exit 0. - **Gates:** `dispatch-gates --commands` on the 12 code paths derived 75 commands. All 75 ran, and every exit code was captured before any pipe. - 73 exited 0 on the first run. - `check-system-context-census` was red: `content/docs/permissions/system-context.mdx` still cited the deleted `assertCanDeletePlatformGlobalRule`. The citation was dropped, and the re-run exited 0. - `check-plugin-teardown-shape --self-test` was a shallow-clone artefact: its pinned fixture commit was absent. It exited 0 once that commit was fetched. - Re-derived on the final 15 paths (1,641 changed lines, under the 3,000 line): the 26 newly derived docs families plus the 15 ratchet and test-reading families, 41 commands, all exit 0. - **More pins the suites found, rewritten:** - `packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts`: the served declared shape now carries the marker. - `sharing-rule-positions-name-authority.test.ts`: retitled. Its delete case is refused by the scope gate, not by the retired guard. - **A sweep of 11 more candidate files, all green:** - 7 dogfood files (scope-depth ×3, BU projection, BU/user delete, single platform insert, single-tenant identity create); - the cli declared-rule-mirrors integration test; - the rest canonical-query-AST test; - the cloud-connection posture-gate test. - **Ablation** of the main pin: restoring the NULL branch (`return SYSTEM_CTX` for every org-less rule) turned exactly the two predicted cases red ("a STORED rule with NO organization …", whole-rule and per-record), and the other 6 stayed green. Restore was proven by blob == HEAD and an empty `git diff HEAD`. ## Acceptance notes - `main`'s `recipient-membership-recompute.ts` (`ruleReadsRowsOf`, landed in `0b7286ff`) still reads `rule.organization_id == null` as "reads every row". With the marker the deployment-wide projection has no `organization_id`, so it is still counted as affected, which is the right answer. The spelling is a NULL-shaped reader outside this part's file surface. - `sharing-plugin.ts`'s `resolveDeclaredRuleScopes` still spells `single` as `[null]`. `setDeclaredRuleScopes` translates it at that one door, so no NULL travels past it. Retyping the plugin side is outside the claimed surface. - `approval-service.ts` `teamIsProvablyOutsideOrg` keeps its "no organization ⇒ leave routing" limb (a team screen). It is not in this part's surface, and its doc cross-reference was corrected. - The ORG-STAMPED pin in `rule-criteria-org-scope.test.ts` still expects `deal_p1` through the driver's NULL record arm. That is 7a-1's arm and 7a-1's pin. Separately, an org-less RECORD matched by two walled projections aborts the second pass with `RECORD_NOT_FOUND`. That case cannot occur on a certified database, and it goes away with 7a-1. - File surface: beyond the claim's list, this PR edits one hand-written docs page (`content/docs/permissions/system-context.mdx`, row 40: the deleted guard's citation, which its census gate requires) and pins in `packages/runtime` and `packages/qa/dogfood`. It touches no `docs/adr/**` and no `content/docs/releases/`. - The seed pin files keep their `null-organization-holder` names: `scripts/engine-double-contract.pinned.json` registers them by path. --- _Generated by [Claude Code](https://claude.ai/code/session_01XFEb4fSDbe4fK94S3x5FKX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent abd5acd commit 7cacad5

16 files changed

Lines changed: 698 additions & 985 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-sharing': minor
4+
'@objectstack/plugin-approvals': minor
5+
'@objectstack/metadata-protocol': minor
6+
---
7+
8+
feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
9+
10+
Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. -->
13+
14+
**BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
15+
16+
- **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
17+
- **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
18+
- **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
19+
- **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
20+
- **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
21+
- **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
22+
23+
Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ One of the largest consumers of the flag;
159159
| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` |
160160
| 38 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink`, `#revokeLink`, `#listLinks` |
161161
| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` |
162-
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, the platform-global-rule delete guard, and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule`, `#bindDeclaredRuleNameGuard` |
162+
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#bindDeclaredRuleNameGuard` |
163163

164164
### 4. Approvals, attachments, comments, knowledge
165165

‎packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts‎

Lines changed: 51 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,23 @@ import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'
66
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
77

88
/**
9-
* [#24301] An organization-scoped load refuses a seed row whose natural key a
10-
* row with NO organization already holds, as a per-row error, instead of
11-
* inserting a twin of that row; and it never adopts the row.
9+
* [ADR-0131 D8] An organization-scoped load reads ITS OWN rows, and only them:
10+
* one read per decision, `organization_id = ORG`, with no NULL arm on any of
11+
* the three readers of `loadExistingRecords` (its #8896 note):
1212
*
13-
* These pins hold the three readers of `loadExistingRecords` (its #8896 note)
14-
* apart, which a real-driver file cannot reach:
13+
* 1. the upsert/ignore/update pre-load;
14+
* 2. `writeBatchPartial`'s retry recheck;
15+
* 3. `writeOne`'s per-row recheck.
1516
*
16-
* 1. the upsert/ignore pre-load sees the unowned holder and refuses;
17-
* 2. `writeBatchPartial`'s retry recheck, and
18-
* 3. `writeOne`'s recheck read this organization's rows only: a row an
19-
* earlier attempt committed carries the organization, so an unowned row
20-
* is never taken for one, and the refused row never reaches either.
17+
* Until step 7 of the organization-ownership line the pre-load also read the
18+
* keys held by rows with NO organization and refused to twin or adopt them
19+
* (#24301). Certification leaves no such row (the ownership ceremony
20+
* attributes every one, D10) and a new write without an organization is
21+
* stamped or refused (D9), so that second read retired. These pins hold the
22+
* retirement: no `{ organization_id: null }` read is issued, whatever the
23+
* mode or path, and the scoped load behaves as the organization's own.
2124
*
22-
* The real drivers' NULL predicate and the stored rows are pinned in
25+
* The real driver's side is pinned in
2326
* `packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts`.
2427
*/
2528

@@ -112,76 +115,47 @@ const widgets = (mode: string) => [{
112115
records: [{ sku: 'W-HELD', label: 'held' }, { sku: 'W-NEW', label: 'new' }],
113116
}] as any[];
114117

115-
/** A row no organization owns, holding the natural key `W-HELD`. */
116-
function plantUnowned(store: Record<string, any[]>) {
117-
store.tw_widget = [{ id: 'w_unowned', sku: 'W-HELD', label: 'old', organization_id: null }];
118+
/** This organization's own row, holding the natural key `W-HELD`. */
119+
function plantOwn(store: Record<string, any[]>) {
120+
store.tw_widget = [{ id: 'w_own', sku: 'W-HELD', label: 'old', organization_id: ORG }];
118121
}
119122

120-
const refusalsOf = <E extends { field: string; sourceObject: string; targetObject: string }>(result: { errors: E[] }) =>
121-
result.errors.filter((e) => e.field === 'organization_id' && e.targetObject === e.sourceObject);
123+
const nullReads = (finds: any[]) => finds.filter((f) => f.where && 'organization_id' in f.where && f.where.organization_id == null);
122124

123-
describe('[#24301] a scoped load over a NULL-organization holder of a natural key', () => {
124-
it.each(['upsert', 'ignore'])('%s: refuses the held key by name, inserts the rest, and leaves the holder alone', async (mode) => {
125-
const { engine, store } = createFaithfulEngine();
126-
plantUnowned(store);
125+
describe('[ADR-0131 D8] a scoped load reads only its organization\'s rows', () => {
126+
it.each(['upsert', 'ignore', 'update'])('%s: one scoped pre-load, no NULL read, its own row matched', async (mode) => {
127+
const { engine, store, finds } = createFaithfulEngine();
128+
plantOwn(store);
127129
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets(mode), config: config(ORG) });
128130

129-
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-NEW']);
130-
expect(store.tw_widget[0]).toEqual({ id: 'w_unowned', sku: 'W-HELD', label: 'old', organization_id: null });
131-
expect(store.tw_widget.find((r) => r.sku === 'W-NEW')?.organization_id).toBe(ORG);
132-
expect(result.summary.totalInserted).toBe(1);
133-
expect(result.summary.totalErrored).toBe(1);
134-
expect(engine.update).not.toHaveBeenCalled();
135-
const [refusal] = refusalsOf(result);
136-
expect(refusal).toMatchObject({ sourceObject: 'tw_widget', targetField: 'sku', attemptedValue: 'W-HELD', recordIndex: 0 });
137-
expect(refusal.message).toContain("'w_unowned'");
138-
// The ceremony is the remedy, with its reviewed plan; the first sentences
139-
// (what was refused, and why) stay.
140-
expect(refusal.message).toContain('which has no organization (organization_id is NULL)');
141-
expect(refusal.message).toContain('decided by the organization-ownership ceremony, not by a seed load.');
142-
expect(refusal.message).toContain(
143-
'Attribute the unowned rows first (`os migrate organization-ownership --out PLAN`, then the same command with '
144-
+ '--apply --plan-file PLAN --backup-taken), then re-run this seed',
145-
);
146-
expect(refusal.message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/);
147-
});
148-
149-
it("the organization's own row wins: a key held both by it and by an unowned row is matched, not refused", async () => {
150-
const { engine, store } = createFaithfulEngine();
151-
plantUnowned(store);
152-
store.tw_widget.push({ id: 'w_own', sku: 'W-HELD', label: 'held', organization_id: ORG });
153-
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('upsert'), config: config(ORG) });
154-
155131
expect(result.errors).toEqual([]);
156-
expect(result.summary.totalSkipped).toBe(1);
157-
expect(store.tw_widget.filter((r) => r.sku === 'W-HELD')).toHaveLength(2);
132+
expect(nullReads(finds)).toEqual([]);
133+
expect(finds.filter((f) => f.objectName === 'tw_widget').map((f) => f.where)).toEqual([{ organization_id: ORG }]);
134+
// The held key is this organization's: matched, never twinned.
135+
expect(store.tw_widget.filter((r) => r.sku === 'W-HELD')).toHaveLength(1);
136+
if (mode === 'update') {
137+
expect(store.tw_widget).toHaveLength(1);
138+
} else {
139+
expect(store.tw_widget.find((r) => r.sku === 'W-NEW')?.organization_id).toBe(ORG);
140+
expect(result.summary.totalInserted).toBe(1);
141+
}
158142
});
159143

160-
it('update mode writes no new row, so it keeps its behaviour: the held key is skipped, nothing refused', async () => {
144+
it('the self-referencing (sequential) path reads the same way', async () => {
161145
const { engine, store, finds } = createFaithfulEngine();
162-
plantUnowned(store);
163-
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('update'), config: config(ORG) });
164-
165-
expect(result.errors).toEqual([]);
166-
expect(result.summary.totalSkipped).toBe(2);
167-
expect(store.tw_widget).toHaveLength(1);
168-
expect(finds.some((f) => f.where?.organization_id === null)).toBe(false);
169-
});
170-
171-
it('the self-referencing (sequential) path refuses the same way', async () => {
172-
const { engine, store } = createFaithfulEngine();
173-
store.tw_unit = [{ id: 'u_unowned', code: 'ROOT', parent: null, organization_id: null }];
146+
store.tw_unit = [{ id: 'u_own', code: 'ROOT', parent: null, organization_id: ORG }];
174147
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
175148
seeds: [{ object: 'tw_unit', externalId: 'code', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ code: 'ROOT' }, { code: 'LEAF' }] }] as any[],
176149
config: config(ORG),
177150
});
151+
expect(result.errors).toEqual([]);
178152
expect(store.tw_unit.map((r) => r.code).sort()).toEqual(['LEAF', 'ROOT']);
179-
expect(refusalsOf(result).map((e) => e.attemptedValue)).toEqual(['ROOT']);
153+
expect(nullReads(finds)).toEqual([]);
180154
});
181155

182-
it('writeBatchPartial retry recheck: reads only the organization\'s rows, and the refused row never reaches an insert', async () => {
156+
it('writeBatchPartial retry recheck: reads only the organization\'s rows, and inserts nothing twice', async () => {
183157
const { engine, store, finds } = createFaithfulEngine();
184-
plantUnowned(store);
158+
plantOwn(store);
185159
// The first array insert commits, then loses its response: the retry's
186160
// recheck must find W-NEW as already written and insert nothing again.
187161
const realInsert = (engine.insert as any).getMockImplementation();
@@ -195,24 +169,19 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke
195169
});
196170
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('upsert'), config: config(ORG) });
197171

172+
expect(calls).toBe(1);
198173
expect(payloads.flat().map((r) => r.sku)).toEqual(['W-NEW']);
199174
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-NEW']);
200-
expect(refusalsOf(result)).toHaveLength(1);
201175
expect(result.summary.totalInserted).toBe(1);
202-
// One NULL read, the pre-load's; every recheck after the first insert is scoped.
203-
const nullReads = finds.filter((f) => f.where?.organization_id === null);
204-
expect(nullReads).toHaveLength(1);
205-
const afterPreload = finds.slice(finds.findIndex((f) => f.where?.organization_id === null) + 1)
206-
.filter((f) => f.objectName === 'tw_widget');
207-
// The retry happened, and its recheck is the one scoped read after the pre-load.
208-
expect(calls).toBe(1);
209-
expect(afterPreload.map((f) => f.where)).toEqual([{ organization_id: ORG }]);
176+
expect(nullReads(finds)).toEqual([]);
177+
// The pre-load and the retry's recheck: both scoped.
178+
expect(finds.filter((f) => f.objectName === 'tw_widget').map((f) => f.where))
179+
.toEqual([{ organization_id: ORG }, { organization_id: ORG }]);
210180
});
211181

212182
it('writeOne recheck (an engine with no insertMany, an uncertain batch): the same, per row', async () => {
213183
const { engine, store, finds } = createFaithfulEngine();
214-
plantUnowned(store);
215-
store.tw_widget.push({ id: 'w_unowned_2', sku: 'W-HELD-2', label: 'old', organization_id: null });
184+
plantOwn(store);
216185
const realInsert = (engine.insert as any).getMockImplementation();
217186
const payloads: any[][] = [];
218187
(engine.insert as any).mockImplementation(async (obj: string, data: any, opts: any) => {
@@ -224,27 +193,26 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke
224193
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
225194
seeds: [{
226195
object: 'tw_widget', externalId: 'sku', mode: 'upsert', env: ['prod', 'dev', 'test'],
227-
records: [{ sku: 'W-HELD' }, { sku: 'W-NEW' }, { sku: 'W-HELD-2' }, { sku: 'W-NEW-2' }],
196+
records: [{ sku: 'W-HELD' }, { sku: 'W-NEW' }, { sku: 'W-NEW-2' }],
228197
}] as any[],
229198
config: config(ORG),
230199
});
231200

232201
// One whole-array attempt, then the per-row degradation: two single inserts.
233202
expect(payloads.map((p) => p.map((r) => r.sku))).toEqual([['W-NEW', 'W-NEW-2'], ['W-NEW'], ['W-NEW-2']]);
234-
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-HELD-2', 'W-NEW', 'W-NEW-2']);
235-
expect(refusalsOf(result).map((e) => e.attemptedValue)).toEqual(['W-HELD', 'W-HELD-2']);
203+
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-NEW', 'W-NEW-2']);
236204
expect(result.summary.totalInserted).toBe(2);
237-
expect(finds.filter((f) => f.where?.organization_id === null)).toHaveLength(1);
205+
expect(nullReads(finds)).toEqual([]);
238206
});
239207

240-
it('an unscoped load reads no NULL holders and keeps matching the unowned row', async () => {
208+
it('an unscoped load reads no NULL holders either, and matches by key alone', async () => {
241209
const { engine, store, finds } = createFaithfulEngine();
242-
plantUnowned(store);
210+
plantOwn(store);
243211
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('upsert'), config: config() });
244212

245-
expect(refusalsOf(result)).toEqual([]);
213+
expect(result.errors).toEqual([]);
246214
expect(store.tw_widget.filter((r) => r.sku === 'W-HELD')).toHaveLength(1);
247215
expect(store.tw_widget.find((r) => r.sku === 'W-HELD')?.label).toBe('held');
248-
expect(finds.some((f) => f.where?.organization_id === null)).toBe(false);
216+
expect(nullReads(finds)).toEqual([]);
249217
});
250218
});

0 commit comments

Comments
 (0)