@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/dist/index.d.cts CHANGED
@@ -110,6 +110,15 @@ export declare interface CreateTestClientOptions {
110
110
  * unconditionally — the legacy behavior.
111
111
  */
112
112
  accessControl?: TestClientAccessControl;
113
+ /**
114
+ * Pluggable write interceptor — runs on each write through the document
115
+ * mutation API (create/patch/delete/transaction; not the release/version
116
+ * action API) before the store changes, and may throw to reject it. The
117
+ * client has no opinion about the policy; callers (e.g. a workflow test bench
118
+ * enforcing lake mutation guards) supply it. Inherited by `withConfig`
119
+ * siblings. See {@link MutationGuard}.
120
+ */
121
+ mutationGuard?: MutationGuard;
113
122
  /**
114
123
  * Default perspective applied to reads. Per-fetch `options.perspective`
115
124
  * overrides this. `withConfig({ perspective })` returns a sibling
@@ -259,6 +268,8 @@ export declare type FakeMutation =
259
268
  *
260
269
  * - `seed`, `add`, `reset`, `snapshot`, `store` — bulk-load helpers,
261
270
  * scoped to the current dataset.
271
+ * - `simulateConcurrentEdit()` — force the next N revision-guarded writes
272
+ * to a document to 409, for optimistic-locking retry tests.
262
273
  * - `datasets()` — lists known dataset keys in this registry.
263
274
  *
264
275
  * Methods deliberately omitted:
@@ -329,6 +340,58 @@ export declare interface MultipleMutationResult {
329
340
  }[];
330
341
  }
331
342
 
343
+ /**
344
+ * A pluggable write interceptor, configured via
345
+ * `createTestClient({ mutationGuard })`. It runs on each write through the
346
+ * document mutation API — `create` / `createOrReplace` / `createIfNotExists` /
347
+ * `patch` / `delete` and the equivalent `transaction()` operations — before the
348
+ * store changes; **throw to reject the write** (the throw propagates out of the
349
+ * client call, exactly as a real lake rejection would). The release/version
350
+ * action API (`client.action`, `client.releases.*`, `createVersion`) is **not**
351
+ * intercepted.
352
+ *
353
+ * The client itself has no opinion about *what* a guard is — it only provides
354
+ * the seam and the before/after/identity context. Callers (e.g. a workflow test
355
+ * bench) supply the policy: evaluate lake mutation guards, enforce field locks,
356
+ * etc. Keeping the policy in the caller avoids duplicating guard-evaluation
357
+ * logic the client has no business owning.
358
+ */
359
+ export declare type MutationGuard = (
360
+ context: MutationGuardContext,
361
+ ) => void | Promise<void>;
362
+
363
+ /** The kind of write a {@link MutationGuard} is asked to vet. */
364
+ export declare type MutationGuardAction = "create" | "update" | "delete";
365
+
366
+ /**
367
+ * The before/after image of a single document write, handed to a
368
+ * {@link MutationGuard} before it is applied.
369
+ *
370
+ * - `before` is null on create; `after` is null on delete.
371
+ * - `identity` is the acting user — the id bound to the client's token
372
+ * (its `accessControl.currentUser`), or undefined when the client has no
373
+ * identity configured.
374
+ *
375
+ * Treat `before`/`after` as read-only — they are the live store images, not
376
+ * copies; a guard that mutates them corrupts the store.
377
+ */
378
+ export declare interface MutationGuardContext {
379
+ action: MutationGuardAction;
380
+ before:
381
+ | (SanityDocumentLike & {
382
+ _id: string;
383
+ _type: string;
384
+ })
385
+ | null;
386
+ after:
387
+ | (SanityDocumentLike & {
388
+ _id: string;
389
+ _type: string;
390
+ })
391
+ | null;
392
+ identity: string | undefined;
393
+ }
394
+
332
395
  /** Append-only record of one committed transaction (a single mutation is a 1-entry transaction). */
333
396
  export declare interface MutationLogEntry {
334
397
  transactionId: string;
@@ -706,6 +769,12 @@ export declare interface TestClient {
706
769
  projectId: string;
707
770
  dataset: string;
708
771
  perspective: Perspective;
772
+ /**
773
+ * Rebind the acting identity (and its grants) on a sibling that shares
774
+ * the same store — the test analogue of a token-scoped client. Lets two
775
+ * actors write the same dataset, e.g. for `identity()`-based guards.
776
+ */
777
+ accessControl: TestClientAccessControl;
709
778
  }>,
710
779
  ) => TestClient;
711
780
  /** Replace the current dataset with the given documents. */
@@ -718,6 +787,15 @@ export declare interface TestClient {
718
787
  snapshot: () => readonly SanityDocument[];
719
788
  /** Direct store access for the current dataset. */
720
789
  store: () => TestStore;
790
+ /**
791
+ * Simulate `count` concurrent edits to `id` in the current dataset: the
792
+ * next `count` revision-guarded mutations (those passing `ifRevisionId`)
793
+ * to that document throw a 409-shaped conflict, as if another client
794
+ * committed between the caller's read and write. Lets a test exercise
795
+ * optimistic-locking retry paths deterministically, without timing or
796
+ * interleaving. Mutations that don't pass `ifRevisionId` are unaffected.
797
+ */
798
+ simulateConcurrentEdit: (id: string, count?: number) => void;
721
799
  /** List `(projectId, dataset)` pairs known in this registry. */
722
800
  datasets: () => {
723
801
  projectId: string;
package/dist/index.d.ts CHANGED
@@ -110,6 +110,15 @@ export declare interface CreateTestClientOptions {
110
110
  * unconditionally — the legacy behavior.
111
111
  */
112
112
  accessControl?: TestClientAccessControl;
113
+ /**
114
+ * Pluggable write interceptor — runs on each write through the document
115
+ * mutation API (create/patch/delete/transaction; not the release/version
116
+ * action API) before the store changes, and may throw to reject it. The
117
+ * client has no opinion about the policy; callers (e.g. a workflow test bench
118
+ * enforcing lake mutation guards) supply it. Inherited by `withConfig`
119
+ * siblings. See {@link MutationGuard}.
120
+ */
121
+ mutationGuard?: MutationGuard;
113
122
  /**
114
123
  * Default perspective applied to reads. Per-fetch `options.perspective`
115
124
  * overrides this. `withConfig({ perspective })` returns a sibling
@@ -259,6 +268,8 @@ export declare type FakeMutation =
259
268
  *
260
269
  * - `seed`, `add`, `reset`, `snapshot`, `store` — bulk-load helpers,
261
270
  * scoped to the current dataset.
271
+ * - `simulateConcurrentEdit()` — force the next N revision-guarded writes
272
+ * to a document to 409, for optimistic-locking retry tests.
262
273
  * - `datasets()` — lists known dataset keys in this registry.
263
274
  *
264
275
  * Methods deliberately omitted:
@@ -329,6 +340,58 @@ export declare interface MultipleMutationResult {
329
340
  }[];
330
341
  }
331
342
 
343
+ /**
344
+ * A pluggable write interceptor, configured via
345
+ * `createTestClient({ mutationGuard })`. It runs on each write through the
346
+ * document mutation API — `create` / `createOrReplace` / `createIfNotExists` /
347
+ * `patch` / `delete` and the equivalent `transaction()` operations — before the
348
+ * store changes; **throw to reject the write** (the throw propagates out of the
349
+ * client call, exactly as a real lake rejection would). The release/version
350
+ * action API (`client.action`, `client.releases.*`, `createVersion`) is **not**
351
+ * intercepted.
352
+ *
353
+ * The client itself has no opinion about *what* a guard is — it only provides
354
+ * the seam and the before/after/identity context. Callers (e.g. a workflow test
355
+ * bench) supply the policy: evaluate lake mutation guards, enforce field locks,
356
+ * etc. Keeping the policy in the caller avoids duplicating guard-evaluation
357
+ * logic the client has no business owning.
358
+ */
359
+ export declare type MutationGuard = (
360
+ context: MutationGuardContext,
361
+ ) => void | Promise<void>;
362
+
363
+ /** The kind of write a {@link MutationGuard} is asked to vet. */
364
+ export declare type MutationGuardAction = "create" | "update" | "delete";
365
+
366
+ /**
367
+ * The before/after image of a single document write, handed to a
368
+ * {@link MutationGuard} before it is applied.
369
+ *
370
+ * - `before` is null on create; `after` is null on delete.
371
+ * - `identity` is the acting user — the id bound to the client's token
372
+ * (its `accessControl.currentUser`), or undefined when the client has no
373
+ * identity configured.
374
+ *
375
+ * Treat `before`/`after` as read-only — they are the live store images, not
376
+ * copies; a guard that mutates them corrupts the store.
377
+ */
378
+ export declare interface MutationGuardContext {
379
+ action: MutationGuardAction;
380
+ before:
381
+ | (SanityDocumentLike & {
382
+ _id: string;
383
+ _type: string;
384
+ })
385
+ | null;
386
+ after:
387
+ | (SanityDocumentLike & {
388
+ _id: string;
389
+ _type: string;
390
+ })
391
+ | null;
392
+ identity: string | undefined;
393
+ }
394
+
332
395
  /** Append-only record of one committed transaction (a single mutation is a 1-entry transaction). */
333
396
  export declare interface MutationLogEntry {
334
397
  transactionId: string;
@@ -706,6 +769,12 @@ export declare interface TestClient {
706
769
  projectId: string;
707
770
  dataset: string;
708
771
  perspective: Perspective;
772
+ /**
773
+ * Rebind the acting identity (and its grants) on a sibling that shares
774
+ * the same store — the test analogue of a token-scoped client. Lets two
775
+ * actors write the same dataset, e.g. for `identity()`-based guards.
776
+ */
777
+ accessControl: TestClientAccessControl;
709
778
  }>,
710
779
  ) => TestClient;
711
780
  /** Replace the current dataset with the given documents. */
@@ -718,6 +787,15 @@ export declare interface TestClient {
718
787
  snapshot: () => readonly SanityDocument[];
719
788
  /** Direct store access for the current dataset. */
720
789
  store: () => TestStore;
790
+ /**
791
+ * Simulate `count` concurrent edits to `id` in the current dataset: the
792
+ * next `count` revision-guarded mutations (those passing `ifRevisionId`)
793
+ * to that document throw a 409-shaped conflict, as if another client
794
+ * committed between the caller's read and write. Lets a test exercise
795
+ * optimistic-locking retry paths deterministically, without timing or
796
+ * interleaving. Mutations that don't pass `ifRevisionId` are unaffected.
797
+ */
798
+ simulateConcurrentEdit: (id: string, count?: number) => void;
721
799
  /** List `(projectId, dataset)` pairs known in this registry. */
722
800
  datasets: () => {
723
801
  projectId: string;
package/dist/index.js CHANGED
@@ -859,7 +859,13 @@ const RECORDED_METHODS = /* @__PURE__ */ new Set([
859
859
  return { projectId, dataset };
860
860
  };
861
861
  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) => {
862
+ const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
863
+ registry: /* @__PURE__ */ new Map(),
864
+ log: [],
865
+ stubs: /* @__PURE__ */ new Map(),
866
+ calls: [],
867
+ concurrentEdits: /* @__PURE__ */ new Map()
868
+ }, registry = backend.registry, ensureStore = (projectId, dataset) => {
863
869
  const key = registryKey(projectId, dataset);
864
870
  let store = registry.get(key);
865
871
  return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
@@ -874,7 +880,8 @@ function createTestClient(options = {}) {
874
880
  defaultProjectId,
875
881
  defaultDataset,
876
882
  options.accessControl,
877
- options.perspective ?? "raw"
883
+ options.perspective ?? "raw",
884
+ options.mutationGuard
878
885
  );
879
886
  }
880
887
  function dedupeIds(results) {
@@ -927,12 +934,12 @@ function toTxnMutations(operations) {
927
934
  }
928
935
  ];
929
936
  }
930
- function makeHandle(backend, projectId, dataset, accessControl, perspective) {
937
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
931
938
  const { registry } = backend, store = (() => {
932
939
  const key = registryKey(projectId, dataset);
933
940
  let s = registry.get(key);
934
941
  return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
935
- })(), record = (transactionId, mutations) => {
942
+ })(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations) => {
936
943
  backend.log.push({ transactionId, projectId, dataset, mutations });
937
944
  }, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
938
945
  documents,
@@ -960,6 +967,11 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
960
967
  };
961
968
  }, checkRevision = (id, ifRevisionId) => {
962
969
  if (ifRevisionId === void 0) return;
970
+ const editKey = concurrentEditKey(id), pendingEdits = backend.concurrentEdits.get(editKey) ?? 0;
971
+ if (pendingEdits > 0)
972
+ throw backend.concurrentEdits.set(editKey, pendingEdits - 1), fakeError(
973
+ `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.)`
974
+ );
963
975
  const existing = store.get(id);
964
976
  if (existing === void 0)
965
977
  throw fakeError(
@@ -976,6 +988,8 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
976
988
  permission,
977
989
  userId: accessControl.currentUser.id
978
990
  });
991
+ }, guardGate = async (action, before, after) => {
992
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
979
993
  }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
980
994
  document,
981
995
  grants: accessControl.grants,
@@ -993,7 +1007,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
993
1007
  _rev: cryptoRandomKey(),
994
1008
  _updatedAt: now
995
1009
  };
996
- return store.put(next), next;
1010
+ return await guardGate("update", existing, next), store.put(next), next;
997
1011
  }, applyTransaction = async (mutations, txnOptions) => {
998
1012
  const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
999
1013
  snapshot.has(id) || snapshot.set(id, store.get(id));
@@ -1008,13 +1022,17 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1008
1022
  throw fakeError(
1009
1023
  `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.`
1010
1024
  );
1011
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1025
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1012
1026
  break;
1013
1027
  }
1014
1028
  case "createOrReplace": {
1015
1029
  remember(mut.doc._id);
1016
1030
  const promoted = promote2(mut.doc), existing = store.get(promoted._id);
1017
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1031
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(
1032
+ existing !== void 0 ? "update" : "create",
1033
+ existing ?? null,
1034
+ promoted
1035
+ );
1018
1036
  const wasPresent = existing !== void 0;
1019
1037
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
1020
1038
  break;
@@ -1022,14 +1040,14 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1022
1040
  case "createIfNotExists": {
1023
1041
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
1024
1042
  const promoted = promote2(mut.doc);
1025
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1043
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1026
1044
  }
1027
1045
  break;
1028
1046
  }
1029
1047
  case "delete": {
1030
1048
  remember(mut.id);
1031
1049
  const existing = store.get(mut.id);
1032
- existing !== void 0 && await gate(existing, "update"), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1050
+ 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" }));
1033
1051
  break;
1034
1052
  }
1035
1053
  case "patch": {
@@ -1101,7 +1119,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1101
1119
  `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.`
1102
1120
  );
1103
1121
  const promoted = promote2(doc);
1104
- await gate(promoted, "create");
1122
+ await gate(promoted, "create"), await guardGate("create", null, promoted);
1105
1123
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1106
1124
  return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
1107
1125
  [promoted],
@@ -1113,7 +1131,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1113
1131
  createOrReplace: async (doc, opts) => {
1114
1132
  checkRevision(doc._id, opts?.ifRevisionId);
1115
1133
  const promoted = promote2(doc), existing = store.get(doc._id);
1116
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1134
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1117
1135
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1118
1136
  opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
1119
1137
  const operation = existing !== void 0 ? "update" : "create";
@@ -1135,7 +1153,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1135
1153
  opts
1136
1154
  );
1137
1155
  const promoted = promote2(doc);
1138
- return await gate(promoted, "create"), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1156
+ return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1139
1157
  [promoted],
1140
1158
  [{ id: promoted._id, operation: "create" }],
1141
1159
  transactionId,
@@ -1145,7 +1163,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1145
1163
  delete: async (id, opts) => {
1146
1164
  checkRevision(id, opts?.ifRevisionId);
1147
1165
  const existing = store.get(id);
1148
- existing !== void 0 && await gate(existing, "update");
1166
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
1149
1167
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1150
1168
  let removed = existing;
1151
1169
  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(
@@ -1214,14 +1232,19 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1214
1232
  backend,
1215
1233
  config.projectId ?? projectId,
1216
1234
  config.dataset ?? dataset,
1217
- accessControl,
1218
- config.perspective ?? perspective
1235
+ config.accessControl ?? accessControl,
1236
+ config.perspective ?? perspective,
1237
+ mutationGuard
1219
1238
  ),
1220
1239
  seed: (documents) => store.seed(documents),
1221
1240
  add: (documents) => store.add(documents),
1222
1241
  reset: () => store.reset(),
1223
1242
  snapshot: () => store.snapshot(),
1224
1243
  store: () => store,
1244
+ simulateConcurrentEdit: (id, count = 1) => {
1245
+ const editKey = concurrentEditKey(id);
1246
+ backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
1247
+ },
1225
1248
  datasets: () => [...registry.keys()].map(parseRegistryKey),
1226
1249
  stub: (name, handler) => {
1227
1250
  backend.stubs.set(name, handler);