|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#14969] `SharingRuleEvaluationResult.grantsRefused?: number` — the OPTIONAL |
| 5 | + * seventh key, lifted into the contract because the wire already carried it: |
| 6 | + * `POST /api/v1/sharing/rules/:idOrName/evaluate` answers the service's return |
| 7 | + * value unfiltered (ledgered `sdk` / `shares.rules.evaluate`), so the declared |
| 8 | + * client type lagged the route by exactly this key and the count could not be |
| 9 | + * read without a cast. |
| 10 | + * |
| 11 | + * Three things are pinned, because each drifts on its own: |
| 12 | + * |
| 13 | + * 1. **Optionality, in both directions, at the type level.** The six counts |
| 14 | + * stay REQUIRED and `grantsRefused` is the ONE optional key. Making it |
| 15 | + * required would break every other `ISharingRuleService` implementer, |
| 16 | + * in-tree and out; a second optional key, or a drift of the value type |
| 17 | + * away from `number`, turns the exported aliases red under |
| 18 | + * `check:test-typecheck`, which compiles this file under |
| 19 | + * `tsconfig.test.json`. |
| 20 | + * 2. **The covariant narrowing composes.** A subtype that REQUIRES the key |
| 21 | + * (`@objectstack/plugin-sharing`'s `SharingRuleReconcilePassResult`) is |
| 22 | + * still a legal `evaluateRule` return type, and a six-key implementation |
| 23 | + * keeps compiling untouched — the two facts the card's "optional, not |
| 24 | + * required" rests on. |
| 25 | + * 3. **The JSDoc carries the absent-is-not-zero rule.** "Unset" means "this |
| 26 | + * implementation does not report refusals", never "no grant was refused". |
| 27 | + * Prose is unassertable except by reading it, so the contract source is |
| 28 | + * read and the doc block above the key is required to say so, and to name |
| 29 | + * what a refusal IS (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on an |
| 30 | + * organization-less insert into a tenant-scoped `sys_record_share`). |
| 31 | + * |
| 32 | + * ⛔ Not pinned, deliberately: whether any implementation COUNTS refusals. |
| 33 | + * That is the services half (`@objectstack/plugin-sharing`'s own |
| 34 | + * `reconcile-refused-grant-continues.test.ts`); this file pins the contract. |
| 35 | + */ |
| 36 | + |
| 37 | +import { readFileSync } from 'node:fs'; |
| 38 | +import { fileURLToPath } from 'node:url'; |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | + |
| 42 | +import type { ISharingRuleService, SharingRuleEvaluationResult } from './sharing-service'; |
| 43 | + |
| 44 | +/** Type-level identity: true iff A and B are the same type. */ |
| 45 | +type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; |
| 46 | +/** Compile error when the argument is not `true`. */ |
| 47 | +type Assert<T extends true> = T; |
| 48 | + |
| 49 | +/** |
| 50 | + * `-?` strips optionality, then `object extends Pick<T, K>` is true exactly |
| 51 | + * when K was optional — so the union is the mandatory keys (the |
| 52 | + * `sharing-service.test.ts` #5858 idiom). |
| 53 | + */ |
| 54 | +type RequiredKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? never : K }[keyof T]; |
| 55 | +/** The complement: the keys a literal of T may omit. */ |
| 56 | +type OptionalKeys<T> = Exclude<keyof T, RequiredKeys<T>>; |
| 57 | + |
| 58 | +/** |
| 59 | + * The six counts every implementation reports, spelled once. `satisfies` |
| 60 | + * proves each is a key; the `Eq` below proves the mandatory set is exactly |
| 61 | + * these and nothing else. |
| 62 | + */ |
| 63 | +export const SHARING_RULE_EVALUATION_REQUIRED_KEYS = [ |
| 64 | + 'ruleId', |
| 65 | + 'matchedRecords', |
| 66 | + 'expandedUsers', |
| 67 | + 'grantsCreated', |
| 68 | + 'grantsUpdated', |
| 69 | + 'grantsRevoked', |
| 70 | +] as const satisfies readonly (keyof SharingRuleEvaluationResult)[]; |
| 71 | + |
| 72 | +/** |
| 73 | + * Exported deliberately — an unread alias inside a test body is TS6196, and a |
| 74 | + * pin no program compiles is no pin at all. |
| 75 | + */ |
| 76 | +export type SixCountsStayRequired = Assert< |
| 77 | + Eq<RequiredKeys<SharingRuleEvaluationResult>, (typeof SHARING_RULE_EVALUATION_REQUIRED_KEYS)[number]> |
| 78 | +>; |
| 79 | +/** `grantsRefused` is the ONE optional key — a second one fails here by name. */ |
| 80 | +export type GrantsRefusedIsTheOnlyOptionalKey = Assert<Eq<OptionalKeys<SharingRuleEvaluationResult>, 'grantsRefused'>>; |
| 81 | +/** …and it is a number when present, exactly `number | undefined` as read. */ |
| 82 | +export type GrantsRefusedIsANumberWhenPresent = Assert<Eq<SharingRuleEvaluationResult['grantsRefused'], number | undefined>>; |
| 83 | + |
| 84 | +/** |
| 85 | + * The plugin-local narrowing, re-declared here under its own name so the |
| 86 | + * composition is pinned against the SHAPE, not against an import of |
| 87 | + * `@objectstack/plugin-sharing` (spec must not depend on a plugin). |
| 88 | + */ |
| 89 | +interface RequiresTheCount extends SharingRuleEvaluationResult { |
| 90 | + grantsRefused: number; |
| 91 | +} |
| 92 | + |
| 93 | +describe('[#14969] SharingRuleEvaluationResult.grantsRefused is optional, and absent is not zero', () => { |
| 94 | + it('reads a non-empty required set (anti-vacuity)', () => { |
| 95 | + expect(SHARING_RULE_EVALUATION_REQUIRED_KEYS).toHaveLength(6); |
| 96 | + const pinned: [SixCountsStayRequired, GrantsRefusedIsTheOnlyOptionalKey, GrantsRefusedIsANumberWhenPresent] = [true, true, true]; |
| 97 | + expect(pinned).toEqual([true, true, true]); |
| 98 | + }); |
| 99 | + |
| 100 | + it('a six-key result and a seven-key result are both members (compile-time)', () => { |
| 101 | + // An implementation that does not count refusals: the key is ABSENT. |
| 102 | + const silent: SharingRuleEvaluationResult = { |
| 103 | + ruleId: 'rule_1', |
| 104 | + matchedRecords: 3, |
| 105 | + expandedUsers: 2, |
| 106 | + grantsCreated: 2, |
| 107 | + grantsUpdated: 0, |
| 108 | + grantsRevoked: 1, |
| 109 | + }; |
| 110 | + // An implementation that does, and refused nothing this pass: a PRESENT 0. |
| 111 | + const counted: SharingRuleEvaluationResult = { ...silent, grantsRefused: 0 }; |
| 112 | + // …and one that refused two grants and CONTINUED — not a failed pass. |
| 113 | + const refused: SharingRuleEvaluationResult = { ...silent, grantsRefused: 2 }; |
| 114 | + |
| 115 | + // @ts-expect-error `grantsRefused` is a count — a string is not a member (#14969) |
| 116 | + const notACount: SharingRuleEvaluationResult = { ...silent, grantsRefused: 'x' }; |
| 117 | + |
| 118 | + // The runtime shape of the distinction the JSDoc draws: `'grantsRefused' in` |
| 119 | + // separates "does not report" from "reported 0"; a `?? 0` consumer would |
| 120 | + // collapse exactly this and is the reading the contract forbids. |
| 121 | + expect('grantsRefused' in silent).toBe(false); |
| 122 | + expect(silent.grantsRefused).toBeUndefined(); |
| 123 | + expect('grantsRefused' in counted).toBe(true); |
| 124 | + expect(counted.grantsRefused).toBe(0); |
| 125 | + expect(refused.grantsRefused).toBe(2); |
| 126 | + expect(notACount.ruleId).toBe('rule_1'); |
| 127 | + }); |
| 128 | + |
| 129 | + it('the required-narrowing subtype composes with ISharingRuleService (compile-time)', async () => { |
| 130 | + // A subtype that REQUIRES the key is still a member of the contract type… |
| 131 | + const narrowed: RequiresTheCount = { |
| 132 | + ruleId: 'rule_1', |
| 133 | + matchedRecords: 1, |
| 134 | + expandedUsers: 1, |
| 135 | + grantsCreated: 0, |
| 136 | + grantsUpdated: 0, |
| 137 | + grantsRevoked: 0, |
| 138 | + grantsRefused: 1, |
| 139 | + }; |
| 140 | + const widened: SharingRuleEvaluationResult = narrowed; |
| 141 | + |
| 142 | + // …and a service whose `evaluateRule` returns the narrowed type is still an |
| 143 | + // `ISharingRuleService['evaluateRule']` — the covariant return the card |
| 144 | + // names as the reason the key must be optional in the spec. |
| 145 | + const evaluateNarrowed = async (): Promise<RequiresTheCount> => narrowed; |
| 146 | + const evaluateRule: ISharingRuleService['evaluateRule'] = evaluateNarrowed; |
| 147 | + |
| 148 | + // The mirror: a six-key implementation keeps compiling untouched, which is |
| 149 | + // exactly what a REQUIRED key would break (in-tree and out). |
| 150 | + const evaluateSilent: ISharingRuleService['evaluateRule'] = async (idOrName) => ({ |
| 151 | + ruleId: idOrName, |
| 152 | + matchedRecords: 0, |
| 153 | + expandedUsers: 0, |
| 154 | + grantsCreated: 0, |
| 155 | + grantsUpdated: 0, |
| 156 | + grantsRevoked: 0, |
| 157 | + }); |
| 158 | + |
| 159 | + // @ts-expect-error the narrowed subtype cannot OMIT the key it requires (#14969) |
| 160 | + const narrowedWithoutCount: RequiresTheCount = { ...widened, grantsRefused: undefined }; |
| 161 | + |
| 162 | + expect(widened.grantsRefused).toBe(1); |
| 163 | + expect((await evaluateRule('rule_1', { userId: 'usr_1' })).grantsRefused).toBe(1); |
| 164 | + expect((await evaluateSilent('rule_1', { userId: 'usr_1' })).grantsRefused).toBeUndefined(); |
| 165 | + expect(narrowedWithoutCount.ruleId).toBe('rule_1'); |
| 166 | + }); |
| 167 | + |
| 168 | + it('the contract JSDoc states absent-is-not-zero beside the key', () => { |
| 169 | + const source = readFileSync(fileURLToPath(new URL('./sharing-service.ts', import.meta.url)), 'utf8'); |
| 170 | + const declaration = 'grantsRefused?: number;'; |
| 171 | + const at = source.indexOf(declaration); |
| 172 | + expect(at).toBeGreaterThan(-1); |
| 173 | + // Exactly one declaration — a second spelling of the key is drift. |
| 174 | + expect(source.indexOf(declaration, at + 1)).toBe(-1); |
| 175 | + // The doc block immediately above the declaration — from its last `/**`, |
| 176 | + // unwrapped: each continuation line's ` * ` prefix becomes one space, so a |
| 177 | + // sentence the author re-wraps is still read as one sentence. |
| 178 | + const docStart = source.lastIndexOf('/**', at); |
| 179 | + const doc = source.slice(docStart, at).replace(/\s*\n\s*\*\s?/g, ' '); |
| 180 | + // What a refusal IS, in the card's own terms. |
| 181 | + expect(doc).toContain('`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on an organization-less insert'); |
| 182 | + expect(doc).toContain('tenant-scoped `sys_record_share`'); |
| 183 | + // The rule the optionality carries: absent, not 0; unset = not reported. |
| 184 | + expect(doc).toContain('ABSENT — not `0` — from any implementation that does not count refusals'); |
| 185 | + expect(doc).toContain('read "unset" as "this implementation does not report refusals"'); |
| 186 | + expect(doc).toContain('never as "no grant was refused"'); |
| 187 | + // A refused grant is not a failed pass. |
| 188 | + expect(doc).toContain('NOT "the pass failed"'); |
| 189 | + }); |
| 190 | +}); |
0 commit comments