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