@company-semantics/contracts 47.0.0 → 47.1.1
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,40 @@ 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
|
+
*
|
|
307
|
+
* EXPORTED, though nothing consumes it. This package ships `src`, so every
|
|
308
|
+
* consumer typechecks this file under ITS OWN compiler options — and the
|
|
309
|
+
* backend sets `noUnusedLocals`, which rejects an unexported type alias that
|
|
310
|
+
* nothing references. Keeping it private broke `tsc` in a consumer while
|
|
311
|
+
* passing here, so the export is what makes the assertion portable, not a
|
|
312
|
+
* widening of the public vocabulary. Do not "tidy" it away.
|
|
313
|
+
*/
|
|
314
|
+
export type RoutingExhaustivenessWitness = Assert<
|
|
315
|
+
[Exclude<ResourceKey["type"], RoutedType>] extends [never]
|
|
316
|
+
? true
|
|
317
|
+
: Exclude<ResourceKey["type"], RoutedType>
|
|
318
|
+
>;
|
|
177
319
|
|
|
178
320
|
/**
|
|
179
321
|
* Canonical ResourceKey → query key conversion.
|
|
@@ -214,6 +356,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
214
356
|
case "orgUnitMemberships":
|
|
215
357
|
case "orgUnitPermissions":
|
|
216
358
|
case "orgUnitOpenRoles":
|
|
359
|
+
case "orgUnitMyAuthority":
|
|
217
360
|
return [key.type, key.orgId, key.unitId] as const;
|
|
218
361
|
|
|
219
362
|
// User-scoped
|
|
@@ -241,6 +384,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
241
384
|
case "deletionEligibility":
|
|
242
385
|
case "transferOwnership":
|
|
243
386
|
case "companyMdDocs":
|
|
387
|
+
case "directGrants":
|
|
244
388
|
case "orgTree":
|
|
245
389
|
case "orgLevelConfig":
|
|
246
390
|
case "peopleOrgChart":
|
|
@@ -255,6 +399,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
255
399
|
case "internalAdminAiRuntimeDefaults":
|
|
256
400
|
case "factoryFloor":
|
|
257
401
|
case "factorySnapshot":
|
|
402
|
+
case "factoryKpis":
|
|
258
403
|
return [key.type, key.scope] as const;
|
|
259
404
|
|
|
260
405
|
default: {
|
|
@@ -277,19 +422,9 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
277
422
|
);
|
|
278
423
|
}
|
|
279
424
|
|
|
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];
|
|
425
|
+
const compositeFields = (
|
|
426
|
+
COMPOSITE_IDENTITY_FIELDS as Record<string, readonly string[] | undefined>
|
|
427
|
+
)[type];
|
|
293
428
|
if (compositeFields) {
|
|
294
429
|
// +1 for the orgId that leads every composite key.
|
|
295
430
|
if (rest.length !== compositeFields.length + 1) {
|
|
@@ -304,33 +439,21 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
304
439
|
return key as ResourceKey;
|
|
305
440
|
}
|
|
306
441
|
|
|
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) {
|
|
442
|
+
// Identities with a single extra segment.
|
|
443
|
+
const identityField = (IDENTITY_FIELDS as Record<string, string | undefined>)[
|
|
444
|
+
type
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
if (identityField !== undefined) {
|
|
325
448
|
if (rest.length !== 2) {
|
|
326
449
|
throw new Error(
|
|
327
|
-
`Invalid query key for '${type}': expected [type, orgId, ${
|
|
450
|
+
`Invalid query key for '${type}': expected [type, orgId, ${identityField}], got ${JSON.stringify(queryKey)}`,
|
|
328
451
|
);
|
|
329
452
|
}
|
|
330
453
|
return {
|
|
331
454
|
type,
|
|
332
455
|
orgId: rest[0],
|
|
333
|
-
[
|
|
456
|
+
[identityField]: rest[1],
|
|
334
457
|
} as ResourceKey;
|
|
335
458
|
}
|
|
336
459
|
|
|
@@ -374,76 +497,26 @@ export function matchesResourceKey(
|
|
|
374
497
|
|
|
375
498
|
if (parsed.type !== targetKey.type) return false;
|
|
376
499
|
|
|
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;
|
|
500
|
+
// Field-agnostic on purpose. This was an eleven-branch ladder naming every
|
|
501
|
+
// scope and identity field by hand — a second, silent mirror of the union's
|
|
502
|
+
// shape. A member whose identity segment the ladder forgot still matched, so
|
|
503
|
+
// one subject's invalidation flushed every other subject's cache; that is the
|
|
504
|
+
// ADR-CONTRACTS-116 `subjectType` defect, and enumerating fields is what made
|
|
505
|
+
// it possible. Comparing whatever the target actually carries covers a new
|
|
506
|
+
// identity field by construction (ADR-CONTRACTS-119).
|
|
507
|
+
//
|
|
508
|
+
// Same-type comparison only — `parsed.type === targetKey.type` above — so the
|
|
509
|
+
// two sides are the same union member and a shared field name means the same
|
|
510
|
+
// thing on both. A field present on one side and absent on the other still
|
|
511
|
+
// matches, which is what lets a slug-less `{type:'companyMdDocs', orgId}`
|
|
512
|
+
// sweep its whole type org-wide, exactly as before.
|
|
513
|
+
const target = targetKey as Record<string, unknown>;
|
|
514
|
+
const candidate = parsed as Record<string, unknown>;
|
|
515
|
+
for (const field of Object.keys(target)) {
|
|
516
|
+
if (field === "type") continue;
|
|
517
|
+
const other = candidate[field];
|
|
518
|
+
if (other !== undefined && other !== target[field]) return false;
|
|
519
|
+
}
|
|
447
520
|
|
|
448
521
|
return true;
|
|
449
522
|
}
|