@sanity-labs/client-fake-for-test 0.4.0 → 0.6.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/dist/index.d.cts CHANGED
@@ -110,6 +110,37 @@ export declare interface CreateTestClientOptions {
110
110
  * unconditionally — the legacy behavior.
111
111
  */
112
112
  accessControl?: TestClientAccessControl;
113
+ /**
114
+ * Token→identity registry, shared by the whole `withConfig` family:
115
+ * multiple identities over one store, keyed by the token a handle is
116
+ * bound to — the token determines identity, exactly like production.
117
+ *
118
+ * ```ts
119
+ * const client = createTestClient({
120
+ * token: "t-admin",
121
+ * identities: {
122
+ * "t-admin": { user: { id: "admin", roles: [{ name: "administrator" }] }, grants: WILDCARD },
123
+ * "t-bob": { user: { id: "bob", roles: [{ name: "editor" }] }, grants: READ_ONLY },
124
+ * },
125
+ * });
126
+ * const bob = client.withConfig({ token: "t-bob" });
127
+ * // same store; bob's /users/me → bob; bob's ACL path → bob's grants
128
+ * ```
129
+ *
130
+ * A token-bound handle behaves exactly like one configured with
131
+ * `accessControl` derived from its identity (identity serving + grant
132
+ * gating; the ACL path defaults per `(projectId, dataset)`). Register
133
+ * more identities later with `addIdentity`.
134
+ */
135
+ identities?: Record<string, TestClientIdentity>;
136
+ /**
137
+ * Token the root handle is bound to — must name an entry in
138
+ * `identities` (unknown tokens throw). Omit to leave the root handle
139
+ * identity-free (a bare factory for token-bound siblings): its
140
+ * `request` is undefined and its reads/writes are ungated. Mutually
141
+ * exclusive with `accessControl`.
142
+ */
143
+ token?: string;
113
144
  /**
114
145
  * Pluggable write interceptor — runs on each write through the document
115
146
  * mutation API (create/patch/delete/transaction; not the release/version
@@ -268,6 +299,8 @@ export declare type FakeMutation =
268
299
  *
269
300
  * - `seed`, `add`, `reset`, `snapshot`, `store` — bulk-load helpers,
270
301
  * scoped to the current dataset.
302
+ * - `simulateConcurrentEdit()` — force the next N revision-guarded writes
303
+ * to a document to 409, for optimistic-locking retry tests.
271
304
  * - `datasets()` — lists known dataset keys in this registry.
272
305
  *
273
306
  * Methods deliberately omitted:
@@ -657,7 +690,7 @@ export declare const SUPPORTED_SURFACE: readonly [
657
690
  "releases",
658
691
  "createVersion",
659
692
  "unpublishVersion",
660
- "request (only with accessControl)",
693
+ "request (only with accessControl or a bound token)",
661
694
  "config",
662
695
  "withConfig",
663
696
  ];
@@ -683,16 +716,12 @@ export declare interface TestClient {
683
716
  options?: FetchOptions,
684
717
  ) => Promise<(T | null)[]>;
685
718
  /**
686
- * Sanity-style HTTP request hook. Present iff the client was
687
- * created with `accessControl`. The engine's access resolver pokes
688
- * `/users/me` and the configured ACL path through this — without it
689
- * the engine throws on any verb that needs an actor (which is the
690
- * cue for the bench to supply an `access` override).
691
- *
692
- * Test cases that want to exercise the engine's token-resolution
693
- * path should configure `accessControl`; cases that want bench-style
694
- * always-allow should leave it off and pass `access` through the
695
- * bench.
719
+ * Sanity-style HTTP request hook. Present iff the handle has an
720
+ * acting identity — created with `accessControl`, or bound to a
721
+ * registered token (`identities` + `token`). The engine's access
722
+ * resolver pokes `/users/me` and the configured ACL path through
723
+ * this — without it the engine throws on any verb that needs an
724
+ * actor.
696
725
  */
697
726
  request?: <T>(opts: {
698
727
  url?: string;
@@ -750,11 +779,15 @@ export declare interface TestClient {
750
779
  releaseId: string;
751
780
  publishedId: string;
752
781
  }) => Promise<ActionResult>;
753
- /** Current `(projectId, dataset)` and active perspective. */
782
+ /**
783
+ * Current `(projectId, dataset)` and active perspective. `token` is
784
+ * present iff the handle is bound to a registered identity.
785
+ */
754
786
  config: () => {
755
787
  projectId: string;
756
788
  dataset: string;
757
789
  perspective: Perspective;
790
+ token?: string;
758
791
  };
759
792
  /**
760
793
  * Return a new client handle pointing at a different `(projectId, dataset)`
@@ -771,8 +804,18 @@ export declare interface TestClient {
771
804
  * Rebind the acting identity (and its grants) on a sibling that shares
772
805
  * the same store — the test analogue of a token-scoped client. Lets two
773
806
  * actors write the same dataset, e.g. for `identity()`-based guards.
807
+ * Mutually exclusive with `token`.
774
808
  */
775
809
  accessControl: TestClientAccessControl;
810
+ /**
811
+ * Bind the sibling to a token registered in the family's identity
812
+ * registry (`createTestClient({ identities })` / `addIdentity`): its
813
+ * `/users/me` serves that identity's user, its ACL path serves that
814
+ * identity's grants, and reads/writes are gated through them — the
815
+ * token determines identity, exactly like production. Unknown tokens
816
+ * throw. Mutually exclusive with `accessControl`.
817
+ */
818
+ token: string;
776
819
  }>,
777
820
  ) => TestClient;
778
821
  /** Replace the current dataset with the given documents. */
@@ -785,6 +828,27 @@ export declare interface TestClient {
785
828
  snapshot: () => readonly SanityDocument[];
786
829
  /** Direct store access for the current dataset. */
787
830
  store: () => TestStore;
831
+ /**
832
+ * Simulate `count` concurrent edits to `id` in the current dataset: the
833
+ * next `count` revision-guarded mutations (those passing `ifRevisionId`)
834
+ * to that document throw a 409-shaped conflict, as if another client
835
+ * committed between the caller's read and write. Mutations that don't
836
+ * pass `ifRevisionId` are unaffected.
837
+ *
838
+ * Reach for this when the code under test reads a document's `_rev` and
839
+ * commits with `ifRevisionId` internally — so you can't stage the race
840
+ * by mutating the document first (the code would just re-read the new
841
+ * revision and its commit would match). The counter is consumed one per
842
+ * guarded commit, so `count` lets a test drive a recover-after-N-losses
843
+ * path or exhaust a retry budget, deterministically and without relying
844
+ * on promise interleaving.
845
+ *
846
+ * Note: this forces the conflict *outcome* — it short-circuits the
847
+ * revision comparison rather than changing the stored `_rev`. The code
848
+ * under test sees the identical 409 error a real lost race produces; the
849
+ * stored revision is left untouched.
850
+ */
851
+ simulateConcurrentEdit: (id: string, count?: number) => void;
788
852
  /** List `(projectId, dataset)` pairs known in this registry. */
789
853
  datasets: () => {
790
854
  projectId: string;
@@ -800,6 +864,16 @@ export declare interface TestClient {
800
864
  stub: (name: string, handler: unknown) => void;
801
865
  /** The currently-registered stubs, keyed by member name. */
802
866
  stubs: () => Record<string, unknown>;
867
+ /**
868
+ * Register (or upsert) a token→identity entry in the family's shared
869
+ * registry. Siblings already bound to `token` resolve lazily, so a
870
+ * re-registration takes effect on them immediately. Registering an
871
+ * identity does not bind it — pass `token` at `createTestClient` or
872
+ * `withConfig({ token })` to act as it.
873
+ */
874
+ addIdentity: (token: string, identity: TestClientIdentity) => void;
875
+ /** Snapshot of the registered token→identity entries. */
876
+ identities: () => Record<string, TestClientIdentity>;
803
877
  /**
804
878
  * Every mutation committed through this client's registry, flattened
805
879
  * and in order — `create` / `createOrReplace` / `createIfNotExists` /
@@ -844,6 +918,20 @@ export declare interface TestClientAccessControl {
844
918
  aclPath?: string;
845
919
  }
846
920
 
921
+ /**
922
+ * One entry in the token→identity registry (`createTestClient({ identities })`).
923
+ * A handle bound to the entry's token behaves exactly like one configured
924
+ * with `accessControl: { currentUser: user, grants }` — `/users/me` serves
925
+ * `user`, the default ACL path serves `grants`, and reads/writes are gated
926
+ * through them.
927
+ */
928
+ export declare interface TestClientIdentity {
929
+ /** What `/users/me` returns for clients bound to this token. */
930
+ user: CurrentUser;
931
+ /** Grants served at the ACL path and enforced on reads/writes. */
932
+ grants: Grant[];
933
+ }
934
+
847
935
  /**
848
936
  * In-memory document store backing a TestClient. Closure-scoped — created
849
937
  * fresh per test, no global state.
package/dist/index.d.ts CHANGED
@@ -110,6 +110,37 @@ export declare interface CreateTestClientOptions {
110
110
  * unconditionally — the legacy behavior.
111
111
  */
112
112
  accessControl?: TestClientAccessControl;
113
+ /**
114
+ * Token→identity registry, shared by the whole `withConfig` family:
115
+ * multiple identities over one store, keyed by the token a handle is
116
+ * bound to — the token determines identity, exactly like production.
117
+ *
118
+ * ```ts
119
+ * const client = createTestClient({
120
+ * token: "t-admin",
121
+ * identities: {
122
+ * "t-admin": { user: { id: "admin", roles: [{ name: "administrator" }] }, grants: WILDCARD },
123
+ * "t-bob": { user: { id: "bob", roles: [{ name: "editor" }] }, grants: READ_ONLY },
124
+ * },
125
+ * });
126
+ * const bob = client.withConfig({ token: "t-bob" });
127
+ * // same store; bob's /users/me → bob; bob's ACL path → bob's grants
128
+ * ```
129
+ *
130
+ * A token-bound handle behaves exactly like one configured with
131
+ * `accessControl` derived from its identity (identity serving + grant
132
+ * gating; the ACL path defaults per `(projectId, dataset)`). Register
133
+ * more identities later with `addIdentity`.
134
+ */
135
+ identities?: Record<string, TestClientIdentity>;
136
+ /**
137
+ * Token the root handle is bound to — must name an entry in
138
+ * `identities` (unknown tokens throw). Omit to leave the root handle
139
+ * identity-free (a bare factory for token-bound siblings): its
140
+ * `request` is undefined and its reads/writes are ungated. Mutually
141
+ * exclusive with `accessControl`.
142
+ */
143
+ token?: string;
113
144
  /**
114
145
  * Pluggable write interceptor — runs on each write through the document
115
146
  * mutation API (create/patch/delete/transaction; not the release/version
@@ -268,6 +299,8 @@ export declare type FakeMutation =
268
299
  *
269
300
  * - `seed`, `add`, `reset`, `snapshot`, `store` — bulk-load helpers,
270
301
  * scoped to the current dataset.
302
+ * - `simulateConcurrentEdit()` — force the next N revision-guarded writes
303
+ * to a document to 409, for optimistic-locking retry tests.
271
304
  * - `datasets()` — lists known dataset keys in this registry.
272
305
  *
273
306
  * Methods deliberately omitted:
@@ -657,7 +690,7 @@ export declare const SUPPORTED_SURFACE: readonly [
657
690
  "releases",
658
691
  "createVersion",
659
692
  "unpublishVersion",
660
- "request (only with accessControl)",
693
+ "request (only with accessControl or a bound token)",
661
694
  "config",
662
695
  "withConfig",
663
696
  ];
@@ -683,16 +716,12 @@ export declare interface TestClient {
683
716
  options?: FetchOptions,
684
717
  ) => Promise<(T | null)[]>;
685
718
  /**
686
- * Sanity-style HTTP request hook. Present iff the client was
687
- * created with `accessControl`. The engine's access resolver pokes
688
- * `/users/me` and the configured ACL path through this — without it
689
- * the engine throws on any verb that needs an actor (which is the
690
- * cue for the bench to supply an `access` override).
691
- *
692
- * Test cases that want to exercise the engine's token-resolution
693
- * path should configure `accessControl`; cases that want bench-style
694
- * always-allow should leave it off and pass `access` through the
695
- * bench.
719
+ * Sanity-style HTTP request hook. Present iff the handle has an
720
+ * acting identity — created with `accessControl`, or bound to a
721
+ * registered token (`identities` + `token`). The engine's access
722
+ * resolver pokes `/users/me` and the configured ACL path through
723
+ * this — without it the engine throws on any verb that needs an
724
+ * actor.
696
725
  */
697
726
  request?: <T>(opts: {
698
727
  url?: string;
@@ -750,11 +779,15 @@ export declare interface TestClient {
750
779
  releaseId: string;
751
780
  publishedId: string;
752
781
  }) => Promise<ActionResult>;
753
- /** Current `(projectId, dataset)` and active perspective. */
782
+ /**
783
+ * Current `(projectId, dataset)` and active perspective. `token` is
784
+ * present iff the handle is bound to a registered identity.
785
+ */
754
786
  config: () => {
755
787
  projectId: string;
756
788
  dataset: string;
757
789
  perspective: Perspective;
790
+ token?: string;
758
791
  };
759
792
  /**
760
793
  * Return a new client handle pointing at a different `(projectId, dataset)`
@@ -771,8 +804,18 @@ export declare interface TestClient {
771
804
  * Rebind the acting identity (and its grants) on a sibling that shares
772
805
  * the same store — the test analogue of a token-scoped client. Lets two
773
806
  * actors write the same dataset, e.g. for `identity()`-based guards.
807
+ * Mutually exclusive with `token`.
774
808
  */
775
809
  accessControl: TestClientAccessControl;
810
+ /**
811
+ * Bind the sibling to a token registered in the family's identity
812
+ * registry (`createTestClient({ identities })` / `addIdentity`): its
813
+ * `/users/me` serves that identity's user, its ACL path serves that
814
+ * identity's grants, and reads/writes are gated through them — the
815
+ * token determines identity, exactly like production. Unknown tokens
816
+ * throw. Mutually exclusive with `accessControl`.
817
+ */
818
+ token: string;
776
819
  }>,
777
820
  ) => TestClient;
778
821
  /** Replace the current dataset with the given documents. */
@@ -785,6 +828,27 @@ export declare interface TestClient {
785
828
  snapshot: () => readonly SanityDocument[];
786
829
  /** Direct store access for the current dataset. */
787
830
  store: () => TestStore;
831
+ /**
832
+ * Simulate `count` concurrent edits to `id` in the current dataset: the
833
+ * next `count` revision-guarded mutations (those passing `ifRevisionId`)
834
+ * to that document throw a 409-shaped conflict, as if another client
835
+ * committed between the caller's read and write. Mutations that don't
836
+ * pass `ifRevisionId` are unaffected.
837
+ *
838
+ * Reach for this when the code under test reads a document's `_rev` and
839
+ * commits with `ifRevisionId` internally — so you can't stage the race
840
+ * by mutating the document first (the code would just re-read the new
841
+ * revision and its commit would match). The counter is consumed one per
842
+ * guarded commit, so `count` lets a test drive a recover-after-N-losses
843
+ * path or exhaust a retry budget, deterministically and without relying
844
+ * on promise interleaving.
845
+ *
846
+ * Note: this forces the conflict *outcome* — it short-circuits the
847
+ * revision comparison rather than changing the stored `_rev`. The code
848
+ * under test sees the identical 409 error a real lost race produces; the
849
+ * stored revision is left untouched.
850
+ */
851
+ simulateConcurrentEdit: (id: string, count?: number) => void;
788
852
  /** List `(projectId, dataset)` pairs known in this registry. */
789
853
  datasets: () => {
790
854
  projectId: string;
@@ -800,6 +864,16 @@ export declare interface TestClient {
800
864
  stub: (name: string, handler: unknown) => void;
801
865
  /** The currently-registered stubs, keyed by member name. */
802
866
  stubs: () => Record<string, unknown>;
867
+ /**
868
+ * Register (or upsert) a token→identity entry in the family's shared
869
+ * registry. Siblings already bound to `token` resolve lazily, so a
870
+ * re-registration takes effect on them immediately. Registering an
871
+ * identity does not bind it — pass `token` at `createTestClient` or
872
+ * `withConfig({ token })` to act as it.
873
+ */
874
+ addIdentity: (token: string, identity: TestClientIdentity) => void;
875
+ /** Snapshot of the registered token→identity entries. */
876
+ identities: () => Record<string, TestClientIdentity>;
803
877
  /**
804
878
  * Every mutation committed through this client's registry, flattened
805
879
  * and in order — `create` / `createOrReplace` / `createIfNotExists` /
@@ -844,6 +918,20 @@ export declare interface TestClientAccessControl {
844
918
  aclPath?: string;
845
919
  }
846
920
 
921
+ /**
922
+ * One entry in the token→identity registry (`createTestClient({ identities })`).
923
+ * A handle bound to the entry's token behaves exactly like one configured
924
+ * with `accessControl: { currentUser: user, grants }` — `/users/me` serves
925
+ * `user`, the default ACL path serves `grants`, and reads/writes are gated
926
+ * through them.
927
+ */
928
+ export declare interface TestClientIdentity {
929
+ /** What `/users/me` returns for clients bound to this token. */
930
+ user: CurrentUser;
931
+ /** Grants served at the ACL path and enforced on reads/writes. */
932
+ grants: Grant[];
933
+ }
934
+
847
935
  /**
848
936
  * In-memory document store backing a TestClient. Closure-scoped — created
849
937
  * fresh per test, no global state.
package/dist/index.js CHANGED
@@ -59,16 +59,15 @@ async function enforceWriteAccess(args) {
59
59
  function defaultAclPath(projectId, dataset) {
60
60
  return `/projects/${projectId}/datasets/${dataset}/acl`;
61
61
  }
62
- function makeRequestImpl(accessControl, projectId, dataset) {
63
- const aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset);
62
+ function makeRequestImpl(getAccess, projectId, dataset) {
64
63
  return async (opts) => {
65
- const path = opts.uri ?? opts.url;
64
+ const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
66
65
  if (path === "/users/me")
67
66
  return accessControl.currentUser;
68
67
  if (path === aclPath)
69
68
  return accessControl.grants;
70
69
  throw fakeError(
71
- `request(): no handler for path "${path}". With accessControl 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.`
70
+ `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.`
72
71
  );
73
72
  };
74
73
  }
@@ -688,7 +687,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
688
687
  "releases",
689
688
  "createVersion",
690
689
  "unpublishVersion",
691
- "request (only with accessControl)",
690
+ "request (only with accessControl or a bound token)",
692
691
  "config",
693
692
  "withConfig"
694
693
  ], UNSUPPORTED_MEMBERS = {
@@ -737,8 +736,8 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
737
736
  workaround: "Out of scope \u2014 seed documents directly or stub the call site."
738
737
  },
739
738
  request: {
740
- what: "generic HTTP request hook \u2014 only wired up when accessControl is configured",
741
- workaround: "Create the client with `accessControl` to enable request() (it serves /users/me + the ACL path), or register a stub."
739
+ what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
740
+ 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."
742
741
  },
743
742
  dryRun: {
744
743
  what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
@@ -854,12 +853,35 @@ const RECORDED_METHODS = /* @__PURE__ */ new Set([
854
853
  "createVersion",
855
854
  "unpublishVersion",
856
855
  "request"
857
- ]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test", registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
856
+ ]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test";
857
+ function requireIdentity(backend, token, site) {
858
+ if (backend.identities.has(token)) return;
859
+ const known = [...backend.identities.keys()];
860
+ throw fakeError(
861
+ `${site}: unknown token "${token}" \u2014 no identity is registered for it. ` + (known.length > 0 ? `Registered tokens: ${known.map((t) => `"${t}"`).join(", ")}. ` : "No identities are registered. ") + "Register identities via createTestClient({ identities }) or client.addIdentity(token, identity)."
862
+ );
863
+ }
864
+ function rejectAmbiguousIdentity(site, config) {
865
+ if (config.token !== void 0 && config.accessControl !== void 0)
866
+ throw fakeError(
867
+ `${site}: \`token\` and \`accessControl\` are mutually exclusive \u2014 a handle acts either as a registered identity (token) or as an inline accessControl config, not both.`
868
+ );
869
+ }
870
+ const registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
858
871
  const [projectId, dataset] = key.split(":");
859
872
  return { projectId, dataset };
860
873
  };
861
874
  function createTestClient(options = {}) {
862
- const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = { registry: /* @__PURE__ */ new Map(), log: [], stubs: /* @__PURE__ */ new Map(), calls: [] }, registry = backend.registry, ensureStore = (projectId, dataset) => {
875
+ const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
876
+ registry: /* @__PURE__ */ new Map(),
877
+ log: [],
878
+ stubs: /* @__PURE__ */ new Map(),
879
+ calls: [],
880
+ concurrentEdits: /* @__PURE__ */ new Map(),
881
+ identities: new Map(Object.entries(options.identities ?? {}))
882
+ }, registry = backend.registry;
883
+ rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()");
884
+ const ensureStore = (projectId, dataset) => {
863
885
  const key = registryKey(projectId, dataset);
864
886
  let store = registry.get(key);
865
887
  return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
@@ -875,7 +897,8 @@ function createTestClient(options = {}) {
875
897
  defaultDataset,
876
898
  options.accessControl,
877
899
  options.perspective ?? "raw",
878
- options.mutationGuard
900
+ options.mutationGuard,
901
+ options.token
879
902
  );
880
903
  }
881
904
  function dedupeIds(results) {
@@ -928,12 +951,23 @@ function toTxnMutations(operations) {
928
951
  }
929
952
  ];
930
953
  }
931
- function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
932
- const { registry } = backend, store = (() => {
954
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard, token) {
955
+ const { registry } = backend, activeAccess = () => {
956
+ if (token === void 0) return accessControl;
957
+ const identity = backend.identities.get(token);
958
+ if (identity === void 0)
959
+ throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
960
+ return { currentUser: identity.user, grants: identity.grants };
961
+ }, requireActiveAccess = () => {
962
+ const access = activeAccess();
963
+ if (access === void 0)
964
+ throw fakeError("this handle has no acting identity (no token or accessControl bound).");
965
+ return access;
966
+ }, store = (() => {
933
967
  const key = registryKey(projectId, dataset);
934
968
  let s = registry.get(key);
935
969
  return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
936
- })(), record = (transactionId, mutations) => {
970
+ })(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations) => {
937
971
  backend.log.push({ transactionId, projectId, dataset, mutations });
938
972
  }, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
939
973
  documents,
@@ -961,6 +995,11 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
961
995
  };
962
996
  }, checkRevision = (id, ifRevisionId) => {
963
997
  if (ifRevisionId === void 0) return;
998
+ const editKey = concurrentEditKey(id), pendingEdits = backend.concurrentEdits.get(editKey) ?? 0;
999
+ if (pendingEdits > 0)
1000
+ throw backend.concurrentEdits.set(editKey, pendingEdits - 1), fakeError(
1001
+ `ifRevisionId check failed: document "${id}" was concurrently edited (simulated via simulateConcurrentEdit) \u2014 the revision-guarded mutation lost the race; re-read and retry. (This mirrors Sanity's optimistic-locking 409.)`
1002
+ );
964
1003
  const existing = store.get(id);
965
1004
  if (existing === void 0)
966
1005
  throw fakeError(
@@ -971,20 +1010,24 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
971
1010
  `ifRevisionId check failed: document "${id}" is at _rev="${existing._rev}" but the mutation required _rev="${ifRevisionId}". The document changed since you read it \u2014 re-read it and retry. (This mirrors Sanity's optimistic-locking 409.)`
972
1011
  );
973
1012
  }, gate = async (document, permission) => {
974
- accessControl !== void 0 && await enforceWriteAccess({
1013
+ const access = activeAccess();
1014
+ access !== void 0 && await enforceWriteAccess({
975
1015
  document,
976
- grants: accessControl.grants,
1016
+ grants: access.grants,
977
1017
  permission,
978
- userId: accessControl.currentUser.id
1018
+ userId: access.currentUser.id
979
1019
  });
980
1020
  }, guardGate = async (action, before, after) => {
981
- mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
982
- }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
983
- document,
984
- grants: accessControl.grants,
985
- permission: "read",
986
- userId: accessControl.currentUser.id
987
- }), applyPatch = async (documentId, ops, ifRev) => {
1021
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: activeAccess()?.currentUser.id });
1022
+ }, canRead = async (document) => {
1023
+ const access = activeAccess();
1024
+ return access === void 0 ? !0 : canAccess({
1025
+ document,
1026
+ grants: access.grants,
1027
+ permission: "read",
1028
+ userId: access.currentUser.id
1029
+ });
1030
+ }, applyPatch = async (documentId, ops, ifRev) => {
988
1031
  const existing = store.get(documentId);
989
1032
  if (existing === void 0)
990
1033
  throw fakeError(
@@ -1060,7 +1103,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1060
1103
  );
1061
1104
  }, accessVisibleDocs = async () => {
1062
1105
  const all = store.all();
1063
- if (accessControl === void 0) return all;
1106
+ if (activeAccess() === void 0) return all;
1064
1107
  const out = [];
1065
1108
  for (const doc of all)
1066
1109
  await canRead(doc) && out.push(doc);
@@ -1215,26 +1258,39 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1215
1258
  versionId: `versions.${releaseId}.${publishedId}`,
1216
1259
  publishedId
1217
1260
  }),
1218
- ...accessControl !== void 0 ? { request: makeRequestImpl(accessControl, projectId, dataset) } : {},
1219
- config: () => ({ projectId, dataset, perspective }),
1220
- withConfig: (config) => makeHandle(
1221
- backend,
1222
- config.projectId ?? projectId,
1223
- config.dataset ?? dataset,
1224
- config.accessControl ?? accessControl,
1225
- config.perspective ?? perspective,
1226
- mutationGuard
1227
- ),
1261
+ ...token !== void 0 || accessControl !== void 0 ? { request: makeRequestImpl(requireActiveAccess, projectId, dataset) } : {},
1262
+ config: () => ({ projectId, dataset, perspective, ...token !== void 0 ? { token } : {} }),
1263
+ withConfig: (config) => {
1264
+ rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
1265
+ const rebinds = config.token !== void 0 || config.accessControl !== void 0;
1266
+ return makeHandle(
1267
+ backend,
1268
+ config.projectId ?? projectId,
1269
+ config.dataset ?? dataset,
1270
+ rebinds ? config.accessControl : accessControl,
1271
+ config.perspective ?? perspective,
1272
+ mutationGuard,
1273
+ rebinds ? config.token : token
1274
+ );
1275
+ },
1228
1276
  seed: (documents) => store.seed(documents),
1229
1277
  add: (documents) => store.add(documents),
1230
1278
  reset: () => store.reset(),
1231
1279
  snapshot: () => store.snapshot(),
1232
1280
  store: () => store,
1281
+ simulateConcurrentEdit: (id, count = 1) => {
1282
+ const editKey = concurrentEditKey(id);
1283
+ backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
1284
+ },
1233
1285
  datasets: () => [...registry.keys()].map(parseRegistryKey),
1234
1286
  stub: (name, handler) => {
1235
1287
  backend.stubs.set(name, handler);
1236
1288
  },
1237
1289
  stubs: () => Object.fromEntries(backend.stubs),
1290
+ addIdentity: (identityToken, identity) => {
1291
+ backend.identities.set(identityToken, identity);
1292
+ },
1293
+ identities: () => Object.fromEntries(backend.identities),
1238
1294
  mutations: () => backend.log.flatMap((entry) => entry.mutations),
1239
1295
  transactions: () => [...backend.log],
1240
1296
  clearLog: () => {