@sanity-labs/client-fake-for-test 0.7.1 → 0.9.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/README.md CHANGED
@@ -77,10 +77,10 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
77
77
  | Transaction | `transaction()` chain → `.commit()` | Mutations: `.create`, `.createOrReplace`, `.createIfNotExists`, `.delete`, `.patch(id, recipe)`. Atomic — any throw rolls back the whole batch. Default result is a `MultipleMutationResult` (`{ transactionId, documentIds, results }`) |
78
78
  | Actions | `action(action \| action[])` | Content Lake release / version actions, applied atomically |
79
79
  | Releases | `releases.*`, `createVersion`, `unpublishVersion` | Content Releases lifecycle — create / edit / schedule / publish / archive / delete, plus version create / replace / discard / unpublish |
80
- | Config | `config()` | Returns `{ projectId, dataset, perspective, token? }` |
81
- | Config | `withConfig({ projectId?, dataset?, perspective?, accessControl?, token? })` | Sibling handle into the same multi-dataset registry; `token` binds a registered identity (see the token→identity registry below), `accessControl` rebinds it inline — the two are mutually exclusive |
80
+ | Config | `config()` | Returns `{ projectId, dataset, perspective, token?, useProjectHostname }` |
81
+ | Config | `withConfig({ projectId?, dataset?, perspective?, accessControl?, token?, useProjectHostname? })` | Sibling handle into the same multi-dataset registry; `token` binds a registered identity (see the token→identity registry below), `accessControl` rebinds it inline — the two are mutually exclusive; `useProjectHostname: false` mints a global-hosted sibling |
82
82
  | Perspectives | `raw` / `published` / `drafts` / release stacks | Per-client default or per-`fetch` override; draft/version layering with `_originalId` projection |
83
- | HTTP | `request({ url? \| uri? })` | Present **only** when the handle has an acting identity (`accessControl`, or a bound `token`). Serves `/users/me` + the configured ACL path. Calling it without one throws a guidance error. |
83
+ | HTTP | `request({ url? \| uri? })` | Present **only** when the handle has an acting identity (`accessControl`, or a bound `token`). Serves `/users/me` (host-aware: project-scoped record on the project host, `globalUser` on a global-hosted handle), the configured ACL path, and `/projects/<projectId>/users/<ids>` (project-user records with `id` + `sanityUserId`). Calling it without one throws a guidance error. |
84
84
 
85
85
  #### Mutation return shapes
86
86
 
@@ -117,6 +117,9 @@ await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
117
117
  await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();
118
118
  ```
119
119
 
120
+ > [!NOTE]
121
+ > Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
122
+
120
123
  #### Identity is the token
121
124
 
122
125
  `identity` is the id of the handle's acting identity. To act as different users against the **same** store (e.g. to exercise `identity()`-based guards), register a token→identity registry and mint token-scoped siblings — the token determines identity, exactly like production:
@@ -137,8 +140,29 @@ const bob = alice.withConfig({ token: "t-bob" });
137
140
 
138
141
  Register more identities after creation with `client.addIdentity(token, { user, grants })` — an upsert into the family-shared registry; already-bound siblings resolve lazily and pick the change up immediately. For a one-off identity without a registry, `withConfig({ accessControl })` still works (mutually exclusive with `token` in the same call).
139
142
 
140
- > [!NOTE]
141
- > Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
143
+ #### Dual-namespace identities: global vs project user ids
144
+
145
+ In production the same token resolves to a **different project-scoped user id in every project** (what lake `identity()` returns) and **one account-global id** (what the global API host's `/users/me` returns); robot tokens carry a single id on every host. An identity can model that split with `globalUser` (served by `/users/me` on a `useProjectHostname: false` handle) and `usersByProject` (per-project records; the handle's project falls back to `user`):
146
+
147
+ ```ts
148
+ const client = createTestClient({
149
+ projectId: "alpha",
150
+ token: "t-snorre",
151
+ identities: {
152
+ "t-snorre": {
153
+ user: { id: "pDefault" },
154
+ globalUser: { id: "gSnorre" },
155
+ usersByProject: { alpha: { id: "pAlpha" }, beta: { id: "pBeta" } },
156
+ grants,
157
+ },
158
+ },
159
+ });
160
+ await client.request({ uri: "/users/me" }); // { id: "pAlpha", ... }
161
+ await client.withConfig({ useProjectHostname: false }).request({ uri: "/users/me" }); // { id: "gSnorre", ... }
162
+ await client.request({ uri: "/projects/alpha/users/pAlpha" }); // { id: "pAlpha", sanityUserId: "gSnorre", ... } (ProjectUserRecord)
163
+ ```
164
+
165
+ Grant-filter and mutation-guard `identity()` stay bound to the **project-scoped** record, matching the lake. Identities without `globalUser` serve one record everywhere — the production robot-token shape.
142
166
 
143
167
  ### Test-only helpers (not on real `SanityClient`)
144
168
 
package/dist/index.cjs CHANGED
@@ -61,69 +61,65 @@ async function enforceWriteAccess(args) {
61
61
  function defaultAclPath(projectId, dataset) {
62
62
  return `/projects/${projectId}/datasets/${dataset}/acl`;
63
63
  }
64
- function makeRequestImpl(getAccess, projectId, dataset) {
64
+ function projectUserOf(identity, projectId) {
65
+ return identity.usersByProject?.[projectId] ?? identity.user;
66
+ }
67
+ function serveProjectUsers(args) {
68
+ const { requestedIds, pathProjectId, identities } = args, toRecord = (identity) => {
69
+ const projectUser = projectUserOf(identity, pathProjectId);
70
+ return {
71
+ id: projectUser.id,
72
+ sanityUserId: identity.globalUser?.id ?? projectUser.id,
73
+ displayName: projectUser.name ?? projectUser.id,
74
+ ...projectUser.email !== void 0 ? { email: projectUser.email } : {}
75
+ };
76
+ }, byId = /* @__PURE__ */ new Map();
77
+ for (const identity of identities) {
78
+ const resolvedId = projectUserOf(identity, pathProjectId).id, existing = byId.get(resolvedId);
79
+ if (existing !== void 0 && JSON.stringify(toRecord(existing)) !== JSON.stringify(toRecord(identity)))
80
+ throw fakeError(
81
+ `request(): two registered identities resolve to project user "${resolvedId}" in project "${pathProjectId}" with different records. The directory cannot answer unambiguously \u2014 fix the identity registrations so duplicate ids carry identical records.`
82
+ );
83
+ byId.set(resolvedId, identity);
84
+ }
85
+ const ids = requestedIds.split(","), [firstId] = ids;
86
+ if (ids.length === 1 && firstId !== void 0) {
87
+ const identity = byId.get(firstId);
88
+ if (identity === void 0)
89
+ throw Object.assign(
90
+ fakeError(
91
+ `request(): no registered identity resolves to project user "${firstId}" in project "${pathProjectId}". Register it via createTestClient({ identities }) or addIdentity().`
92
+ ),
93
+ { statusCode: 404 }
94
+ );
95
+ return toRecord(identity);
96
+ }
97
+ return ids.flatMap((id) => {
98
+ const identity = byId.get(id);
99
+ return identity === void 0 ? [] : [toRecord(identity)];
100
+ });
101
+ }
102
+ const PROJECT_USERS_PATH = /^\/projects\/([^/]+)\/users\/([^/]+)$/;
103
+ function makeRequestImpl(ctx) {
104
+ const { getAccess, projectId, dataset, useProjectHostname, listIdentities } = ctx;
65
105
  return async (opts) => {
66
106
  const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
67
107
  if (path === "/users/me")
68
- return accessControl.currentUser;
108
+ return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
69
109
  if (path === aclPath)
70
110
  return accessControl.grants;
111
+ const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
112
+ if (pathProjectId !== void 0 && requestedIds !== void 0)
113
+ return serveProjectUsers({
114
+ requestedIds,
115
+ pathProjectId,
116
+ identities: listIdentities()
117
+ });
71
118
  throw fakeError(
72
- `request(): no handler for path "${path}". With access control configured, the fake only serves "/users/me" and "${aclPath}". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
119
+ `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "${aclPath}", and "/projects/<projectId>/users/<ids>". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
73
120
  );
74
121
  };
75
122
  }
76
- function createTestStore(initial) {
77
- const docs = /* @__PURE__ */ new Map(), promote2 = (doc) => {
78
- if (typeof doc._id != "string" || doc._id.length === 0)
79
- throw fakeError(
80
- `Cannot seed/store a document without a non-empty string \`_id\` (got ${JSON.stringify(doc._id)}). This fake never auto-generates ids \u2014 give every document an explicit _id. Document: ${JSON.stringify(doc)}`
81
- );
82
- if (typeof doc._type != "string" || doc._type.length === 0)
83
- throw fakeError(
84
- `Document "${doc._id}" is missing a non-empty string \`_type\` (got ${JSON.stringify(doc._type)}). Every Sanity document needs a _type.`
85
- );
86
- const now = (/* @__PURE__ */ new Date()).toISOString();
87
- return {
88
- ...doc,
89
- _id: doc._id,
90
- _type: doc._type,
91
- _rev: doc._rev ?? cryptoRandomKey(),
92
- _createdAt: doc._createdAt ?? now,
93
- _updatedAt: doc._updatedAt ?? now
94
- };
95
- }, seed = (documents) => {
96
- docs.clear();
97
- for (const d of documents) {
98
- const promoted = promote2(d);
99
- docs.set(promoted._id, promoted);
100
- }
101
- };
102
- return initial && seed(initial), {
103
- seed,
104
- add: (documents) => {
105
- for (const d of documents) {
106
- const promoted = promote2(d);
107
- docs.set(promoted._id, promoted);
108
- }
109
- },
110
- reset: () => docs.clear(),
111
- all: () => [...docs.values()],
112
- get: (id) => docs.get(id),
113
- put: (doc) => {
114
- docs.set(doc._id, doc);
115
- },
116
- remove: (id) => {
117
- const doc = docs.get(id);
118
- return doc && docs.delete(id), doc;
119
- },
120
- snapshot: () => [...docs.values()]
121
- };
122
- }
123
- function cryptoRandomKey(length = 12) {
124
- const bytes = new Uint8Array(length);
125
- return globalThis.crypto.getRandomValues(bytes), [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length);
126
- }
127
123
  function describeType(value) {
128
124
  return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`;
129
125
  }
@@ -326,13 +322,140 @@ function insertIntoArray(doc, position, anchor, items) {
326
322
  const idx = resolveSelector(arr, last.selector);
327
323
  position === "replace" ? arr.splice(idx, 1, ...items) : position === "before" ? arr.splice(idx, 0, ...items) : arr.splice(idx + 1, 0, ...items);
328
324
  }
329
- const RELEASE_DOC_PREFIX$1 = "_.releases.", VERSIONS_PREFIX$1 = "versions.", releaseDocId = (releaseId) => `${RELEASE_DOC_PREFIX$1}${releaseId}`, versionDocId = (releaseId, publishedId) => `${VERSIONS_PREFIX$1}${releaseId}.${publishedId}`;
325
+ const DRAFTS_PREFIX = "drafts.", VERSIONS_PREFIX$1 = "versions.", RELEASE_DOC_PREFIX$1 = "_.releases.";
326
+ function publishedIdOf(id) {
327
+ if (id.startsWith(DRAFTS_PREFIX)) return id.slice(DRAFTS_PREFIX.length);
328
+ if (id.startsWith(VERSIONS_PREFIX$1)) {
329
+ const rest = id.slice(VERSIONS_PREFIX$1.length), dot = rest.indexOf(".");
330
+ return dot === -1 ? rest : rest.slice(dot + 1);
331
+ }
332
+ return id;
333
+ }
334
+ function releaseNameOf(id) {
335
+ if (!id.startsWith(VERSIONS_PREFIX$1)) return;
336
+ const rest = id.slice(VERSIONS_PREFIX$1.length), dot = rest.indexOf(".");
337
+ if (dot !== -1)
338
+ return rest.slice(0, dot);
339
+ }
340
+ const isDraftId = (id) => id.startsWith(DRAFTS_PREFIX), isVersionId = (id) => id.startsWith(VERSIONS_PREFIX$1), isSystemId = (id) => id.startsWith("_."), isReleaseDocId = (id) => id.startsWith(RELEASE_DOC_PREFIX$1);
341
+ function applyPerspective(docs, perspective) {
342
+ if (perspective === "raw") return [...docs];
343
+ if (perspective === "published")
344
+ return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
345
+ if (perspective === "drafts")
346
+ return mergeStack(docs, [], { includeDrafts: !0, includeReleaseDocs: !1 });
347
+ const stack = [];
348
+ let includeDrafts = !1;
349
+ for (const entry of perspective)
350
+ entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
351
+ return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
352
+ }
353
+ function mergeStack(docs, releaseStack, options) {
354
+ const groups = /* @__PURE__ */ new Map(), systemReleases = [];
355
+ for (const doc of docs) {
356
+ if (isReleaseDocId(doc._id)) {
357
+ options.includeReleaseDocs && systemReleases.push(doc);
358
+ continue;
359
+ }
360
+ if (isSystemId(doc._id)) continue;
361
+ const pubId = publishedIdOf(doc._id);
362
+ let group = groups.get(pubId);
363
+ if (group === void 0 && (group = { versions: /* @__PURE__ */ new Map() }, groups.set(pubId, group)), isDraftId(doc._id))
364
+ group.draft = doc;
365
+ else if (isVersionId(doc._id)) {
366
+ const releaseName = releaseNameOf(doc._id);
367
+ releaseName !== void 0 && group.versions.set(releaseName, doc);
368
+ } else
369
+ group.published = doc;
370
+ }
371
+ const out = [];
372
+ for (const [pubId, group] of groups) {
373
+ let chosen;
374
+ for (const releaseName of releaseStack) {
375
+ const v = group.versions.get(releaseName);
376
+ if (v !== void 0) {
377
+ if (isUnpublishStub(v)) {
378
+ chosen = void 0;
379
+ break;
380
+ }
381
+ chosen = v;
382
+ break;
383
+ }
384
+ }
385
+ chosen === void 0 && options.includeDrafts && group.draft !== void 0 && (chosen = group.draft), chosen === void 0 && group.published !== void 0 && (releaseStack.some((name) => {
386
+ const v = group.versions.get(name);
387
+ return v !== void 0 && isUnpublishStub(v);
388
+ }) || (chosen = group.published)), chosen !== void 0 && out.push(projectToPublishedId(chosen, pubId));
389
+ }
390
+ return [...out, ...systemReleases];
391
+ }
392
+ function isUnpublishStub(doc) {
393
+ return doc._system?.delete === !0;
394
+ }
395
+ function projectToPublishedId(doc, pubId) {
396
+ return doc._id === pubId ? doc : { ...doc, _id: pubId, _originalId: doc._id };
397
+ }
398
+ function resolveDocument(docs, id, perspective) {
399
+ return perspective === "raw" ? docs.find((d) => d._id === id) : applyPerspective(docs, perspective).find((d) => d._id === id);
400
+ }
401
+ function createTestStore(initial) {
402
+ const docs = /* @__PURE__ */ new Map(), promote2 = (doc) => {
403
+ if (typeof doc._id != "string" || doc._id.length === 0)
404
+ throw fakeError(
405
+ `Cannot seed/store a document without a non-empty string \`_id\` (got ${JSON.stringify(doc._id)}). This fake never auto-generates ids \u2014 give every document an explicit _id. Document: ${JSON.stringify(doc)}`
406
+ );
407
+ if (typeof doc._type != "string" || doc._type.length === 0)
408
+ throw fakeError(
409
+ `Document "${doc._id}" is missing a non-empty string \`_type\` (got ${JSON.stringify(doc._type)}). Every Sanity document needs a _type.`
410
+ );
411
+ const now = (/* @__PURE__ */ new Date()).toISOString();
412
+ return {
413
+ ...doc,
414
+ _id: doc._id,
415
+ _type: doc._type,
416
+ _rev: doc._rev ?? cryptoRandomKey(),
417
+ _createdAt: doc._createdAt ?? now,
418
+ _updatedAt: doc._updatedAt ?? now
419
+ };
420
+ }, seed = (documents) => {
421
+ docs.clear();
422
+ for (const d of documents) {
423
+ const promoted = promote2(d);
424
+ docs.set(promoted._id, promoted);
425
+ }
426
+ };
427
+ return initial && seed(initial), {
428
+ seed,
429
+ add: (documents) => {
430
+ for (const d of documents) {
431
+ const promoted = promote2(d);
432
+ docs.set(promoted._id, promoted);
433
+ }
434
+ },
435
+ reset: () => docs.clear(),
436
+ all: () => [...docs.values()],
437
+ get: (id) => docs.get(id),
438
+ put: (doc) => {
439
+ docs.set(doc._id, doc);
440
+ },
441
+ remove: (id) => {
442
+ const doc = docs.get(id);
443
+ return doc && docs.delete(id), doc;
444
+ },
445
+ snapshot: () => [...docs.values()]
446
+ };
447
+ }
448
+ function cryptoRandomKey(length = 12) {
449
+ const bytes = new Uint8Array(length);
450
+ return globalThis.crypto.getRandomValues(bytes), [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length);
451
+ }
452
+ const RELEASE_DOC_PREFIX = "_.releases.", VERSIONS_PREFIX = "versions.", releaseDocId = (releaseId) => `${RELEASE_DOC_PREFIX}${releaseId}`, versionDocId = (releaseId, publishedId) => `${VERSIONS_PREFIX}${releaseId}.${publishedId}`;
330
453
  function parseVersionId(versionId) {
331
- if (!versionId.startsWith(VERSIONS_PREFIX$1))
454
+ if (!versionId.startsWith(VERSIONS_PREFIX))
332
455
  throw fakeError(
333
456
  `"${versionId}" is not a version document id. Version ids look like "versions.<releaseId>.<publishedId>" (e.g. "versions.summer-drop.product-1").`
334
457
  );
335
- const rest = versionId.slice(VERSIONS_PREFIX$1.length), dot = rest.indexOf(".");
458
+ const rest = versionId.slice(VERSIONS_PREFIX.length), dot = rest.indexOf(".");
336
459
  if (dot === -1)
337
460
  throw fakeError(
338
461
  `"${versionId}" is not a version document id. Version ids look like "versions.<releaseId>.<publishedId>" (e.g. "versions.summer-drop.product-1").`
@@ -378,7 +501,7 @@ function requireActive(release) {
378
501
  `Release "${release.name}" is "${release.state}", but only "active" releases accept version writes (create / replace / discard / unpublish). Unschedule or unarchive it back to "active" first, or target a different release.`
379
502
  );
380
503
  }
381
- function applyAction(store, action) {
504
+ async function applyAction(store, action, hooks) {
382
505
  switch (action.actionType) {
383
506
  case "sanity.action.release.create": {
384
507
  if (maybeRelease(store, action.releaseId) !== void 0)
@@ -436,7 +559,7 @@ function applyAction(store, action) {
436
559
  throw fakeError(
437
560
  `Cannot publish release "${action.releaseId}": it is "${release.state}", and only "active" or "scheduled" releases can be published.`
438
561
  );
439
- const prefix = `${VERSIONS_PREFIX$1}${action.releaseId}.`, sources = store.all().filter((d) => d._id.startsWith(prefix));
562
+ const prefix = `${VERSIONS_PREFIX}${action.releaseId}.`, sources = store.all().filter((d) => d._id.startsWith(prefix));
440
563
  for (const v of sources) {
441
564
  const pubId = v._id.slice(prefix.length);
442
565
  if (v._system?.delete === !0)
@@ -476,7 +599,7 @@ function applyAction(store, action) {
476
599
  throw fakeError(
477
600
  `Cannot delete release "${action.releaseId}": it is "${release.state}", and only "archived" or "published" releases can be deleted. Archive it first (sanity.action.release.archive) or publish it.`
478
601
  );
479
- const prefix = `${VERSIONS_PREFIX$1}${action.releaseId}.`;
602
+ const prefix = `${VERSIONS_PREFIX}${action.releaseId}.`;
480
603
  for (const v of store.all())
481
604
  v._id.startsWith(prefix) && store.remove(v._id);
482
605
  return store.remove(release._id), { transactionId: cryptoRandomKey() };
@@ -520,9 +643,46 @@ function applyAction(store, action) {
520
643
  };
521
644
  return store.put(promote(doc)), { transactionId: cryptoRandomKey() };
522
645
  }
646
+ case "sanity.action.document.publish": {
647
+ requireDraftOf(action);
648
+ const draft = store.get(action.draftId);
649
+ if (draft === void 0)
650
+ throw fakeError(
651
+ `Cannot publish "${action.publishedId}": draft "${action.draftId}" not found. document.publish promotes an existing draft onto the published id \u2014 create the draft first (e.g. client.createOrReplace({ _id: "${action.draftId}", ... })).`
652
+ );
653
+ const published = store.get(action.publishedId);
654
+ requireRevisionMatch(action.draftId, draft._rev, action.ifDraftRevisionId), requireRevisionMatch(action.publishedId, published?._rev, action.ifPublishedRevisionId);
655
+ const promoted = promote({ ...draft, _id: action.publishedId });
656
+ return published !== void 0 && (promoted._createdAt = published._createdAt), await hooks?.gate?.(promoted, published === void 0 ? "create" : "update"), await hooks?.guard?.("publish", published ?? null, promoted), store.put(promoted), store.remove(action.draftId), { transactionId: cryptoRandomKey() };
657
+ }
658
+ case "sanity.action.document.unpublish": {
659
+ requireDraftOf(action);
660
+ const published = store.get(action.publishedId);
661
+ if (published === void 0)
662
+ throw fakeError(
663
+ `Cannot unpublish "${action.publishedId}": no published document with that _id exists. document.unpublish retracts a live published document into "${action.draftId}".`
664
+ );
665
+ if (await hooks?.gate?.(published, "update"), await hooks?.guard?.("unpublish", published, null), store.get(action.draftId) === void 0) {
666
+ const draft = promote({ ...published, _id: action.draftId });
667
+ draft._createdAt = published._createdAt, store.put(draft);
668
+ }
669
+ return store.remove(action.publishedId), { transactionId: cryptoRandomKey() };
670
+ }
523
671
  }
524
672
  }
525
- function applyActions(store, actions) {
673
+ function requireDraftOf(action) {
674
+ if (!(publishedIdOf(action.draftId) === action.publishedId && action.draftId !== action.publishedId))
675
+ throw fakeError(
676
+ `${action.actionType}: draftId "${action.draftId}" is not the draft of publishedId "${action.publishedId}" \u2014 expected draftId "drafts.${action.publishedId}".`
677
+ );
678
+ }
679
+ function requireRevisionMatch(documentId, actualRev, expectedRev) {
680
+ if (!(expectedRev === void 0 || actualRev === expectedRev))
681
+ throw fakeError(
682
+ `Revision precondition failed for "${documentId}": expected _rev "${expectedRev}" but the document is at ${actualRev === void 0 ? "no revision (missing)" : `"${actualRev}"`}.`
683
+ );
684
+ }
685
+ async function applyActions(store, actions, hooks) {
526
686
  const list = Array.isArray(actions) ? actions : [actions], snapshot = /* @__PURE__ */ new Map(), remember = (id) => {
527
687
  snapshot.has(id) || snapshot.set(id, store.get(id));
528
688
  };
@@ -537,11 +697,11 @@ function applyActions(store, actions) {
537
697
  case "sanity.action.release.archive":
538
698
  case "sanity.action.release.unarchive":
539
699
  case "sanity.action.release.delete":
540
- remember(`${RELEASE_DOC_PREFIX$1}${a.releaseId}`);
700
+ remember(`${RELEASE_DOC_PREFIX}${a.releaseId}`);
541
701
  break;
542
702
  case "sanity.action.release.publish": {
543
- remember(`${RELEASE_DOC_PREFIX$1}${a.releaseId}`);
544
- const prefix = `${VERSIONS_PREFIX$1}${a.releaseId}.`;
703
+ remember(`${RELEASE_DOC_PREFIX}${a.releaseId}`);
704
+ const prefix = `${VERSIONS_PREFIX}${a.releaseId}.`;
545
705
  for (const d of store.all())
546
706
  d._id.startsWith(prefix) && (remember(d._id), remember(d._id.slice(prefix.length)));
547
707
  break;
@@ -558,8 +718,12 @@ function applyActions(store, actions) {
558
718
  case "sanity.action.document.version.unpublish":
559
719
  remember(a.versionId);
560
720
  break;
721
+ case "sanity.action.document.publish":
722
+ case "sanity.action.document.unpublish":
723
+ remember(a.draftId), remember(a.publishedId);
724
+ break;
561
725
  }
562
- last = applyAction(store, a);
726
+ last = await applyAction(store, a, hooks);
563
727
  }
564
728
  return last;
565
729
  } catch (err) {
@@ -568,82 +732,6 @@ function applyActions(store, actions) {
568
732
  throw err;
569
733
  }
570
734
  }
571
- const DRAFTS_PREFIX = "drafts.", VERSIONS_PREFIX = "versions.", RELEASE_DOC_PREFIX = "_.releases.";
572
- function publishedIdOf(id) {
573
- if (id.startsWith(DRAFTS_PREFIX)) return id.slice(DRAFTS_PREFIX.length);
574
- if (id.startsWith(VERSIONS_PREFIX)) {
575
- const rest = id.slice(VERSIONS_PREFIX.length), dot = rest.indexOf(".");
576
- return dot === -1 ? rest : rest.slice(dot + 1);
577
- }
578
- return id;
579
- }
580
- function releaseNameOf(id) {
581
- if (!id.startsWith(VERSIONS_PREFIX)) return;
582
- const rest = id.slice(VERSIONS_PREFIX.length), dot = rest.indexOf(".");
583
- if (dot !== -1)
584
- return rest.slice(0, dot);
585
- }
586
- const isDraftId = (id) => id.startsWith(DRAFTS_PREFIX), isVersionId = (id) => id.startsWith(VERSIONS_PREFIX), isSystemId = (id) => id.startsWith("_."), isReleaseDocId = (id) => id.startsWith(RELEASE_DOC_PREFIX);
587
- function applyPerspective(docs, perspective) {
588
- if (perspective === "raw") return [...docs];
589
- if (perspective === "published")
590
- return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
591
- if (perspective === "drafts")
592
- return mergeStack(docs, [], { includeDrafts: !0, includeReleaseDocs: !1 });
593
- const stack = [];
594
- let includeDrafts = !1;
595
- for (const entry of perspective)
596
- entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
597
- return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
598
- }
599
- function mergeStack(docs, releaseStack, options) {
600
- const groups = /* @__PURE__ */ new Map(), systemReleases = [];
601
- for (const doc of docs) {
602
- if (isReleaseDocId(doc._id)) {
603
- options.includeReleaseDocs && systemReleases.push(doc);
604
- continue;
605
- }
606
- if (isSystemId(doc._id)) continue;
607
- const pubId = publishedIdOf(doc._id);
608
- let group = groups.get(pubId);
609
- if (group === void 0 && (group = { versions: /* @__PURE__ */ new Map() }, groups.set(pubId, group)), isDraftId(doc._id))
610
- group.draft = doc;
611
- else if (isVersionId(doc._id)) {
612
- const releaseName = releaseNameOf(doc._id);
613
- releaseName !== void 0 && group.versions.set(releaseName, doc);
614
- } else
615
- group.published = doc;
616
- }
617
- const out = [];
618
- for (const [pubId, group] of groups) {
619
- let chosen;
620
- for (const releaseName of releaseStack) {
621
- const v = group.versions.get(releaseName);
622
- if (v !== void 0) {
623
- if (isUnpublishStub(v)) {
624
- chosen = void 0;
625
- break;
626
- }
627
- chosen = v;
628
- break;
629
- }
630
- }
631
- chosen === void 0 && options.includeDrafts && group.draft !== void 0 && (chosen = group.draft), chosen === void 0 && group.published !== void 0 && (releaseStack.some((name) => {
632
- const v = group.versions.get(name);
633
- return v !== void 0 && isUnpublishStub(v);
634
- }) || (chosen = group.published)), chosen !== void 0 && out.push(projectToPublishedId(chosen, pubId));
635
- }
636
- return [...out, ...systemReleases];
637
- }
638
- function isUnpublishStub(doc) {
639
- return doc._system?.delete === !0;
640
- }
641
- function projectToPublishedId(doc, pubId) {
642
- return doc._id === pubId ? doc : { ...doc, _id: pubId, _originalId: doc._id };
643
- }
644
- function resolveDocument(docs, id, perspective) {
645
- return perspective === "raw" ? docs.find((d) => d._id === id) : applyPerspective(docs, perspective).find((d) => d._id === id);
646
- }
647
735
  function createTransaction(executor) {
648
736
  const mutations = [], handle = {
649
737
  create: (doc) => (mutations.push({ kind: "create", doc }), handle),
@@ -739,7 +827,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
739
827
  },
740
828
  request: {
741
829
  what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
742
- workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me + the ACL path). Or register a stub."
830
+ workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me, the ACL path, and /projects/<projectId>/users/<ids>). Or register a stub."
743
831
  },
744
832
  dryRun: {
745
833
  what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
@@ -922,15 +1010,16 @@ function createTestClient(options = {}) {
922
1010
  const { projectId, dataset } = parseRegistryKey(key);
923
1011
  ensureStore(projectId, dataset).seed(docs);
924
1012
  }
925
- return makeHandle(
1013
+ return makeHandle({
926
1014
  backend,
927
- defaultProjectId,
928
- defaultDataset,
929
- options.accessControl,
930
- options.perspective ?? "raw",
931
- options.mutationGuard,
932
- options.token
933
- );
1015
+ projectId: defaultProjectId,
1016
+ dataset: defaultDataset,
1017
+ accessControl: options.accessControl,
1018
+ perspective: options.perspective ?? "raw",
1019
+ mutationGuard: options.mutationGuard,
1020
+ token: options.token,
1021
+ useProjectHostname: options.useProjectHostname ?? !0
1022
+ });
934
1023
  }
935
1024
  function dedupeIds(results) {
936
1025
  const seen = /* @__PURE__ */ new Set(), out = [];
@@ -982,13 +1071,26 @@ function toTxnMutations(operations) {
982
1071
  }
983
1072
  ];
984
1073
  }
985
- function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard, token) {
986
- const { registry } = backend, activeAccess = () => {
1074
+ function makeHandle(handleArgs) {
1075
+ const {
1076
+ backend,
1077
+ projectId,
1078
+ dataset,
1079
+ accessControl,
1080
+ perspective,
1081
+ mutationGuard,
1082
+ token,
1083
+ useProjectHostname
1084
+ } = handleArgs, { registry } = backend, activeAccess = () => {
987
1085
  if (token === void 0) return accessControl;
988
1086
  const identity = backend.identities.get(token);
989
1087
  if (identity === void 0)
990
1088
  throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
991
- return { currentUser: identity.user, grants: identity.grants };
1089
+ return {
1090
+ currentUser: projectUserOf(identity, projectId),
1091
+ ...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
1092
+ grants: identity.grants
1093
+ };
992
1094
  }, requireActiveAccess = () => {
993
1095
  const access = activeAccess();
994
1096
  if (access === void 0)
@@ -1138,7 +1240,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1138
1240
  txnOptions?.returnDocuments ?? !1,
1139
1241
  txnOptions?.returnFirst ?? !1
1140
1242
  );
1141
- }, runAction = (actions) => serializeWrite(backend, async () => applyActions(store, actions)), accessVisibleDocs = async () => {
1243
+ }, runAction = (actions) => serializeWrite(backend, async () => applyActions(store, actions, { gate, guard: guardGate })), accessVisibleDocs = async () => {
1142
1244
  const all = store.all();
1143
1245
  if (activeAccess() === void 0) return all;
1144
1246
  const out = [];
@@ -1309,20 +1411,35 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1309
1411
  versionId: `versions.${releaseId}.${publishedId}`,
1310
1412
  publishedId
1311
1413
  }),
1312
- ...token !== void 0 || accessControl !== void 0 ? { request: makeRequestImpl(requireActiveAccess, projectId, dataset) } : {},
1313
- config: () => ({ projectId, dataset, perspective, ...token !== void 0 ? { token } : {} }),
1414
+ ...token !== void 0 || accessControl !== void 0 ? {
1415
+ request: makeRequestImpl({
1416
+ getAccess: requireActiveAccess,
1417
+ projectId,
1418
+ dataset,
1419
+ useProjectHostname,
1420
+ listIdentities: () => [...backend.identities.values()]
1421
+ })
1422
+ } : {},
1423
+ config: () => ({
1424
+ projectId,
1425
+ dataset,
1426
+ perspective,
1427
+ ...token !== void 0 ? { token } : {},
1428
+ useProjectHostname
1429
+ }),
1314
1430
  withConfig: (config) => {
1315
1431
  rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
1316
1432
  const rebinds = config.token !== void 0 || config.accessControl !== void 0;
1317
- return makeHandle(
1433
+ return makeHandle({
1318
1434
  backend,
1319
- config.projectId ?? projectId,
1320
- config.dataset ?? dataset,
1321
- rebinds ? config.accessControl : accessControl,
1322
- config.perspective ?? perspective,
1435
+ projectId: config.projectId ?? projectId,
1436
+ dataset: config.dataset ?? dataset,
1437
+ accessControl: rebinds ? config.accessControl : accessControl,
1438
+ perspective: config.perspective ?? perspective,
1323
1439
  mutationGuard,
1324
- rebinds ? config.token : token
1325
- );
1440
+ token: rebinds ? config.token : token,
1441
+ useProjectHostname: config.useProjectHostname ?? useProjectHostname
1442
+ });
1326
1443
  },
1327
1444
  seed: (documents) => store.seed(documents),
1328
1445
  add: (documents) => store.add(documents),