@sanity-labs/client-fake-for-test 0.1.0 → 0.3.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,11 +67,65 @@ 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
  }
75
+ function createTestStore(initial) {
76
+ const docs = /* @__PURE__ */ new Map(), promote2 = (doc) => {
77
+ if (typeof doc._id != "string" || doc._id.length === 0)
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
+ );
81
+ if (typeof doc._type != "string" || doc._type.length === 0)
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
+ );
85
+ const now = (/* @__PURE__ */ new Date()).toISOString();
86
+ return {
87
+ ...doc,
88
+ _id: doc._id,
89
+ _type: doc._type,
90
+ _rev: doc._rev ?? cryptoRandomKey(),
91
+ _createdAt: doc._createdAt ?? now,
92
+ _updatedAt: doc._updatedAt ?? now
93
+ };
94
+ }, seed = (documents) => {
95
+ docs.clear();
96
+ for (const d of documents) {
97
+ const promoted = promote2(d);
98
+ docs.set(promoted._id, promoted);
99
+ }
100
+ };
101
+ return initial && seed(initial), {
102
+ seed,
103
+ add: (documents) => {
104
+ for (const d of documents) {
105
+ const promoted = promote2(d);
106
+ docs.set(promoted._id, promoted);
107
+ }
108
+ },
109
+ reset: () => docs.clear(),
110
+ all: () => [...docs.values()],
111
+ get: (id) => docs.get(id),
112
+ put: (doc) => {
113
+ docs.set(doc._id, doc);
114
+ },
115
+ remove: (id) => {
116
+ const doc = docs.get(id);
117
+ return doc && docs.delete(id), doc;
118
+ },
119
+ snapshot: () => [...docs.values()]
120
+ };
121
+ }
122
+ function cryptoRandomKey(length = 12) {
123
+ const bytes = new Uint8Array(length);
124
+ return globalThis.crypto.getRandomValues(bytes), [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length);
125
+ }
126
+ function describeType(value) {
127
+ return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`;
128
+ }
71
129
  function createPatch(documentId, executor) {
72
130
  const ops = [];
73
131
  let ifRev;
@@ -128,7 +186,10 @@ function parsePath(path) {
128
186
  const segments = [];
129
187
  for (const raw of path.split(".")) {
130
188
  const match = /^([A-Za-z_$][\w$]*)(?:\[(.+)\])?$/.exec(raw);
131
- 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
+ );
132
193
  const key = match[1], selectorRaw = match[2];
133
194
  if (selectorRaw === void 0) {
134
195
  segments.push({ key });
@@ -147,7 +208,9 @@ function parsePath(path) {
147
208
  });
148
209
  continue;
149
210
  }
150
- 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
+ );
151
214
  }
152
215
  return segments;
153
216
  }
@@ -157,7 +220,9 @@ function resolveSelector(array, selector) {
157
220
  (entry) => typeof entry == "object" && entry !== null && entry[selector.field] === selector.value
158
221
  );
159
222
  if (idx === -1)
160
- 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
+ );
161
226
  return idx;
162
227
  }
163
228
  function getAtPath(doc, path) {
@@ -175,14 +240,17 @@ function getAtPath(doc, path) {
175
240
  }
176
241
  function setAtPath(doc, path, value) {
177
242
  const segments = parsePath(path);
178
- 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.");
179
245
  let cursor = doc;
180
246
  for (let i = 0; i < segments.length - 1; i++) {
181
247
  const seg = segments[i];
182
248
  let next = cursor[seg.key];
183
249
  if (next === void 0 && (next = seg.selector?.kind === "index" || seg.selector?.kind === "keyed" ? [] : {}, cursor[seg.key] = next), seg.selector !== void 0) {
184
250
  if (!Array.isArray(next))
185
- 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
+ );
186
254
  const idx = resolveSelector(next, seg.selector);
187
255
  cursor = next[idx];
188
256
  } else
@@ -192,7 +260,9 @@ function setAtPath(doc, path, value) {
192
260
  if (last.selector !== void 0) {
193
261
  const arr = cursor[last.key];
194
262
  if (!Array.isArray(arr))
195
- 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
+ );
196
266
  const idx = resolveSelector(arr, last.selector);
197
267
  arr[idx] = value;
198
268
  } else
@@ -224,74 +294,354 @@ function unsetAtPath(doc, path) {
224
294
  }
225
295
  function insertIntoArray(doc, position, anchor, items) {
226
296
  const segments = parsePath(anchor);
227
- 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.");
228
299
  const last = segments[segments.length - 1];
229
300
  if (last.selector === void 0)
230
- 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
+ );
231
304
  let cursor = doc;
232
305
  for (let i = 0; i < segments.length - 1; i++) {
233
306
  const seg = segments[i], next = cursor[seg.key];
234
307
  if (next == null)
235
- 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
+ );
236
311
  if (seg.selector !== void 0) {
237
312
  if (!Array.isArray(next))
238
- 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
+ );
239
316
  cursor = next[resolveSelector(next, seg.selector)];
240
317
  } else
241
318
  cursor = next;
242
319
  }
243
320
  const arr = cursor[last.key];
244
321
  if (!Array.isArray(arr))
245
- 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
+ );
246
325
  const idx = resolveSelector(arr, last.selector);
247
326
  position === "replace" ? arr.splice(idx, 1, ...items) : position === "before" ? arr.splice(idx, 0, ...items) : arr.splice(idx + 1, 0, ...items);
248
327
  }
249
- function createTestStore(initial) {
250
- const docs = /* @__PURE__ */ new Map(), promote = (doc) => {
251
- if (typeof doc._id != "string" || doc._id.length === 0)
252
- throw new Error(`document is missing _id: ${JSON.stringify(doc)}`);
253
- if (typeof doc._type != "string" || doc._type.length === 0)
254
- throw new Error(`document ${doc._id} is missing _type`);
255
- const now = (/* @__PURE__ */ new Date()).toISOString();
256
- return {
257
- ...doc,
258
- _id: doc._id,
259
- _type: doc._type,
260
- _rev: doc._rev ?? cryptoRandomKey(),
261
- _createdAt: doc._createdAt ?? now,
262
- _updatedAt: doc._updatedAt ?? now
263
- };
264
- }, seed = (documents) => {
265
- docs.clear();
266
- for (const d of documents) {
267
- const promoted = promote(d);
268
- docs.set(promoted._id, promoted);
269
- }
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}`;
329
+ function parseVersionId(versionId) {
330
+ if (!versionId.startsWith(VERSIONS_PREFIX$1))
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
+ );
334
+ const rest = versionId.slice(VERSIONS_PREFIX$1.length), dot = rest.indexOf(".");
335
+ if (dot === -1)
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
+ );
339
+ return { releaseId: rest.slice(0, dot), publishedId: rest.slice(dot + 1) };
340
+ }
341
+ function getRelease(store, releaseId) {
342
+ const doc = store.get(releaseDocId(releaseId));
343
+ if (doc === void 0)
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
+ );
347
+ return doc;
348
+ }
349
+ function maybeRelease(store, releaseId) {
350
+ return store.get(releaseDocId(releaseId));
351
+ }
352
+ function nowIso() {
353
+ return (/* @__PURE__ */ new Date()).toISOString();
354
+ }
355
+ function promote(doc) {
356
+ const now = nowIso();
357
+ return {
358
+ ...doc,
359
+ _id: doc._id,
360
+ _type: doc._type,
361
+ _rev: cryptoRandomKey(),
362
+ _createdAt: doc._createdAt ?? now,
363
+ _updatedAt: now
270
364
  };
271
- return initial && seed(initial), {
272
- seed,
273
- add: (documents) => {
274
- for (const d of documents) {
275
- const promoted = promote(d);
276
- docs.set(promoted._id, promoted);
365
+ }
366
+ function bumpRev(doc, patch) {
367
+ return {
368
+ ...doc,
369
+ ...patch,
370
+ _rev: cryptoRandomKey(),
371
+ _updatedAt: nowIso()
372
+ };
373
+ }
374
+ function requireActive(release) {
375
+ if (release.state !== "active")
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.`
378
+ );
379
+ }
380
+ function applyAction(store, action) {
381
+ switch (action.actionType) {
382
+ case "sanity.action.release.create": {
383
+ if (maybeRelease(store, action.releaseId) !== void 0)
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
+ );
387
+ const now = nowIso(), metadata = {
388
+ releaseType: "undecided",
389
+ ...action.metadata
390
+ }, doc = {
391
+ _id: releaseDocId(action.releaseId),
392
+ _type: "system.release",
393
+ _rev: cryptoRandomKey(),
394
+ _createdAt: now,
395
+ _updatedAt: now,
396
+ name: action.releaseId,
397
+ state: "active",
398
+ metadata
399
+ };
400
+ return store.put(doc), { transactionId: cryptoRandomKey() };
401
+ }
402
+ case "sanity.action.release.edit": {
403
+ const release = getRelease(store, action.releaseId), patchOps = [];
404
+ action.patch.set !== void 0 && patchOps.push({ kind: "set", props: action.patch.set }), action.patch.unset !== void 0 && patchOps.push({ kind: "unset", paths: action.patch.unset });
405
+ const patched = applyPatchOps(release, patchOps);
406
+ return store.put(bumpRev(patched, {})), { transactionId: cryptoRandomKey() };
407
+ }
408
+ case "sanity.action.release.schedule": {
409
+ const release = getRelease(store, action.releaseId);
410
+ if (release.state !== "active")
411
+ throw fakeError(
412
+ `Cannot schedule release "${action.releaseId}": it is "${release.state}", and only "active" releases can be scheduled.`
413
+ );
414
+ return store.put(
415
+ bumpRev(release, {
416
+ state: "scheduled",
417
+ publishAt: action.publishAt
418
+ })
419
+ ), { transactionId: cryptoRandomKey() };
420
+ }
421
+ case "sanity.action.release.unschedule": {
422
+ const release = getRelease(store, action.releaseId);
423
+ if (release.state !== "scheduled")
424
+ throw fakeError(
425
+ `Cannot unschedule release "${action.releaseId}": it is "${release.state}", and only "scheduled" releases can be unscheduled.`
426
+ );
427
+ const next = { ...release, state: "active" };
428
+ return delete next.publishAt, store.put(bumpRev(next, {})), { transactionId: cryptoRandomKey() };
429
+ }
430
+ case "sanity.action.release.publish": {
431
+ const release = getRelease(store, action.releaseId);
432
+ if (release.state === "published")
433
+ return { transactionId: cryptoRandomKey() };
434
+ if (release.state !== "active" && release.state !== "scheduled")
435
+ throw fakeError(
436
+ `Cannot publish release "${action.releaseId}": it is "${release.state}", and only "active" or "scheduled" releases can be published.`
437
+ );
438
+ const prefix = `${VERSIONS_PREFIX$1}${action.releaseId}.`, sources = store.all().filter((d) => d._id.startsWith(prefix));
439
+ for (const v of sources) {
440
+ const pubId = v._id.slice(prefix.length);
441
+ if (v._system?.delete === !0)
442
+ store.remove(pubId);
443
+ else {
444
+ const body = {
445
+ ...v,
446
+ _id: pubId
447
+ };
448
+ delete body._originalId, delete body._system;
449
+ const promoted = promote(body), existing = store.get(pubId);
450
+ existing !== void 0 && (promoted._createdAt = existing._createdAt), store.put(promoted);
451
+ }
452
+ store.remove(v._id);
277
453
  }
278
- },
279
- reset: () => docs.clear(),
280
- all: () => [...docs.values()],
281
- get: (id) => docs.get(id),
282
- put: (doc) => {
283
- docs.set(doc._id, doc);
284
- },
285
- remove: (id) => {
286
- const doc = docs.get(id);
287
- return doc && docs.delete(id), doc;
288
- },
289
- snapshot: () => [...docs.values()]
454
+ return store.put(bumpRev(release, { state: "published" })), { transactionId: cryptoRandomKey() };
455
+ }
456
+ case "sanity.action.release.archive": {
457
+ const release = getRelease(store, action.releaseId);
458
+ if (release.state !== "active" && release.state !== "scheduled")
459
+ throw fakeError(
460
+ `Cannot archive release "${action.releaseId}": it is "${release.state}", and only "active" or "scheduled" releases can be archived.`
461
+ );
462
+ return store.put(bumpRev(release, { state: "archived" })), { transactionId: cryptoRandomKey() };
463
+ }
464
+ case "sanity.action.release.unarchive": {
465
+ const release = getRelease(store, action.releaseId);
466
+ if (release.state !== "archived")
467
+ throw fakeError(
468
+ `Cannot unarchive release "${action.releaseId}": it is "${release.state}", and only "archived" releases can be unarchived.`
469
+ );
470
+ return store.put(bumpRev(release, { state: "active" })), { transactionId: cryptoRandomKey() };
471
+ }
472
+ case "sanity.action.release.delete": {
473
+ const release = getRelease(store, action.releaseId);
474
+ if (release.state !== "archived" && release.state !== "published")
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.`
477
+ );
478
+ const prefix = `${VERSIONS_PREFIX$1}${action.releaseId}.`;
479
+ for (const v of store.all())
480
+ v._id.startsWith(prefix) && store.remove(v._id);
481
+ return store.remove(release._id), { transactionId: cryptoRandomKey() };
482
+ }
483
+ case "sanity.action.document.version.create": {
484
+ const releaseId = action.releaseId ?? (action.document._id !== void 0 ? parseVersionId(action.document._id).releaseId : void 0);
485
+ if (releaseId === void 0)
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
+ );
489
+ const release = getRelease(store, releaseId);
490
+ requireActive(release);
491
+ const vid = versionDocId(releaseId, action.publishedId), doc = {
492
+ ...action.document,
493
+ _id: vid
494
+ }, existing = store.get(vid), promoted = promote(doc);
495
+ return existing !== void 0 && (promoted._createdAt = existing._createdAt), store.put(promoted), { transactionId: cryptoRandomKey() };
496
+ }
497
+ case "sanity.action.document.version.replace": {
498
+ const id = action.document._id;
499
+ if (typeof id != "string")
500
+ throw fakeError(
501
+ 'version.replace needs `document._id` (a version id like "versions.<releaseId>.<publishedId>") to identify which version document to replace.'
502
+ );
503
+ const { releaseId } = parseVersionId(id), release = getRelease(store, releaseId);
504
+ requireActive(release);
505
+ const existing = store.get(id), promoted = promote(action.document);
506
+ return existing !== void 0 && (promoted._createdAt = existing._createdAt), store.put(promoted), { transactionId: cryptoRandomKey() };
507
+ }
508
+ case "sanity.action.document.version.discard": {
509
+ const { releaseId } = parseVersionId(action.versionId), release = getRelease(store, releaseId);
510
+ return requireActive(release), store.remove(action.versionId), { transactionId: cryptoRandomKey() };
511
+ }
512
+ case "sanity.action.document.version.unpublish": {
513
+ const { releaseId } = parseVersionId(action.versionId), release = getRelease(store, releaseId);
514
+ requireActive(release);
515
+ const doc = {
516
+ _id: action.versionId,
517
+ _type: "stub",
518
+ _system: { delete: !0 }
519
+ };
520
+ return store.put(promote(doc)), { transactionId: cryptoRandomKey() };
521
+ }
522
+ }
523
+ }
524
+ function applyActions(store, actions) {
525
+ const list = Array.isArray(actions) ? actions : [actions], snapshot = /* @__PURE__ */ new Map(), remember = (id) => {
526
+ snapshot.has(id) || snapshot.set(id, store.get(id));
290
527
  };
528
+ try {
529
+ let last = { transactionId: cryptoRandomKey() };
530
+ for (const a of list) {
531
+ switch (a.actionType) {
532
+ case "sanity.action.release.create":
533
+ case "sanity.action.release.edit":
534
+ case "sanity.action.release.schedule":
535
+ case "sanity.action.release.unschedule":
536
+ case "sanity.action.release.archive":
537
+ case "sanity.action.release.unarchive":
538
+ case "sanity.action.release.delete":
539
+ remember(`${RELEASE_DOC_PREFIX$1}${a.releaseId}`);
540
+ break;
541
+ case "sanity.action.release.publish": {
542
+ remember(`${RELEASE_DOC_PREFIX$1}${a.releaseId}`);
543
+ const prefix = `${VERSIONS_PREFIX$1}${a.releaseId}.`;
544
+ for (const d of store.all())
545
+ d._id.startsWith(prefix) && (remember(d._id), remember(d._id.slice(prefix.length)));
546
+ break;
547
+ }
548
+ case "sanity.action.document.version.create": {
549
+ const releaseId = a.releaseId ?? (typeof a.document._id == "string" ? parseVersionId(a.document._id).releaseId : void 0);
550
+ releaseId !== void 0 && remember(versionDocId(releaseId, a.publishedId));
551
+ break;
552
+ }
553
+ case "sanity.action.document.version.replace":
554
+ typeof a.document._id == "string" && remember(a.document._id);
555
+ break;
556
+ case "sanity.action.document.version.discard":
557
+ case "sanity.action.document.version.unpublish":
558
+ remember(a.versionId);
559
+ break;
560
+ }
561
+ last = applyAction(store, a);
562
+ }
563
+ return last;
564
+ } catch (err) {
565
+ for (const [id, doc] of snapshot)
566
+ doc === void 0 ? store.remove(id) : store.put(doc);
567
+ throw err;
568
+ }
291
569
  }
292
- function cryptoRandomKey(length = 12) {
293
- const bytes = new Uint8Array(length);
294
- return globalThis.crypto.getRandomValues(bytes), [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length);
570
+ const DRAFTS_PREFIX = "drafts.", VERSIONS_PREFIX = "versions.", RELEASE_DOC_PREFIX = "_.releases.";
571
+ function publishedIdOf(id) {
572
+ if (id.startsWith(DRAFTS_PREFIX)) return id.slice(DRAFTS_PREFIX.length);
573
+ if (id.startsWith(VERSIONS_PREFIX)) {
574
+ const rest = id.slice(VERSIONS_PREFIX.length), dot = rest.indexOf(".");
575
+ return dot === -1 ? rest : rest.slice(dot + 1);
576
+ }
577
+ return id;
578
+ }
579
+ function releaseNameOf(id) {
580
+ if (!id.startsWith(VERSIONS_PREFIX)) return;
581
+ const rest = id.slice(VERSIONS_PREFIX.length), dot = rest.indexOf(".");
582
+ if (dot !== -1)
583
+ return rest.slice(0, dot);
584
+ }
585
+ const isDraftId = (id) => id.startsWith(DRAFTS_PREFIX), isVersionId = (id) => id.startsWith(VERSIONS_PREFIX), isSystemId = (id) => id.startsWith("_."), isReleaseDocId = (id) => id.startsWith(RELEASE_DOC_PREFIX);
586
+ function applyPerspective(docs, perspective) {
587
+ if (perspective === "raw") return [...docs];
588
+ if (perspective === "published")
589
+ return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
590
+ if (perspective === "drafts")
591
+ return mergeStack(docs, [], { includeDrafts: !0, includeReleaseDocs: !1 });
592
+ const stack = [];
593
+ let includeDrafts = !1;
594
+ for (const entry of perspective)
595
+ entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
596
+ return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
597
+ }
598
+ function mergeStack(docs, releaseStack, options) {
599
+ const groups = /* @__PURE__ */ new Map(), systemReleases = [];
600
+ for (const doc of docs) {
601
+ if (isReleaseDocId(doc._id)) {
602
+ options.includeReleaseDocs && systemReleases.push(doc);
603
+ continue;
604
+ }
605
+ if (isSystemId(doc._id)) continue;
606
+ const pubId = publishedIdOf(doc._id);
607
+ let group = groups.get(pubId);
608
+ if (group === void 0 && (group = { versions: /* @__PURE__ */ new Map() }, groups.set(pubId, group)), isDraftId(doc._id))
609
+ group.draft = doc;
610
+ else if (isVersionId(doc._id)) {
611
+ const releaseName = releaseNameOf(doc._id);
612
+ releaseName !== void 0 && group.versions.set(releaseName, doc);
613
+ } else
614
+ group.published = doc;
615
+ }
616
+ const out = [];
617
+ for (const [pubId, group] of groups) {
618
+ let chosen;
619
+ for (const releaseName of releaseStack) {
620
+ const v = group.versions.get(releaseName);
621
+ if (v !== void 0) {
622
+ if (isUnpublishStub(v)) {
623
+ chosen = void 0;
624
+ break;
625
+ }
626
+ chosen = v;
627
+ break;
628
+ }
629
+ }
630
+ chosen === void 0 && options.includeDrafts && group.draft !== void 0 && (chosen = group.draft), chosen === void 0 && group.published !== void 0 && (releaseStack.some((name) => {
631
+ const v = group.versions.get(name);
632
+ return v !== void 0 && isUnpublishStub(v);
633
+ }) || (chosen = group.published)), chosen !== void 0 && out.push(projectToPublishedId(chosen, pubId));
634
+ }
635
+ return [...out, ...systemReleases];
636
+ }
637
+ function isUnpublishStub(doc) {
638
+ return doc._system?.delete === !0;
639
+ }
640
+ function projectToPublishedId(doc, pubId) {
641
+ return doc._id === pubId ? doc : { ...doc, _id: pubId, _originalId: doc._id };
642
+ }
643
+ function resolveDocument(docs, id, perspective) {
644
+ return perspective === "raw" ? docs.find((d) => d._id === id) : applyPerspective(docs, perspective).find((d) => d._id === id);
295
645
  }
296
646
  function createTransaction(executor) {
297
647
  const mutations = [], handle = {
@@ -323,12 +673,193 @@ function createTransaction(executor) {
323
673
  };
324
674
  return handle;
325
675
  }
326
- 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) => {
327
858
  const [projectId, dataset] = key.split(":");
328
859
  return { projectId, dataset };
329
860
  };
330
861
  function createTestClient(options = {}) {
331
- 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) => {
332
863
  const key = registryKey(projectId, dataset);
333
864
  let store = registry.get(key);
334
865
  return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
@@ -338,19 +869,87 @@ function createTestClient(options = {}) {
338
869
  const { projectId, dataset } = parseRegistryKey(key);
339
870
  ensureStore(projectId, dataset).seed(docs);
340
871
  }
341
- return makeHandle(registry, defaultProjectId, defaultDataset, options.accessControl);
872
+ return makeHandle(
873
+ backend,
874
+ defaultProjectId,
875
+ defaultDataset,
876
+ options.accessControl,
877
+ options.perspective ?? "raw"
878
+ );
879
+ }
880
+ function dedupeIds(results) {
881
+ const seen = /* @__PURE__ */ new Set(), out = [];
882
+ for (const r of results)
883
+ seen.has(r.id) || (seen.add(r.id), out.push(r.id));
884
+ return out;
342
885
  }
343
- function makeHandle(registry, projectId, dataset, accessControl) {
344
- const store = (() => {
886
+ function formatMutationResult(documents, results, transactionId, returnDocuments, returnFirst) {
887
+ return returnDocuments ? returnFirst ? documents[0] : documents : returnFirst ? {
888
+ transactionId,
889
+ documentId: results[0]?.id,
890
+ results
891
+ } : {
892
+ transactionId,
893
+ documentIds: dedupeIds(results),
894
+ results
895
+ };
896
+ }
897
+ function toSingleTxnMutation(op) {
898
+ if ("create" in op) return { kind: "create", doc: op.create };
899
+ if ("createOrReplace" in op) return { kind: "createOrReplace", doc: op.createOrReplace };
900
+ if ("createIfNotExists" in op) return { kind: "createIfNotExists", doc: op.createIfNotExists };
901
+ if ("delete" in op) return { kind: "delete", id: op.delete.id };
902
+ const p = op.patch, ops = [];
903
+ 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) {
904
+ const { before, after, replace, items } = p.insert, position = before !== void 0 ? "before" : after !== void 0 ? "after" : "replace", anchor = before ?? after ?? replace;
905
+ if (anchor === void 0)
906
+ throw fakeError(
907
+ '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.'
908
+ );
909
+ ops.push({ kind: "insert", position, anchor, items });
910
+ }
911
+ return {
912
+ kind: "patch",
913
+ documentId: p.id,
914
+ ops,
915
+ ...p.ifRevisionID !== void 0 ? { ifRev: p.ifRevisionID } : {}
916
+ };
917
+ }
918
+ function toTxnMutations(operations) {
919
+ if (Array.isArray(operations)) return operations.map(toSingleTxnMutation);
920
+ const json = operations.toJSON();
921
+ return Array.isArray(json) ? json : [
922
+ {
923
+ kind: "patch",
924
+ documentId: json.id,
925
+ ops: json.ops,
926
+ ...json.ifRev !== void 0 ? { ifRev: json.ifRev } : {}
927
+ }
928
+ ];
929
+ }
930
+ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
931
+ const { registry } = backend, store = (() => {
345
932
  const key = registryKey(projectId, dataset);
346
933
  let s = registry.get(key);
347
934
  return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
348
- })(), promote = (doc) => {
935
+ })(), record = (transactionId, mutations) => {
936
+ backend.log.push({ transactionId, projectId, dataset, mutations });
937
+ }, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
938
+ documents,
939
+ results,
940
+ transactionId,
941
+ opts?.returnDocuments ?? !0,
942
+ opts?.returnFirst ?? !0
943
+ ), promote2 = (doc) => {
349
944
  const now = (/* @__PURE__ */ new Date()).toISOString();
350
945
  if (typeof doc._id != "string" || doc._id.length === 0)
351
- throw new Error("document is missing _id");
946
+ throw fakeError(
947
+ `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.`
948
+ );
352
949
  if (typeof doc._type != "string" || doc._type.length === 0)
353
- throw new Error(`document ${doc._id} is missing _type`);
950
+ throw fakeError(
951
+ `Document "${doc._id}" is missing a non-empty string \`_type\` (got ${JSON.stringify(doc._type)}). Every Sanity document needs a _type.`
952
+ );
354
953
  return {
355
954
  ...doc,
356
955
  _id: doc._id,
@@ -363,10 +962,12 @@ function makeHandle(registry, projectId, dataset, accessControl) {
363
962
  if (ifRevisionId === void 0) return;
364
963
  const existing = store.get(id);
365
964
  if (existing === void 0)
366
- throw new Error(`ifRevisionId check failed: document ${id} does not exist`);
965
+ throw fakeError(
966
+ `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.`
967
+ );
367
968
  if (existing._rev !== ifRevisionId)
368
- throw new Error(
369
- `ifRevisionId check failed: document ${id} has _rev=${existing._rev}, expected ${ifRevisionId}`
969
+ throw fakeError(
970
+ `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.)`
370
971
  );
371
972
  }, gate = async (document, permission) => {
372
973
  accessControl !== void 0 && await enforceWriteAccess({
@@ -383,7 +984,9 @@ function makeHandle(registry, projectId, dataset, accessControl) {
383
984
  }), applyPatch = async (documentId, ops, ifRev) => {
384
985
  const existing = store.get(documentId);
385
986
  if (existing === void 0)
386
- throw new Error(`Cannot patch document ${documentId}: not found`);
987
+ throw fakeError(
988
+ `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.`
989
+ );
387
990
  checkRevision(documentId, ifRev), await gate(existing, "update");
388
991
  const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
389
992
  ...patched,
@@ -392,7 +995,7 @@ function makeHandle(registry, projectId, dataset, accessControl) {
392
995
  };
393
996
  return store.put(next), next;
394
997
  }, applyTransaction = async (mutations, txnOptions) => {
395
- const snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
998
+ const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
396
999
  snapshot.has(id) || snapshot.set(id, store.get(id));
397
1000
  };
398
1001
  try {
@@ -400,15 +1003,17 @@ function makeHandle(registry, projectId, dataset, accessControl) {
400
1003
  switch (mut.kind) {
401
1004
  case "create": {
402
1005
  remember(mut.doc._id);
403
- const promoted = promote(mut.doc);
1006
+ const promoted = promote2(mut.doc);
404
1007
  if (store.get(promoted._id) !== void 0)
405
- throw new Error(`create failed: document ${promoted._id} already exists`);
1008
+ throw fakeError(
1009
+ `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
+ );
406
1011
  await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
407
1012
  break;
408
1013
  }
409
1014
  case "createOrReplace": {
410
1015
  remember(mut.doc._id);
411
- const promoted = promote(mut.doc), existing = store.get(promoted._id);
1016
+ const promoted = promote2(mut.doc), existing = store.get(promoted._id);
412
1017
  await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
413
1018
  const wasPresent = existing !== void 0;
414
1019
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
@@ -416,7 +1021,7 @@ function makeHandle(registry, projectId, dataset, accessControl) {
416
1021
  }
417
1022
  case "createIfNotExists": {
418
1023
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
419
- const promoted = promote(mut.doc);
1024
+ const promoted = promote2(mut.doc);
420
1025
  await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
421
1026
  }
422
1027
  break;
@@ -437,86 +1042,233 @@ function makeHandle(registry, projectId, dataset, accessControl) {
437
1042
  doc === void 0 ? store.remove(id) : store.put(doc);
438
1043
  throw error;
439
1044
  }
440
- if (txnOptions?.returnDocuments) {
441
- const docs = touchedIds.map((id) => store.get(id)).filter((d) => d !== void 0);
442
- return txnOptions.returnFirst ? docs[0] : docs;
443
- }
444
- return {
445
- transactionId: cryptoRandomKey(),
446
- results: operations
447
- };
448
- }, visibleDocs = async () => {
1045
+ record(transactionId, mutations);
1046
+ const documents = touchedIds.map((id) => store.get(id)).filter((d) => d !== void 0);
1047
+ return formatMutationResult(
1048
+ documents,
1049
+ operations,
1050
+ transactionId,
1051
+ txnOptions?.returnDocuments ?? !1,
1052
+ txnOptions?.returnFirst ?? !1
1053
+ );
1054
+ }, accessVisibleDocs = async () => {
449
1055
  const all = store.all();
450
1056
  if (accessControl === void 0) return all;
451
1057
  const out = [];
452
1058
  for (const doc of all)
453
1059
  await canRead(doc) && out.push(doc);
454
1060
  return out;
455
- };
456
- return {
457
- fetch: async (query, params) => {
458
- const tree = parse(query, params ? { params } : void 0);
459
- return await (await evaluate(tree, {
460
- dataset: await visibleDocs(),
1061
+ }, visibleDocs = async (override) => {
1062
+ const accessible = await accessVisibleDocs();
1063
+ return applyPerspective(accessible, override ?? perspective);
1064
+ }, client = {
1065
+ fetch: async (query, params, opts) => {
1066
+ const start = Date.now(), tree = parse(query, params ? { params } : void 0), result = await (await evaluate(tree, {
1067
+ dataset: await visibleDocs(opts?.perspective),
461
1068
  ...params !== void 0 ? { params } : {}
462
1069
  })).get();
1070
+ if (opts?.filterResponse === !1) {
1071
+ const ms = Date.now() - start;
1072
+ return opts.returnQuery === !1 ? { ms, result } : { query, ms, result };
1073
+ }
1074
+ return result;
463
1075
  },
464
- getDocument: async (id) => {
465
- const doc = store.get(id);
466
- return doc === void 0 || !await canRead(doc) ? null : doc;
467
- },
468
- getDocuments: async (ids) => {
469
- const result = [];
470
- for (const id of ids) {
1076
+ getDocument: async (id, opts) => {
1077
+ const effective = opts?.perspective ?? perspective;
1078
+ if (effective === "raw") {
471
1079
  const doc = store.get(id);
472
- doc === void 0 ? result.push(null) : await canRead(doc) ? result.push(doc) : result.push(null);
1080
+ return doc === void 0 || !await canRead(doc) ? void 0 : doc;
473
1081
  }
474
- return result;
1082
+ const view = await visibleDocs(effective);
1083
+ return resolveDocument(view, id, effective) ?? void 0;
1084
+ },
1085
+ getDocuments: async (ids, opts) => {
1086
+ const effective = opts?.perspective ?? perspective;
1087
+ if (effective === "raw") {
1088
+ const result = [];
1089
+ for (const id of ids) {
1090
+ const doc = store.get(id);
1091
+ doc === void 0 ? result.push(null) : await canRead(doc) ? result.push(doc) : result.push(null);
1092
+ }
1093
+ return result;
1094
+ }
1095
+ const view = await visibleDocs(effective);
1096
+ return ids.map((id) => resolveDocument(view, id, effective) ?? null);
475
1097
  },
476
1098
  create: async (doc, opts) => {
477
1099
  if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
478
- throw new Error(`create failed: document ${doc._id} already exists`);
479
- const promoted = promote(doc);
480
- return await gate(promoted, "create"), store.put(promoted), promoted;
1100
+ throw fakeError(
1101
+ `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
+ );
1103
+ const promoted = promote2(doc);
1104
+ await gate(promoted, "create");
1105
+ const transactionId = opts?.transactionId ?? cryptoRandomKey();
1106
+ return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
1107
+ [promoted],
1108
+ [{ id: promoted._id, operation: "create" }],
1109
+ transactionId,
1110
+ opts
1111
+ );
481
1112
  },
482
1113
  createOrReplace: async (doc, opts) => {
483
1114
  checkRevision(doc._id, opts?.ifRevisionId);
484
- const promoted = promote(doc), existing = store.get(doc._id);
485
- return await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), store.put(promoted), promoted;
1115
+ const promoted = promote2(doc), existing = store.get(doc._id);
1116
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1117
+ const transactionId = opts?.transactionId ?? cryptoRandomKey();
1118
+ opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
1119
+ const operation = existing !== void 0 ? "update" : "create";
1120
+ return writeResult(
1121
+ [promoted],
1122
+ [{ id: promoted._id, operation }],
1123
+ transactionId,
1124
+ opts
1125
+ );
486
1126
  },
487
1127
  createIfNotExists: async (doc, opts) => {
488
1128
  checkRevision(doc._id, opts?.ifRevisionId);
489
- const existing = store.get(doc._id);
490
- if (existing !== void 0) return existing;
491
- const promoted = promote(doc);
492
- return await gate(promoted, "create"), store.put(promoted), promoted;
1129
+ const existing = store.get(doc._id), transactionId = opts?.transactionId ?? cryptoRandomKey();
1130
+ if (existing !== void 0)
1131
+ return writeResult(
1132
+ [existing],
1133
+ [{ id: existing._id, operation: "none" }],
1134
+ transactionId,
1135
+ opts
1136
+ );
1137
+ const promoted = promote2(doc);
1138
+ return await gate(promoted, "create"), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1139
+ [promoted],
1140
+ [{ id: promoted._id, operation: "create" }],
1141
+ transactionId,
1142
+ opts
1143
+ );
493
1144
  },
494
1145
  delete: async (id, opts) => {
495
1146
  checkRevision(id, opts?.ifRevisionId);
496
1147
  const existing = store.get(id);
497
- return existing !== void 0 && await gate(existing, "update"), store.remove(id) ? { _id: id } : null;
1148
+ existing !== void 0 && await gate(existing, "update");
1149
+ const transactionId = opts?.transactionId ?? cryptoRandomKey();
1150
+ let removed = existing;
1151
+ 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(
1152
+ removed !== void 0 ? [removed] : [],
1153
+ removed !== void 0 ? [{ id, operation: "delete" }] : [],
1154
+ transactionId,
1155
+ opts.returnDocuments ?? !0,
1156
+ opts.returnFirst ?? !0
1157
+ );
498
1158
  },
499
- patch: (documentId) => createPatch(documentId, { apply: applyPatch }),
1159
+ patch: (documentId) => createPatch(documentId, {
1160
+ apply: async (docId, ops, ifRev) => {
1161
+ const doc = await applyPatch(docId, ops, ifRev);
1162
+ return record(cryptoRandomKey(), [
1163
+ { kind: "patch", documentId: docId, ops, ...ifRev !== void 0 ? { ifRev } : {} }
1164
+ ]), doc;
1165
+ }
1166
+ }),
500
1167
  transaction: () => createTransaction({ apply: applyTransaction }),
1168
+ mutate: async (operations, options) => applyTransaction(toTxnMutations(operations), options),
1169
+ action: async (action) => applyActions(store, action),
1170
+ releases: {
1171
+ create: async ({ releaseId, metadata }) => {
1172
+ const result = applyActions(store, {
1173
+ actionType: "sanity.action.release.create",
1174
+ releaseId,
1175
+ ...metadata !== void 0 ? { metadata } : {}
1176
+ }), doc = store.get(`_.releases.${releaseId}`);
1177
+ return {
1178
+ ...result,
1179
+ releaseId,
1180
+ metadata: doc?.metadata ?? { releaseType: "undecided" }
1181
+ };
1182
+ },
1183
+ edit: async ({ releaseId, patch }) => applyActions(store, { actionType: "sanity.action.release.edit", releaseId, patch }),
1184
+ get: async ({ releaseId }) => store.get(`_.releases.${releaseId}`),
1185
+ fetchDocuments: async ({ releaseId }) => {
1186
+ const prefix = `versions.${releaseId}.`;
1187
+ return store.all().filter((d) => d._id.startsWith(prefix));
1188
+ },
1189
+ schedule: async ({ releaseId, publishAt }) => applyActions(store, {
1190
+ actionType: "sanity.action.release.schedule",
1191
+ releaseId,
1192
+ publishAt
1193
+ }),
1194
+ unschedule: async ({ releaseId }) => applyActions(store, { actionType: "sanity.action.release.unschedule", releaseId }),
1195
+ publish: async ({ releaseId }) => applyActions(store, { actionType: "sanity.action.release.publish", releaseId }),
1196
+ archive: async ({ releaseId }) => applyActions(store, { actionType: "sanity.action.release.archive", releaseId }),
1197
+ unarchive: async ({ releaseId }) => applyActions(store, { actionType: "sanity.action.release.unarchive", releaseId }),
1198
+ delete: async ({ releaseId }) => applyActions(store, { actionType: "sanity.action.release.delete", releaseId })
1199
+ },
1200
+ createVersion: async ({ releaseId, publishedId, document }) => applyActions(store, {
1201
+ actionType: "sanity.action.document.version.create",
1202
+ releaseId,
1203
+ publishedId,
1204
+ document
1205
+ }),
1206
+ unpublishVersion: async ({ releaseId, publishedId }) => applyActions(store, {
1207
+ actionType: "sanity.action.document.version.unpublish",
1208
+ versionId: `versions.${releaseId}.${publishedId}`,
1209
+ publishedId
1210
+ }),
501
1211
  ...accessControl !== void 0 ? { request: makeRequestImpl(accessControl, projectId, dataset) } : {},
502
- config: () => ({ projectId, dataset }),
503
- withConfig: (config) => makeHandle(registry, config.projectId ?? projectId, config.dataset ?? dataset, accessControl),
1212
+ config: () => ({ projectId, dataset, perspective }),
1213
+ withConfig: (config) => makeHandle(
1214
+ backend,
1215
+ config.projectId ?? projectId,
1216
+ config.dataset ?? dataset,
1217
+ accessControl,
1218
+ config.perspective ?? perspective
1219
+ ),
504
1220
  seed: (documents) => store.seed(documents),
505
1221
  add: (documents) => store.add(documents),
506
1222
  reset: () => store.reset(),
507
1223
  snapshot: () => store.snapshot(),
508
1224
  store: () => store,
509
- datasets: () => [...registry.keys()].map(parseRegistryKey)
1225
+ datasets: () => [...registry.keys()].map(parseRegistryKey),
1226
+ stub: (name, handler) => {
1227
+ backend.stubs.set(name, handler);
1228
+ },
1229
+ stubs: () => Object.fromEntries(backend.stubs),
1230
+ mutations: () => backend.log.flatMap((entry) => entry.mutations),
1231
+ transactions: () => [...backend.log],
1232
+ clearLog: () => {
1233
+ backend.log.length = 0;
1234
+ },
1235
+ calls: (method) => method === void 0 ? [...backend.calls] : backend.calls.filter((c) => c.method === method),
1236
+ callCount: (method) => method === void 0 ? backend.calls.length : backend.calls.filter((c) => c.method === method).length,
1237
+ clearCalls: () => {
1238
+ backend.calls.length = 0;
1239
+ }
510
1240
  };
1241
+ return wrapWithGuards(client, {
1242
+ getStub: (name) => backend.stubs.get(name),
1243
+ onCall: (method, args) => {
1244
+ const rec = { method, args, projectId, dataset };
1245
+ return backend.calls.push(rec), (outcome) => {
1246
+ "error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
1247
+ };
1248
+ },
1249
+ recorded: RECORDED_METHODS
1250
+ });
511
1251
  }
512
1252
  export {
1253
+ ERR_PREFIX,
1254
+ SUPPORTED_SURFACE,
1255
+ UNSUPPORTED_MEMBERS,
513
1256
  WriteAccessDeniedError,
514
1257
  applyPatchOps,
1258
+ applyPerspective,
515
1259
  createPatch,
516
1260
  createTestClient,
517
1261
  createTestStore,
518
1262
  createTransaction,
519
1263
  defaultAclPath,
520
- evaluateWriteAccess
1264
+ evaluateWriteAccess,
1265
+ fakeError,
1266
+ isDraftId,
1267
+ isReleaseDocId,
1268
+ isSystemId,
1269
+ isVersionId,
1270
+ publishedIdOf,
1271
+ releaseNameOf,
1272
+ wrapWithGuards
521
1273
  };
522
1274
  //# sourceMappingURL=index.js.map