@sanity-labs/client-fake-for-test 0.12.0 → 0.14.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
@@ -3,6 +3,516 @@ const ERR_PREFIX = "[client-fake-for-test]";
3
3
  function fakeError(message) {
4
4
  return new Error(`${ERR_PREFIX} ${message}`);
5
5
  }
6
+ function createDocumentHistory() {
7
+ const transactions = [], revisions = /* @__PURE__ */ new Map(), keep = (doc) => revisions.set(`${doc._id} ${doc._rev}`, structuredClone(doc)), deleted = /* @__PURE__ */ new Map();
8
+ return {
9
+ commit: ({ transactionId, author, before, after }) => {
10
+ const afterById = new Map(after.map((doc) => [doc._id, doc])), ids = /* @__PURE__ */ new Set([...before.keys(), ...afterById.keys()]), effects = [];
11
+ for (const id of ids) {
12
+ const previous = before.get(id), next = afterById.get(id);
13
+ previous !== next && (previous !== void 0 && keep(previous), next !== void 0 && keep(next), next === void 0 && previous !== void 0 ? deleted.set(id, structuredClone(previous)) : deleted.delete(id), effects.push({
14
+ documentId: id,
15
+ after: next === void 0 ? void 0 : structuredClone(next),
16
+ existed: previous !== void 0
17
+ }));
18
+ }
19
+ effects.length !== 0 && transactions.push({
20
+ id: transactionId,
21
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
22
+ author,
23
+ effects
24
+ });
25
+ },
26
+ documentsAt: ({ ids, revision, current }) => ids.flatMap((id) => {
27
+ const image = revisions.get(`${id} ${revision}`) ?? current.find((doc) => doc._id === id && doc._rev === revision);
28
+ return image === void 0 ? [] : [structuredClone(image)];
29
+ }),
30
+ lastImage: (id) => deleted.get(id),
31
+ documentIds: () => [
32
+ ...new Set(transactions.flatMap((txn) => txn.effects.map((effect) => effect.documentId)))
33
+ ],
34
+ transactions: (ids, visible) => transactions.filter((txn) => txn.effects.some((effect) => ids.includes(effect.documentId))).map((txn) => ({ ...txn, effects: txn.effects.filter((e) => visible(e.documentId)) })).map((txn) => ({
35
+ id: txn.id,
36
+ timestamp: txn.timestamp,
37
+ ...txn.author !== void 0 ? { author: txn.author } : {},
38
+ mutations: txn.effects.map((effect) => {
39
+ if (effect.after === void 0) return { delete: { id: effect.documentId } };
40
+ const after = structuredClone(effect.after);
41
+ return effect.existed ? { createOrReplace: after } : { create: after };
42
+ }),
43
+ documentIDs: txn.effects.map((effect) => effect.documentId)
44
+ })),
45
+ events: (ids) => Object.fromEntries(
46
+ ids.map((id) => [
47
+ id,
48
+ transactions.flatMap(
49
+ (txn) => txn.effects.filter((effect) => effect.documentId === id && effect.after !== void 0).map(
50
+ (effect) => ({
51
+ type: effect.existed ? "updateLiveDocument" : "createLiveDocument",
52
+ id: txn.id,
53
+ documentId: id,
54
+ revisionId: effect.after._rev,
55
+ timestamp: txn.timestamp,
56
+ ...txn.author !== void 0 ? { author: txn.author } : {}
57
+ })
58
+ )
59
+ ).toReversed()
60
+ ])
61
+ )
62
+ };
63
+ }
64
+ function historyIds(list) {
65
+ return list.split(",").map((id) => decodeURIComponent(id));
66
+ }
67
+ class PlatformHttpError extends Error {
68
+ statusCode;
69
+ response;
70
+ responseBody;
71
+ details;
72
+ constructor(args) {
73
+ const { method, path, statusCode, statusMessage, body } = args;
74
+ super(clientErrorMessage(args)), this.name = "PlatformHttpError", this.statusCode = statusCode, this.response = { body, url: path, method, headers: {}, statusCode, statusMessage }, this.responseBody = typeof body == "string" ? body : JSON.stringify(body, null, 2);
75
+ const error = isRecord(body) ? body.error : void 0;
76
+ this.details = isRecord(error) ? error : void 0;
77
+ }
78
+ }
79
+ function isRecord(value) {
80
+ return typeof value == "object" && value !== null;
81
+ }
82
+ function clientErrorMessage(args) {
83
+ const { body } = args;
84
+ if (isRecord(body)) {
85
+ const { error, message } = body;
86
+ if (isRecord(error) && typeof error.description == "string") return error.description;
87
+ if (typeof message == "string") return message;
88
+ }
89
+ const details = typeof body == "string" ? ` (${body.length > 100 ? `${body.slice(0, 100)}\u2026` : body})` : "";
90
+ return `${args.method}-request to ${args.path} resulted in HTTP ${args.statusCode} ${args.statusMessage}${details}`;
91
+ }
92
+ const notMounted = ({ method, path }) => new PlatformHttpError({
93
+ method,
94
+ path,
95
+ statusCode: 404,
96
+ statusMessage: "Not Found",
97
+ body: `Cannot ${method} ${path}`
98
+ });
99
+ function accessApiError(args) {
100
+ const { request, statusCode, statusMessage, message } = args;
101
+ return new PlatformHttpError({
102
+ ...request,
103
+ statusCode,
104
+ statusMessage,
105
+ body: { statusCode, error: statusMessage, message }
106
+ });
107
+ }
108
+ const noPermission = (request) => accessApiError({
109
+ request,
110
+ statusCode: 403,
111
+ statusMessage: "Forbidden",
112
+ message: "You do not have permission to access this resource"
113
+ });
114
+ function invalidAccessType(request, type) {
115
+ return accessApiError({
116
+ request,
117
+ statusCode: 400,
118
+ statusMessage: "Bad Request",
119
+ message: `Invalid resource type ${type}`
120
+ });
121
+ }
122
+ function notFound(name) {
123
+ return (request) => accessApiError({
124
+ request,
125
+ statusCode: 404,
126
+ statusMessage: "Not Found",
127
+ message: `${name} not found`
128
+ });
129
+ }
130
+ const releaseLimitExceeded = ({ method, path }) => new PlatformHttpError({
131
+ method,
132
+ path,
133
+ statusCode: 403,
134
+ statusMessage: "Forbidden",
135
+ body: {
136
+ error: {
137
+ description: "active release count exceeds the limit of 0",
138
+ type: "releaseLimitExceededError"
139
+ }
140
+ }
141
+ });
142
+ function serveCapability(capability, request, serve) {
143
+ if (capability !== "served") throw capability(request);
144
+ return serve();
145
+ }
146
+ const ACCESS_KEY_SEPARATOR = ":";
147
+ function accessKey(type, id) {
148
+ return `${type}${ACCESS_KEY_SEPARATOR}${id}`;
149
+ }
150
+ function segmentBase(segment) {
151
+ const pattern = new RegExp(`^/${segment}/([^/]+)(/.*)?$`);
152
+ return (path) => {
153
+ const match = path.match(pattern);
154
+ if (match !== null)
155
+ return { id: decodeURIComponent(match[1]), rest: match[2] ?? "" };
156
+ };
157
+ }
158
+ const DATASET_BASE = /^\/projects\/([^/]+)\/datasets\/([^/]+)(\/.*)?$/;
159
+ function datasetParts(id) {
160
+ const dot = id.indexOf(".");
161
+ return { projectId: id.slice(0, dot), dataset: id.slice(dot + 1) };
162
+ }
163
+ function accessEntryRoles(type) {
164
+ return ({ identity, id }) => identity.access?.[accessKey(type, id)]?.roles;
165
+ }
166
+ const RESOURCE_CAPABILITIES = {
167
+ dataset: {
168
+ label: (id) => {
169
+ const { projectId, dataset } = datasetParts(id);
170
+ return `dataset "${projectId}:${dataset}"`;
171
+ },
172
+ dataBase: (id) => {
173
+ const { projectId, dataset } = datasetParts(id);
174
+ return `/projects/${projectId}/datasets/${dataset}`;
175
+ },
176
+ matchBase: (path) => {
177
+ const match = path.match(DATASET_BASE);
178
+ if (match !== null)
179
+ return { id: `${match[1]}.${match[2]}`, rest: match[3] ?? "" };
180
+ },
181
+ // A dataset's organization comes from its project: `GET /projects/<projectId>`.
182
+ lookupAtBase: !1,
183
+ identity: "project",
184
+ acl: "served",
185
+ history: "served",
186
+ actions: "served",
187
+ refusedActions: {},
188
+ access: { type: "project", id: (id) => datasetParts(id).projectId }
189
+ },
190
+ "media-library": {
191
+ label: (id) => `media library "${id}"`,
192
+ dataBase: (id) => `/media-libraries/${id}`,
193
+ matchBase: segmentBase("media-libraries"),
194
+ lookupAtBase: !0,
195
+ identity: "global",
196
+ acl: "served",
197
+ history: "served",
198
+ actions: "served",
199
+ refusedActions: { "sanity.action.release.create": releaseLimitExceeded },
200
+ access: { type: "media-library", id: (id) => id }
201
+ },
202
+ canvas: {
203
+ label: (id) => `canvas "${id}"`,
204
+ dataBase: (id) => `/canvases/${id}`,
205
+ matchBase: segmentBase("canvases"),
206
+ lookupAtBase: !0,
207
+ identity: "global",
208
+ acl: "served",
209
+ history: "served",
210
+ actions: notMounted,
211
+ refusedActions: {},
212
+ access: { type: "canvas", id: (id) => id }
213
+ },
214
+ dashboard: {
215
+ label: (id) => `dashboard "${id}"`,
216
+ dataBase: (id) => `/dashboards/${id}`,
217
+ matchBase: segmentBase("dashboards"),
218
+ lookupAtBase: !0,
219
+ identity: "global",
220
+ acl: notMounted,
221
+ history: notMounted,
222
+ actions: notMounted,
223
+ refusedActions: {},
224
+ access: { type: "dashboard", id: (id) => id }
225
+ }
226
+ };
227
+ function capabilitiesOf(type) {
228
+ const row = Object.hasOwn(RESOURCE_CAPABILITIES, type) ? RESOURCE_CAPABILITIES[type] : void 0;
229
+ if (row === void 0)
230
+ throw fakeError(
231
+ `Unsupported resource type ${JSON.stringify(type)}. The fake serves ${Object.keys(RESOURCE_CAPABILITIES).map((t) => `"${t}"`).join(", ")}.`
232
+ );
233
+ return row;
234
+ }
235
+ function validateResource(resource) {
236
+ const row = capabilitiesOf(resource.type);
237
+ if (resource.type === "dataset" && resource.id.split(".").length !== 2)
238
+ throw fakeError(
239
+ `Dataset resource ID must be in the format "project.dataset" (got ${JSON.stringify(resource.id)}), the same rule @sanity/client applies to \`resource: { type: 'dataset', id }\`.`
240
+ );
241
+ return row;
242
+ }
243
+ function matchResourcePath(path) {
244
+ for (const [type, row] of Object.entries(RESOURCE_CAPABILITIES)) {
245
+ const match = row.matchBase(path);
246
+ if (match !== void 0)
247
+ return { resource: { type, id: match.id }, rest: match.rest };
248
+ }
249
+ }
250
+ function accessResourceOf(resource) {
251
+ const { access } = capabilitiesOf(resource.type);
252
+ return { type: access.type, id: access.id(resource.id) };
253
+ }
254
+ function holdsResource(type) {
255
+ return (registry, id) => registry.resources().some((resource) => {
256
+ const access = accessResourceOf(resource);
257
+ return access.type === type && access.id === id;
258
+ });
259
+ }
260
+ const ACCESS_CAPABILITIES = {
261
+ organization: {
262
+ // Proven on staging, but outside what the fake serves: refused like a missing permission.
263
+ roles: noPermission,
264
+ users: "served",
265
+ user: "served",
266
+ // Granted by the built-in organization `member` role.
267
+ usersPermission: "sanity.organization.members.read",
268
+ unknown: {
269
+ holds: (registry, id) => id === registry.organizationId,
270
+ refusal: notFound("Organization")
271
+ },
272
+ memberRoles: accessEntryRoles("organization"),
273
+ // Every resource of the family belongs to its organization (see `organizationId`).
274
+ owns: (registry) => {
275
+ const byKey = /* @__PURE__ */ new Map();
276
+ for (const resource of registry.resources()) {
277
+ const access = accessResourceOf(resource);
278
+ byKey.set(accessKey(access.type, access.id), access);
279
+ }
280
+ return [...byKey.values()];
281
+ }
282
+ },
283
+ project: {
284
+ roles: "served",
285
+ users: "served",
286
+ // Single-user reads are served only where the fake checks members read (the
287
+ // organization); elsewhere they refuse rather than answer unchecked.
288
+ user: noPermission,
289
+ // Every registered identity is a member of every project, with its project roles.
290
+ memberRoles: ({ identity, id }) => (identity.usersByProject?.[id] ?? identity.user).roles ?? []
291
+ },
292
+ "media-library": {
293
+ // Unproven on staging (no token held roles read): refused like a missing permission.
294
+ roles: noPermission,
295
+ users: "served",
296
+ // Unproven on staging (only the listing was probed); refused as on `project`.
297
+ user: noPermission,
298
+ unknown: { holds: holdsResource("media-library"), refusal: notFound("Media library") },
299
+ memberRoles: accessEntryRoles("media-library")
300
+ },
301
+ canvas: {
302
+ // Unproven on staging (no canvas roles/members permission exists to grant).
303
+ roles: noPermission,
304
+ users: noPermission,
305
+ user: noPermission,
306
+ unknown: { holds: holdsResource("canvas"), refusal: notFound("Canvas") },
307
+ memberRoles: accessEntryRoles("canvas")
308
+ },
309
+ dashboard: {
310
+ // Unproven on staging, as for canvas.
311
+ roles: noPermission,
312
+ users: noPermission,
313
+ user: noPermission,
314
+ unknown: { holds: holdsResource("dashboard"), refusal: notFound("Dashboard") },
315
+ memberRoles: accessEntryRoles("dashboard")
316
+ }
317
+ };
318
+ function accessCapabilitiesOf(type) {
319
+ return Object.hasOwn(ACCESS_CAPABILITIES, type) ? ACCESS_CAPABILITIES[type] : void 0;
320
+ }
321
+ function dataUrl(args) {
322
+ const { resource, dataset, operation, path } = args;
323
+ if (resource !== void 0) {
324
+ const uri2 = path !== void 0 ? `${operation}/${path}` : operation;
325
+ return `${capabilitiesOf(resource.type).dataBase(resource.id)}/${uri2}`.replace(
326
+ /\/($|\?)/,
327
+ "$1"
328
+ );
329
+ }
330
+ const base = `/${operation}/${dataset}`;
331
+ return `/data${path !== void 0 ? `${base}/${path}` : base}`.replace(/\/($|\?)/, "$1");
332
+ }
333
+ const ACCESS_PATH = /^\/access\/([^/]+)\/([^/]+)(\/.*)?$/, ACCESS_USER_PATH = /^\/users\/([^/]+)$/, DATA_HISTORY_PATH = /^\/data\/history\/([^/]+)(\/.*)$/;
334
+ function page(data) {
335
+ return { data, nextCursor: null };
336
+ }
337
+ function usersPage(data) {
338
+ return { ...page(data), totalCount: data.length };
339
+ }
340
+ const UNMODELLED_PROFILE = { provider: "sanity", createdAt: (/* @__PURE__ */ new Date(0)).toISOString() };
341
+ async function servePlatformRequest(ctx, url) {
342
+ const queryStart = url.indexOf("?"), path = queryStart === -1 ? url : url.slice(0, queryStart), query = new URLSearchParams(queryStart === -1 ? "" : url.slice(queryStart + 1)), request = { method: "GET", path }, access = path.match(ACCESS_PATH);
343
+ if (access !== null) {
344
+ const [, type, id, rest2] = access;
345
+ return serveAccess({ ctx, request, type, id: decodeURIComponent(id), rest: rest2 ?? "", query });
346
+ }
347
+ const dataHistory = path.match(DATA_HISTORY_PATH);
348
+ if (dataHistory !== null) {
349
+ const [, dataset, rest2] = dataHistory, resource2 = { type: "dataset", id: `${ctx.projectId}.${dataset}` };
350
+ return serveHistory({ ctx, request, resource: resource2, rest: rest2, query });
351
+ }
352
+ const matched = matchResourcePath(path);
353
+ if (matched === void 0) return;
354
+ const { resource, rest } = matched, row = capabilitiesOf(resource.type);
355
+ if (rest === "") return row.lookupAtBase ? serveLookup(ctx, resource) : void 0;
356
+ if (rest === "/acl")
357
+ return serveCapability(row.acl, request, () => {
358
+ const own = ctx.own(resource);
359
+ return own === void 0 ? void 0 : { value: own.grants };
360
+ });
361
+ if (rest.startsWith("/history/"))
362
+ return serveHistory({ ctx, request, resource, rest: rest.slice(8), query });
363
+ }
364
+ function serveLookup(ctx, resource) {
365
+ if (ctx.organizationId === void 0)
366
+ throw fakeError(
367
+ `request(): GET ${capabilitiesOf(resource.type).dataBase(resource.id)} answers the resource's organization, but no organization is configured. Pass createTestClient({ organizationId }).`
368
+ );
369
+ return { value: { organizationId: ctx.organizationId, id: resource.id, status: "active" } };
370
+ }
371
+ function serveHistory(args) {
372
+ const { ctx, request, resource, rest, query } = args;
373
+ return serveCapability(capabilitiesOf(resource.type).history, request, () => {
374
+ const own = ctx.own(resource);
375
+ return own === void 0 ? void 0 : readHistory({ own, request, rest, query });
376
+ });
377
+ }
378
+ async function readHistory(args) {
379
+ const { own, request, rest, query } = args, current = new Map(own.documents().map((doc) => [doc._id, doc])), hidden = /* @__PURE__ */ new Set();
380
+ for (const id of /* @__PURE__ */ new Set([...current.keys(), ...own.history.documentIds()])) {
381
+ const image = current.get(id) ?? own.history.lastImage(id);
382
+ image !== void 0 && !await own.canRead(image) && hidden.add(id);
383
+ }
384
+ const visible = (id) => !hidden.has(id), readable = (list) => historyIds(list).filter(visible), documents = rest.match(/^\/documents\/([^/]+)$/);
385
+ if (documents !== null) {
386
+ const revision = query.get("revision");
387
+ if (revision === null)
388
+ throw fakeError(
389
+ `request(): ${request.path} is served for \`?revision=<rev>\` reads only (time-based history reads are not simulated).`
390
+ );
391
+ const ids = readable(documents[1]);
392
+ return {
393
+ value: { documents: own.history.documentsAt({ ids, revision, current: own.documents() }) }
394
+ };
395
+ }
396
+ const transactions = rest.match(/^\/transactions\/([^/]+)$/);
397
+ if (transactions !== null)
398
+ return { value: own.history.transactions(readable(transactions[1]), visible).map((record) => JSON.stringify(record)).join(`
399
+ `) };
400
+ const events = rest.match(/^\/events\/documents\/([^/]+)$/);
401
+ if (events !== null)
402
+ return {
403
+ value: {
404
+ events: own.history.events(readable(events[1])),
405
+ nextCursor: ""
406
+ }
407
+ };
408
+ }
409
+ function serveAccess(args) {
410
+ const { ctx, request, type, id, rest, query } = args, row = accessCapabilitiesOf(type);
411
+ if (row === void 0) throw invalidAccessType(request, type);
412
+ const resource = { type, id }, registry = {
413
+ organizationId: ctx.organizationId,
414
+ resources: ctx.resources
415
+ }, permissions = ctx.actorAccess()?.[accessKey(resource.type, id)]?.permissions ?? [];
416
+ if (rest === "/user-permissions/me/check") {
417
+ const requested = query.getAll("permissions").flatMap((value) => value.split(","));
418
+ return {
419
+ value: { data: Object.fromEntries(requested.map((p) => [p, permissions.includes(p)])) }
420
+ };
421
+ }
422
+ if (row.unknown !== void 0 && !row.unknown.holds(registry, id))
423
+ throw row.unknown.refusal(request);
424
+ const users = (capability, serve) => serveCapability(capability, request, () => {
425
+ if (row.usersPermission !== void 0 && !permissions.includes(row.usersPermission))
426
+ throw noPermission(request);
427
+ return serve();
428
+ }), user = rest.match(ACCESS_USER_PATH);
429
+ if (user !== null)
430
+ return users(row.user, () => {
431
+ const sanityUserId = decodeURIComponent(user[1]), record = usersOn({ resource, registry, identities: ctx.identities() }).find(
432
+ (candidate) => candidate.sanityUserId === sanityUserId
433
+ );
434
+ if (record === void 0) throw notFound("User")(request);
435
+ return { value: record };
436
+ });
437
+ switch (rest) {
438
+ case "/roles":
439
+ return serveCapability(row.roles, request, () => ({
440
+ value: page(rolesOn({ resource, identities: ctx.identities() }))
441
+ }));
442
+ case "/users":
443
+ return users(row.users, () => ({
444
+ value: usersPage(usersOn({ resource, registry, identities: ctx.identities() }))
445
+ }));
446
+ case "/user-permissions/me":
447
+ return {
448
+ value: page(
449
+ permissions.map((permission) => {
450
+ const dot = permission.lastIndexOf(".");
451
+ return {
452
+ type: permission.slice(0, dot),
453
+ resourceType: resource.type,
454
+ resourceId: id,
455
+ action: permission.slice(dot + 1)
456
+ };
457
+ })
458
+ )
459
+ };
460
+ default:
461
+ return;
462
+ }
463
+ }
464
+ function rolesOn(args) {
465
+ const { resource } = args, byName = /* @__PURE__ */ new Map();
466
+ for (const identity of args.identities) {
467
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
468
+ for (const role of roles ?? [])
469
+ byName.set(role.name, {
470
+ name: role.name,
471
+ ...role.title !== void 0 ? { title: role.title } : {},
472
+ ...role.description !== void 0 ? { description: role.description } : {}
473
+ });
474
+ }
475
+ return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
476
+ }
477
+ function membershipOn(identity, resource) {
478
+ const roles = ACCESS_CAPABILITIES[resource.type].memberRoles({ identity, id: resource.id });
479
+ if (roles !== void 0)
480
+ return {
481
+ resourceType: resource.type,
482
+ resourceId: resource.id,
483
+ roleNames: roles.map((role) => role.name).toSorted()
484
+ };
485
+ }
486
+ function usersOn(args) {
487
+ const { resource, registry, identities } = args, owned = ACCESS_CAPABILITIES[resource.type].owns?.(registry) ?? [], bySanityUserId = /* @__PURE__ */ new Map();
488
+ for (const identity of identities) {
489
+ const own = membershipOn(identity, resource);
490
+ if (own === void 0) continue;
491
+ const user = identity.globalUser ?? identity.user, record = {
492
+ sanityUserId: user.id,
493
+ profile: {
494
+ id: user.id,
495
+ displayName: user.name ?? user.id,
496
+ email: user.email ?? "",
497
+ ...UNMODELLED_PROFILE
498
+ },
499
+ memberships: [
500
+ own,
501
+ // A user is part of a resource only through a role on it.
502
+ ...owned.flatMap((child) => {
503
+ const membership = membershipOn(identity, child);
504
+ return membership === void 0 || membership.roleNames.length === 0 ? [] : [membership];
505
+ })
506
+ ]
507
+ }, existing = bySanityUserId.get(record.sanityUserId);
508
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(record))
509
+ throw fakeError(
510
+ `request(): two registered identities resolve to user "${record.sanityUserId}" on ${resource.type} "${resource.id}" with different records. Fix the identity registrations so duplicate ids carry identical records.`
511
+ );
512
+ bySanityUserId.set(record.sanityUserId, record);
513
+ }
514
+ return [...bySanityUserId.values()];
515
+ }
6
516
  class WriteAccessDeniedError extends Error {
7
517
  documentId;
8
518
  permission;
@@ -59,6 +569,12 @@ async function enforceWriteAccess(args) {
59
569
  function defaultAclPath(projectId, dataset) {
60
570
  return `/projects/${projectId}/datasets/${dataset}/acl`;
61
571
  }
572
+ function refusedAcl(path) {
573
+ const matched = matchResourcePath(path);
574
+ if (matched?.rest !== "/acl") return;
575
+ const { acl } = capabilitiesOf(matched.resource.type);
576
+ return acl === "served" ? void 0 : acl({ method: "GET", path });
577
+ }
62
578
  function projectUserOf(identity, projectId) {
63
579
  return identity.usersByProject?.[projectId] ?? identity.user;
64
580
  }
@@ -122,16 +638,19 @@ function serveProject(args) {
122
638
  ...projectUser.id.startsWith("p-") ? { isRobot: !0 } : {},
123
639
  ...projectUser.roles !== void 0 ? { roles: canonicalProjectRoles(projectUser.roles) } : {}
124
640
  };
125
- });
126
- return { members: [...consistentProjectRecords({ projectId: args.pathProjectId, records }).values()] };
641
+ }), members = consistentProjectRecords({ projectId: args.pathProjectId, records });
642
+ return {
643
+ ...args.organizationId !== void 0 ? { organizationId: args.organizationId } : {},
644
+ members: [...members.values()]
645
+ };
127
646
  }
128
647
  function makeRequestImpl(ctx) {
129
- const { getAccess, projectId, dataset, useProjectHostname, listIdentities } = ctx;
648
+ const { getAccess, useProjectHostname, listIdentities, organizationId, platform } = ctx;
130
649
  return async (opts) => {
131
- const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
650
+ const accessControl = getAccess(), path = opts.uri ?? opts.url;
132
651
  if (path === "/users/me")
133
652
  return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
134
- if (path === aclPath)
653
+ if (path !== void 0 && path === accessControl.aclPath && refusedAcl(path) === void 0)
135
654
  return accessControl.grants;
136
655
  const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
137
656
  if (pathProjectId !== void 0 && requestedIds !== void 0)
@@ -144,10 +663,13 @@ function makeRequestImpl(ctx) {
144
663
  if (membersProjectId !== void 0)
145
664
  return serveProject({
146
665
  pathProjectId: membersProjectId,
147
- identities: listIdentities()
666
+ identities: listIdentities(),
667
+ organizationId
148
668
  });
669
+ const served = path === void 0 ? void 0 : await servePlatformRequest(platform, path);
670
+ if (served !== void 0) return served.value;
149
671
  throw fakeError(
150
- `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "${aclPath}", "/projects/<projectId>" (members), and "/projects/<projectId>/users/<ids>". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
672
+ `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", the handle's ACL ("<data base>/acl"), "/projects/<projectId>" (members), "/projects/<projectId>/users/<ids>", the History API (client.getDataUrl("history", \u2026)), resource lookup ("/media-libraries/<id>", "/canvases/<id>", "/dashboards/<id>") and the Access API ("/access/<type>/<id>/roles | users[/<id>] | user-permissions/me[/check]"). For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
151
673
  );
152
674
  };
153
675
  }
@@ -411,7 +933,8 @@ function releaseNameOf(id) {
411
933
  return rest.slice(0, dot);
412
934
  }
413
935
  const isDraftId = (id) => id.startsWith(DRAFTS_PREFIX), isVersionId = (id) => id.startsWith(VERSIONS_PREFIX$1), isSystemId = (id) => id.startsWith("_."), isReleaseDocId = (id) => id.startsWith(RELEASE_DOC_PREFIX$1);
414
- function applyPerspective(docs, perspective) {
936
+ function applyPerspective(docs, requested) {
937
+ const perspective = normalizePerspective(requested);
415
938
  if (perspective === "raw") return [...docs];
416
939
  if (perspective === "published")
417
940
  return docs.filter((d) => !isDraftId(d._id) && !isVersionId(d._id) && !isSystemId(d._id));
@@ -423,6 +946,20 @@ function applyPerspective(docs, perspective) {
423
946
  entry === "drafts" ? includeDrafts = !0 : stack.push(entry);
424
947
  return mergeStack(docs, stack, { includeDrafts, includeReleaseDocs: !0 });
425
948
  }
949
+ function normalizePerspective(perspective) {
950
+ if (Array.isArray(perspective)) return perspective;
951
+ switch (perspective) {
952
+ case "raw":
953
+ return "raw";
954
+ case "published":
955
+ return "published";
956
+ case "drafts":
957
+ case "previewDrafts":
958
+ return "drafts";
959
+ default:
960
+ return perspective.split(",");
961
+ }
962
+ }
426
963
  function mergeStack(docs, releaseStack, options) {
427
964
  const groups = /* @__PURE__ */ new Map(), systemReleases = [];
428
965
  for (const doc of docs) {
@@ -856,6 +1393,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
856
1393
  "createVersion",
857
1394
  "unpublishVersion",
858
1395
  "request (only with accessControl or a bound token)",
1396
+ "getDataUrl",
859
1397
  "config",
860
1398
  "withConfig"
861
1399
  ], UNSUPPORTED_MEMBERS = {
@@ -875,10 +1413,6 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
875
1413
  what: "build an absolute API URL for a path",
876
1414
  workaround: "Hard-code the expected URL in your test, or stub it."
877
1415
  },
878
- getDataUrl: {
879
- what: "build an absolute data-API URL",
880
- workaround: "Hard-code the expected URL in your test, or stub it."
881
- },
882
1416
  assets: {
883
1417
  what: "asset upload / metadata namespace",
884
1418
  workaround: "Seed asset documents into the store directly with `seed()` / `add()`."
@@ -905,7 +1439,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
905
1439
  },
906
1440
  request: {
907
1441
  what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
908
- workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me, the ACL path, /projects/<projectId>, and /projects/<projectId>/users/<ids>). Or register a stub."
1442
+ workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me, the ACL path, /projects/<projectId>, /projects/<projectId>/users/<ids>, the History API, resource lookup and the Access API). Or register a stub."
909
1443
  },
910
1444
  dryRun: {
911
1445
  what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
@@ -1063,13 +1597,32 @@ function rejectAmbiguousIdentity(site, config) {
1063
1597
  `${site}: \`token\` and \`accessControl\` are mutually exclusive \u2014 a handle acts either as a registered identity (token) or as an inline accessControl config, not both.`
1064
1598
  );
1065
1599
  }
1066
- const registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
1600
+ const registryKey = (resource) => `${resource.type}:${resource.id}`, datasetResource = (projectId, dataset) => ({
1601
+ type: "dataset",
1602
+ id: `${projectId}.${dataset}`
1603
+ }), parseDatasetKey = (key) => {
1067
1604
  const [projectId, dataset] = key.split(":");
1068
1605
  return { projectId, dataset };
1606
+ }, parseResourceKey = (key) => {
1607
+ const separator = key.indexOf(":"), resource = {
1608
+ type: key.slice(0, separator),
1609
+ id: key.slice(separator + 1)
1610
+ };
1611
+ if (separator <= 0 || resource.id.length === 0)
1612
+ throw fakeError(
1613
+ `createTestClient(): \`resources\` keys are "<type>:<id>" (e.g. "media-library:ml1"); got "${key}".`
1614
+ );
1615
+ return validateResource(resource), resource;
1069
1616
  };
1617
+ function ensureEntry(registry, resource) {
1618
+ const key = registryKey(resource);
1619
+ let entry = registry.get(key);
1620
+ return entry === void 0 && (entry = { resource, store: createTestStore(), history: createDocumentHistory() }, registry.set(key, entry)), entry;
1621
+ }
1070
1622
  function createTestClient(options = {}) {
1071
1623
  const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
1072
1624
  registry: /* @__PURE__ */ new Map(),
1625
+ organizationId: options.organizationId,
1073
1626
  log: [],
1074
1627
  stubs: /* @__PURE__ */ new Map(),
1075
1628
  calls: [],
@@ -1077,21 +1630,23 @@ function createTestClient(options = {}) {
1077
1630
  identities: new Map(Object.entries(options.identities ?? {})),
1078
1631
  writeChain: Promise.resolve()
1079
1632
  }, registry = backend.registry;
1080
- rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()");
1081
- const ensureStore = (projectId, dataset) => {
1082
- const key = registryKey(projectId, dataset);
1083
- let store = registry.get(key);
1084
- return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
1085
- };
1086
- if (options.documents && ensureStore(defaultProjectId, defaultDataset).seed(options.documents), options.datasets)
1633
+ if (rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()"), options.resource !== void 0 && validateResource(options.resource), options.documents) {
1634
+ const root = options.resource ?? datasetResource(defaultProjectId, defaultDataset);
1635
+ ensureEntry(registry, root).store.seed(options.documents);
1636
+ }
1637
+ if (options.datasets)
1087
1638
  for (const [key, docs] of Object.entries(options.datasets)) {
1088
- const { projectId, dataset } = parseRegistryKey(key);
1089
- ensureStore(projectId, dataset).seed(docs);
1639
+ const { projectId, dataset } = parseDatasetKey(key);
1640
+ ensureEntry(registry, datasetResource(projectId, dataset)).store.seed(docs);
1090
1641
  }
1642
+ if (options.resources)
1643
+ for (const [key, docs] of Object.entries(options.resources))
1644
+ ensureEntry(registry, parseResourceKey(key)).store.seed(docs);
1091
1645
  return makeHandle({
1092
1646
  backend,
1093
1647
  projectId: defaultProjectId,
1094
1648
  dataset: defaultDataset,
1649
+ resource: options.resource,
1095
1650
  accessControl: options.accessControl,
1096
1651
  perspective: options.perspective ?? "raw",
1097
1652
  requestTagPrefix: options.requestTagPrefix,
@@ -1153,39 +1708,52 @@ function toTxnMutations(operations) {
1153
1708
  function makeHandle(handleArgs) {
1154
1709
  const {
1155
1710
  backend,
1156
- projectId,
1157
- dataset,
1711
+ resource,
1158
1712
  accessControl,
1159
1713
  perspective,
1160
1714
  requestTagPrefix,
1161
1715
  mutationGuard,
1162
1716
  token,
1163
1717
  useProjectHostname
1164
- } = handleArgs, { registry } = backend, activeAccess = () => {
1718
+ } = handleArgs, { registry } = backend, { projectId, dataset } = resource?.type === "dataset" ? datasetParts(resource.id) : handleArgs, target = resource ?? datasetResource(projectId, dataset), activeAccess = () => {
1165
1719
  if (token === void 0) return accessControl;
1166
1720
  const identity = backend.identities.get(token);
1167
1721
  if (identity === void 0)
1168
1722
  throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
1169
1723
  return {
1170
- currentUser: projectUserOf(identity, projectId),
1724
+ // Per-project records apply only where the resource lives in a project.
1725
+ currentUser: capabilitiesOf(target.type).identity === "project" ? projectUserOf(identity, projectId) : identity.user,
1171
1726
  ...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
1172
- grants: identity.grants
1727
+ grants: identity.grants,
1728
+ ...identity.access !== void 0 ? { access: identity.access } : {}
1173
1729
  };
1174
1730
  }, requireActiveAccess = () => {
1175
1731
  const access = activeAccess();
1176
1732
  if (access === void 0)
1177
1733
  throw fakeError("this handle has no acting identity (no token or accessControl bound).");
1178
1734
  return access;
1179
- }, store = (() => {
1180
- const key = registryKey(projectId, dataset);
1181
- let s = registry.get(key);
1182
- return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
1183
- })(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations, tag) => {
1735
+ }, capabilities = capabilitiesOf(target.type), targetLabel = capabilities.label(target.id), { store, history } = ensureEntry(registry, target), concurrentEditKey = (id) => registryKey(target) + " " + id, actorId = () => {
1736
+ const access = activeAccess();
1737
+ if (access !== void 0)
1738
+ return capabilities.identity === "global" ? (access.globalUser ?? access.currentUser).id : access.currentUser.id;
1739
+ };
1740
+ let beforeWrite;
1741
+ const write = (run) => serializeWrite(backend, async () => {
1742
+ beforeWrite = new Map(store.all().map((doc) => [doc._id, doc]));
1743
+ try {
1744
+ return await run();
1745
+ } finally {
1746
+ beforeWrite = void 0;
1747
+ }
1748
+ }), commitHistory = (transactionId) => {
1749
+ beforeWrite !== void 0 && history.commit({ transactionId, author: actorId(), before: beforeWrite, after: store.all() });
1750
+ }, record = (transactionId, mutations, tag) => {
1184
1751
  const effectiveTag = effectiveRequestTag(tag);
1185
- backend.log.push({
1752
+ commitHistory(transactionId), backend.log.push({
1186
1753
  transactionId,
1187
1754
  projectId,
1188
1755
  dataset,
1756
+ ...resource !== void 0 ? { resource } : {},
1189
1757
  mutations,
1190
1758
  ...effectiveTag !== void 0 ? { tag: effectiveTag } : {}
1191
1759
  });
@@ -1235,10 +1803,10 @@ function makeHandle(handleArgs) {
1235
1803
  document,
1236
1804
  grants: access.grants,
1237
1805
  permission: document._id !== void 0 && isSystemId(document._id) ? "manage" : permission,
1238
- userId: access.currentUser.id
1806
+ userId: actorId()
1239
1807
  });
1240
1808
  }, guardGate = async (contexts) => {
1241
- const identity = activeAccess()?.currentUser.id;
1809
+ const identity = actorId();
1242
1810
  await enforceMutationGuards(
1243
1811
  mutationGuard,
1244
1812
  contexts.map((context) => ({ ...context, identity }))
@@ -1251,13 +1819,13 @@ function makeHandle(handleArgs) {
1251
1819
  document,
1252
1820
  grants: access.grants,
1253
1821
  permission: "read",
1254
- userId: access.currentUser.id
1822
+ userId: actorId()
1255
1823
  });
1256
1824
  }, applyPatch = async (documentId, ops, ifRev, options = {}) => {
1257
1825
  const existing = store.get(documentId);
1258
1826
  if (existing === void 0)
1259
1827
  throw fakeError(
1260
- `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.`
1828
+ `Cannot patch document "${documentId}": not found \u2014 no document with that _id exists in ${targetLabel}. patch() requires the target to already exist; use createOrReplace() / createIfNotExists() to upsert, or seed the document first.`
1261
1829
  );
1262
1830
  checkRevision(documentId, ifRev), await gate(existing, "update");
1263
1831
  const patched = applyPatchOps(existing, ops), now = (/* @__PURE__ */ new Date()).toISOString(), next = {
@@ -1278,7 +1846,7 @@ function makeHandle(handleArgs) {
1278
1846
  const promoted = promote2(mut.doc);
1279
1847
  if (store.get(promoted._id) !== void 0)
1280
1848
  throw fakeError(
1281
- `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.`
1849
+ `create() failed: a document with _id "${promoted._id}" already exists in ${targetLabel}. Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1282
1850
  );
1283
1851
  await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1284
1852
  break;
@@ -1327,7 +1895,21 @@ function makeHandle(handleArgs) {
1327
1895
  txnOptions?.returnDocuments ?? !1,
1328
1896
  txnOptions?.returnFirst ?? !1
1329
1897
  );
1330
- }, runAction = (actions) => serializeWrite(backend, async () => applyActions(store, actions, { gate, guard: guardGate })), accessVisibleDocs = async () => {
1898
+ }, runAction = async (actions) => {
1899
+ const request = {
1900
+ method: "POST",
1901
+ path: `${capabilities.dataBase(target.id)}/actions`
1902
+ };
1903
+ if (capabilities.actions !== "served") throw capabilities.actions(request);
1904
+ for (const action of Array.isArray(actions) ? actions : [actions]) {
1905
+ const refused = capabilities.refusedActions[action.actionType];
1906
+ if (refused !== void 0) throw refused(request);
1907
+ }
1908
+ return write(async () => {
1909
+ const result = await applyActions(store, actions, { gate, guard: guardGate });
1910
+ return commitHistory(result.transactionId), result;
1911
+ });
1912
+ }, accessVisibleDocs = async () => {
1331
1913
  const all = store.all();
1332
1914
  if (activeAccess() === void 0) return all;
1333
1915
  const out = [];
@@ -1371,10 +1953,11 @@ function makeHandle(handleArgs) {
1371
1953
  const view = await visibleDocs(effective);
1372
1954
  return ids.map((id) => resolveDocument(view, id, effective) ?? null);
1373
1955
  },
1374
- create: (doc, opts) => serializeWrite(backend, async () => {
1956
+ getDataUrl: (operation, path) => dataUrl({ resource, dataset, operation, path }),
1957
+ create: (doc, opts) => write(async () => {
1375
1958
  if (checkRevision(doc._id, opts?.ifRevisionId), store.get(doc._id) !== void 0)
1376
1959
  throw fakeError(
1377
- `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.`
1960
+ `create() failed: a document with _id "${doc._id}" already exists in ${targetLabel}. Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1378
1961
  );
1379
1962
  const promoted = promote2(doc);
1380
1963
  await gate(promoted, "create"), await guardOne("create", null, promoted);
@@ -1386,7 +1969,7 @@ function makeHandle(handleArgs) {
1386
1969
  opts
1387
1970
  );
1388
1971
  }),
1389
- createOrReplace: (doc, opts) => serializeWrite(backend, async () => {
1972
+ createOrReplace: (doc, opts) => write(async () => {
1390
1973
  checkRevision(doc._id, opts?.ifRevisionId);
1391
1974
  const promoted = promote2(doc), existing = store.get(doc._id);
1392
1975
  await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardOne(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
@@ -1400,7 +1983,7 @@ function makeHandle(handleArgs) {
1400
1983
  opts
1401
1984
  );
1402
1985
  }),
1403
- createIfNotExists: (doc, opts) => serializeWrite(backend, async () => {
1986
+ createIfNotExists: (doc, opts) => write(async () => {
1404
1987
  checkRevision(doc._id, opts?.ifRevisionId);
1405
1988
  const existing = store.get(doc._id), transactionId = opts?.transactionId ?? cryptoRandomKey();
1406
1989
  if (existing !== void 0)
@@ -1418,7 +2001,7 @@ function makeHandle(handleArgs) {
1418
2001
  opts
1419
2002
  );
1420
2003
  }),
1421
- delete: (id, opts) => serializeWrite(backend, async () => {
2004
+ delete: (id, opts) => write(async () => {
1422
2005
  checkRevision(id, opts?.ifRevisionId);
1423
2006
  const existing = store.get(id);
1424
2007
  existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardOne("delete", existing, null);
@@ -1433,7 +2016,7 @@ function makeHandle(handleArgs) {
1433
2016
  );
1434
2017
  }),
1435
2018
  patch: (documentId) => createPatch(documentId, {
1436
- apply: (docId, ops, ifRev, options) => serializeWrite(backend, async () => {
2019
+ apply: (docId, ops, ifRev, options) => write(async () => {
1437
2020
  const doc = await applyPatch(docId, ops, ifRev);
1438
2021
  return record(
1439
2022
  cryptoRandomKey(),
@@ -1450,9 +2033,9 @@ function makeHandle(handleArgs) {
1450
2033
  })
1451
2034
  }),
1452
2035
  transaction: () => createTransaction({
1453
- apply: (mutations, txnOptions) => serializeWrite(backend, () => applyTransaction(mutations, txnOptions))
2036
+ apply: (mutations, txnOptions) => write(() => applyTransaction(mutations, txnOptions))
1454
2037
  }),
1455
- mutate: (operations, options) => serializeWrite(backend, () => applyTransaction(toTxnMutations(operations), options)),
2038
+ mutate: (operations, options) => write(() => applyTransaction(toTxnMutations(operations), options)),
1456
2039
  // Synchronous writes still take the lock: an action landing while a
1457
2040
  // guarded patch is parked in its gate would otherwise be clobbered
1458
2041
  // when that patch resumes over its pre-action snapshot.
@@ -1501,15 +2084,30 @@ function makeHandle(handleArgs) {
1501
2084
  ...token !== void 0 || accessControl !== void 0 ? {
1502
2085
  request: makeRequestImpl({
1503
2086
  getAccess: requireActiveAccess,
1504
- projectId,
1505
- dataset,
1506
- useProjectHostname,
1507
- listIdentities: () => [...backend.identities.values()]
2087
+ // `@sanity/client` sends every request of a `resource`-configured
2088
+ // client to the global API host.
2089
+ useProjectHostname: useProjectHostname && resource === void 0,
2090
+ organizationId: backend.organizationId,
2091
+ listIdentities: () => [...backend.identities.values()],
2092
+ platform: {
2093
+ projectId,
2094
+ organizationId: backend.organizationId,
2095
+ own: (asked) => registryKey(asked) === registryKey(target) ? {
2096
+ grants: requireActiveAccess().grants,
2097
+ documents: () => store.all(),
2098
+ history,
2099
+ canRead
2100
+ } : void 0,
2101
+ actorAccess: () => requireActiveAccess().access,
2102
+ identities: () => [...backend.identities.values()],
2103
+ resources: () => [...registry.values()].map((entry) => entry.resource)
2104
+ }
1508
2105
  })
1509
2106
  } : {},
1510
2107
  config: () => ({
1511
2108
  projectId,
1512
2109
  dataset,
2110
+ ...resource !== void 0 ? { resource } : {},
1513
2111
  perspective,
1514
2112
  ...requestTagPrefix !== void 0 ? { requestTagPrefix } : {},
1515
2113
  ...token !== void 0 ? { token } : {},
@@ -1517,11 +2115,12 @@ function makeHandle(handleArgs) {
1517
2115
  }),
1518
2116
  withConfig: (config) => {
1519
2117
  rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
1520
- const rebinds = config.token !== void 0 || config.accessControl !== void 0;
1521
- return makeHandle({
2118
+ const rebinds = config.token !== void 0 || config.accessControl !== void 0, nextResource = "resource" in config ? config.resource : resource;
2119
+ return nextResource !== void 0 && validateResource(nextResource), makeHandle({
1522
2120
  backend,
1523
2121
  projectId: config.projectId ?? projectId,
1524
2122
  dataset: config.dataset ?? dataset,
2123
+ resource: nextResource,
1525
2124
  accessControl: rebinds ? config.accessControl : accessControl,
1526
2125
  perspective: config.perspective ?? perspective,
1527
2126
  requestTagPrefix: config.requestTagPrefix ?? requestTagPrefix,
@@ -1539,7 +2138,8 @@ function makeHandle(handleArgs) {
1539
2138
  const editKey = concurrentEditKey(id);
1540
2139
  backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
1541
2140
  },
1542
- datasets: () => [...registry.keys()].map(parseRegistryKey),
2141
+ datasets: () => [...registry.values()].filter((entry) => entry.resource.type === "dataset").map((entry) => datasetParts(entry.resource.id)),
2142
+ resources: () => [...registry.values()].map((entry) => ({ ...entry.resource })),
1543
2143
  stub: (name, handler) => {
1544
2144
  backend.stubs.set(name, handler);
1545
2145
  },
@@ -1562,7 +2162,13 @@ function makeHandle(handleArgs) {
1562
2162
  return wrapWithGuards(client, {
1563
2163
  getStub: (name) => backend.stubs.get(name),
1564
2164
  onCall: (method, args) => {
1565
- const rec = { method, args, projectId, dataset }, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
2165
+ const rec = {
2166
+ method,
2167
+ args,
2168
+ projectId,
2169
+ dataset,
2170
+ ...resource !== void 0 ? { resource } : {}
2171
+ }, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
1566
2172
  return tag !== void 0 && (rec.tag = tag), backend.calls.push(rec), (outcome) => {
1567
2173
  "error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
1568
2174
  };
@@ -1573,6 +2179,7 @@ function makeHandle(handleArgs) {
1573
2179
  export {
1574
2180
  ERR_PREFIX,
1575
2181
  GuardDeniedError,
2182
+ PlatformHttpError,
1576
2183
  SUPPORTED_SURFACE,
1577
2184
  UNSUPPORTED_MEMBERS,
1578
2185
  WriteAccessDeniedError,