@company-semantics/contracts 47.0.0 → 47.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -60,6 +60,65 @@ describe("resource-keys: orgUnitOpenRoles (per-unit identity)", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("resource-keys: directGrants (org-scoped)", () => {
|
|
64
|
+
const key: ResourceKey = { type: "directGrants", orgId: ORG_ID };
|
|
65
|
+
|
|
66
|
+
it("toQueryKey produces [type, orgId]", () => {
|
|
67
|
+
const qk = toQueryKey(key);
|
|
68
|
+
expect(qk).toEqual(["directGrants", ORG_ID]);
|
|
69
|
+
expect(qk).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("roundtrips through fromQueryKey", () => {
|
|
73
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("matchesResourceKey is org-scoped: rejects a different orgId", () => {
|
|
77
|
+
const other = "22222222-2222-4222-8222-222222222222";
|
|
78
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
79
|
+
expect(matchesResourceKey(["directGrants", other], key)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resource-keys: orgUnitMyAuthority (per-unit identity)", () => {
|
|
84
|
+
const UNIT_ID = "33333333-3333-4333-8333-333333333333";
|
|
85
|
+
const key: ResourceKey = {
|
|
86
|
+
type: "orgUnitMyAuthority",
|
|
87
|
+
orgId: ORG_ID,
|
|
88
|
+
unitId: UNIT_ID,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
it("toQueryKey produces [type, orgId, unitId]", () => {
|
|
92
|
+
const qk = toQueryKey(key);
|
|
93
|
+
expect(qk).toEqual(["orgUnitMyAuthority", ORG_ID, UNIT_ID]);
|
|
94
|
+
expect(qk).toHaveLength(3);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("roundtrips through fromQueryKey", () => {
|
|
98
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("matchesResourceKey is unitId-scoped: rejects a different unit", () => {
|
|
102
|
+
const otherUnit = "44444444-4444-4444-8444-444444444444";
|
|
103
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
104
|
+
expect(
|
|
105
|
+
matchesResourceKey(["orgUnitMyAuthority", ORG_ID, otherUnit], key),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not collide with orgUnitPermissions on the same unit", () => {
|
|
110
|
+
const permissions: ResourceKey = {
|
|
111
|
+
type: "orgUnitPermissions",
|
|
112
|
+
orgId: ORG_ID,
|
|
113
|
+
unitId: UNIT_ID,
|
|
114
|
+
};
|
|
115
|
+
// Same subject, different resource: an invalidation of one must not sweep
|
|
116
|
+
// the other, or the split that motivated registering this key is inert.
|
|
117
|
+
expect(matchesResourceKey(toQueryKey(permissions), key)).toBe(false);
|
|
118
|
+
expect(matchesResourceKey(toQueryKey(key), permissions)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
63
122
|
describe("resource-keys: system-scoped internalAdmin* types", () => {
|
|
64
123
|
const systemTypes = [
|
|
65
124
|
"internalAdminAiProviders",
|
|
@@ -67,6 +126,7 @@ describe("resource-keys: system-scoped internalAdmin* types", () => {
|
|
|
67
126
|
"internalAdminAiRuntimeDefaults",
|
|
68
127
|
"factoryFloor",
|
|
69
128
|
"factorySnapshot",
|
|
129
|
+
"factoryKpis",
|
|
70
130
|
] as const;
|
|
71
131
|
|
|
72
132
|
for (const type of systemTypes) {
|
package/src/comments/README.md
CHANGED
|
@@ -55,8 +55,9 @@ arbitrate — see the first invariant.
|
|
|
55
55
|
- **Mention identity renders from the mention row, never from the body
|
|
56
56
|
characters under the range.** A stale or forged range can only
|
|
57
57
|
mis-**highlight**; it can never misattribute a mention to the wrong person.
|
|
58
|
-
Offsets are nullable
|
|
59
|
-
|
|
58
|
+
Offsets are nullable — treat null as "highlight nothing", not as an error.
|
|
59
|
+
Mentions written since ADR-BE-522 carry a real range; older rows carry null
|
|
60
|
+
permanently, since recovering one would mean searching the body for a name.
|
|
60
61
|
- **These shapes are MIRRORED from the backend route boundary, not authored
|
|
61
62
|
here.** Every bound matches
|
|
62
63
|
`company-semantics-backend/src/api/http/routes/comments/comments.schemas.ts`,
|
|
@@ -147,8 +147,9 @@ describe("CommentSchema and the redaction invariant", () => {
|
|
|
147
147
|
|
|
148
148
|
describe("CommentMentionSchema offsets", () => {
|
|
149
149
|
it("parses a mention whose range is null", () => {
|
|
150
|
-
//
|
|
151
|
-
//
|
|
150
|
+
// The state of every mention written before ADR-BE-522, permanently: the
|
|
151
|
+
// ranges were never stored and cannot be recovered. A client must render
|
|
152
|
+
// these as "highlight nothing", so they must parse.
|
|
152
153
|
expect(
|
|
153
154
|
CommentMentionSchema.safeParse(
|
|
154
155
|
makeMention({ startOffset: null, endOffset: null }),
|
package/src/comments/schemas.ts
CHANGED
|
@@ -75,12 +75,13 @@ export type CommentSubjectType = z.infer<typeof CommentSubjectTypeSchema>;
|
|
|
75
75
|
* RANGE. A stale or forged range can therefore only mis-HIGHLIGHT; it can never
|
|
76
76
|
* misattribute a mention to the wrong person.
|
|
77
77
|
*
|
|
78
|
-
* `startOffset`/`endOffset` are NULLABLE, and null
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
78
|
+
* `startOffset`/`endOffset` are NULLABLE, and null means "this mention is in
|
|
79
|
+
* the body somewhere, highlight nothing" — NEVER an error. Every mention
|
|
80
|
+
* written since ADR-BE-522 carries a real range; rows written before it have
|
|
81
|
+
* none and never will, because the only way to invent one is to search the body
|
|
82
|
+
* for a display name, which is the body-as-identity inversion this contract
|
|
83
|
+
* exists to prevent. A client therefore has to render both, and the two coexist
|
|
84
|
+
* with no version check on either side.
|
|
84
85
|
*
|
|
85
86
|
* `displayName` is resolved AT READ TIME from the current user record and is
|
|
86
87
|
* never stored on the mention row: a rename re-renders with no backfill, and an
|
package/src/resource-keys.ts
CHANGED
|
@@ -58,6 +58,9 @@ export type ResourceKey =
|
|
|
58
58
|
| { type: "deletionEligibility"; orgId: string }
|
|
59
59
|
| { type: "transferOwnership"; orgId: string }
|
|
60
60
|
| { type: "companyMdDocs"; orgId: string }
|
|
61
|
+
// Directly-granted (non-inherited) ACL rows for the org, read by the admin
|
|
62
|
+
// grants surface (ADR-CONTRACTS-119).
|
|
63
|
+
| { type: "directGrants"; orgId: string }
|
|
61
64
|
// OrgUnit canonical model (ADR-BE-120) — Phase 2 Wave 4
|
|
62
65
|
| { type: "orgTree"; orgId: string }
|
|
63
66
|
| { type: "orgLevelConfig"; orgId: string }
|
|
@@ -74,6 +77,12 @@ export type ResourceKey =
|
|
|
74
77
|
// Org-unit owners list (ADR-CONTRACTS-052) — owners are an org-wide
|
|
75
78
|
// projection, not a per-unit collection, so unitId is intentionally excluded.
|
|
76
79
|
| { type: "orgUnitOwners"; orgId: string }
|
|
80
|
+
// THIS VIEWER'S authority over one unit (ADR-CONTRACTS-119) — what the viewer
|
|
81
|
+
// may do here, not what the unit's permission rows say. Deliberately distinct
|
|
82
|
+
// from `orgUnitPermissions`: same subject, different resource and different
|
|
83
|
+
// invalidation trigger (a membership change alters the viewer's authority
|
|
84
|
+
// without touching the unit's rows), the `actionItems`/`feed` split above.
|
|
85
|
+
| { type: "orgUnitMyAuthority"; orgId: string; unitId: string }
|
|
77
86
|
// People reporting (ADR-BE-166) — drives the settings Org chart drill-down
|
|
78
87
|
| { type: "peopleOrgChart"; orgId: string }
|
|
79
88
|
// System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources.
|
|
@@ -85,6 +94,11 @@ export type ResourceKey =
|
|
|
85
94
|
// dashboard reads over Global-infra factory tables; tenant-less, scope 'system'.
|
|
86
95
|
| { type: "factoryFloor"; scope: "system" }
|
|
87
96
|
| { type: "factorySnapshot"; scope: "system" }
|
|
97
|
+
// The KPI rollup over the same factory tables (ADR-CONTRACTS-119). Registered
|
|
98
|
+
// late: the app shipped it as a double-cast key literal, and because
|
|
99
|
+
// `useResource` disables a query whose `toQueryKey` throws, the panel it backs
|
|
100
|
+
// never fetched at all. An unregistered key is not a stale read, it is no read.
|
|
101
|
+
| { type: "factoryKpis"; scope: "system" }
|
|
88
102
|
// Everything awaiting the viewer's decision, across every domain
|
|
89
103
|
// (ADR-CONT-104). ORG-scoped, not user-scoped, even though the content is
|
|
90
104
|
// per-viewer: the bucket differs per org, so a user-keyed entry would serve
|
|
@@ -128,7 +142,101 @@ export function resolveScope(context: {
|
|
|
128
142
|
return { orgId: context.impersonatedOrgId ?? context.orgId };
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
|
|
145
|
+
/*
|
|
146
|
+
* ---------------------------------------------------------------------------
|
|
147
|
+
* fromQueryKey's routing tables, and the drift detector over them.
|
|
148
|
+
* ---------------------------------------------------------------------------
|
|
149
|
+
*
|
|
150
|
+
* `toQueryKey` has always been safe: its switch is exhaustive and its `never`
|
|
151
|
+
* guard makes a new union member a compile error. `fromQueryKey` was not. Its
|
|
152
|
+
* five lookup tables are hand-maintained, and a member missing from all of them
|
|
153
|
+
* fell through to a RUNTIME `throw` — which `matchesResourceKey` catches and
|
|
154
|
+
* turns into "matches nothing", and which `useResource` turns into a query that
|
|
155
|
+
* never fetches. Silent in both directions (ADR-CONTRACTS-119).
|
|
156
|
+
*
|
|
157
|
+
* Two mechanisms, and BOTH are needed:
|
|
158
|
+
*
|
|
159
|
+
* `satisfies` on each table catches a WRONG entry — a typo, or a literal
|
|
160
|
+
* parked under the wrong scope. It cannot catch an entry that is simply
|
|
161
|
+
* absent, because an array is free to be a subset of its element type.
|
|
162
|
+
*
|
|
163
|
+
* `_EveryResourceKeyIsRouted` below catches the ABSENT entry, which is the
|
|
164
|
+
* failure that actually shipped. It is the half that closes the hole.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The identity segments a member carries — every field that is neither the type
|
|
169
|
+
* tag nor a scope discriminator. `member` → `"memberId"`; `commentThreads` →
|
|
170
|
+
* `"subjectType" | "subjectId"`; a scope-only key → `never`.
|
|
171
|
+
*/
|
|
172
|
+
type IdentityFieldsOf<T extends ResourceKey["type"]> = Exclude<
|
|
173
|
+
keyof Extract<ResourceKey, { type: T }>,
|
|
174
|
+
"type" | "orgId" | "userId" | "scope"
|
|
175
|
+
>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Type literals whose whole shape is the tag plus one scope discriminator —
|
|
179
|
+
* i.e. the members that belong in a scope ARRAY rather than an identity MAP.
|
|
180
|
+
* Distributes over the union (`K` is a naked type parameter).
|
|
181
|
+
*/
|
|
182
|
+
type ScopeOnlyType<K = ResourceKey> = K extends {
|
|
183
|
+
type: infer T extends ResourceKey["type"];
|
|
184
|
+
}
|
|
185
|
+
? [IdentityFieldsOf<T>] extends [never]
|
|
186
|
+
? T
|
|
187
|
+
: never
|
|
188
|
+
: never;
|
|
189
|
+
|
|
190
|
+
type OrgScopedType = Extract<
|
|
191
|
+
ScopeOnlyType,
|
|
192
|
+
Extract<ResourceKey, { orgId: string }>["type"]
|
|
193
|
+
>;
|
|
194
|
+
type UserScopedType = Extract<
|
|
195
|
+
ScopeOnlyType,
|
|
196
|
+
Extract<ResourceKey, { userId: string }>["type"]
|
|
197
|
+
>;
|
|
198
|
+
type SystemScopedType = Extract<
|
|
199
|
+
ScopeOnlyType,
|
|
200
|
+
Extract<ResourceKey, { scope: "system" }>["type"]
|
|
201
|
+
>;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Identities with a single extra segment. The `satisfies` checks the FIELD NAME
|
|
205
|
+
* against that member's own keys, so renaming `memberId` in the union — or
|
|
206
|
+
* pointing a key at a field it does not have — fails to compile here.
|
|
207
|
+
*/
|
|
208
|
+
const IDENTITY_FIELDS = {
|
|
209
|
+
member: "memberId",
|
|
210
|
+
team: "teamId",
|
|
211
|
+
department: "departmentId",
|
|
212
|
+
chat: "chatId",
|
|
213
|
+
companyMdDoc: "slug",
|
|
214
|
+
companyMdContextBank: "slug",
|
|
215
|
+
companyMdAccessRequests: "docId",
|
|
216
|
+
orgUnit: "unitId",
|
|
217
|
+
orgUnitChildren: "unitId",
|
|
218
|
+
orgUnitAncestors: "unitId",
|
|
219
|
+
orgUnitMemberships: "unitId",
|
|
220
|
+
orgUnitPermissions: "unitId",
|
|
221
|
+
orgUnitOpenRoles: "unitId",
|
|
222
|
+
orgUnitMyAuthority: "unitId",
|
|
223
|
+
} as const satisfies { [T in ResourceKey["type"]]?: IdentityFieldsOf<T> };
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Identities with a COMPOSITE (multi-segment) identity. Checked before
|
|
227
|
+
* {@link IDENTITY_FIELDS}, which hard-asserts a two-element `rest` and would
|
|
228
|
+
* otherwise reject these.
|
|
229
|
+
*
|
|
230
|
+
* A map of its own rather than widening `IDENTITY_FIELDS` to `string | string[]`:
|
|
231
|
+
* the single-segment case is every other key in the union, and making it pay for
|
|
232
|
+
* this one would put a branch in the hot path for no reader's benefit.
|
|
233
|
+
*/
|
|
234
|
+
const COMPOSITE_IDENTITY_FIELDS = {
|
|
235
|
+
commentThreads: ["subjectType", "subjectId"],
|
|
236
|
+
} as const satisfies {
|
|
237
|
+
[T in ResourceKey["type"]]?: readonly IdentityFieldsOf<T>[];
|
|
238
|
+
};
|
|
239
|
+
|
|
132
240
|
const ORG_SCOPED_TYPES = [
|
|
133
241
|
"members",
|
|
134
242
|
"departments",
|
|
@@ -146,13 +254,14 @@ const ORG_SCOPED_TYPES = [
|
|
|
146
254
|
"deletionEligibility",
|
|
147
255
|
"transferOwnership",
|
|
148
256
|
"companyMdDocs",
|
|
257
|
+
"directGrants",
|
|
149
258
|
"orgTree",
|
|
150
259
|
"orgLevelConfig",
|
|
151
260
|
"peopleOrgChart",
|
|
152
261
|
"orgUnitOwners",
|
|
153
262
|
"actionItems",
|
|
154
263
|
"feed",
|
|
155
|
-
] as const;
|
|
264
|
+
] as const satisfies readonly OrgScopedType[];
|
|
156
265
|
|
|
157
266
|
const USER_SCOPED_TYPES = [
|
|
158
267
|
"dismissedBanners",
|
|
@@ -160,7 +269,7 @@ const USER_SCOPED_TYPES = [
|
|
|
160
269
|
"sessions",
|
|
161
270
|
"userMd",
|
|
162
271
|
"viewer",
|
|
163
|
-
] as const;
|
|
272
|
+
] as const satisfies readonly UserScopedType[];
|
|
164
273
|
|
|
165
274
|
/**
|
|
166
275
|
* System-scoped types (ADR-CONTRACTS-052) — tenant-less super-admin resources.
|
|
@@ -173,7 +282,34 @@ const SYSTEM_SCOPED_TYPES = [
|
|
|
173
282
|
"internalAdminAiRuntimeDefaults",
|
|
174
283
|
"factoryFloor",
|
|
175
284
|
"factorySnapshot",
|
|
176
|
-
|
|
285
|
+
"factoryKpis",
|
|
286
|
+
] as const satisfies readonly SystemScopedType[];
|
|
287
|
+
|
|
288
|
+
/** Every literal `fromQueryKey` can route, across all five tables. */
|
|
289
|
+
type RoutedType =
|
|
290
|
+
| keyof typeof IDENTITY_FIELDS
|
|
291
|
+
| keyof typeof COMPOSITE_IDENTITY_FIELDS
|
|
292
|
+
| (typeof ORG_SCOPED_TYPES)[number]
|
|
293
|
+
| (typeof USER_SCOPED_TYPES)[number]
|
|
294
|
+
| (typeof SYSTEM_SCOPED_TYPES)[number];
|
|
295
|
+
|
|
296
|
+
type Assert<T extends true> = T;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* THE DRIFT DETECTOR. Register a union member and forget its routing table and
|
|
300
|
+
* this fails to compile — instead of shipping a key that parses nowhere,
|
|
301
|
+
* matches nothing, and disables the query that reads it.
|
|
302
|
+
*
|
|
303
|
+
* The false branch resolves to the UNROUTED LITERALS rather than to `false`, so
|
|
304
|
+
* the diagnostic names the culprit:
|
|
305
|
+
* `Type '"directGrants"' does not satisfy the constraint 'true'`.
|
|
306
|
+
* Deliberately unexported: this is an assertion, not vocabulary.
|
|
307
|
+
*/
|
|
308
|
+
type _EveryResourceKeyIsRouted = Assert<
|
|
309
|
+
[Exclude<ResourceKey["type"], RoutedType>] extends [never]
|
|
310
|
+
? true
|
|
311
|
+
: Exclude<ResourceKey["type"], RoutedType>
|
|
312
|
+
>;
|
|
177
313
|
|
|
178
314
|
/**
|
|
179
315
|
* Canonical ResourceKey → query key conversion.
|
|
@@ -214,6 +350,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
214
350
|
case "orgUnitMemberships":
|
|
215
351
|
case "orgUnitPermissions":
|
|
216
352
|
case "orgUnitOpenRoles":
|
|
353
|
+
case "orgUnitMyAuthority":
|
|
217
354
|
return [key.type, key.orgId, key.unitId] as const;
|
|
218
355
|
|
|
219
356
|
// User-scoped
|
|
@@ -241,6 +378,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
241
378
|
case "deletionEligibility":
|
|
242
379
|
case "transferOwnership":
|
|
243
380
|
case "companyMdDocs":
|
|
381
|
+
case "directGrants":
|
|
244
382
|
case "orgTree":
|
|
245
383
|
case "orgLevelConfig":
|
|
246
384
|
case "peopleOrgChart":
|
|
@@ -255,6 +393,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
255
393
|
case "internalAdminAiRuntimeDefaults":
|
|
256
394
|
case "factoryFloor":
|
|
257
395
|
case "factorySnapshot":
|
|
396
|
+
case "factoryKpis":
|
|
258
397
|
return [key.type, key.scope] as const;
|
|
259
398
|
|
|
260
399
|
default: {
|
|
@@ -277,19 +416,9 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
277
416
|
);
|
|
278
417
|
}
|
|
279
418
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
// A map of its own rather than a widening of `identityFields` to a
|
|
285
|
-
// `string | string[]`: the single-segment case is every other key in the
|
|
286
|
-
// union, and making it pay for this one would put a branch in the hot path
|
|
287
|
-
// for no reader's benefit.
|
|
288
|
-
const compositeIdentityFields: Record<string, readonly string[]> = {
|
|
289
|
-
commentThreads: ["subjectType", "subjectId"],
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const compositeFields = compositeIdentityFields[type];
|
|
419
|
+
const compositeFields = (
|
|
420
|
+
COMPOSITE_IDENTITY_FIELDS as Record<string, readonly string[] | undefined>
|
|
421
|
+
)[type];
|
|
293
422
|
if (compositeFields) {
|
|
294
423
|
// +1 for the orgId that leads every composite key.
|
|
295
424
|
if (rest.length !== compositeFields.length + 1) {
|
|
@@ -304,33 +433,21 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
304
433
|
return key as ResourceKey;
|
|
305
434
|
}
|
|
306
435
|
|
|
307
|
-
// Identities with extra
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
companyMdDoc: "slug",
|
|
314
|
-
companyMdContextBank: "slug",
|
|
315
|
-
companyMdAccessRequests: "docId",
|
|
316
|
-
orgUnit: "unitId",
|
|
317
|
-
orgUnitChildren: "unitId",
|
|
318
|
-
orgUnitAncestors: "unitId",
|
|
319
|
-
orgUnitMemberships: "unitId",
|
|
320
|
-
orgUnitPermissions: "unitId",
|
|
321
|
-
orgUnitOpenRoles: "unitId",
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
if (type in identityFields) {
|
|
436
|
+
// Identities with a single extra segment.
|
|
437
|
+
const identityField = (IDENTITY_FIELDS as Record<string, string | undefined>)[
|
|
438
|
+
type
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
if (identityField !== undefined) {
|
|
325
442
|
if (rest.length !== 2) {
|
|
326
443
|
throw new Error(
|
|
327
|
-
`Invalid query key for '${type}': expected [type, orgId, ${
|
|
444
|
+
`Invalid query key for '${type}': expected [type, orgId, ${identityField}], got ${JSON.stringify(queryKey)}`,
|
|
328
445
|
);
|
|
329
446
|
}
|
|
330
447
|
return {
|
|
331
448
|
type,
|
|
332
449
|
orgId: rest[0],
|
|
333
|
-
[
|
|
450
|
+
[identityField]: rest[1],
|
|
334
451
|
} as ResourceKey;
|
|
335
452
|
}
|
|
336
453
|
|
|
@@ -374,76 +491,26 @@ export function matchesResourceKey(
|
|
|
374
491
|
|
|
375
492
|
if (parsed.type !== targetKey.type) return false;
|
|
376
493
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
"teamId" in targetKey &&
|
|
398
|
-
parsed.teamId !== targetKey.teamId
|
|
399
|
-
)
|
|
400
|
-
return false;
|
|
401
|
-
if (
|
|
402
|
-
"departmentId" in parsed &&
|
|
403
|
-
"departmentId" in targetKey &&
|
|
404
|
-
parsed.departmentId !== targetKey.departmentId
|
|
405
|
-
)
|
|
406
|
-
return false;
|
|
407
|
-
if (
|
|
408
|
-
"chatId" in parsed &&
|
|
409
|
-
"chatId" in targetKey &&
|
|
410
|
-
parsed.chatId !== targetKey.chatId
|
|
411
|
-
)
|
|
412
|
-
return false;
|
|
413
|
-
if ("slug" in parsed && "slug" in targetKey && parsed.slug !== targetKey.slug)
|
|
414
|
-
return false;
|
|
415
|
-
if (
|
|
416
|
-
"docId" in parsed &&
|
|
417
|
-
"docId" in targetKey &&
|
|
418
|
-
parsed.docId !== targetKey.docId
|
|
419
|
-
)
|
|
420
|
-
return false;
|
|
421
|
-
if (
|
|
422
|
-
"unitId" in parsed &&
|
|
423
|
-
"unitId" in targetKey &&
|
|
424
|
-
parsed.unitId !== targetKey.unitId
|
|
425
|
-
)
|
|
426
|
-
return false;
|
|
427
|
-
// Both halves of a composite identity, or a comment thread list on one
|
|
428
|
-
// subject would be invalidated by every other subject in the org.
|
|
429
|
-
if (
|
|
430
|
-
"subjectType" in parsed &&
|
|
431
|
-
"subjectType" in targetKey &&
|
|
432
|
-
parsed.subjectType !== targetKey.subjectType
|
|
433
|
-
)
|
|
434
|
-
return false;
|
|
435
|
-
if (
|
|
436
|
-
"subjectId" in parsed &&
|
|
437
|
-
"subjectId" in targetKey &&
|
|
438
|
-
parsed.subjectId !== targetKey.subjectId
|
|
439
|
-
)
|
|
440
|
-
return false;
|
|
441
|
-
if (
|
|
442
|
-
"scope" in parsed &&
|
|
443
|
-
"scope" in targetKey &&
|
|
444
|
-
parsed.scope !== targetKey.scope
|
|
445
|
-
)
|
|
446
|
-
return false;
|
|
494
|
+
// Field-agnostic on purpose. This was an eleven-branch ladder naming every
|
|
495
|
+
// scope and identity field by hand — a second, silent mirror of the union's
|
|
496
|
+
// shape. A member whose identity segment the ladder forgot still matched, so
|
|
497
|
+
// one subject's invalidation flushed every other subject's cache; that is the
|
|
498
|
+
// ADR-CONTRACTS-116 `subjectType` defect, and enumerating fields is what made
|
|
499
|
+
// it possible. Comparing whatever the target actually carries covers a new
|
|
500
|
+
// identity field by construction (ADR-CONTRACTS-119).
|
|
501
|
+
//
|
|
502
|
+
// Same-type comparison only — `parsed.type === targetKey.type` above — so the
|
|
503
|
+
// two sides are the same union member and a shared field name means the same
|
|
504
|
+
// thing on both. A field present on one side and absent on the other still
|
|
505
|
+
// matches, which is what lets a slug-less `{type:'companyMdDocs', orgId}`
|
|
506
|
+
// sweep its whole type org-wide, exactly as before.
|
|
507
|
+
const target = targetKey as Record<string, unknown>;
|
|
508
|
+
const candidate = parsed as Record<string, unknown>;
|
|
509
|
+
for (const field of Object.keys(target)) {
|
|
510
|
+
if (field === "type") continue;
|
|
511
|
+
const other = candidate[field];
|
|
512
|
+
if (other !== undefined && other !== target[field]) return false;
|
|
513
|
+
}
|
|
447
514
|
|
|
448
515
|
return true;
|
|
449
516
|
}
|