@sanity/sdk 2.10.0 → 2.11.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.
Files changed (60) hide show
  1. package/dist/_chunks-dts/utils.d.ts +200 -28
  2. package/dist/_chunks-es/_internal.js +3 -14
  3. package/dist/_chunks-es/_internal.js.map +1 -1
  4. package/dist/_chunks-es/createGroqSearchFilter.js +17 -19
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  6. package/dist/_chunks-es/version.js +1 -1
  7. package/dist/_exports/_internal.d.ts +16 -2
  8. package/dist/_exports/_internal.js +3 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +564 -459
  11. package/dist/index.js.map +1 -1
  12. package/package.json +16 -18
  13. package/src/_exports/_internal.ts +1 -0
  14. package/src/_exports/index.ts +25 -2
  15. package/src/agent/agentActions.ts +21 -25
  16. package/src/auth/refreshStampedToken.test.ts +2 -2
  17. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +116 -0
  18. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +27 -9
  19. package/src/client/clientStore.test.ts +10 -46
  20. package/src/client/clientStore.ts +7 -14
  21. package/src/comlink/node/actions/getOrCreateNode.test.ts +5 -2
  22. package/src/comlink/node/actions/releaseNode.test.ts +3 -3
  23. package/src/config/sanityConfig.ts +0 -1
  24. package/src/document/documentStore.ts +3 -8
  25. package/src/document/permissions.ts +1 -1
  26. package/src/document/processActions/create.ts +135 -0
  27. package/src/document/processActions/delete.ts +100 -0
  28. package/src/document/processActions/discard.ts +63 -0
  29. package/src/document/processActions/edit.ts +176 -0
  30. package/src/document/processActions/processActions.ts +168 -0
  31. package/src/document/processActions/publish.ts +120 -0
  32. package/src/document/processActions/shared.ts +47 -0
  33. package/src/document/processActions/unpublish.ts +85 -0
  34. package/src/document/processActions.test.ts +1 -1
  35. package/src/document/reducers.ts +1 -1
  36. package/src/document/sharedListener.ts +3 -5
  37. package/src/organization/organization.test-d.ts +102 -0
  38. package/src/organization/organization.test.ts +138 -0
  39. package/src/organization/organization.ts +166 -0
  40. package/src/organizations/organizations.test-d.ts +77 -0
  41. package/src/organizations/organizations.test.ts +150 -0
  42. package/src/organizations/organizations.ts +132 -0
  43. package/src/presence/presenceStore.test.ts +5 -5
  44. package/src/preview/previewProjectionUtils.ts +2 -3
  45. package/src/project/project.test-d.ts +93 -0
  46. package/src/project/project.test.ts +108 -10
  47. package/src/project/project.ts +152 -26
  48. package/src/projection/subscribeToStateAndFetchBatches.ts +4 -9
  49. package/src/projects/projects.test-d.ts +38 -0
  50. package/src/projects/projects.test.ts +104 -38
  51. package/src/projects/projects.ts +74 -14
  52. package/src/query/queryStore.ts +2 -3
  53. package/src/releases/releasesStore.test.ts +1 -1
  54. package/src/releases/releasesStore.ts +2 -2
  55. package/src/store/createSanityInstance.ts +3 -3
  56. package/src/telemetry/devMode.test.ts +8 -0
  57. package/src/telemetry/devMode.ts +10 -9
  58. package/src/telemetry/initTelemetry.test.ts +0 -17
  59. package/src/telemetry/initTelemetry.ts +2 -12
  60. package/src/document/processActions.ts +0 -735
@@ -0,0 +1,120 @@
1
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
2
+ import {type Mutation, type Reference, type SanityDocument} from '@sanity/types'
3
+
4
+ import {isReleasePerspective} from '../../releases/utils/isReleasePerspective'
5
+ import {isDeepEqual} from '../../utils/object'
6
+ import {type PublishDocumentAction} from '../actions'
7
+ import {getId, processMutations} from '../processMutations'
8
+ import {
9
+ ActionError,
10
+ type ActionHandlerContext,
11
+ type ActionHandlerResult,
12
+ checkGrant,
13
+ PermissionActionError,
14
+ } from './shared'
15
+
16
+ export function handlePublish(
17
+ action: PublishDocumentAction,
18
+ ctx: ActionHandlerContext,
19
+ ): ActionHandlerResult {
20
+ const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
21
+ let {base, working} = ctx
22
+
23
+ const documentId = getId(action.documentId)
24
+
25
+ if (action.liveEdit || isReleasePerspective(action.perspective)) {
26
+ throw new ActionError({
27
+ documentId,
28
+ transactionId,
29
+ message: `Cannot publish this document. Publishing is not supported for liveEdit or version (release) documents.`,
30
+ })
31
+ }
32
+
33
+ // Standard draft/published logic
34
+ const draftId = getDraftId(DocumentId(documentId))
35
+ const publishedId = getPublishedId(DocumentId(documentId))
36
+
37
+ const workingDraft = working[draftId]
38
+ const baseDraft = base[draftId]
39
+ if (!workingDraft || !baseDraft) {
40
+ throw new ActionError({
41
+ documentId,
42
+ transactionId,
43
+ message: `Cannot publish because no draft version was found for document "${documentId}".`,
44
+ })
45
+ }
46
+
47
+ // Before proceeding, verify that the working draft is identical to the base draft.
48
+ // TODO: is it enough just to check for the _rev or nah?
49
+ if (!isDeepEqual(workingDraft, baseDraft)) {
50
+ throw new ActionError({
51
+ documentId,
52
+ transactionId,
53
+ message: `Publish aborted: The document has changed elsewhere. Please try again.`,
54
+ })
55
+ }
56
+
57
+ const newPublishedFromDraft = {...strengthenOnPublish(workingDraft), _id: publishedId}
58
+
59
+ const mutations: Mutation[] = [{delete: {id: draftId}}, {createOrReplace: newPublishedFromDraft}]
60
+
61
+ if (working[draftId] && !checkGrant(grants.update, working[draftId])) {
62
+ throw new PermissionActionError({
63
+ documentId,
64
+ transactionId,
65
+ message: `Publish failed: You do not have permission to update the draft for "${documentId}".`,
66
+ })
67
+ }
68
+
69
+ if (working[publishedId] && !checkGrant(grants.update, newPublishedFromDraft)) {
70
+ throw new PermissionActionError({
71
+ documentId,
72
+ transactionId,
73
+ message: `Publish failed: You do not have permission to update the published version of "${documentId}".`,
74
+ })
75
+ } else if (!working[publishedId] && !checkGrant(grants.create, newPublishedFromDraft)) {
76
+ throw new PermissionActionError({
77
+ documentId,
78
+ transactionId,
79
+ message: `Publish failed: You do not have permission to publish a new version of "${documentId}".`,
80
+ })
81
+ }
82
+
83
+ base = processMutations({documents: base, transactionId, mutations, timestamp})
84
+ working = processMutations({documents: working, transactionId, mutations, timestamp})
85
+
86
+ outgoingMutations.push(...mutations)
87
+ outgoingActions.push({
88
+ actionType: 'sanity.action.document.publish',
89
+ draftId,
90
+ publishedId,
91
+ })
92
+ return {base, working}
93
+ }
94
+
95
+ function strengthenOnPublish(draft: SanityDocument): SanityDocument {
96
+ const isStrengthenReference = (
97
+ value: object,
98
+ ): value is Reference & Required<Pick<Reference, '_strengthenOnPublish'>> =>
99
+ '_strengthenOnPublish' in value
100
+
101
+ function strengthen(value: unknown): unknown {
102
+ if (typeof value !== 'object' || !value) return value
103
+
104
+ if (isStrengthenReference(value)) {
105
+ const {_strengthenOnPublish, _weak, ...rest} = value
106
+ return {
107
+ ...rest,
108
+ ...(_strengthenOnPublish.weak && {_weak: true}),
109
+ }
110
+ }
111
+
112
+ if (Array.isArray(value)) {
113
+ return value.map(strengthen)
114
+ }
115
+
116
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, strengthen(v)]))
117
+ }
118
+
119
+ return strengthen(draft) as SanityDocument
120
+ }
@@ -0,0 +1,47 @@
1
+ import {type Mutation, type SanityDocument} from '@sanity/types'
2
+ import {evaluateSync, type ExprNode} from 'groq-js'
3
+
4
+ import {type Grant} from '../permissions'
5
+ import {type DocumentSet} from '../processMutations'
6
+ import {type HttpAction} from '../reducers'
7
+
8
+ export interface ActionHandlerContext {
9
+ base: DocumentSet
10
+ working: DocumentSet
11
+ transactionId: string
12
+ timestamp: string
13
+ grants: Record<Grant, ExprNode>
14
+ outgoingActions: HttpAction[]
15
+ outgoingMutations: Mutation[]
16
+ }
17
+
18
+ export interface ActionHandlerResult {
19
+ base: DocumentSet
20
+ working: DocumentSet
21
+ }
22
+
23
+ export function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean {
24
+ const value = evaluateSync(grantExpr, {params: {document}})
25
+ return value.type === 'boolean' && value.data
26
+ }
27
+
28
+ interface ActionErrorOptions {
29
+ message: string
30
+ documentId: string
31
+ transactionId: string
32
+ }
33
+
34
+ /**
35
+ * Thrown when a precondition for an action failed.
36
+ */
37
+ export class ActionError extends Error implements ActionErrorOptions {
38
+ documentId!: string
39
+ transactionId!: string
40
+
41
+ constructor(options: ActionErrorOptions) {
42
+ super(options.message)
43
+ Object.assign(this, options)
44
+ }
45
+ }
46
+
47
+ export class PermissionActionError extends ActionError {}
@@ -0,0 +1,85 @@
1
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
2
+ import {type Mutation, type SanityDocument} from '@sanity/types'
3
+
4
+ import {isReleasePerspective} from '../../releases/utils/isReleasePerspective'
5
+ import {type UnpublishDocumentAction} from '../actions'
6
+ import {getId, processMutations} from '../processMutations'
7
+ import {
8
+ ActionError,
9
+ type ActionHandlerContext,
10
+ type ActionHandlerResult,
11
+ checkGrant,
12
+ PermissionActionError,
13
+ } from './shared'
14
+
15
+ export function handleUnpublish(
16
+ action: UnpublishDocumentAction,
17
+ ctx: ActionHandlerContext,
18
+ ): ActionHandlerResult {
19
+ const {transactionId, timestamp, grants, outgoingActions, outgoingMutations} = ctx
20
+ let {base, working} = ctx
21
+
22
+ const documentId = getId(action.documentId)
23
+
24
+ if (action.liveEdit || isReleasePerspective(action.perspective)) {
25
+ throw new ActionError({
26
+ documentId,
27
+ transactionId,
28
+ message: `Cannot unpublish this document. Unpublishing is not supported for liveEdit or version (release) documents.`,
29
+ })
30
+ }
31
+
32
+ // Standard draft/published or version logic
33
+ const draftId = getDraftId(DocumentId(documentId))
34
+ const publishedId = getPublishedId(DocumentId(documentId))
35
+
36
+ if (!working[publishedId] && !base[publishedId]) {
37
+ throw new ActionError({
38
+ documentId,
39
+ transactionId,
40
+ message: `Cannot unpublish because the document "${documentId}" is not currently published.`,
41
+ })
42
+ }
43
+
44
+ const sourceDoc = working[publishedId] ?? (base[publishedId] as SanityDocument)
45
+ const newDraftFromPublished = {...sourceDoc, _id: draftId}
46
+ const mutations: Mutation[] = [
47
+ {delete: {id: publishedId}},
48
+ {createIfNotExists: newDraftFromPublished},
49
+ ]
50
+
51
+ if (!checkGrant(grants.update, sourceDoc)) {
52
+ throw new PermissionActionError({
53
+ documentId,
54
+ transactionId,
55
+ message: `You do not have permission to unpublish the document "${documentId}".`,
56
+ })
57
+ }
58
+
59
+ if (!working[draftId] && !checkGrant(grants.create, newDraftFromPublished)) {
60
+ throw new PermissionActionError({
61
+ documentId,
62
+ transactionId,
63
+ message: `You do not have permission to create a draft from the published version of "${documentId}".`,
64
+ })
65
+ }
66
+
67
+ base = processMutations({
68
+ documents: base,
69
+ transactionId,
70
+ mutations: [
71
+ {delete: {id: publishedId}},
72
+ {createIfNotExists: {...(base[publishedId] ?? sourceDoc), _id: draftId}},
73
+ ],
74
+ timestamp,
75
+ })
76
+ working = processMutations({documents: working, transactionId, mutations, timestamp})
77
+
78
+ outgoingMutations.push(...mutations)
79
+ outgoingActions.push({
80
+ actionType: 'sanity.action.document.unpublish',
81
+ draftId,
82
+ publishedId,
83
+ })
84
+ return {base, working}
85
+ }
@@ -3,7 +3,7 @@ import {parse} from 'groq-js'
3
3
  import {describe, expect, it} from 'vitest'
4
4
 
5
5
  import {type DocumentAction} from './actions'
6
- import {ActionError, processActions} from './processActions'
6
+ import {ActionError, processActions} from './processActions/processActions'
7
7
  import {type DocumentSet} from './processMutations'
8
8
 
9
9
  // Helper: Create a sample document that conforms to SanityDocument.
@@ -11,7 +11,7 @@ import {type DocumentAction} from './actions'
11
11
  import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
12
12
  import {type DocumentState, type DocumentStoreState} from './documentStore'
13
13
  import {type RemoteDocument} from './listen'
14
- import {ActionError, processActions} from './processActions'
14
+ import {ActionError, processActions} from './processActions/processActions'
15
15
  import {type DocumentSet} from './processMutations'
16
16
 
17
17
  const EMPTY_REVISIONS: NonNullable<Required<DocumentState['unverifiedRevisions']>> = {}
@@ -14,7 +14,7 @@ import {
14
14
  } from 'rxjs'
15
15
 
16
16
  import {getClientState} from '../client/clientStore'
17
- import {type DocumentResource, isDatasetResource} from '../config/sanityConfig'
17
+ import {type DocumentResource} from '../config/sanityConfig'
18
18
  import {type SanityInstance} from '../store/createSanityInstance'
19
19
 
20
20
  const API_VERSION = 'v2025-05-06'
@@ -31,8 +31,7 @@ export function createSharedListener(
31
31
  const dispose$ = new Subject<void>()
32
32
  const events$ = getClientState(instance, {
33
33
  apiVersion: API_VERSION,
34
- // TODO: remove in v3 when we're ready for everything to be queried via resource
35
- resource: resource && !isDatasetResource(resource) ? resource : undefined,
34
+ resource,
36
35
  }).observable.pipe(
37
36
  switchMap((client) =>
38
37
  // TODO: it seems like the client.listen method is not emitting disconnected
@@ -72,8 +71,7 @@ export function createFetchDocument(instance: SanityInstance, resource?: Documen
72
71
  return function (documentId: string): Observable<SanityDocument | null> {
73
72
  return getClientState(instance, {
74
73
  apiVersion: API_VERSION,
75
- // TODO: remove in v3 when we're ready for everything to be queried via resource
76
- resource: resource && !isDatasetResource(resource) ? resource : undefined,
74
+ resource,
77
75
  }).observable.pipe(
78
76
  switchMap((client) => {
79
77
  // creates a observable request to the /doc/{documentId} endpoint for a given document id
@@ -0,0 +1,102 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {type SanityInstance} from '../store/createSanityInstance'
4
+ import {type StateSource} from '../store/createStateSourceAction'
5
+ import {
6
+ getOrganizationState,
7
+ type Organization,
8
+ type OrganizationBase,
9
+ type OrganizationMember,
10
+ resolveOrganization,
11
+ } from './organization'
12
+
13
+ const instance = {} as SanityInstance
14
+
15
+ test('resolveOrganization — default call: members and features both omitted by default', () => {
16
+ expectTypeOf(resolveOrganization(instance, {organizationId: 'org_1'})).resolves.toEqualTypeOf<
17
+ Organization<false, false>
18
+ >()
19
+ type Result = Awaited<ReturnType<typeof resolveOrganization<false, false>>>
20
+ expectTypeOf<Result['id']>().toEqualTypeOf<string>()
21
+ })
22
+
23
+ test('resolveOrganization — includeMembers: true adds members to the type', () => {
24
+ expectTypeOf(
25
+ resolveOrganization(instance, {organizationId: 'org_1', includeMembers: true}),
26
+ ).resolves.toEqualTypeOf<Organization<true, false>>()
27
+ type Result = Awaited<ReturnType<typeof resolveOrganization<true, false>>>
28
+ expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[]>()
29
+ })
30
+
31
+ test('resolveOrganization — includeFeatures: true adds features to the type', () => {
32
+ expectTypeOf(
33
+ resolveOrganization(instance, {organizationId: 'org_1', includeFeatures: true}),
34
+ ).resolves.toEqualTypeOf<Organization<false, true>>()
35
+ })
36
+
37
+ test('resolveOrganization — both flags true → both arrays present', () => {
38
+ expectTypeOf(
39
+ resolveOrganization(instance, {
40
+ organizationId: 'org_1',
41
+ includeMembers: true,
42
+ includeFeatures: true,
43
+ }),
44
+ ).resolves.toEqualTypeOf<Organization<true, true>>()
45
+ })
46
+
47
+ test('resolveOrganization — rejects non-boolean flag values', () => {
48
+ // @ts-expect-error — includeMembers must be a boolean
49
+ void resolveOrganization(instance, {organizationId: 'org_1', includeMembers: 'yes'})
50
+ })
51
+
52
+ test('resolveOrganization — requires organizationId', () => {
53
+ // @ts-expect-error — organizationId is required
54
+ void resolveOrganization(instance, {})
55
+ })
56
+
57
+ test('resolveOrganization — non-literal boolean flag makes members optional', () => {
58
+ const includeMembers = false as boolean
59
+ expectTypeOf(
60
+ resolveOrganization(instance, {organizationId: 'org_1', includeMembers}),
61
+ ).resolves.toEqualTypeOf<Organization<boolean, false>>()
62
+ })
63
+
64
+ test('getOrganizationState — default call returns bare-base StateSource', () => {
65
+ expectTypeOf(getOrganizationState(instance, {organizationId: 'org_1'})).toEqualTypeOf<
66
+ StateSource<Organization<false, false> | undefined>
67
+ >()
68
+ })
69
+
70
+ test('getOrganizationState — both flags true narrows the StateSource value type', () => {
71
+ expectTypeOf(
72
+ getOrganizationState(instance, {
73
+ organizationId: 'org_1',
74
+ includeMembers: true,
75
+ includeFeatures: true,
76
+ }),
77
+ ).toEqualTypeOf<StateSource<Organization<true, true> | undefined>>()
78
+ })
79
+
80
+ test('Organization — wide boolean for IncludeMembers makes members optional', () => {
81
+ expectTypeOf<Organization<boolean, true>>().toEqualTypeOf<
82
+ OrganizationBase & {members?: OrganizationMember[]} & {features: string[]}
83
+ >()
84
+ expectTypeOf<Pick<Organization<boolean, true>, 'members'>>().toEqualTypeOf<{
85
+ members?: OrganizationMember[]
86
+ }>()
87
+ })
88
+
89
+ test('Organization — wide boolean for IncludeFeatures makes features optional', () => {
90
+ expectTypeOf<Organization<true, boolean>>().toEqualTypeOf<
91
+ OrganizationBase & {members: OrganizationMember[]} & {features?: string[]}
92
+ >()
93
+ expectTypeOf<Pick<Organization<true, boolean>, 'features'>>().toEqualTypeOf<{
94
+ features?: string[]
95
+ }>()
96
+ })
97
+
98
+ test('Organization — both wide booleans make both fields optional', () => {
99
+ expectTypeOf<Organization<boolean, boolean>>().toEqualTypeOf<
100
+ OrganizationBase & {members?: OrganizationMember[]} & {features?: string[]}
101
+ >()
102
+ })
@@ -0,0 +1,138 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {of} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, it} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {getOrganizationCacheKey, resolveOrganization} from './organization'
9
+
10
+ vi.mock('../client/clientStore')
11
+
12
+ describe('organization', () => {
13
+ let instance: SanityInstance
14
+
15
+ beforeEach(() => {
16
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
17
+ })
18
+
19
+ afterEach(() => {
20
+ instance.dispose()
21
+ })
22
+
23
+ it('calls `client.observable.request` against `/organizations/<id>` and returns the result', async () => {
24
+ const organization = {id: 'org_1'}
25
+ const request = vi.fn().mockReturnValue(of(organization))
26
+
27
+ const mockClient = {
28
+ observable: {request} as unknown as SanityClient['observable'],
29
+ } as SanityClient
30
+
31
+ vi.mocked(getClientState).mockReturnValue({
32
+ observable: of(mockClient),
33
+ } as StateSource<SanityClient>)
34
+
35
+ const result = await resolveOrganization(instance, {organizationId: 'org_1'})
36
+ expect(result).toEqual(organization)
37
+ expect(request).toHaveBeenCalledWith({
38
+ uri: '/organizations/org_1',
39
+ query: {includeMembers: 'false', includeFeatures: 'false'},
40
+ tag: 'organization.get',
41
+ })
42
+ })
43
+
44
+ it('serializes query params (booleans → strings) and respects flags', async () => {
45
+ const request = vi.fn().mockReturnValue(of({id: 'org_1'}))
46
+ const mockClient = {
47
+ observable: {request} as unknown as SanityClient['observable'],
48
+ } as SanityClient
49
+
50
+ vi.mocked(getClientState).mockReturnValue({
51
+ observable: of(mockClient),
52
+ } as StateSource<SanityClient>)
53
+
54
+ await resolveOrganization(instance, {
55
+ organizationId: 'org_1',
56
+ includeMembers: true,
57
+ includeFeatures: true,
58
+ })
59
+
60
+ expect(request).toHaveBeenCalledWith({
61
+ uri: '/organizations/org_1',
62
+ query: {
63
+ includeMembers: 'true',
64
+ includeFeatures: 'true',
65
+ },
66
+ tag: 'organization.get',
67
+ })
68
+ })
69
+
70
+ it('throws when no organizationId is provided', async () => {
71
+ await expect(resolveOrganization(instance, {organizationId: ''} as never)).rejects.toThrow(
72
+ 'An organizationId is required to use the organization API.',
73
+ )
74
+ })
75
+ })
76
+
77
+ describe('organization cache key generation', () => {
78
+ let instance: SanityInstance
79
+
80
+ beforeEach(() => {
81
+ instance = createSanityInstance({})
82
+ })
83
+
84
+ afterEach(() => {
85
+ instance.dispose()
86
+ })
87
+
88
+ it('default call excludes :members and :features (both default-false)', () => {
89
+ expect(getOrganizationCacheKey(instance, {organizationId: 'org_1'})).toBe('organization:org_1')
90
+ })
91
+
92
+ it('treats undefined and the matching default as the same key', () => {
93
+ expect(getOrganizationCacheKey(instance, {organizationId: 'org_1'})).toBe(
94
+ getOrganizationCacheKey(instance, {
95
+ organizationId: 'org_1',
96
+ includeMembers: false,
97
+ includeFeatures: false,
98
+ }),
99
+ )
100
+ })
101
+
102
+ it('explicit includeMembers: true appends :members', () => {
103
+ expect(getOrganizationCacheKey(instance, {organizationId: 'org_1', includeMembers: true})).toBe(
104
+ 'organization:org_1:members',
105
+ )
106
+ })
107
+
108
+ it('explicit includeFeatures: true appends :features', () => {
109
+ expect(
110
+ getOrganizationCacheKey(instance, {organizationId: 'org_1', includeFeatures: true}),
111
+ ).toBe('organization:org_1:features')
112
+ })
113
+
114
+ it('combines all segments in order', () => {
115
+ expect(
116
+ getOrganizationCacheKey(instance, {
117
+ organizationId: 'org_1',
118
+ includeMembers: true,
119
+ includeFeatures: true,
120
+ }),
121
+ ).toBe('organization:org_1:members:features')
122
+ })
123
+
124
+ it('produces distinct keys for each meaningful option permutation', () => {
125
+ const keys = new Set([
126
+ getOrganizationCacheKey(instance, {organizationId: 'org_1'}),
127
+ getOrganizationCacheKey(instance, {organizationId: 'org_1', includeMembers: true}),
128
+ getOrganizationCacheKey(instance, {organizationId: 'org_1', includeFeatures: true}),
129
+ getOrganizationCacheKey(instance, {
130
+ organizationId: 'org_1',
131
+ includeMembers: true,
132
+ includeFeatures: true,
133
+ }),
134
+ getOrganizationCacheKey(instance, {organizationId: 'org_2'}),
135
+ ])
136
+ expect(keys.size).toBe(5)
137
+ })
138
+ })