@sanity-labs/client-fake-for-test 0.0.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/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @sanity-labs/client-fake-for-test
2
+
3
+ > [!NOTE]
4
+ > In-memory fake of `@sanity/client` for tests. Drop-in for the data-plane methods (`fetch`, `getDocument`, `patch`, `createOrReplace`, `transaction`, …), backed by [groq-js](https://github.com/sanity-io/groq-js) and a closure-scoped document store. No network, no global state, fresh per test.
5
+
6
+ ```bash
7
+ npm install -D @sanity-labs/client-fake-for-test
8
+ ```
9
+
10
+ ## What this is for
11
+
12
+ Any code typed against `SanityClient` (or a narrow subset of its methods) that you want to test without:
13
+
14
+ - Hitting the network — real datasets are slow, flaky, rate-limited, and shared across runs.
15
+ - Mocking individual methods one-by-one — drifts away from the real client's surface shape, and breaks the moment you add a call.
16
+ - Standing up a stub HTTP server (MSW, nock) — fine for HTTP-shaped tests, overkill for "did my patch chain do the right thing".
17
+
18
+ Typical use cases:
19
+
20
+ - **Server functions** that read or write Sanity content — Sanity Functions, route handlers, custom backends.
21
+ - **Hooks and utilities** typed against `SanityClient`.
22
+ - **Migration scripts** — exercise the patch chain against a synthetic document set, assert end state.
23
+ - **Schema validators** and other tooling that walks documents via GROQ.
24
+ - **Workflow / orchestration engines** that mutate Sanity state — see [`@sanity-labs/workflow-engine-explore`](https://github.com/sanity-labs/workflow-slop), the original consumer.
25
+
26
+ ## Quickstart
27
+
28
+ ```ts
29
+ import { createTestClient } from "@sanity-labs/client-fake-for-test";
30
+
31
+ const client = createTestClient({
32
+ documents: [
33
+ { _id: "article-1", _type: "article", title: "Hello", state: "draft" },
34
+ { _id: "article-2", _type: "article", title: "World", state: "approved" },
35
+ ],
36
+ });
37
+
38
+ // fetch — GROQ via groq-js against the in-memory store
39
+ const approved = await client.fetch<{ _id: string }[]>(
40
+ `*[_type == "article" && state == $s]{_id}`,
41
+ { s: "approved" },
42
+ );
43
+ // → [{ _id: "article-2" }]
44
+
45
+ // patch + commit
46
+ await client.patch("article-1").set({ state: "approved" }).commit();
47
+
48
+ // transaction — atomic batch
49
+ await client.transaction()
50
+ .patch("article-1", (p) => p.set({ reviewedBy: "alice" }))
51
+ .patch("article-2", (p) => p.set({ reviewedBy: "alice" }))
52
+ .commit();
53
+
54
+ // snapshot — read the raw store for assertions
55
+ expect(client.snapshot().find((d) => d._id === "article-1")?.state).toBe("approved");
56
+ ```
57
+
58
+ More patterns — passing the fake to code typed against `SanityClient`, asserting on writes, sharing a store across consumers, simulating ACL denials — in [docs/patterns.md](https://github.com/sanity-labs/client-fake-for-test/blob/main/docs/patterns.md).
59
+
60
+ ## Client surface — what's supported and what isn't
61
+
62
+ `SanityClient` is large. This fake covers the data-plane methods most code reaches for, and intentionally leaves the rest out. Compatibility is structural — if your code is typed against the supported subset (or all of `SanityClient` and only touches these methods), the fake drops in unchanged.
63
+
64
+ ### Supported
65
+
66
+ | Area | Method | Notes |
67
+ | --- | --- | --- |
68
+ | Read | `fetch<T>(query, params?)` | GROQ via groq-js against the in-memory store |
69
+ | Read | `getDocument<T>(id)` | Returns `null` if missing (or hidden by ACL) |
70
+ | Read | `getDocuments<T>(ids)` | Element-wise null for missing |
71
+ | Write | `create(doc, { ifRevisionId? })` | Throws if `_id` already exists |
72
+ | Write | `createOrReplace(doc, { ifRevisionId? })` | Upsert |
73
+ | Write | `createIfNotExists(doc, { ifRevisionId? })` | No-op if `_id` exists |
74
+ | Write | `delete(id, { ifRevisionId? })` | Returns `{ _id }` or `null` |
75
+ | Patch | `patch(id)` chain → `.commit()` | Ops: `.set`, `.setIfMissing`, `.unset`, `.inc`, `.dec`, `.insert(position, anchor, items)`, `.ifRevisionId` |
76
+ | Transaction | `transaction()` chain → `.commit()` | Mutations: `.create`, `.createOrReplace`, `.createIfNotExists`, `.delete`, `.patch(id, recipe)`. Atomic — any throw rolls back the whole batch |
77
+ | Config | `config()` | Returns `{ projectId, dataset }` |
78
+ | Config | `withConfig({ projectId?, dataset? })` | Sibling handle into the same multi-dataset registry |
79
+ | HTTP | `request({ url? \| uri? })` | Present **only** when the client is created with `accessControl`. Serves `/users/me` + the configured ACL path. Absent otherwise. |
80
+
81
+ ### Test-only helpers (not on real `SanityClient`)
82
+
83
+ Useful in test bodies, but a giveaway that the code under test isn't using the real client. Keep them in the test file, not in the code being tested.
84
+
85
+ | Method | Use |
86
+ | --- | --- |
87
+ | `client.snapshot()` | Read-only `readonly SanityDocument[]` of the current dataset |
88
+ | `client.seed(documents)` | Replace the current dataset's contents |
89
+ | `client.add(documents)` | Merge documents in without resetting |
90
+ | `client.reset()` | Drop everything in the current dataset |
91
+ | `client.store()` | Direct access to the underlying `TestStore` |
92
+ | `client.datasets()` | `{ projectId, dataset }[]` known to the registry |
93
+ | `createTestClient({ datasets: { "p:d": [...] } })` | Seed multiple `(projectId, dataset)` stores up front |
94
+
95
+ ### Not supported (by design)
96
+
97
+ The whole-client surface listed below throws / returns `undefined` / is simply absent. Code that depends on these needs a different test strategy (real client against an ephemeral dataset, MSW for HTTP intercepts, or a hand-rolled stub).
98
+
99
+ | Area | Status | Workaround |
100
+ | --- | --- | --- |
101
+ | `listen(query, params, options)` — real-time | ❌ Absent | Test the subscription logic separately, mock the observable |
102
+ | `mutate(mutations, options)` — bulk array form | ❌ Absent | Use `transaction()` chain instead |
103
+ | `action(actions, options)` — releases / Content Lake actions | ❌ Absent | Stub the call site, or use MSW |
104
+ | `clone()` | ❌ Absent | `withConfig({})` returns a sibling handle |
105
+ | `getUrl(uri)` / `getDataUrl(...)` — URL builders | ❌ Absent | Hard-code in tests |
106
+ | `request(...)` — generic HTTP | ⚠️ Only with `accessControl` | Use [MSW](https://mswjs.io) for arbitrary HTTP |
107
+ | `agent.*` — Agent Actions | ❌ Absent | Stub the namespace |
108
+ | `assets.*` — uploads, metadata | ❌ Absent | Seed asset docs manually |
109
+ | `users.*` / `projects.*` / `datasets.*` / `releases.*` | ❌ Absent | Stub per call site |
110
+ | `observable.*` — RxJS API | ❌ Absent | Wrap the Promise API with `from()` if needed |
111
+ | Perspectives (`drafts.*` / `published.*` / `raw` config) | ❌ All docs visible | Treat IDs literally; no draft/published split |
112
+ | Patch ops: `.diffMatchPatch`, `.merge`, `.append`, `.prepend`, `.replace` | ❌ Absent | `.insert("after", "field[-1]", items)` for append; `.insert("before", "field[0]", items)` for prepend |
113
+
114
+ > [!IMPORTANT]
115
+ > Unsupported methods aren't silently no-op'd — calling one throws (`...is not a function` for the absent ones, `accessControl required` for `request` without setup). If you type the test variable as `TestClient`, TypeScript catches misuse at compile time; if you cast through `SanityClient`, you'll see the error at runtime instead.
116
+
117
+ ## Design constraints
118
+
119
+ - **Closure-scoped isolation.** `createTestClient({ documents })` returns a client whose store is local to that closure. Two clients in two tests do not see each other's writes. Pass the *same* client to two consumers if they need to share a store.
120
+ - **Structural compatibility, not nominal.** Methods accept the same arg shapes as `@sanity/client`, so any code typed against `SanityClient` (or a narrow subset) can take a `TestClient` without an adapter.
121
+ - **groq-js directly.** Same GROQ evaluator the real client supports server-side, against the in-memory document set. What groq-js supports, this supports. What it doesn't (some `&&` short-circuit edge cases, custom functions), this doesn't either.
122
+
123
+ ## License
124
+
125
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,522 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: !0 });
3
+ var groqJs = require("groq-js");
4
+ class WriteAccessDeniedError extends Error {
5
+ documentId;
6
+ permission;
7
+ matchingGrants;
8
+ constructor(args) {
9
+ super(
10
+ `Sanity (test): permission denied \u2014 actor lacks "${args.permission}" on document "${args.documentId}" (${args.matchingGrants} grant${args.matchingGrants === 1 ? "" : "s"} matched the document but none included "${args.permission}").`
11
+ ), this.name = "WriteAccessDeniedError", this.documentId = args.documentId, this.permission = args.permission, this.matchingGrants = args.matchingGrants;
12
+ }
13
+ }
14
+ async function canAccess(args) {
15
+ return (await evaluateWriteAccess(args)).allowed;
16
+ }
17
+ const parsedFilters = /* @__PURE__ */ new Map();
18
+ async function matchesFilter(args) {
19
+ const { document, filter, userId } = args;
20
+ parsedFilters.has(filter) || parsedFilters.set(filter, groqJs.parse(`*[${filter}]`));
21
+ const ast = parsedFilters.get(filter), data = await (await groqJs.evaluate(ast, {
22
+ dataset: [document],
23
+ identity: userId
24
+ })).get();
25
+ return Array.isArray(data) && data.length === 1;
26
+ }
27
+ async function evaluateWriteAccess(args) {
28
+ const { document, grants, permission, userId } = args;
29
+ let matching = 0;
30
+ for (const grant of grants)
31
+ if (await matchesFilter({
32
+ document,
33
+ filter: grant.filter,
34
+ userId
35
+ }) && (matching++, grant.permissions.includes(permission)))
36
+ return { allowed: !0, matchingGrants: matching };
37
+ return { allowed: !1, matchingGrants: matching };
38
+ }
39
+ async function enforceWriteAccess(args) {
40
+ const { document, grants, permission, userId } = args, { allowed, matchingGrants } = await evaluateWriteAccess({
41
+ document,
42
+ grants,
43
+ permission,
44
+ userId
45
+ });
46
+ if (!allowed)
47
+ throw typeof document._id != "string" ? new WriteAccessDeniedError({
48
+ documentId: "(unknown)",
49
+ permission,
50
+ matchingGrants
51
+ }) : new WriteAccessDeniedError({
52
+ documentId: document._id,
53
+ permission,
54
+ matchingGrants
55
+ });
56
+ }
57
+ function defaultAclPath(projectId, dataset) {
58
+ return `/projects/${projectId}/datasets/${dataset}/acl`;
59
+ }
60
+ function makeRequestImpl(accessControl, projectId, dataset) {
61
+ const aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset);
62
+ return async (opts) => {
63
+ const path = opts.uri ?? opts.url;
64
+ if (path === "/users/me")
65
+ return accessControl.currentUser;
66
+ if (path === aclPath)
67
+ 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 }).`
70
+ );
71
+ };
72
+ }
73
+ function createPatch(documentId, executor) {
74
+ const ops = [];
75
+ let ifRev;
76
+ const handle = {
77
+ set: (props) => (ops.push({ kind: "set", props }), handle),
78
+ setIfMissing: (props) => (ops.push({ kind: "setIfMissing", props }), handle),
79
+ unset: (paths) => (ops.push({ kind: "unset", paths }), handle),
80
+ inc: (props) => (ops.push({ kind: "inc", props }), handle),
81
+ dec: (props) => (ops.push({ kind: "dec", props }), handle),
82
+ insert: (position, anchor, items) => (ops.push({ kind: "insert", position, anchor, items }), handle),
83
+ ifRevisionId: (rev) => (ifRev = rev, handle),
84
+ commit: () => executor.apply(documentId, ops, ifRev),
85
+ toJSON: () => ({
86
+ id: documentId,
87
+ ops: [...ops],
88
+ ...ifRev !== void 0 ? { ifRev } : {}
89
+ })
90
+ };
91
+ return handle;
92
+ }
93
+ function applyPatchOps(doc, ops) {
94
+ let next = structuredClone(doc);
95
+ for (const op of ops)
96
+ next = applyOne(next, op);
97
+ return next;
98
+ }
99
+ function applyOne(doc, op) {
100
+ switch (op.kind) {
101
+ case "set":
102
+ for (const [path, value] of Object.entries(op.props))
103
+ setAtPath(doc, path, value);
104
+ return doc;
105
+ case "setIfMissing":
106
+ for (const [path, value] of Object.entries(op.props))
107
+ getAtPath(doc, path) === void 0 && setAtPath(doc, path, value);
108
+ return doc;
109
+ case "unset":
110
+ for (const path of op.paths)
111
+ unsetAtPath(doc, path);
112
+ return doc;
113
+ case "inc":
114
+ for (const [path, delta] of Object.entries(op.props)) {
115
+ const current = getAtPath(doc, path);
116
+ setAtPath(doc, path, (typeof current == "number" ? current : 0) + delta);
117
+ }
118
+ return doc;
119
+ case "dec":
120
+ for (const [path, delta] of Object.entries(op.props)) {
121
+ const current = getAtPath(doc, path);
122
+ setAtPath(doc, path, (typeof current == "number" ? current : 0) - delta);
123
+ }
124
+ return doc;
125
+ case "insert":
126
+ return insertIntoArray(doc, op.position, op.anchor, op.items), doc;
127
+ }
128
+ }
129
+ function parsePath(path) {
130
+ const segments = [];
131
+ for (const raw of path.split(".")) {
132
+ const match = /^([A-Za-z_$][\w$]*)(?:\[(.+)\])?$/.exec(raw);
133
+ if (!match) throw new Error(`Unsupported patch path segment: ${raw}`);
134
+ const key = match[1], selectorRaw = match[2];
135
+ if (selectorRaw === void 0) {
136
+ segments.push({ key });
137
+ continue;
138
+ }
139
+ const indexMatch = /^(\d+)$/.exec(selectorRaw);
140
+ if (indexMatch) {
141
+ segments.push({ key, selector: { kind: "index", index: Number(indexMatch[1]) } });
142
+ continue;
143
+ }
144
+ const keyedMatch = /^([A-Za-z_$][\w$]*)\s*==\s*"([^"]*)"$/.exec(selectorRaw);
145
+ if (keyedMatch) {
146
+ segments.push({
147
+ key,
148
+ selector: { kind: "keyed", field: keyedMatch[1], value: keyedMatch[2] }
149
+ });
150
+ continue;
151
+ }
152
+ throw new Error(`Unsupported array selector in patch path: ${selectorRaw}`);
153
+ }
154
+ return segments;
155
+ }
156
+ function resolveSelector(array, selector) {
157
+ if (selector.kind === "index") return selector.index;
158
+ const idx = array.findIndex(
159
+ (entry) => typeof entry == "object" && entry !== null && entry[selector.field] === selector.value
160
+ );
161
+ if (idx === -1)
162
+ throw new Error(`No array entry matched ${selector.field}=="${selector.value}" in patch path`);
163
+ return idx;
164
+ }
165
+ function getAtPath(doc, path) {
166
+ const segments = parsePath(path);
167
+ let cursor = doc;
168
+ for (const seg of segments) {
169
+ if (cursor == null) return;
170
+ if (cursor = cursor[seg.key], seg.selector !== void 0) {
171
+ if (!Array.isArray(cursor)) return;
172
+ const idx = resolveSelector(cursor, seg.selector);
173
+ cursor = cursor[idx];
174
+ }
175
+ }
176
+ return cursor;
177
+ }
178
+ function setAtPath(doc, path, value) {
179
+ const segments = parsePath(path);
180
+ if (segments.length === 0) throw new Error("Empty patch path");
181
+ let cursor = doc;
182
+ for (let i = 0; i < segments.length - 1; i++) {
183
+ const seg = segments[i];
184
+ let next = cursor[seg.key];
185
+ if (next === void 0 && (next = seg.selector?.kind === "index" || seg.selector?.kind === "keyed" ? [] : {}, cursor[seg.key] = next), seg.selector !== void 0) {
186
+ if (!Array.isArray(next))
187
+ throw new Error(`Patch path crosses non-array at segment ${seg.key}`);
188
+ const idx = resolveSelector(next, seg.selector);
189
+ cursor = next[idx];
190
+ } else
191
+ cursor = next;
192
+ }
193
+ const last = segments[segments.length - 1];
194
+ if (last.selector !== void 0) {
195
+ const arr = cursor[last.key];
196
+ if (!Array.isArray(arr))
197
+ throw new Error(`Patch path crosses non-array at segment ${last.key}`);
198
+ const idx = resolveSelector(arr, last.selector);
199
+ arr[idx] = value;
200
+ } else
201
+ cursor[last.key] = value;
202
+ }
203
+ function unsetAtPath(doc, path) {
204
+ const segments = parsePath(path);
205
+ if (segments.length === 0) return;
206
+ let cursor = doc;
207
+ for (let i = 0; i < segments.length - 1; i++) {
208
+ const seg = segments[i];
209
+ let next = cursor[seg.key];
210
+ if (next == null) return;
211
+ if (seg.selector !== void 0) {
212
+ if (!Array.isArray(next)) return;
213
+ const idx = resolveSelector(next, seg.selector);
214
+ next = next[idx];
215
+ }
216
+ cursor = next;
217
+ }
218
+ const last = segments[segments.length - 1];
219
+ if (last.selector !== void 0) {
220
+ const arr = cursor[last.key];
221
+ if (!Array.isArray(arr)) return;
222
+ const idx = resolveSelector(arr, last.selector);
223
+ arr.splice(idx, 1);
224
+ } else
225
+ delete cursor[last.key];
226
+ }
227
+ function insertIntoArray(doc, position, anchor, items) {
228
+ const segments = parsePath(anchor);
229
+ if (segments.length === 0) throw new Error("Empty insert anchor");
230
+ const last = segments[segments.length - 1];
231
+ if (last.selector === void 0)
232
+ throw new Error(`Insert anchor must end with an array selector: ${anchor}`);
233
+ let cursor = doc;
234
+ for (let i = 0; i < segments.length - 1; i++) {
235
+ const seg = segments[i], next = cursor[seg.key];
236
+ if (next == null)
237
+ throw new Error(`Insert anchor traverses missing field: ${seg.key}`);
238
+ if (seg.selector !== void 0) {
239
+ if (!Array.isArray(next))
240
+ throw new Error(`Insert anchor traverses non-array at: ${seg.key}`);
241
+ cursor = next[resolveSelector(next, seg.selector)];
242
+ } else
243
+ cursor = next;
244
+ }
245
+ const arr = cursor[last.key];
246
+ if (!Array.isArray(arr))
247
+ throw new Error(`Insert anchor target is not an array: ${last.key}`);
248
+ const idx = resolveSelector(arr, last.selector);
249
+ position === "replace" ? arr.splice(idx, 1, ...items) : position === "before" ? arr.splice(idx, 0, ...items) : arr.splice(idx + 1, 0, ...items);
250
+ }
251
+ function createTestStore(initial) {
252
+ const docs = /* @__PURE__ */ new Map(), promote = (doc) => {
253
+ if (typeof doc._id != "string" || doc._id.length === 0)
254
+ throw new Error(`document is missing _id: ${JSON.stringify(doc)}`);
255
+ if (typeof doc._type != "string" || doc._type.length === 0)
256
+ throw new Error(`document ${doc._id} is missing _type`);
257
+ const now = (/* @__PURE__ */ new Date()).toISOString();
258
+ return {
259
+ ...doc,
260
+ _id: doc._id,
261
+ _type: doc._type,
262
+ _rev: doc._rev ?? cryptoRandomKey(),
263
+ _createdAt: doc._createdAt ?? now,
264
+ _updatedAt: doc._updatedAt ?? now
265
+ };
266
+ }, seed = (documents) => {
267
+ docs.clear();
268
+ for (const d of documents) {
269
+ const promoted = promote(d);
270
+ docs.set(promoted._id, promoted);
271
+ }
272
+ };
273
+ return initial && seed(initial), {
274
+ seed,
275
+ add: (documents) => {
276
+ for (const d of documents) {
277
+ const promoted = promote(d);
278
+ docs.set(promoted._id, promoted);
279
+ }
280
+ },
281
+ reset: () => docs.clear(),
282
+ all: () => [...docs.values()],
283
+ get: (id) => docs.get(id),
284
+ put: (doc) => {
285
+ docs.set(doc._id, doc);
286
+ },
287
+ remove: (id) => {
288
+ const doc = docs.get(id);
289
+ return doc && docs.delete(id), doc;
290
+ },
291
+ snapshot: () => [...docs.values()]
292
+ };
293
+ }
294
+ function cryptoRandomKey(length = 12) {
295
+ const bytes = new Uint8Array(length);
296
+ return globalThis.crypto.getRandomValues(bytes), [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, length);
297
+ }
298
+ function createTransaction(executor) {
299
+ const mutations = [], handle = {
300
+ create: (doc) => (mutations.push({ kind: "create", doc }), handle),
301
+ createOrReplace: (doc) => (mutations.push({ kind: "createOrReplace", doc }), handle),
302
+ createIfNotExists: (doc) => (mutations.push({ kind: "createIfNotExists", doc }), handle),
303
+ delete: (id) => (mutations.push({ kind: "delete", id }), handle),
304
+ patch: (documentIdOrPatch, ops, ifRev) => {
305
+ if (typeof documentIdOrPatch == "string")
306
+ mutations.push({
307
+ kind: "patch",
308
+ documentId: documentIdOrPatch,
309
+ ops: ops ?? [],
310
+ ...ifRev !== void 0 ? { ifRev } : {}
311
+ });
312
+ else {
313
+ const json = documentIdOrPatch.toJSON();
314
+ mutations.push({
315
+ kind: "patch",
316
+ documentId: json.id,
317
+ ops: json.ops,
318
+ ...json.ifRev !== void 0 ? { ifRev: json.ifRev } : {}
319
+ });
320
+ }
321
+ return handle;
322
+ },
323
+ commit: (options) => executor.apply(mutations, options),
324
+ toJSON: () => [...mutations]
325
+ };
326
+ return handle;
327
+ }
328
+ const DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test", registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
329
+ const [projectId, dataset] = key.split(":");
330
+ return { projectId, dataset };
331
+ };
332
+ function createTestClient(options = {}) {
333
+ const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, registry = /* @__PURE__ */ new Map(), ensureStore = (projectId, dataset) => {
334
+ const key = registryKey(projectId, dataset);
335
+ let store = registry.get(key);
336
+ return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
337
+ };
338
+ if (options.documents && ensureStore(defaultProjectId, defaultDataset).seed(options.documents), options.datasets)
339
+ for (const [key, docs] of Object.entries(options.datasets)) {
340
+ const { projectId, dataset } = parseRegistryKey(key);
341
+ ensureStore(projectId, dataset).seed(docs);
342
+ }
343
+ return makeHandle(registry, defaultProjectId, defaultDataset, options.accessControl);
344
+ }
345
+ function makeHandle(registry, projectId, dataset, accessControl) {
346
+ const store = (() => {
347
+ const key = registryKey(projectId, dataset);
348
+ let s = registry.get(key);
349
+ return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
350
+ })(), promote = (doc) => {
351
+ const now = (/* @__PURE__ */ new Date()).toISOString();
352
+ if (typeof doc._id != "string" || doc._id.length === 0)
353
+ throw new Error("document is missing _id");
354
+ if (typeof doc._type != "string" || doc._type.length === 0)
355
+ throw new Error(`document ${doc._id} is missing _type`);
356
+ return {
357
+ ...doc,
358
+ _id: doc._id,
359
+ _type: doc._type,
360
+ _rev: cryptoRandomKey(),
361
+ _createdAt: doc._createdAt ?? now,
362
+ _updatedAt: now
363
+ };
364
+ }, checkRevision = (id, ifRevisionId) => {
365
+ if (ifRevisionId === void 0) return;
366
+ const existing = store.get(id);
367
+ if (existing === void 0)
368
+ throw new Error(`ifRevisionId check failed: document ${id} does not exist`);
369
+ if (existing._rev !== ifRevisionId)
370
+ throw new Error(
371
+ `ifRevisionId check failed: document ${id} has _rev=${existing._rev}, expected ${ifRevisionId}`
372
+ );
373
+ }, gate = async (document, permission) => {
374
+ accessControl !== void 0 && await enforceWriteAccess({
375
+ document,
376
+ grants: accessControl.grants,
377
+ permission,
378
+ userId: accessControl.currentUser.id
379
+ });
380
+ }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
381
+ document,
382
+ grants: accessControl.grants,
383
+ permission: "read",
384
+ userId: accessControl.currentUser.id
385
+ }), applyPatch = async (documentId, ops, ifRev) => {
386
+ const existing = store.get(documentId);
387
+ if (existing === void 0)
388
+ throw new Error(`Cannot patch document ${documentId}: not found`);
389
+ checkRevision(documentId, ifRev), await gate(existing, "update");
390
+ const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
391
+ ...patched,
392
+ _rev: cryptoRandomKey(),
393
+ _updatedAt: now
394
+ };
395
+ return store.put(next), next;
396
+ }, applyTransaction = async (mutations, txnOptions) => {
397
+ const snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
398
+ snapshot.has(id) || snapshot.set(id, store.get(id));
399
+ };
400
+ try {
401
+ for (const mut of mutations)
402
+ switch (mut.kind) {
403
+ case "create": {
404
+ remember(mut.doc._id);
405
+ const promoted = promote(mut.doc);
406
+ if (store.get(promoted._id) !== void 0)
407
+ throw new Error(`create failed: document ${promoted._id} already exists`);
408
+ await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
409
+ break;
410
+ }
411
+ case "createOrReplace": {
412
+ remember(mut.doc._id);
413
+ const promoted = promote(mut.doc), existing = store.get(promoted._id);
414
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
415
+ const wasPresent = existing !== void 0;
416
+ store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
417
+ break;
418
+ }
419
+ case "createIfNotExists": {
420
+ if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
421
+ const promoted = promote(mut.doc);
422
+ await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
423
+ }
424
+ break;
425
+ }
426
+ case "delete": {
427
+ remember(mut.id);
428
+ const existing = store.get(mut.id);
429
+ existing !== void 0 && await gate(existing, "update"), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
430
+ break;
431
+ }
432
+ case "patch": {
433
+ remember(mut.documentId), await applyPatch(mut.documentId, mut.ops, mut.ifRev), touchedIds.push(mut.documentId), operations.push({ id: mut.documentId, operation: "update" });
434
+ break;
435
+ }
436
+ }
437
+ } catch (error) {
438
+ for (const [id, doc] of snapshot)
439
+ doc === void 0 ? store.remove(id) : store.put(doc);
440
+ throw error;
441
+ }
442
+ if (txnOptions?.returnDocuments) {
443
+ const docs = touchedIds.map((id) => store.get(id)).filter((d) => d !== void 0);
444
+ return txnOptions.returnFirst ? docs[0] : docs;
445
+ }
446
+ return {
447
+ transactionId: cryptoRandomKey(),
448
+ results: operations
449
+ };
450
+ }, visibleDocs = async () => {
451
+ const all = store.all();
452
+ if (accessControl === void 0) return all;
453
+ const out = [];
454
+ for (const doc of all)
455
+ await canRead(doc) && out.push(doc);
456
+ return out;
457
+ };
458
+ return {
459
+ fetch: async (query, params) => {
460
+ const tree = groqJs.parse(query, params ? { params } : void 0);
461
+ return await (await groqJs.evaluate(tree, {
462
+ dataset: await visibleDocs(),
463
+ ...params !== void 0 ? { params } : {}
464
+ })).get();
465
+ },
466
+ getDocument: async (id) => {
467
+ const doc = store.get(id);
468
+ return doc === void 0 || !await canRead(doc) ? null : doc;
469
+ },
470
+ getDocuments: async (ids) => {
471
+ const result = [];
472
+ for (const id of ids) {
473
+ const doc = store.get(id);
474
+ doc === void 0 ? result.push(null) : await canRead(doc) ? result.push(doc) : result.push(null);
475
+ }
476
+ return result;
477
+ },
478
+ create: async (doc, opts) => {
479
+ if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
480
+ throw new Error(`create failed: document ${doc._id} already exists`);
481
+ const promoted = promote(doc);
482
+ return await gate(promoted, "create"), store.put(promoted), promoted;
483
+ },
484
+ createOrReplace: async (doc, opts) => {
485
+ checkRevision(doc._id, opts?.ifRevisionId);
486
+ const promoted = promote(doc), existing = store.get(doc._id);
487
+ return await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), store.put(promoted), promoted;
488
+ },
489
+ createIfNotExists: async (doc, opts) => {
490
+ checkRevision(doc._id, opts?.ifRevisionId);
491
+ const existing = store.get(doc._id);
492
+ if (existing !== void 0) return existing;
493
+ const promoted = promote(doc);
494
+ return await gate(promoted, "create"), store.put(promoted), promoted;
495
+ },
496
+ delete: async (id, opts) => {
497
+ checkRevision(id, opts?.ifRevisionId);
498
+ const existing = store.get(id);
499
+ return existing !== void 0 && await gate(existing, "update"), store.remove(id) ? { _id: id } : null;
500
+ },
501
+ patch: (documentId) => createPatch(documentId, { apply: applyPatch }),
502
+ transaction: () => createTransaction({ apply: applyTransaction }),
503
+ ...accessControl !== void 0 ? { request: makeRequestImpl(accessControl, projectId, dataset) } : {},
504
+ config: () => ({ projectId, dataset }),
505
+ withConfig: (config) => makeHandle(registry, config.projectId ?? projectId, config.dataset ?? dataset, accessControl),
506
+ seed: (documents) => store.seed(documents),
507
+ add: (documents) => store.add(documents),
508
+ reset: () => store.reset(),
509
+ snapshot: () => store.snapshot(),
510
+ store: () => store,
511
+ datasets: () => [...registry.keys()].map(parseRegistryKey)
512
+ };
513
+ }
514
+ exports.WriteAccessDeniedError = WriteAccessDeniedError;
515
+ exports.applyPatchOps = applyPatchOps;
516
+ exports.createPatch = createPatch;
517
+ exports.createTestClient = createTestClient;
518
+ exports.createTestStore = createTestStore;
519
+ exports.createTransaction = createTransaction;
520
+ exports.defaultAclPath = defaultAclPath;
521
+ exports.evaluateWriteAccess = evaluateWriteAccess;
522
+ //# sourceMappingURL=index.cjs.map