@sanity-labs/client-fake-for-test 0.3.0 → 0.5.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
@@ -78,7 +78,7 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
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
80
  | Config | `config()` | Returns `{ projectId, dataset, perspective }` |
81
- | Config | `withConfig({ projectId?, dataset?, perspective? })` | Sibling handle into the same multi-dataset registry |
81
+ | Config | `withConfig({ projectId?, dataset?, perspective?, accessControl? })` | Sibling handle into the same multi-dataset registry; `accessControl` rebinds the acting identity (a token-scoped sibling sharing the store) |
82
82
  | Perspectives | `raw` / `published` / `drafts` / release stacks | Per-client default or per-`fetch` override; draft/version layering with `_originalId` projection |
83
83
  | HTTP | `request({ url? \| uri? })` | Present **only** when the client is created with `accessControl`. Serves `/users/me` + the configured ACL path. Calling it without `accessControl` throws a guidance error. |
84
84
 
@@ -96,6 +96,44 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
96
96
  > [!NOTE]
97
97
  > The static return type of `create` / `delete` reflects the **default** shape (the document / `{ _id } | null`). When you pass `returnDocuments: false` to get an id-shaped result, supply a type argument or cast — the runtime value is correct either way.
98
98
 
99
+ ### Gating writes — `mutationGuard`
100
+
101
+ `createTestClient({ mutationGuard })` installs a pluggable write interceptor. It runs on every write through the document mutation API — `create` / `createOrReplace` / `createIfNotExists` / `patch` / `delete` and the equivalent `transaction()` / `mutate()` operations — _before_ the store changes, and **throws to reject** the write (the throw propagates out of the client call, like a real lake rejection). The release/version action API (`action`, `releases.*`, `createVersion`) is **not** intercepted.
102
+
103
+ The client owns the _seam_; the caller owns the _policy_. This is the hook a workflow test bench uses to enforce Content Lake **mutation guards** at the write path without re-implementing guard evaluation. Each call receives the before/after image (`before` is `null` on create, `after` is `null` on delete), the `action`, and the acting `identity`.
104
+
105
+ ```ts
106
+ const client = createTestClient({
107
+ documents: [{ _id: "article-1", _type: "article", body: "draft" }],
108
+ mutationGuard: ({ action, before, after }) => {
109
+ // Freeze `body` once an article is locked.
110
+ if (action === "update" && before?.locked && before.body !== after?.body) {
111
+ throw new Error("body is locked");
112
+ }
113
+ },
114
+ });
115
+
116
+ await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
117
+ await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();
118
+ ```
119
+
120
+ #### Identity is the token
121
+
122
+ `identity` is the id bound to the client's token — its `accessControl.currentUser`. To act as a different user against the **same** store (e.g. to exercise `identity()`-based guards), branch a token-scoped sibling with `withConfig({ accessControl })`:
123
+
124
+ ```ts
125
+ const alice = createTestClient({
126
+ documents: [article],
127
+ accessControl: { currentUser: { id: "alice" }, grants },
128
+ mutationGuard: onlyTheAssignedReviewerMayWrite,
129
+ });
130
+ const bob = alice.withConfig({ accessControl: { currentUser: { id: "bob" }, grants } });
131
+ // alice and bob share one store; each write carries its own identity().
132
+ ```
133
+
134
+ > [!NOTE]
135
+ > Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
136
+
99
137
  ### Test-only helpers (not on real `SanityClient`)
100
138
 
101
139
  Useful in test bodies, but a giveaway that the code under test isn't using the real client. Keep them in the test file, not in the code being tested.
package/dist/index.cjs CHANGED
@@ -861,7 +861,13 @@ const RECORDED_METHODS = /* @__PURE__ */ new Set([
861
861
  return { projectId, dataset };
862
862
  };
863
863
  function createTestClient(options = {}) {
864
- 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) => {
864
+ const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
865
+ registry: /* @__PURE__ */ new Map(),
866
+ log: [],
867
+ stubs: /* @__PURE__ */ new Map(),
868
+ calls: [],
869
+ concurrentEdits: /* @__PURE__ */ new Map()
870
+ }, registry = backend.registry, ensureStore = (projectId, dataset) => {
865
871
  const key = registryKey(projectId, dataset);
866
872
  let store = registry.get(key);
867
873
  return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
@@ -876,7 +882,8 @@ function createTestClient(options = {}) {
876
882
  defaultProjectId,
877
883
  defaultDataset,
878
884
  options.accessControl,
879
- options.perspective ?? "raw"
885
+ options.perspective ?? "raw",
886
+ options.mutationGuard
880
887
  );
881
888
  }
882
889
  function dedupeIds(results) {
@@ -929,12 +936,12 @@ function toTxnMutations(operations) {
929
936
  }
930
937
  ];
931
938
  }
932
- function makeHandle(backend, projectId, dataset, accessControl, perspective) {
939
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
933
940
  const { registry } = backend, store = (() => {
934
941
  const key = registryKey(projectId, dataset);
935
942
  let s = registry.get(key);
936
943
  return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
937
- })(), record = (transactionId, mutations) => {
944
+ })(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations) => {
938
945
  backend.log.push({ transactionId, projectId, dataset, mutations });
939
946
  }, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
940
947
  documents,
@@ -962,6 +969,11 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
962
969
  };
963
970
  }, checkRevision = (id, ifRevisionId) => {
964
971
  if (ifRevisionId === void 0) return;
972
+ const editKey = concurrentEditKey(id), pendingEdits = backend.concurrentEdits.get(editKey) ?? 0;
973
+ if (pendingEdits > 0)
974
+ throw backend.concurrentEdits.set(editKey, pendingEdits - 1), fakeError(
975
+ `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.)`
976
+ );
965
977
  const existing = store.get(id);
966
978
  if (existing === void 0)
967
979
  throw fakeError(
@@ -978,6 +990,8 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
978
990
  permission,
979
991
  userId: accessControl.currentUser.id
980
992
  });
993
+ }, guardGate = async (action, before, after) => {
994
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
981
995
  }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
982
996
  document,
983
997
  grants: accessControl.grants,
@@ -995,7 +1009,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
995
1009
  _rev: cryptoRandomKey(),
996
1010
  _updatedAt: now
997
1011
  };
998
- return store.put(next), next;
1012
+ return await guardGate("update", existing, next), store.put(next), next;
999
1013
  }, applyTransaction = async (mutations, txnOptions) => {
1000
1014
  const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
1001
1015
  snapshot.has(id) || snapshot.set(id, store.get(id));
@@ -1010,13 +1024,17 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1010
1024
  throw fakeError(
1011
1025
  `create() failed: a document with _id "${promoted._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1012
1026
  );
1013
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1027
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1014
1028
  break;
1015
1029
  }
1016
1030
  case "createOrReplace": {
1017
1031
  remember(mut.doc._id);
1018
1032
  const promoted = promote2(mut.doc), existing = store.get(promoted._id);
1019
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1033
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(
1034
+ existing !== void 0 ? "update" : "create",
1035
+ existing ?? null,
1036
+ promoted
1037
+ );
1020
1038
  const wasPresent = existing !== void 0;
1021
1039
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
1022
1040
  break;
@@ -1024,14 +1042,14 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1024
1042
  case "createIfNotExists": {
1025
1043
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
1026
1044
  const promoted = promote2(mut.doc);
1027
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1045
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1028
1046
  }
1029
1047
  break;
1030
1048
  }
1031
1049
  case "delete": {
1032
1050
  remember(mut.id);
1033
1051
  const existing = store.get(mut.id);
1034
- existing !== void 0 && await gate(existing, "update"), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1052
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1035
1053
  break;
1036
1054
  }
1037
1055
  case "patch": {
@@ -1103,7 +1121,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1103
1121
  `create() failed: a document with _id "${doc._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1104
1122
  );
1105
1123
  const promoted = promote2(doc);
1106
- await gate(promoted, "create");
1124
+ await gate(promoted, "create"), await guardGate("create", null, promoted);
1107
1125
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1108
1126
  return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
1109
1127
  [promoted],
@@ -1115,7 +1133,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1115
1133
  createOrReplace: async (doc, opts) => {
1116
1134
  checkRevision(doc._id, opts?.ifRevisionId);
1117
1135
  const promoted = promote2(doc), existing = store.get(doc._id);
1118
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1136
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1119
1137
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1120
1138
  opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
1121
1139
  const operation = existing !== void 0 ? "update" : "create";
@@ -1137,7 +1155,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1137
1155
  opts
1138
1156
  );
1139
1157
  const promoted = promote2(doc);
1140
- return await gate(promoted, "create"), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1158
+ return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1141
1159
  [promoted],
1142
1160
  [{ id: promoted._id, operation: "create" }],
1143
1161
  transactionId,
@@ -1147,7 +1165,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1147
1165
  delete: async (id, opts) => {
1148
1166
  checkRevision(id, opts?.ifRevisionId);
1149
1167
  const existing = store.get(id);
1150
- existing !== void 0 && await gate(existing, "update");
1168
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
1151
1169
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1152
1170
  let removed = existing;
1153
1171
  return opts?.dryRun !== !0 && (removed = store.remove(id), removed !== void 0 && record(transactionId, [{ kind: "delete", id }])), opts?.returnDocuments === void 0 && opts?.returnFirst === void 0 ? removed !== void 0 ? { _id: id } : null : formatMutationResult(
@@ -1216,14 +1234,19 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1216
1234
  backend,
1217
1235
  config.projectId ?? projectId,
1218
1236
  config.dataset ?? dataset,
1219
- accessControl,
1220
- config.perspective ?? perspective
1237
+ config.accessControl ?? accessControl,
1238
+ config.perspective ?? perspective,
1239
+ mutationGuard
1221
1240
  ),
1222
1241
  seed: (documents) => store.seed(documents),
1223
1242
  add: (documents) => store.add(documents),
1224
1243
  reset: () => store.reset(),
1225
1244
  snapshot: () => store.snapshot(),
1226
1245
  store: () => store,
1246
+ simulateConcurrentEdit: (id, count = 1) => {
1247
+ const editKey = concurrentEditKey(id);
1248
+ backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
1249
+ },
1227
1250
  datasets: () => [...registry.keys()].map(parseRegistryKey),
1228
1251
  stub: (name, handler) => {
1229
1252
  backend.stubs.set(name, handler);