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