@sanity-labs/client-fake-for-test 0.3.0 → 0.4.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
@@ -329,6 +338,58 @@ export declare interface MultipleMutationResult {
329
338
  }[];
330
339
  }
331
340
 
341
+ /**
342
+ * A pluggable write interceptor, configured via
343
+ * `createTestClient({ mutationGuard })`. It runs on each write through the
344
+ * document mutation API — `create` / `createOrReplace` / `createIfNotExists` /
345
+ * `patch` / `delete` and the equivalent `transaction()` operations — before the
346
+ * store changes; **throw to reject the write** (the throw propagates out of the
347
+ * client call, exactly as a real lake rejection would). The release/version
348
+ * action API (`client.action`, `client.releases.*`, `createVersion`) is **not**
349
+ * intercepted.
350
+ *
351
+ * The client itself has no opinion about *what* a guard is — it only provides
352
+ * the seam and the before/after/identity context. Callers (e.g. a workflow test
353
+ * bench) supply the policy: evaluate lake mutation guards, enforce field locks,
354
+ * etc. Keeping the policy in the caller avoids duplicating guard-evaluation
355
+ * logic the client has no business owning.
356
+ */
357
+ export declare type MutationGuard = (
358
+ context: MutationGuardContext,
359
+ ) => void | Promise<void>;
360
+
361
+ /** The kind of write a {@link MutationGuard} is asked to vet. */
362
+ export declare type MutationGuardAction = "create" | "update" | "delete";
363
+
364
+ /**
365
+ * The before/after image of a single document write, handed to a
366
+ * {@link MutationGuard} before it is applied.
367
+ *
368
+ * - `before` is null on create; `after` is null on delete.
369
+ * - `identity` is the acting user — the id bound to the client's token
370
+ * (its `accessControl.currentUser`), or undefined when the client has no
371
+ * identity configured.
372
+ *
373
+ * Treat `before`/`after` as read-only — they are the live store images, not
374
+ * copies; a guard that mutates them corrupts the store.
375
+ */
376
+ export declare interface MutationGuardContext {
377
+ action: MutationGuardAction;
378
+ before:
379
+ | (SanityDocumentLike & {
380
+ _id: string;
381
+ _type: string;
382
+ })
383
+ | null;
384
+ after:
385
+ | (SanityDocumentLike & {
386
+ _id: string;
387
+ _type: string;
388
+ })
389
+ | null;
390
+ identity: string | undefined;
391
+ }
392
+
332
393
  /** Append-only record of one committed transaction (a single mutation is a 1-entry transaction). */
333
394
  export declare interface MutationLogEntry {
334
395
  transactionId: string;
@@ -706,6 +767,12 @@ export declare interface TestClient {
706
767
  projectId: string;
707
768
  dataset: string;
708
769
  perspective: Perspective;
770
+ /**
771
+ * Rebind the acting identity (and its grants) on a sibling that shares
772
+ * the same store — the test analogue of a token-scoped client. Lets two
773
+ * actors write the same dataset, e.g. for `identity()`-based guards.
774
+ */
775
+ accessControl: TestClientAccessControl;
709
776
  }>,
710
777
  ) => TestClient;
711
778
  /** Replace the current dataset with the given documents. */
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
@@ -329,6 +338,58 @@ export declare interface MultipleMutationResult {
329
338
  }[];
330
339
  }
331
340
 
341
+ /**
342
+ * A pluggable write interceptor, configured via
343
+ * `createTestClient({ mutationGuard })`. It runs on each write through the
344
+ * document mutation API — `create` / `createOrReplace` / `createIfNotExists` /
345
+ * `patch` / `delete` and the equivalent `transaction()` operations — before the
346
+ * store changes; **throw to reject the write** (the throw propagates out of the
347
+ * client call, exactly as a real lake rejection would). The release/version
348
+ * action API (`client.action`, `client.releases.*`, `createVersion`) is **not**
349
+ * intercepted.
350
+ *
351
+ * The client itself has no opinion about *what* a guard is — it only provides
352
+ * the seam and the before/after/identity context. Callers (e.g. a workflow test
353
+ * bench) supply the policy: evaluate lake mutation guards, enforce field locks,
354
+ * etc. Keeping the policy in the caller avoids duplicating guard-evaluation
355
+ * logic the client has no business owning.
356
+ */
357
+ export declare type MutationGuard = (
358
+ context: MutationGuardContext,
359
+ ) => void | Promise<void>;
360
+
361
+ /** The kind of write a {@link MutationGuard} is asked to vet. */
362
+ export declare type MutationGuardAction = "create" | "update" | "delete";
363
+
364
+ /**
365
+ * The before/after image of a single document write, handed to a
366
+ * {@link MutationGuard} before it is applied.
367
+ *
368
+ * - `before` is null on create; `after` is null on delete.
369
+ * - `identity` is the acting user — the id bound to the client's token
370
+ * (its `accessControl.currentUser`), or undefined when the client has no
371
+ * identity configured.
372
+ *
373
+ * Treat `before`/`after` as read-only — they are the live store images, not
374
+ * copies; a guard that mutates them corrupts the store.
375
+ */
376
+ export declare interface MutationGuardContext {
377
+ action: MutationGuardAction;
378
+ before:
379
+ | (SanityDocumentLike & {
380
+ _id: string;
381
+ _type: string;
382
+ })
383
+ | null;
384
+ after:
385
+ | (SanityDocumentLike & {
386
+ _id: string;
387
+ _type: string;
388
+ })
389
+ | null;
390
+ identity: string | undefined;
391
+ }
392
+
332
393
  /** Append-only record of one committed transaction (a single mutation is a 1-entry transaction). */
333
394
  export declare interface MutationLogEntry {
334
395
  transactionId: string;
@@ -706,6 +767,12 @@ export declare interface TestClient {
706
767
  projectId: string;
707
768
  dataset: string;
708
769
  perspective: Perspective;
770
+ /**
771
+ * Rebind the acting identity (and its grants) on a sibling that shares
772
+ * the same store — the test analogue of a token-scoped client. Lets two
773
+ * actors write the same dataset, e.g. for `identity()`-based guards.
774
+ */
775
+ accessControl: TestClientAccessControl;
709
776
  }>,
710
777
  ) => TestClient;
711
778
  /** Replace the current dataset with the given documents. */
package/dist/index.js CHANGED
@@ -874,7 +874,8 @@ function createTestClient(options = {}) {
874
874
  defaultProjectId,
875
875
  defaultDataset,
876
876
  options.accessControl,
877
- options.perspective ?? "raw"
877
+ options.perspective ?? "raw",
878
+ options.mutationGuard
878
879
  );
879
880
  }
880
881
  function dedupeIds(results) {
@@ -927,7 +928,7 @@ function toTxnMutations(operations) {
927
928
  }
928
929
  ];
929
930
  }
930
- function makeHandle(backend, projectId, dataset, accessControl, perspective) {
931
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
931
932
  const { registry } = backend, store = (() => {
932
933
  const key = registryKey(projectId, dataset);
933
934
  let s = registry.get(key);
@@ -976,6 +977,8 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
976
977
  permission,
977
978
  userId: accessControl.currentUser.id
978
979
  });
980
+ }, guardGate = async (action, before, after) => {
981
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
979
982
  }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
980
983
  document,
981
984
  grants: accessControl.grants,
@@ -993,7 +996,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
993
996
  _rev: cryptoRandomKey(),
994
997
  _updatedAt: now
995
998
  };
996
- return store.put(next), next;
999
+ return await guardGate("update", existing, next), store.put(next), next;
997
1000
  }, applyTransaction = async (mutations, txnOptions) => {
998
1001
  const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
999
1002
  snapshot.has(id) || snapshot.set(id, store.get(id));
@@ -1008,13 +1011,17 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1008
1011
  throw fakeError(
1009
1012
  `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
1013
  );
1011
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1014
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1012
1015
  break;
1013
1016
  }
1014
1017
  case "createOrReplace": {
1015
1018
  remember(mut.doc._id);
1016
1019
  const promoted = promote2(mut.doc), existing = store.get(promoted._id);
1017
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1020
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(
1021
+ existing !== void 0 ? "update" : "create",
1022
+ existing ?? null,
1023
+ promoted
1024
+ );
1018
1025
  const wasPresent = existing !== void 0;
1019
1026
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
1020
1027
  break;
@@ -1022,14 +1029,14 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1022
1029
  case "createIfNotExists": {
1023
1030
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
1024
1031
  const promoted = promote2(mut.doc);
1025
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1032
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1026
1033
  }
1027
1034
  break;
1028
1035
  }
1029
1036
  case "delete": {
1030
1037
  remember(mut.id);
1031
1038
  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" }));
1039
+ 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
1040
  break;
1034
1041
  }
1035
1042
  case "patch": {
@@ -1101,7 +1108,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1101
1108
  `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
1109
  );
1103
1110
  const promoted = promote2(doc);
1104
- await gate(promoted, "create");
1111
+ await gate(promoted, "create"), await guardGate("create", null, promoted);
1105
1112
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1106
1113
  return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
1107
1114
  [promoted],
@@ -1113,7 +1120,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1113
1120
  createOrReplace: async (doc, opts) => {
1114
1121
  checkRevision(doc._id, opts?.ifRevisionId);
1115
1122
  const promoted = promote2(doc), existing = store.get(doc._id);
1116
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1123
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1117
1124
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1118
1125
  opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
1119
1126
  const operation = existing !== void 0 ? "update" : "create";
@@ -1135,7 +1142,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1135
1142
  opts
1136
1143
  );
1137
1144
  const promoted = promote2(doc);
1138
- return await gate(promoted, "create"), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1145
+ return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1139
1146
  [promoted],
1140
1147
  [{ id: promoted._id, operation: "create" }],
1141
1148
  transactionId,
@@ -1145,7 +1152,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1145
1152
  delete: async (id, opts) => {
1146
1153
  checkRevision(id, opts?.ifRevisionId);
1147
1154
  const existing = store.get(id);
1148
- existing !== void 0 && await gate(existing, "update");
1155
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
1149
1156
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1150
1157
  let removed = existing;
1151
1158
  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,8 +1221,9 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1214
1221
  backend,
1215
1222
  config.projectId ?? projectId,
1216
1223
  config.dataset ?? dataset,
1217
- accessControl,
1218
- config.perspective ?? perspective
1224
+ config.accessControl ?? accessControl,
1225
+ config.perspective ?? perspective,
1226
+ mutationGuard
1219
1227
  ),
1220
1228
  seed: (documents) => store.seed(documents),
1221
1229
  add: (documents) => store.add(documents),