@sanity-labs/client-fake-for-test 0.2.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.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { parse, evaluate } from "groq-js";
2
+ const ERR_PREFIX = "[client-fake-for-test]";
3
+ function fakeError(message) {
4
+ return new Error(`${ERR_PREFIX} ${message}`);
5
+ }
2
6
  class WriteAccessDeniedError extends Error {
3
7
  documentId;
4
8
  permission;
@@ -63,17 +67,21 @@ function makeRequestImpl(accessControl, projectId, dataset) {
63
67
  return accessControl.currentUser;
64
68
  if (path === aclPath)
65
69
  return accessControl.grants;
66
- throw new Error(
67
- `TestClient.request: no handler for path "${path}". The TestClient only serves "/users/me" and "${aclPath}". Configure additional endpoints via createTestClient({ accessControl }).`
70
+ 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.`
68
72
  );
69
73
  };
70
74
  }
71
75
  function createTestStore(initial) {
72
76
  const docs = /* @__PURE__ */ new Map(), promote2 = (doc) => {
73
77
  if (typeof doc._id != "string" || doc._id.length === 0)
74
- throw new Error(`document is missing _id: ${JSON.stringify(doc)}`);
78
+ throw fakeError(
79
+ `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)}`
80
+ );
75
81
  if (typeof doc._type != "string" || doc._type.length === 0)
76
- throw new Error(`document ${doc._id} is missing _type`);
82
+ throw fakeError(
83
+ `Document "${doc._id}" is missing a non-empty string \`_type\` (got ${JSON.stringify(doc._type)}). Every Sanity document needs a _type.`
84
+ );
77
85
  const now = (/* @__PURE__ */ new Date()).toISOString();
78
86
  return {
79
87
  ...doc,
@@ -115,6 +123,9 @@ function cryptoRandomKey(length = 12) {
115
123
  const bytes = new Uint8Array(length);
116
124
  return globalThis.crypto.getRandomValues(bytes), [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length);
117
125
  }
126
+ function describeType(value) {
127
+ return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`;
128
+ }
118
129
  function createPatch(documentId, executor) {
119
130
  const ops = [];
120
131
  let ifRev;
@@ -175,7 +186,10 @@ function parsePath(path) {
175
186
  const segments = [];
176
187
  for (const raw of path.split(".")) {
177
188
  const match = /^([A-Za-z_$][\w$]*)(?:\[(.+)\])?$/.exec(raw);
178
- if (!match) throw new Error(`Unsupported patch path segment: ${raw}`);
189
+ if (!match)
190
+ throw fakeError(
191
+ `Unsupported patch path segment "${raw}" (in path "${path}"). This fake supports dotted field access ("a.b.c"), array index ("items[0]"), and keyed lookup (\`items[_key=="x"]\`). Recursive descent ("..") and GROQ filter expressions are not supported.`
192
+ );
179
193
  const key = match[1], selectorRaw = match[2];
180
194
  if (selectorRaw === void 0) {
181
195
  segments.push({ key });
@@ -194,7 +208,9 @@ function parsePath(path) {
194
208
  });
195
209
  continue;
196
210
  }
197
- throw new Error(`Unsupported array selector in patch path: ${selectorRaw}`);
211
+ throw fakeError(
212
+ `Unsupported array selector "[${selectorRaw}]" in patch path "${path}". Supported selectors are a numeric index (e.g. "[0]") and a single keyed match (e.g. \`[_key=="abc"]\`). Multi-key matches and arbitrary GROQ filters are not supported.`
213
+ );
198
214
  }
199
215
  return segments;
200
216
  }
@@ -204,7 +220,9 @@ function resolveSelector(array, selector) {
204
220
  (entry) => typeof entry == "object" && entry !== null && entry[selector.field] === selector.value
205
221
  );
206
222
  if (idx === -1)
207
- throw new Error(`No array entry matched ${selector.field}=="${selector.value}" in patch path`);
223
+ throw fakeError(
224
+ `Patch path matched no array entry where ${selector.field}=="${selector.value}". The array has ${array.length} item(s); check the key exists (and that you're patching the right document).`
225
+ );
208
226
  return idx;
209
227
  }
210
228
  function getAtPath(doc, path) {
@@ -222,14 +240,17 @@ function getAtPath(doc, path) {
222
240
  }
223
241
  function setAtPath(doc, path, value) {
224
242
  const segments = parsePath(path);
225
- if (segments.length === 0) throw new Error("Empty patch path");
243
+ if (segments.length === 0)
244
+ throw fakeError("Empty patch path \u2014 a set/setIfMissing op needs a target field.");
226
245
  let cursor = doc;
227
246
  for (let i = 0; i < segments.length - 1; i++) {
228
247
  const seg = segments[i];
229
248
  let next = cursor[seg.key];
230
249
  if (next === void 0 && (next = seg.selector?.kind === "index" || seg.selector?.kind === "keyed" ? [] : {}, cursor[seg.key] = next), seg.selector !== void 0) {
231
250
  if (!Array.isArray(next))
232
- throw new Error(`Patch path crosses non-array at segment ${seg.key}`);
251
+ throw fakeError(
252
+ `Patch path "${path}" applies an array selector to field "${seg.key}", but that field holds ${describeType(next)}, not an array.`
253
+ );
233
254
  const idx = resolveSelector(next, seg.selector);
234
255
  cursor = next[idx];
235
256
  } else
@@ -239,7 +260,9 @@ function setAtPath(doc, path, value) {
239
260
  if (last.selector !== void 0) {
240
261
  const arr = cursor[last.key];
241
262
  if (!Array.isArray(arr))
242
- throw new Error(`Patch path crosses non-array at segment ${last.key}`);
263
+ throw fakeError(
264
+ `Patch path "${path}" applies an array selector to field "${last.key}", but that field holds ${describeType(arr)}, not an array.`
265
+ );
243
266
  const idx = resolveSelector(arr, last.selector);
244
267
  arr[idx] = value;
245
268
  } else
@@ -271,41 +294,56 @@ function unsetAtPath(doc, path) {
271
294
  }
272
295
  function insertIntoArray(doc, position, anchor, items) {
273
296
  const segments = parsePath(anchor);
274
- if (segments.length === 0) throw new Error("Empty insert anchor");
297
+ if (segments.length === 0)
298
+ throw fakeError("Empty insert anchor \u2014 insert() needs an array path to anchor against.");
275
299
  const last = segments[segments.length - 1];
276
300
  if (last.selector === void 0)
277
- throw new Error(`Insert anchor must end with an array selector: ${anchor}`);
301
+ throw fakeError(
302
+ `Insert anchor "${anchor}" must end with an array selector (an index like "[0]" or a keyed match like \`[_key=="x"]\`) so the fake knows where in the array to insert.`
303
+ );
278
304
  let cursor = doc;
279
305
  for (let i = 0; i < segments.length - 1; i++) {
280
306
  const seg = segments[i], next = cursor[seg.key];
281
307
  if (next == null)
282
- throw new Error(`Insert anchor traverses missing field: ${seg.key}`);
308
+ throw fakeError(
309
+ `Insert anchor "${anchor}" traverses field "${seg.key}", which is missing on the document. setIfMissing the parent first, or anchor against an existing array.`
310
+ );
283
311
  if (seg.selector !== void 0) {
284
312
  if (!Array.isArray(next))
285
- throw new Error(`Insert anchor traverses non-array at: ${seg.key}`);
313
+ throw fakeError(
314
+ `Insert anchor "${anchor}" applies an array selector to field "${seg.key}", but it holds ${describeType(next)}, not an array.`
315
+ );
286
316
  cursor = next[resolveSelector(next, seg.selector)];
287
317
  } else
288
318
  cursor = next;
289
319
  }
290
320
  const arr = cursor[last.key];
291
321
  if (!Array.isArray(arr))
292
- throw new Error(`Insert anchor target is not an array: ${last.key}`);
322
+ throw fakeError(
323
+ `Insert anchor "${anchor}" targets field "${last.key}", but it holds ${describeType(arr)}, not an array.`
324
+ );
293
325
  const idx = resolveSelector(arr, last.selector);
294
326
  position === "replace" ? arr.splice(idx, 1, ...items) : position === "before" ? arr.splice(idx, 0, ...items) : arr.splice(idx + 1, 0, ...items);
295
327
  }
296
328
  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}`;
297
329
  function parseVersionId(versionId) {
298
330
  if (!versionId.startsWith(VERSIONS_PREFIX$1))
299
- throw new Error(`Not a version id: ${versionId}`);
331
+ throw fakeError(
332
+ `"${versionId}" is not a version document id. Version ids look like "versions.<releaseId>.<publishedId>" (e.g. "versions.summer-drop.product-1").`
333
+ );
300
334
  const rest = versionId.slice(VERSIONS_PREFIX$1.length), dot = rest.indexOf(".");
301
335
  if (dot === -1)
302
- throw new Error(`Not a version id: ${versionId}`);
336
+ throw fakeError(
337
+ `"${versionId}" is not a version document id. Version ids look like "versions.<releaseId>.<publishedId>" (e.g. "versions.summer-drop.product-1").`
338
+ );
303
339
  return { releaseId: rest.slice(0, dot), publishedId: rest.slice(dot + 1) };
304
340
  }
305
341
  function getRelease(store, releaseId) {
306
342
  const doc = store.get(releaseDocId(releaseId));
307
343
  if (doc === void 0)
308
- throw new Error(`Release not found: ${releaseId}`);
344
+ throw fakeError(
345
+ `Release "${releaseId}" not found. Create it first with client.releases.create({ releaseId: "${releaseId}" }) (or a "sanity.action.release.create" action) before editing, scheduling, or adding versions to it.`
346
+ );
309
347
  return doc;
310
348
  }
311
349
  function maybeRelease(store, releaseId) {
@@ -335,15 +373,17 @@ function bumpRev(doc, patch) {
335
373
  }
336
374
  function requireActive(release) {
337
375
  if (release.state !== "active")
338
- throw new Error(
339
- `Release ${release.name} is ${release.state}; only active releases accept version writes`
376
+ throw fakeError(
377
+ `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.`
340
378
  );
341
379
  }
342
380
  function applyAction(store, action) {
343
381
  switch (action.actionType) {
344
382
  case "sanity.action.release.create": {
345
383
  if (maybeRelease(store, action.releaseId) !== void 0)
346
- throw new Error(`Release ${action.releaseId} already exists`);
384
+ throw fakeError(
385
+ `Release "${action.releaseId}" already exists. release.create expects a new releaseId; use release.edit to change its metadata, or pick a different releaseId.`
386
+ );
347
387
  const now = nowIso(), metadata = {
348
388
  releaseType: "undecided",
349
389
  ...action.metadata
@@ -368,7 +408,9 @@ function applyAction(store, action) {
368
408
  case "sanity.action.release.schedule": {
369
409
  const release = getRelease(store, action.releaseId);
370
410
  if (release.state !== "active")
371
- throw new Error(`Cannot schedule release in state ${release.state}`);
411
+ throw fakeError(
412
+ `Cannot schedule release "${action.releaseId}": it is "${release.state}", and only "active" releases can be scheduled.`
413
+ );
372
414
  return store.put(
373
415
  bumpRev(release, {
374
416
  state: "scheduled",
@@ -379,7 +421,9 @@ function applyAction(store, action) {
379
421
  case "sanity.action.release.unschedule": {
380
422
  const release = getRelease(store, action.releaseId);
381
423
  if (release.state !== "scheduled")
382
- throw new Error(`Cannot unschedule release in state ${release.state}`);
424
+ throw fakeError(
425
+ `Cannot unschedule release "${action.releaseId}": it is "${release.state}", and only "scheduled" releases can be unscheduled.`
426
+ );
383
427
  const next = { ...release, state: "active" };
384
428
  return delete next.publishAt, store.put(bumpRev(next, {})), { transactionId: cryptoRandomKey() };
385
429
  }
@@ -388,7 +432,9 @@ function applyAction(store, action) {
388
432
  if (release.state === "published")
389
433
  return { transactionId: cryptoRandomKey() };
390
434
  if (release.state !== "active" && release.state !== "scheduled")
391
- throw new Error(`Cannot publish release in state ${release.state}`);
435
+ throw fakeError(
436
+ `Cannot publish release "${action.releaseId}": it is "${release.state}", and only "active" or "scheduled" releases can be published.`
437
+ );
392
438
  const prefix = `${VERSIONS_PREFIX$1}${action.releaseId}.`, sources = store.all().filter((d) => d._id.startsWith(prefix));
393
439
  for (const v of sources) {
394
440
  const pubId = v._id.slice(prefix.length);
@@ -410,20 +456,24 @@ function applyAction(store, action) {
410
456
  case "sanity.action.release.archive": {
411
457
  const release = getRelease(store, action.releaseId);
412
458
  if (release.state !== "active" && release.state !== "scheduled")
413
- throw new Error(`Cannot archive release in state ${release.state}`);
459
+ throw fakeError(
460
+ `Cannot archive release "${action.releaseId}": it is "${release.state}", and only "active" or "scheduled" releases can be archived.`
461
+ );
414
462
  return store.put(bumpRev(release, { state: "archived" })), { transactionId: cryptoRandomKey() };
415
463
  }
416
464
  case "sanity.action.release.unarchive": {
417
465
  const release = getRelease(store, action.releaseId);
418
466
  if (release.state !== "archived")
419
- throw new Error(`Cannot unarchive release in state ${release.state}`);
467
+ throw fakeError(
468
+ `Cannot unarchive release "${action.releaseId}": it is "${release.state}", and only "archived" releases can be unarchived.`
469
+ );
420
470
  return store.put(bumpRev(release, { state: "active" })), { transactionId: cryptoRandomKey() };
421
471
  }
422
472
  case "sanity.action.release.delete": {
423
473
  const release = getRelease(store, action.releaseId);
424
474
  if (release.state !== "archived" && release.state !== "published")
425
- throw new Error(
426
- `Cannot delete release in state ${release.state}; archive or publish it first`
475
+ throw fakeError(
476
+ `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.`
427
477
  );
428
478
  const prefix = `${VERSIONS_PREFIX$1}${action.releaseId}.`;
429
479
  for (const v of store.all())
@@ -433,7 +483,9 @@ function applyAction(store, action) {
433
483
  case "sanity.action.document.version.create": {
434
484
  const releaseId = action.releaseId ?? (action.document._id !== void 0 ? parseVersionId(action.document._id).releaseId : void 0);
435
485
  if (releaseId === void 0)
436
- throw new Error("version.create needs releaseId or a document._id that encodes it");
486
+ throw fakeError(
487
+ 'version.create needs a release to attach to: pass `releaseId`, or a `document._id` shaped "versions.<releaseId>.<publishedId>" so the release can be inferred.'
488
+ );
437
489
  const release = getRelease(store, releaseId);
438
490
  requireActive(release);
439
491
  const vid = versionDocId(releaseId, action.publishedId), doc = {
@@ -445,7 +497,9 @@ function applyAction(store, action) {
445
497
  case "sanity.action.document.version.replace": {
446
498
  const id = action.document._id;
447
499
  if (typeof id != "string")
448
- throw new Error("version.replace needs document._id");
500
+ throw fakeError(
501
+ 'version.replace needs `document._id` (a version id like "versions.<releaseId>.<publishedId>") to identify which version document to replace.'
502
+ );
449
503
  const { releaseId } = parseVersionId(id), release = getRelease(store, releaseId);
450
504
  requireActive(release);
451
505
  const existing = store.get(id), promoted = promote(action.document);
@@ -619,12 +673,193 @@ function createTransaction(executor) {
619
673
  };
620
674
  return handle;
621
675
  }
622
- const DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test", registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
676
+ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues", SUPPORTED_SURFACE = [
677
+ "fetch",
678
+ "getDocument",
679
+ "getDocuments",
680
+ "create",
681
+ "createOrReplace",
682
+ "createIfNotExists",
683
+ "delete",
684
+ "patch",
685
+ "transaction",
686
+ "mutate",
687
+ "action",
688
+ "releases",
689
+ "createVersion",
690
+ "unpublishVersion",
691
+ "request (only with accessControl)",
692
+ "config",
693
+ "withConfig"
694
+ ], UNSUPPORTED_MEMBERS = {
695
+ listen: {
696
+ what: "real-time listener (returns an RxJS Observable of mutation events)",
697
+ workaround: 'Test your subscription logic in isolation, or register a fake: client.stub("listen", () => myFakeObservable).'
698
+ },
699
+ observable: {
700
+ what: "the RxJS-flavoured mirror of the client API",
701
+ workaround: "Use the Promise-based methods and wrap them with `from()` if you need an Observable."
702
+ },
703
+ clone: {
704
+ what: "clone the client with the same config",
705
+ workaround: "Use `withConfig({})` \u2014 it returns a sibling handle into the same store."
706
+ },
707
+ getUrl: {
708
+ what: "build an absolute API URL for a path",
709
+ workaround: "Hard-code the expected URL in your test, or stub it."
710
+ },
711
+ getDataUrl: {
712
+ what: "build an absolute data-API URL",
713
+ workaround: "Hard-code the expected URL in your test, or stub it."
714
+ },
715
+ assets: {
716
+ what: "asset upload / metadata namespace",
717
+ workaround: "Seed asset documents into the store directly with `seed()` / `add()`."
718
+ },
719
+ agent: {
720
+ what: "Agent Actions namespace (generate / transform / \u2026)",
721
+ workaround: 'Stub the call site: client.stub("agent", myFakeAgent).'
722
+ },
723
+ projects: {
724
+ what: "project-management namespace",
725
+ workaround: "Out of scope for a data-plane fake \u2014 stub the call site."
726
+ },
727
+ users: {
728
+ what: "user-management namespace",
729
+ workaround: "Out of scope for a data-plane fake \u2014 stub the call site."
730
+ },
731
+ live: {
732
+ what: "Live Content API namespace",
733
+ workaround: "Out of scope \u2014 stub the call site or test the consumer separately."
734
+ },
735
+ mediaLibrary: {
736
+ what: "Media Library namespace",
737
+ workaround: "Out of scope \u2014 seed documents directly or stub the call site."
738
+ },
739
+ 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."
742
+ },
743
+ dryRun: {
744
+ what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
745
+ workaround: "Pass `{ dryRun: true }` as the second arg to create / mutate / delete."
746
+ }
747
+ }, PROBE_NAMES = /* @__PURE__ */ new Set([
748
+ "then",
749
+ "catch",
750
+ "finally",
751
+ "toJSON",
752
+ "asymmetricMatch",
753
+ "$$typeof",
754
+ "nodeType",
755
+ "_isMockFunction"
756
+ ]);
757
+ function guardMessage(path, detail, workaround) {
758
+ return [
759
+ `[client-fake-for-test] \`client.${path}\` ${detail}`,
760
+ "",
761
+ "This is an in-memory fake of @sanity/client. It implements:",
762
+ ` ${SUPPORTED_SURFACE.join(", ")}.`,
763
+ "",
764
+ `\u2192 ${workaround}`,
765
+ "",
766
+ `Register a stub to fill any gap: client.stub(${JSON.stringify(path.split(".")[0])}, handler)`,
767
+ `If this should be supported, open an issue: ${ISSUES_URL}`
768
+ ].join(`
769
+ `);
770
+ }
771
+ function makeGuard(path, message) {
772
+ const thrower = () => {
773
+ throw new Error(message);
774
+ };
775
+ return new Proxy(thrower, {
776
+ apply: () => thrower(),
777
+ get: (_t, prop) => {
778
+ if (typeof prop == "symbol" || PROBE_NAMES.has(prop)) return;
779
+ const childPath = `${path}.${String(prop)}`, top = path.split(".")[0], entry = UNSUPPORTED_MEMBERS[top], detail = entry ? `is not implemented (${entry.what}).` : "is not implemented.", workaround = entry?.workaround ?? "Register a stub or use a different test strategy.";
780
+ return makeGuard(childPath, guardMessage(childPath, detail, workaround));
781
+ }
782
+ });
783
+ }
784
+ function wrapRecorded(method, fn, onCall) {
785
+ return (...args) => {
786
+ const settle = onCall(method, args);
787
+ try {
788
+ const out = fn(...args);
789
+ return out !== null && typeof out == "object" && typeof out.then == "function" ? out.then(
790
+ (result) => (settle({ result }), result),
791
+ (error) => {
792
+ throw settle({ error }), error;
793
+ }
794
+ ) : (settle({ result: out }), out);
795
+ } catch (error) {
796
+ throw settle({ error }), error;
797
+ }
798
+ };
799
+ }
800
+ function wrapNamespace(ns, prefix, onCall) {
801
+ return new Proxy(ns, {
802
+ get(target, prop, receiver) {
803
+ const value = Reflect.get(target, prop, receiver);
804
+ return typeof prop == "string" && typeof value == "function" ? wrapRecorded(`${prefix}.${prop}`, value, onCall) : value;
805
+ }
806
+ });
807
+ }
808
+ function wrapWithGuards(client, config) {
809
+ const { getStub, onCall, recorded } = config;
810
+ return new Proxy(client, {
811
+ get(target, prop, receiver) {
812
+ if (typeof prop == "symbol") return Reflect.get(target, prop, receiver);
813
+ if (prop in target) {
814
+ const value = Reflect.get(target, prop, receiver);
815
+ if (onCall !== void 0 && recorded?.has(prop) === !0) {
816
+ if (typeof value == "function")
817
+ return wrapRecorded(prop, value, onCall);
818
+ if (value !== null && typeof value == "object")
819
+ return wrapNamespace(value, prop, onCall);
820
+ }
821
+ return value;
822
+ }
823
+ if (PROBE_NAMES.has(prop)) return;
824
+ const stub = getStub(prop);
825
+ if (stub !== void 0) return stub;
826
+ const entry = UNSUPPORTED_MEMBERS[prop];
827
+ return entry !== void 0 ? makeGuard(
828
+ prop,
829
+ guardMessage(prop, `is not implemented (${entry.what}).`, entry.workaround)
830
+ ) : makeGuard(
831
+ prop,
832
+ guardMessage(
833
+ prop,
834
+ "is not a method on this fake (unknown member or unimplemented @sanity/client API).",
835
+ "Check for a typo, register a stub, or use a different test strategy."
836
+ )
837
+ );
838
+ }
839
+ });
840
+ }
841
+ const RECORDED_METHODS = /* @__PURE__ */ new Set([
842
+ "fetch",
843
+ "getDocument",
844
+ "getDocuments",
845
+ "create",
846
+ "createOrReplace",
847
+ "createIfNotExists",
848
+ "delete",
849
+ "patch",
850
+ "transaction",
851
+ "mutate",
852
+ "action",
853
+ "releases",
854
+ "createVersion",
855
+ "unpublishVersion",
856
+ "request"
857
+ ]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test", registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
623
858
  const [projectId, dataset] = key.split(":");
624
859
  return { projectId, dataset };
625
860
  };
626
861
  function createTestClient(options = {}) {
627
- const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, registry = /* @__PURE__ */ new Map(), ensureStore = (projectId, dataset) => {
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) => {
628
863
  const key = registryKey(projectId, dataset);
629
864
  let store = registry.get(key);
630
865
  return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
@@ -635,24 +870,87 @@ function createTestClient(options = {}) {
635
870
  ensureStore(projectId, dataset).seed(docs);
636
871
  }
637
872
  return makeHandle(
638
- registry,
873
+ backend,
639
874
  defaultProjectId,
640
875
  defaultDataset,
641
876
  options.accessControl,
642
- options.perspective ?? "raw"
877
+ options.perspective ?? "raw",
878
+ options.mutationGuard
643
879
  );
644
880
  }
645
- function makeHandle(registry, projectId, dataset, accessControl, perspective) {
646
- const store = (() => {
881
+ function dedupeIds(results) {
882
+ const seen = /* @__PURE__ */ new Set(), out = [];
883
+ for (const r of results)
884
+ seen.has(r.id) || (seen.add(r.id), out.push(r.id));
885
+ return out;
886
+ }
887
+ function formatMutationResult(documents, results, transactionId, returnDocuments, returnFirst) {
888
+ return returnDocuments ? returnFirst ? documents[0] : documents : returnFirst ? {
889
+ transactionId,
890
+ documentId: results[0]?.id,
891
+ results
892
+ } : {
893
+ transactionId,
894
+ documentIds: dedupeIds(results),
895
+ results
896
+ };
897
+ }
898
+ function toSingleTxnMutation(op) {
899
+ if ("create" in op) return { kind: "create", doc: op.create };
900
+ if ("createOrReplace" in op) return { kind: "createOrReplace", doc: op.createOrReplace };
901
+ if ("createIfNotExists" in op) return { kind: "createIfNotExists", doc: op.createIfNotExists };
902
+ if ("delete" in op) return { kind: "delete", id: op.delete.id };
903
+ const p = op.patch, ops = [];
904
+ if (p.set !== void 0 && ops.push({ kind: "set", props: p.set }), p.setIfMissing !== void 0 && ops.push({ kind: "setIfMissing", props: p.setIfMissing }), p.unset !== void 0 && ops.push({ kind: "unset", paths: p.unset }), p.inc !== void 0 && ops.push({ kind: "inc", props: p.inc }), p.dec !== void 0 && ops.push({ kind: "dec", props: p.dec }), p.insert !== void 0) {
905
+ const { before, after, replace, items } = p.insert, position = before !== void 0 ? "before" : after !== void 0 ? "after" : "replace", anchor = before ?? after ?? replace;
906
+ if (anchor === void 0)
907
+ throw fakeError(
908
+ 'mutate(): a `patch.insert` operation must name exactly one anchor \u2014 `before`, `after`, or `replace` \u2014 pointing at an array path (e.g. "tags[-1]" or `tags[_key=="x"]`), alongside `items`. None was set.'
909
+ );
910
+ ops.push({ kind: "insert", position, anchor, items });
911
+ }
912
+ return {
913
+ kind: "patch",
914
+ documentId: p.id,
915
+ ops,
916
+ ...p.ifRevisionID !== void 0 ? { ifRev: p.ifRevisionID } : {}
917
+ };
918
+ }
919
+ function toTxnMutations(operations) {
920
+ if (Array.isArray(operations)) return operations.map(toSingleTxnMutation);
921
+ const json = operations.toJSON();
922
+ return Array.isArray(json) ? json : [
923
+ {
924
+ kind: "patch",
925
+ documentId: json.id,
926
+ ops: json.ops,
927
+ ...json.ifRev !== void 0 ? { ifRev: json.ifRev } : {}
928
+ }
929
+ ];
930
+ }
931
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
932
+ const { registry } = backend, store = (() => {
647
933
  const key = registryKey(projectId, dataset);
648
934
  let s = registry.get(key);
649
935
  return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
650
- })(), promote2 = (doc) => {
936
+ })(), record = (transactionId, mutations) => {
937
+ backend.log.push({ transactionId, projectId, dataset, mutations });
938
+ }, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
939
+ documents,
940
+ results,
941
+ transactionId,
942
+ opts?.returnDocuments ?? !0,
943
+ opts?.returnFirst ?? !0
944
+ ), promote2 = (doc) => {
651
945
  const now = (/* @__PURE__ */ new Date()).toISOString();
652
946
  if (typeof doc._id != "string" || doc._id.length === 0)
653
- throw new Error("document is missing _id");
947
+ throw fakeError(
948
+ `Cannot store a document without a non-empty string \`_id\` (got ${JSON.stringify(doc._id)}). Unlike @sanity/client, this fake never auto-generates ids \u2014 give every document an explicit _id.`
949
+ );
654
950
  if (typeof doc._type != "string" || doc._type.length === 0)
655
- throw new Error(`document ${doc._id} is missing _type`);
951
+ throw fakeError(
952
+ `Document "${doc._id}" is missing a non-empty string \`_type\` (got ${JSON.stringify(doc._type)}). Every Sanity document needs a _type.`
953
+ );
656
954
  return {
657
955
  ...doc,
658
956
  _id: doc._id,
@@ -665,10 +963,12 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
665
963
  if (ifRevisionId === void 0) return;
666
964
  const existing = store.get(id);
667
965
  if (existing === void 0)
668
- throw new Error(`ifRevisionId check failed: document ${id} does not exist`);
966
+ throw fakeError(
967
+ `ifRevisionId check failed: document "${id}" does not exist, so its revision cannot match the expected _rev="${ifRevisionId}". Drop ifRevisionId when creating a new document, or seed/create the document before guarding on its revision.`
968
+ );
669
969
  if (existing._rev !== ifRevisionId)
670
- throw new Error(
671
- `ifRevisionId check failed: document ${id} has _rev=${existing._rev}, expected ${ifRevisionId}`
970
+ throw fakeError(
971
+ `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.)`
672
972
  );
673
973
  }, gate = async (document, permission) => {
674
974
  accessControl !== void 0 && await enforceWriteAccess({
@@ -677,6 +977,8 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
677
977
  permission,
678
978
  userId: accessControl.currentUser.id
679
979
  });
980
+ }, guardGate = async (action, before, after) => {
981
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
680
982
  }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
681
983
  document,
682
984
  grants: accessControl.grants,
@@ -685,16 +987,18 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
685
987
  }), applyPatch = async (documentId, ops, ifRev) => {
686
988
  const existing = store.get(documentId);
687
989
  if (existing === void 0)
688
- throw new Error(`Cannot patch document ${documentId}: not found`);
990
+ throw fakeError(
991
+ `Cannot patch document "${documentId}": not found \u2014 no document with that _id exists in dataset "${projectId}:${dataset}". patch() requires the target to already exist; use createOrReplace() / createIfNotExists() to upsert, or seed the document first.`
992
+ );
689
993
  checkRevision(documentId, ifRev), await gate(existing, "update");
690
994
  const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
691
995
  ...patched,
692
996
  _rev: cryptoRandomKey(),
693
997
  _updatedAt: now
694
998
  };
695
- return store.put(next), next;
999
+ return await guardGate("update", existing, next), store.put(next), next;
696
1000
  }, applyTransaction = async (mutations, txnOptions) => {
697
- const snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
1001
+ const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
698
1002
  snapshot.has(id) || snapshot.set(id, store.get(id));
699
1003
  };
700
1004
  try {
@@ -704,14 +1008,20 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
704
1008
  remember(mut.doc._id);
705
1009
  const promoted = promote2(mut.doc);
706
1010
  if (store.get(promoted._id) !== void 0)
707
- throw new Error(`create failed: document ${promoted._id} already exists`);
708
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1011
+ throw fakeError(
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.`
1013
+ );
1014
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
709
1015
  break;
710
1016
  }
711
1017
  case "createOrReplace": {
712
1018
  remember(mut.doc._id);
713
1019
  const promoted = promote2(mut.doc), existing = store.get(promoted._id);
714
- 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
+ );
715
1025
  const wasPresent = existing !== void 0;
716
1026
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
717
1027
  break;
@@ -719,14 +1029,14 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
719
1029
  case "createIfNotExists": {
720
1030
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
721
1031
  const promoted = promote2(mut.doc);
722
- 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" });
723
1033
  }
724
1034
  break;
725
1035
  }
726
1036
  case "delete": {
727
1037
  remember(mut.id);
728
1038
  const existing = store.get(mut.id);
729
- 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" }));
730
1040
  break;
731
1041
  }
732
1042
  case "patch": {
@@ -739,14 +1049,15 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
739
1049
  doc === void 0 ? store.remove(id) : store.put(doc);
740
1050
  throw error;
741
1051
  }
742
- if (txnOptions?.returnDocuments) {
743
- const docs = touchedIds.map((id) => store.get(id)).filter((d) => d !== void 0);
744
- return txnOptions.returnFirst ? docs[0] : docs;
745
- }
746
- return {
747
- transactionId: cryptoRandomKey(),
748
- results: operations
749
- };
1052
+ record(transactionId, mutations);
1053
+ const documents = touchedIds.map((id) => store.get(id)).filter((d) => d !== void 0);
1054
+ return formatMutationResult(
1055
+ documents,
1056
+ operations,
1057
+ transactionId,
1058
+ txnOptions?.returnDocuments ?? !1,
1059
+ txnOptions?.returnFirst ?? !1
1060
+ );
750
1061
  }, accessVisibleDocs = async () => {
751
1062
  const all = store.all();
752
1063
  if (accessControl === void 0) return all;
@@ -757,23 +1068,26 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
757
1068
  }, visibleDocs = async (override) => {
758
1069
  const accessible = await accessVisibleDocs();
759
1070
  return applyPerspective(accessible, override ?? perspective);
760
- };
761
- return {
1071
+ }, client = {
762
1072
  fetch: async (query, params, opts) => {
763
- const tree = parse(query, params ? { params } : void 0);
764
- return await (await evaluate(tree, {
1073
+ const start = Date.now(), tree = parse(query, params ? { params } : void 0), result = await (await evaluate(tree, {
765
1074
  dataset: await visibleDocs(opts?.perspective),
766
1075
  ...params !== void 0 ? { params } : {}
767
1076
  })).get();
1077
+ if (opts?.filterResponse === !1) {
1078
+ const ms = Date.now() - start;
1079
+ return opts.returnQuery === !1 ? { ms, result } : { query, ms, result };
1080
+ }
1081
+ return result;
768
1082
  },
769
1083
  getDocument: async (id, opts) => {
770
1084
  const effective = opts?.perspective ?? perspective;
771
1085
  if (effective === "raw") {
772
1086
  const doc = store.get(id);
773
- return doc === void 0 || !await canRead(doc) ? null : doc;
1087
+ return doc === void 0 || !await canRead(doc) ? void 0 : doc;
774
1088
  }
775
1089
  const view = await visibleDocs(effective);
776
- return resolveDocument(view, id, effective) ?? null;
1090
+ return resolveDocument(view, id, effective) ?? void 0;
777
1091
  },
778
1092
  getDocuments: async (ids, opts) => {
779
1093
  const effective = opts?.perspective ?? perspective;
@@ -790,29 +1104,75 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
790
1104
  },
791
1105
  create: async (doc, opts) => {
792
1106
  if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
793
- throw new Error(`create failed: document ${doc._id} already exists`);
1107
+ throw fakeError(
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.`
1109
+ );
794
1110
  const promoted = promote2(doc);
795
- return await gate(promoted, "create"), store.put(promoted), promoted;
1111
+ await gate(promoted, "create"), await guardGate("create", null, promoted);
1112
+ const transactionId = opts?.transactionId ?? cryptoRandomKey();
1113
+ return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
1114
+ [promoted],
1115
+ [{ id: promoted._id, operation: "create" }],
1116
+ transactionId,
1117
+ opts
1118
+ );
796
1119
  },
797
1120
  createOrReplace: async (doc, opts) => {
798
1121
  checkRevision(doc._id, opts?.ifRevisionId);
799
1122
  const promoted = promote2(doc), existing = store.get(doc._id);
800
- return await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), store.put(promoted), promoted;
1123
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1124
+ const transactionId = opts?.transactionId ?? cryptoRandomKey();
1125
+ opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
1126
+ const operation = existing !== void 0 ? "update" : "create";
1127
+ return writeResult(
1128
+ [promoted],
1129
+ [{ id: promoted._id, operation }],
1130
+ transactionId,
1131
+ opts
1132
+ );
801
1133
  },
802
1134
  createIfNotExists: async (doc, opts) => {
803
1135
  checkRevision(doc._id, opts?.ifRevisionId);
804
- const existing = store.get(doc._id);
805
- if (existing !== void 0) return existing;
1136
+ const existing = store.get(doc._id), transactionId = opts?.transactionId ?? cryptoRandomKey();
1137
+ if (existing !== void 0)
1138
+ return writeResult(
1139
+ [existing],
1140
+ [{ id: existing._id, operation: "none" }],
1141
+ transactionId,
1142
+ opts
1143
+ );
806
1144
  const promoted = promote2(doc);
807
- return await gate(promoted, "create"), store.put(promoted), promoted;
1145
+ return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1146
+ [promoted],
1147
+ [{ id: promoted._id, operation: "create" }],
1148
+ transactionId,
1149
+ opts
1150
+ );
808
1151
  },
809
1152
  delete: async (id, opts) => {
810
1153
  checkRevision(id, opts?.ifRevisionId);
811
1154
  const existing = store.get(id);
812
- return existing !== void 0 && await gate(existing, "update"), store.remove(id) ? { _id: id } : null;
1155
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
1156
+ const transactionId = opts?.transactionId ?? cryptoRandomKey();
1157
+ let removed = existing;
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(
1159
+ removed !== void 0 ? [removed] : [],
1160
+ removed !== void 0 ? [{ id, operation: "delete" }] : [],
1161
+ transactionId,
1162
+ opts.returnDocuments ?? !0,
1163
+ opts.returnFirst ?? !0
1164
+ );
813
1165
  },
814
- patch: (documentId) => createPatch(documentId, { apply: applyPatch }),
1166
+ patch: (documentId) => createPatch(documentId, {
1167
+ apply: async (docId, ops, ifRev) => {
1168
+ const doc = await applyPatch(docId, ops, ifRev);
1169
+ return record(cryptoRandomKey(), [
1170
+ { kind: "patch", documentId: docId, ops, ...ifRev !== void 0 ? { ifRev } : {} }
1171
+ ]), doc;
1172
+ }
1173
+ }),
815
1174
  transaction: () => createTransaction({ apply: applyTransaction }),
1175
+ mutate: async (operations, options) => applyTransaction(toTxnMutations(operations), options),
816
1176
  action: async (action) => applyActions(store, action),
817
1177
  releases: {
818
1178
  create: async ({ releaseId, metadata }) => {
@@ -858,21 +1218,49 @@ function makeHandle(registry, projectId, dataset, accessControl, perspective) {
858
1218
  ...accessControl !== void 0 ? { request: makeRequestImpl(accessControl, projectId, dataset) } : {},
859
1219
  config: () => ({ projectId, dataset, perspective }),
860
1220
  withConfig: (config) => makeHandle(
861
- registry,
1221
+ backend,
862
1222
  config.projectId ?? projectId,
863
1223
  config.dataset ?? dataset,
864
- accessControl,
865
- config.perspective ?? perspective
1224
+ config.accessControl ?? accessControl,
1225
+ config.perspective ?? perspective,
1226
+ mutationGuard
866
1227
  ),
867
1228
  seed: (documents) => store.seed(documents),
868
1229
  add: (documents) => store.add(documents),
869
1230
  reset: () => store.reset(),
870
1231
  snapshot: () => store.snapshot(),
871
1232
  store: () => store,
872
- datasets: () => [...registry.keys()].map(parseRegistryKey)
1233
+ datasets: () => [...registry.keys()].map(parseRegistryKey),
1234
+ stub: (name, handler) => {
1235
+ backend.stubs.set(name, handler);
1236
+ },
1237
+ stubs: () => Object.fromEntries(backend.stubs),
1238
+ mutations: () => backend.log.flatMap((entry) => entry.mutations),
1239
+ transactions: () => [...backend.log],
1240
+ clearLog: () => {
1241
+ backend.log.length = 0;
1242
+ },
1243
+ calls: (method) => method === void 0 ? [...backend.calls] : backend.calls.filter((c) => c.method === method),
1244
+ callCount: (method) => method === void 0 ? backend.calls.length : backend.calls.filter((c) => c.method === method).length,
1245
+ clearCalls: () => {
1246
+ backend.calls.length = 0;
1247
+ }
873
1248
  };
1249
+ return wrapWithGuards(client, {
1250
+ getStub: (name) => backend.stubs.get(name),
1251
+ onCall: (method, args) => {
1252
+ const rec = { method, args, projectId, dataset };
1253
+ return backend.calls.push(rec), (outcome) => {
1254
+ "error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
1255
+ };
1256
+ },
1257
+ recorded: RECORDED_METHODS
1258
+ });
874
1259
  }
875
1260
  export {
1261
+ ERR_PREFIX,
1262
+ SUPPORTED_SURFACE,
1263
+ UNSUPPORTED_MEMBERS,
876
1264
  WriteAccessDeniedError,
877
1265
  applyPatchOps,
878
1266
  applyPerspective,
@@ -882,11 +1270,13 @@ export {
882
1270
  createTransaction,
883
1271
  defaultAclPath,
884
1272
  evaluateWriteAccess,
1273
+ fakeError,
885
1274
  isDraftId,
886
1275
  isReleaseDocId,
887
1276
  isSystemId,
888
1277
  isVersionId,
889
1278
  publishedIdOf,
890
- releaseNameOf
1279
+ releaseNameOf,
1280
+ wrapWithGuards
891
1281
  };
892
1282
  //# sourceMappingURL=index.js.map