@protobase/client 0.59.3 → 0.60.1

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.
@@ -1,4 +1,4 @@
1
1
  import { CallTrack, PublishOptions } from "./media-link.js";
2
2
  import { Call, CallState, JoinCallOptions, joinCall } from "./join-call.js";
3
- import { CallLeftReason, CallMode, CallTrackSource } from "@protobase/schema/internal";
4
- export { type Call, type CallLeftReason, type CallMode, type CallState, type CallTrack, type CallTrackSource, type JoinCallOptions, type PublishOptions, joinCall };
3
+ import { CallLeftReason, CallMode, CallRosterMember, CallTrackSource } from "@protobase/schema/internal";
4
+ export { type Call, type CallLeftReason, type CallMode, type CallRosterMember, type CallState, type CallTrack, type CallTrackSource, type JoinCallOptions, type PublishOptions, joinCall };
@@ -1,5 +1,5 @@
1
1
  import { CallTrack, PublishOptions } from "./media-link.js";
2
- import { CallLeftReason, CallTrackSource } from "@protobase/schema/internal";
2
+ import { CallLeftReason, CallRosterMember, CallTrackSource } from "@protobase/schema/internal";
3
3
  //#region src/calls/join-call.d.ts
4
4
  /** Where a call stands: `resuming` while the link to Protobase is restored, with media still flowing. */
5
5
  export type CallState = {
@@ -21,6 +21,8 @@ export type JoinCallOptions = {
21
21
  url: string;
22
22
  /** Every track of other members the member hears, whenever one comes, goes or its media arrives. */
23
23
  onTracks?: (tracks: CallTrack[]) => void;
24
+ /** Everyone in the call, the member too, in the order they came, whenever that changes. */
25
+ onMembers?: (members: CallRosterMember[]) => void;
24
26
  /** Who speaks how loudly, from 0 to 1, for the members whose level changed. */
25
27
  onSpeakers?: (speakers: {
26
28
  member: string;
@@ -31,14 +33,18 @@ export type JoinCallOptions = {
31
33
  onUnpublished?: (source: CallTrackSource) => void;
32
34
  /** How long to wait before the `attempt`th try to restore a lost link. */
33
35
  retryDelayMs?: (attempt: number) => number;
34
- /** How many tries in a row to restore a lost link before the call is left. Default 8. */
36
+ /** How many tries in a row to restore a lost link, or to get in while the server fails, before the call is left. Default 8. */
35
37
  maxRetries?: number;
36
38
  WebSocket?: typeof WebSocket;
37
39
  createPeerConnection?: (configuration: RTCConfiguration) => RTCPeerConnection;
38
40
  };
39
41
  export type Call = {
42
+ /** The call, as the record's calls list it and `calls.remove` takes it. */
43
+ readonly id: string;
40
44
  /** The member's participant in the media server. */
41
45
  readonly participant: string;
46
+ /** Everyone in the call now, the member too. */
47
+ members: () => CallRosterMember[];
42
48
  /** Sends tracks under the sources they carry, such as `{ camera, microphone }`; rejects when the member may not. */
43
49
  publish: (tracks: Partial<Record<CallTrackSource, MediaStreamTrack>>, options?: PublishOptions) => Promise<void>;
44
50
  unpublish: (source: CallTrackSource) => Promise<void>;
@@ -12,6 +12,8 @@ const leftCodes = {
12
12
  1e3: "ended",
13
13
  1008: "removed"
14
14
  };
15
+ /** Close codes of a server that failed, or was too busy, for now: getting in is tried again. */
16
+ const failedCodes = /* @__PURE__ */ new Set([1011, 1013]);
15
17
  /**
16
18
  * Joins a call through the app's function socket and resolves once the member is in it. Media flows over plain
17
19
  * `RTCPeerConnection`s, to the media server or straight to the other members, as the app's calls are configured;
@@ -26,6 +28,8 @@ const joinCall = (options) => {
26
28
  let socket;
27
29
  let link;
28
30
  let participant = "";
31
+ let callId = "";
32
+ let members = [];
29
33
  let remote = [];
30
34
  let ended = false;
31
35
  let rejoin = false;
@@ -74,6 +78,7 @@ const joinCall = (options) => {
74
78
  const before = link?.sending();
75
79
  link?.close();
76
80
  participant = message.participant;
81
+ callId = message.call;
77
82
  link = newLink(message);
78
83
  failures = 0;
79
84
  setState({ state: "joined" });
@@ -90,6 +95,10 @@ const joinCall = (options) => {
90
95
  case "speakers":
91
96
  options.onSpeakers?.(message.speakers);
92
97
  return;
98
+ case "members":
99
+ members = message.members;
100
+ options.onMembers?.(members);
101
+ return;
93
102
  case "left":
94
103
  if (message.reason === "rejoin") rejoin = true;
95
104
  else end(message.reason);
@@ -107,6 +116,7 @@ const joinCall = (options) => {
107
116
  const resume = mode === "resume" && link ? await link.resumeState(participant) : void 0;
108
117
  send(resume ? {
109
118
  type: "resume",
119
+ call: callId,
110
120
  ...resume
111
121
  } : { type: "join" });
112
122
  });
@@ -127,19 +137,23 @@ const joinCall = (options) => {
127
137
  }
128
138
  const left = leftCodes[code];
129
139
  if (left) return end(left);
130
- if (!participant) return end("refused");
140
+ if (!participant && !failedCodes.has(code)) return end("refused");
131
141
  failures++;
132
- if (failures > maxRetries) return end("ended");
133
- setState({ state: "resuming" });
142
+ if (failures > maxRetries) return end(participant ? "ended" : "unavailable");
143
+ setState(participant ? { state: "resuming" } : { state: "connecting" });
134
144
  const next = mode === "resume" && opened && !resumedSinceOpen ? "join" : "resume";
135
145
  setTimeout(() => {
136
146
  if (!ended) connect(next);
137
147
  }, retryDelay(failures - 1));
138
148
  };
139
149
  const call = {
150
+ get id() {
151
+ return callId;
152
+ },
140
153
  get participant() {
141
154
  return participant;
142
155
  },
156
+ members: () => members,
143
157
  publish: async (tracks, publishOptions) => {
144
158
  if (!link) throw new Error("The call was left");
145
159
  await link.publish(tracks, publishOptions);
@@ -1,16 +1,16 @@
1
1
  //#region src/calls/msid-tracks.ts
2
2
  /**
3
- * The `mid` of each media section of a description by the sending browser's track id, which its `a=msid` line names: a
4
- * receiver's own track may have another id, when the browser reused one of its transceivers for the section.
3
+ * The `mid` of each media section of a description by the id of the stream its `a=msid` line names. Unlike the track
4
+ * id there, which Firefox makes up for each sender, both browsers write the sending page's stream id.
5
5
  */
6
- const midsByTrackId = (sdp) => {
6
+ const midsByStreamId = (sdp) => {
7
7
  const mids = /* @__PURE__ */ new Map();
8
8
  for (const section of sdp.split(/\r?\nm=/).slice(1)) {
9
9
  const mid = /^a=mid:(\S+)/m.exec(section)?.[1];
10
- const track = /^a=msid:\S+ (\S+)/m.exec(section)?.[1];
11
- if (mid !== void 0 && track !== void 0) mids.set(track, mid);
10
+ const stream = /^a=msid:(\S+) \S+/m.exec(section)?.[1];
11
+ if (mid !== void 0 && stream !== void 0 && stream !== "-") mids.set(stream, mid);
12
12
  }
13
13
  return mids;
14
14
  };
15
15
  //#endregion
16
- export { midsByTrackId };
16
+ export { midsByStreamId };
@@ -1,11 +1,11 @@
1
1
  import { createPeerConnection } from "./media-link.js";
2
- import { midsByTrackId } from "./msid-tracks.js";
2
+ import { midsByStreamId } from "./msid-tracks.js";
3
3
  //#region src/calls/peer-link.ts
4
4
  /**
5
5
  * The media link without a media server: one `RTCPeerConnection` to each other member, whose browser answers, so either
6
6
  * side may offer. Protobase passes the descriptions and candidates between the two ("perfect negotiation": the member
7
7
  * whose id sorts first gives way when both offer at once) and says who is in the call and which tracks each member
8
- * hears. A track goes by the id the sending browser gave it, which the receiving browser sees too.
8
+ * hears. A track goes by the id of the stream the sending browser sends it on, which the receiving browser sees too.
9
9
  */
10
10
  const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: createPeerConnection$1 = createPeerConnection }) => {
11
11
  const peers = /* @__PURE__ */ new Map();
@@ -13,10 +13,13 @@ const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: crea
13
13
  let remote = [];
14
14
  let publishing;
15
15
  let publishes = Promise.resolve();
16
- const sendOn = (pc, track) => pc.addTransceiver(track, { direction: "sendonly" }).sender;
16
+ const sendOn = (pc, { track, stream }) => pc.addTransceiver(track, {
17
+ direction: "sendonly",
18
+ streams: [stream]
19
+ }).sender;
17
20
  const receivedTrack = ({ member, sid }) => {
18
21
  const pc = peers.get(member)?.pc;
19
- const mid = pc?.remoteDescription && midsByTrackId(pc.remoteDescription.sdp).get(sid);
22
+ const mid = pc?.remoteDescription && midsByStreamId(pc.remoteDescription.sdp).get(sid);
20
23
  return mid === void 0 ? void 0 : pc.getTransceivers().find((transceiver) => transceiver.mid === mid)?.receiver.track;
21
24
  };
22
25
  const emitTracks = () => onTracks(remote.map((track) => ({
@@ -57,7 +60,7 @@ const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: crea
57
60
  if (pc.iceConnectionState === "failed") pc.restartIce();
58
61
  };
59
62
  pc.ontrack = () => emitTracks();
60
- for (const [source, { track }] of local) peer.senders.set(source, sendOn(pc, track));
63
+ for (const [source, sending] of local) peer.senders.set(source, sendOn(pc, sending));
61
64
  return peer;
62
65
  };
63
66
  const disconnect = (member) => {
@@ -111,14 +114,22 @@ const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: crea
111
114
  const added = Object.entries(tracks);
112
115
  for (const [source] of added) if (local.has(source)) throw new Error(`The call already sends a ${source} track: unpublish it first`);
113
116
  if (added.length === 0) return;
117
+ const sending = added.map(([source, track]) => {
118
+ const stream = new MediaStream([track]);
119
+ return [source, {
120
+ track,
121
+ sid: stream.id,
122
+ stream
123
+ }];
124
+ });
114
125
  const answered = new Promise((resolve, reject) => publishing = {
115
126
  resolve,
116
127
  reject
117
128
  });
118
129
  send({
119
130
  type: "publish",
120
- tracks: added.map(([source, track]) => ({
121
- cid: track.id,
131
+ tracks: sending.map(([source, { track, sid }]) => ({
132
+ cid: sid,
122
133
  kind: track.kind,
123
134
  source
124
135
  }))
@@ -128,11 +139,8 @@ const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: crea
128
139
  } finally {
129
140
  publishing = void 0;
130
141
  }
131
- for (const [source, track] of added) {
132
- local.set(source, {
133
- track,
134
- sid: track.id
135
- });
142
+ for (const [source, track] of sending) {
143
+ local.set(source, track);
136
144
  for (const peer of peers.values()) peer.senders.set(source, sendOn(peer.pc, track));
137
145
  }
138
146
  };
@@ -173,7 +181,7 @@ const peerLink = (self, { iceServers, send, onTracks, createPeerConnection: crea
173
181
  resumeState: async (participant) => ({
174
182
  participant,
175
183
  tracks: [...local].map(([source, { track, sid }]) => ({
176
- cid: track.id,
184
+ cid: sid,
177
185
  sid,
178
186
  kind: track.kind,
179
187
  source
package/dist/channels.js CHANGED
@@ -23,13 +23,25 @@ const channelsClient = (options) => {
23
23
  const entries = [...channel.presence.values()];
24
24
  for (const listener of channel.presenceListeners) listener(entries, change);
25
25
  };
26
+ const forget = (id) => {
27
+ joined.delete(id);
28
+ if (joined.size === 0) connection.stop();
29
+ };
26
30
  const join = (channel) => {
31
+ const id = `${channel.resource}/${channel.key}`;
27
32
  connection.request({
28
33
  type: "join",
29
34
  resource: channel.resource,
30
35
  key: channel.key
31
36
  }).then((reply) => {
32
- if (joined.get(`${channel.resource}/${channel.key}`) !== channel) return;
37
+ if (joined.get(id) !== channel) return;
38
+ if (channel.holders === 0) {
39
+ connection.post({
40
+ type: "leave",
41
+ channel: reply.channel
42
+ });
43
+ return forget(id);
44
+ }
33
45
  channel.name = reply.channel;
34
46
  byName.set(channel.name, channel);
35
47
  setStatus(channel, { state: "joined" });
@@ -39,6 +51,7 @@ const channelsClient = (options) => {
39
51
  state: channel.tracked.state
40
52
  }).catch(unlessDropped);
41
53
  }).catch((error) => {
54
+ if (joined.get(id) === channel && channel.holders === 0 && error instanceof ChannelRefusal) return forget(id);
42
55
  if (!(error instanceof ChannelRefusal) || error.code === "disconnected") return unlessDropped(error);
43
56
  setStatus(channel, {
44
57
  state: "refused",
@@ -87,7 +100,11 @@ const channelsClient = (options) => {
87
100
  const connection = channelConnection({
88
101
  ...options,
89
102
  onOpen: () => {
90
- for (const channel of joined.values()) if (channel.status.state === "joining") join(channel);
103
+ for (const [id, channel] of joined) {
104
+ if (channel.status.state !== "joining") continue;
105
+ if (channel.holders === 0) forget(id);
106
+ else join(channel);
107
+ }
91
108
  },
92
109
  onDown: () => {
93
110
  byName.clear();
@@ -98,7 +115,8 @@ const channelsClient = (options) => {
98
115
  const release = (id, channel) => {
99
116
  channel.holders -= 1;
100
117
  if (channel.holders > 0) return;
101
- joined.delete(id);
118
+ channel.tracked = void 0;
119
+ if (channel.status.state === "joining") return;
102
120
  if (channel.name !== void 0) {
103
121
  byName.delete(channel.name);
104
122
  connection.post({
@@ -106,7 +124,7 @@ const channelsClient = (options) => {
106
124
  channel: channel.name
107
125
  });
108
126
  }
109
- if (joined.size === 0) connection.stop();
127
+ forget(id);
110
128
  };
111
129
  const handleOf = (id, channel) => {
112
130
  let left = false;
package/dist/client.d.ts CHANGED
@@ -3,9 +3,10 @@ import { ClientOptions } from "./transport.js";
3
3
  import { RegisteredDevice, WebDeviceRegistration } from "./devices.js";
4
4
  import { InboxPage } from "./notification-history.js";
5
5
  import { FilterInput, ListParams, MapParams } from "./query-string.js";
6
+ import { Aggregate, AggregateParams, BatchOp, BatchResult, CommentPage, Facet, FormChallenge, FormProof, Histogram, HistoryPage, ListPage, MapResult, MentionableUser, MetaResult, PublicForm, RecordComment, RecordOf, ResourceRef, Series, SeriesParams, Stored } from "./types.js";
7
+ import { RecordCall, RecordCallsHandlers } from "./record-calls.js";
6
8
  import { UploadProgress } from "./upload-request.js";
7
9
  import { LiveHandlers } from "./live.js";
8
- import { Aggregate, AggregateParams, BatchOp, BatchResult, CommentPage, Facet, FormChallenge, FormProof, Histogram, HistoryPage, ListPage, MapResult, MentionableUser, MetaResult, PublicForm, RecordComment, RecordOf, ResourceRef, Series, SeriesParams, Stored } from "./types.js";
9
10
  import { KeyValue } from "@protobase/schema";
10
11
  //#region src/client.d.ts
11
12
  /** How `invoke` calls a function: GET without a body, POST with one, unless `method` says otherwise. */
@@ -118,6 +119,8 @@ export declare const createClient: (options?: ClientOptions) => {
118
119
  clear: () => Promise<void>;
119
120
  people: () => Promise<import("@protobase/schema/internal").PersonStatus[]>;
120
121
  };
122
+ calls: (resource: ResourceRef, key: KeyValue | readonly KeyValue[]) => Promise<RecordCall[]>;
123
+ watchCalls: (resource: ResourceRef, key: KeyValue | readonly KeyValue[], handlers: RecordCallsHandlers) => () => void;
121
124
  };
122
125
  export type Client = ReturnType<typeof createClient>;
123
126
  //#endregion
package/dist/client.js CHANGED
@@ -8,6 +8,7 @@ import { channelsClient } from "./channels.js";
8
8
  import { devicesClient } from "./devices.js";
9
9
  import { statusClient } from "./status.js";
10
10
  import { notificationHistoryClient } from "./notification-history.js";
11
+ import { recordCallsClient } from "./record-calls.js";
11
12
  import { encodeKey } from "@protobase/schema/internal";
12
13
  //#region src/client.ts
13
14
  const nameOf = (resource) => typeof resource === "string" ? resource : resource.toModel().name;
@@ -17,6 +18,8 @@ const channelsUrl = (baseUrl = "/api/v1") => {
17
18
  return url.toString();
18
19
  };
19
20
  const keyPath = (resource, key) => `/${nameOf(resource)}/${encodeKey(key)}`;
21
+ /** Where a record's calls are read and followed. */
22
+ const callsPath = (resource, key) => `${keyPath(resource, key)}:calls`;
20
23
  const toPage = (body, response, origin) => ({
21
24
  items: body.items.map((item) => withFileOrigin(item, origin)),
22
25
  nextPageToken: body.next_page_token,
@@ -431,6 +434,12 @@ const createClient = (options = {}) => {
431
434
  socket: options.webSocket ?? ((url, protocols) => new WebSocket(url, protocols)),
432
435
  retryDelay: (attempt) => Math.min(3e4, 1e3 * 2 ** attempt)
433
436
  });
437
+ /** The signed-in user's own devices, where they receive push (see `devicesClient`). */
438
+ const devices = devicesClient(send);
439
+ /** The signed-in user's own notification history, their inbox (see `notificationHistoryClient`). */
440
+ const notifications = notificationHistoryClient(send);
441
+ /** A record's calls, live with `watchCalls`, for whoever may read the record (see `recordCallsClient`). */
442
+ const { calls, watchCalls } = recordCallsClient(send, callsPath);
434
443
  return {
435
444
  list,
436
445
  search,
@@ -460,10 +469,12 @@ const createClient = (options = {}) => {
460
469
  submitForm,
461
470
  live,
462
471
  channel,
463
- devices: devicesClient(send),
464
- notifications: notificationHistoryClient(send),
465
- status: statusClient(send)
472
+ devices,
473
+ notifications,
474
+ status: statusClient(send),
475
+ calls,
476
+ watchCalls
466
477
  };
467
478
  };
468
479
  //#endregion
469
- export { createClient };
480
+ export { callsPath, createClient };
package/dist/index.d.ts CHANGED
@@ -5,9 +5,10 @@ import { ClientOptions } from "./transport.js";
5
5
  import { DevicesClient, RegisteredDevice, WebDeviceRegistration } from "./devices.js";
6
6
  import { InboxNotification, InboxPage, NotificationHistoryClient } from "./notification-history.js";
7
7
  import { FilterInput, ListParams, MapParams, filterText, listWire } from "./query-string.js";
8
+ import { Aggregate, AggregateGroup, AggregateParams, AggregateSplitTotal, BatchOp, BatchRef, BatchResult, CommentPage, CommentPerson, Facet, FormChallenge, FormProof, Histogram, HistogramBucket, HistoryChange, HistoryPage, ListPage, MapBounds, MapBucket, MapLayerModel, MapResult, MentionableUser, Meta, MetaResult, PluginRef, PublicForm, RecordComment, RecordOf, RecordPermissions, RecordPhotos, RelatedLabels, ResourcePermissions, ResourceRef, Series, SeriesParams, SeriesPoint, SourceRef, Stored, StoredFile, StoredFileVariant } from "./types.js";
9
+ import { RecordCall, RecordCallsHandlers } from "./record-calls.js";
8
10
  import { UploadProgress } from "./upload-request.js";
9
11
  import { LiveChange, LiveHandlers } from "./live.js";
10
- import { Aggregate, AggregateGroup, AggregateParams, AggregateSplitTotal, BatchOp, BatchRef, BatchResult, CommentPage, CommentPerson, Facet, FormChallenge, FormProof, Histogram, HistogramBucket, HistoryChange, HistoryPage, ListPage, MapBounds, MapBucket, MapLayerModel, MapResult, MentionableUser, Meta, MetaResult, PluginRef, PublicForm, RecordComment, RecordOf, RecordPermissions, RecordPhotos, RelatedLabels, ResourcePermissions, ResourceRef, Series, SeriesParams, SeriesPoint, SourceRef, Stored, StoredFile, StoredFileVariant } from "./types.js";
11
12
  import { Client, InvokeOptions, UploadOptions, UploadResult, createClient } from "./client.js";
12
13
  import { Availability, PersonStatus, StatusClient, StatusInput } from "./status.js";
13
14
  import { ApiError, PreconditionFailedError, ProblemDetails, ProblemError, isApiError } from "./problem.js";
@@ -29,4 +30,4 @@ import { createStaticSession } from "./static-session.js";
29
30
  import { AssistantClient, AssistantClientOptions, AssistantConnection, createAssistantClient } from "./assistant.js";
30
31
  import { RuntimeClient, RuntimeClientOptions, RuntimeLastUpdate, RuntimePolicy, RuntimeStatus, RuntimeUpdateStage, createRuntimeClient } from "./runtime.js";
31
32
  import { availabilities, heldStatus, maxStatusText, statusTextProblem } from "@protobase/schema/internal";
32
- export { type AccountSecurity, type AccountSignIn, type ActiveRole, type Aggregate, type AggregateGroup, type AggregateParams, type AggregateSplitTotal, ApiError, type AppUser, type AssistantClient, type AssistantClientOptions, type AssistantConnection, AuthError, type AuthSession, type AuthSessionOptions, type AuthUser, type AuthenticatorSetup, type Availability, type BatchOp, type BatchRef, type BatchResult, type Channel, type ChannelError, type ChannelMessage, ChannelRefusal, type ChannelStatus, type ChannelUser, type Client, type ClientOptions, type CommentPage, type CommentPerson, type ConnectClient, type ConnectedApp, type ConnectingClient, type CurrentOrganization, type DeviceSession, type DevicesClient, type Facet, type FilterInput, type FormChallenge, type FormProof, type Histogram, type HistogramBucket, type HistoryChange, type HistoryPage, type InboxNotification, type InboxPage, type InvitationPreview, type InvitationSent, type InvokeOptions, type ListPage, type ListParams, type LiveChange, type LiveHandlers, type MapBounds, type MapBucket, type MapLayerModel, type MapParams, type MapResult, type MentionableUser, type Meta, type MetaResult, type NotificationHistoryClient, type Organization, type OrganizationInvitation, type OrganizationMember, type OrganizationPerson, type OrganizationRole, type OrganizationsClient, type OrganizationsFound, type Passkey, type PersonStatus, type PlatformSignIn, type PluginRef, PreconditionFailedError, type PresenceChange, type PresenceEntry, type ProblemDetails, type ProblemError, type PublicForm, type RecordComment, type RecordOf, type RecordPermissions, type RecordPhotos, type RegisteredDevice, type RelatedLabels, type RequiredSetup, type ResourcePermissions, type ResourceRef, type RoleLabel, type RoleOption, type RolesClient, type RuntimeClient, type RuntimeClientOptions, type RuntimeLastUpdate, type RuntimePolicy, type RuntimeStatus, type RuntimeUpdateStage, type SamlSignIn, type Series, type SeriesParams, type SeriesPoint, type SetupStatus, type SignInMethod, type SignInPolicy, type SignInPolicyClient, type SignInPolicyState, type SignInResult, type SignInRule, type SocketLike, type SourceMeta, type SourceRef, type StaffAccessRule, type StaffSignIn, type StaffSignInClient, type StatusClient, type StatusInput, type Stored, type StoredFile, type StoredFileVariant, type TwoFactorMethod, type UploadOptions, type UploadProgress, type UploadResult, type UserInvitation, type UserInvitationSent, type UsersClient, type UsersFound, type WebDeviceRegistration, availabilities, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, heldStatus, isApiError, listWire, maxStatusText, routedClient, sourceMeta, statusTextProblem, tokenExpiry };
33
+ export { type AccountSecurity, type AccountSignIn, type ActiveRole, type Aggregate, type AggregateGroup, type AggregateParams, type AggregateSplitTotal, ApiError, type AppUser, type AssistantClient, type AssistantClientOptions, type AssistantConnection, AuthError, type AuthSession, type AuthSessionOptions, type AuthUser, type AuthenticatorSetup, type Availability, type BatchOp, type BatchRef, type BatchResult, type Channel, type ChannelError, type ChannelMessage, ChannelRefusal, type ChannelStatus, type ChannelUser, type Client, type ClientOptions, type CommentPage, type CommentPerson, type ConnectClient, type ConnectedApp, type ConnectingClient, type CurrentOrganization, type DeviceSession, type DevicesClient, type Facet, type FilterInput, type FormChallenge, type FormProof, type Histogram, type HistogramBucket, type HistoryChange, type HistoryPage, type InboxNotification, type InboxPage, type InvitationPreview, type InvitationSent, type InvokeOptions, type ListPage, type ListParams, type LiveChange, type LiveHandlers, type MapBounds, type MapBucket, type MapLayerModel, type MapParams, type MapResult, type MentionableUser, type Meta, type MetaResult, type NotificationHistoryClient, type Organization, type OrganizationInvitation, type OrganizationMember, type OrganizationPerson, type OrganizationRole, type OrganizationsClient, type OrganizationsFound, type Passkey, type PersonStatus, type PlatformSignIn, type PluginRef, PreconditionFailedError, type PresenceChange, type PresenceEntry, type ProblemDetails, type ProblemError, type PublicForm, type RecordCall, type RecordCallsHandlers, type RecordComment, type RecordOf, type RecordPermissions, type RecordPhotos, type RegisteredDevice, type RelatedLabels, type RequiredSetup, type ResourcePermissions, type ResourceRef, type RoleLabel, type RoleOption, type RolesClient, type RuntimeClient, type RuntimeClientOptions, type RuntimeLastUpdate, type RuntimePolicy, type RuntimeStatus, type RuntimeUpdateStage, type SamlSignIn, type Series, type SeriesParams, type SeriesPoint, type SetupStatus, type SignInMethod, type SignInPolicy, type SignInPolicyClient, type SignInPolicyState, type SignInResult, type SignInRule, type SocketLike, type SourceMeta, type SourceRef, type StaffAccessRule, type StaffSignIn, type StaffSignInClient, type StatusClient, type StatusInput, type Stored, type StoredFile, type StoredFileVariant, type TwoFactorMethod, type UploadOptions, type UploadProgress, type UploadResult, type UserInvitation, type UserInvitationSent, type UsersClient, type UsersFound, type WebDeviceRegistration, availabilities, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, heldStatus, isApiError, listWire, maxStatusText, routedClient, sourceMeta, statusTextProblem, tokenExpiry };
@@ -0,0 +1,26 @@
1
+ import "./types.js";
2
+ import { KeyValue } from "@protobase/schema";
3
+ //#region src/record-calls.d.ts
4
+ /** A call on a record, as its readers see it: who started it, when, when it ended, who is in it and who was removed. */
5
+ export type RecordCall = {
6
+ id: string;
7
+ started_by: string;
8
+ started_at: string;
9
+ /** `null` while it goes on. */
10
+ ended_at: string | null;
11
+ members: {
12
+ member: string;
13
+ joined_at: string;
14
+ }[];
15
+ removed: {
16
+ member: string;
17
+ removed_at: string;
18
+ }[];
19
+ };
20
+ export type RecordCallsHandlers = {
21
+ /** The record's latest calls, newest first, the one going on first: at once, then whenever they change. */
22
+ onCalls: (calls: RecordCall[]) => void;
23
+ /** The stream failed, with why, or ended (`undefined`); it is followed again after a pause. */
24
+ onRetry?: (error: unknown) => void;
25
+ };
26
+ //#endregion
@@ -0,0 +1,49 @@
1
+ import { readEventStream } from "@protobase/schema/internal";
2
+ //#region src/record-calls.ts
3
+ const backoff = (attempt) => Math.min(3e4, 1e3 * 2 ** attempt);
4
+ const pause = (ms, signal) => new Promise((resolve) => {
5
+ const timer = setTimeout(resolve, ms);
6
+ signal.addEventListener("abort", () => {
7
+ clearTimeout(timer);
8
+ resolve();
9
+ }, { once: true });
10
+ });
11
+ /** A record's calls, for whoever may read the record (`GET /api/{resource}/{key}:calls`). `path` names the record's calls. */
12
+ const recordCallsClient = (send, path, retryDelay = backoff) => ({
13
+ /** The record's latest calls, newest first, the one going on first. */
14
+ calls: async (resource, key) => (await (await send({
15
+ method: "GET",
16
+ path: path(resource, key)
17
+ })).json()).items,
18
+ /** Follows the record's calls until the returned function is called; a stream that ends or fails is followed again. */
19
+ watchCalls: (resource, key, handlers) => {
20
+ const controller = new AbortController();
21
+ const { signal } = controller;
22
+ let failures = 0;
23
+ const follow = async () => {
24
+ const response = await send({
25
+ method: "GET",
26
+ path: path(resource, key),
27
+ query: "?watch=true",
28
+ headers: { accept: "text/event-stream" },
29
+ signal
30
+ });
31
+ if (!response.body) throw new Error("The server answered the calls stream without a body");
32
+ await readEventStream(response.body, (data) => {
33
+ failures = 0;
34
+ handlers.onCalls(JSON.parse(data).items);
35
+ }, signal);
36
+ };
37
+ (async () => {
38
+ while (!signal.aborted) {
39
+ const error = await follow().then(() => void 0, (reason) => reason);
40
+ if (signal.aborted) return;
41
+ handlers.onRetry?.(error);
42
+ await pause(retryDelay(failures++), signal);
43
+ }
44
+ })();
45
+ return () => controller.abort();
46
+ }
47
+ });
48
+ //#endregion
49
+ export { recordCallsClient };
@@ -43,7 +43,9 @@ const routedClient = (own, sourceOf) => {
43
43
  channel: (resource, key) => own.channel(resource, key),
44
44
  devices: own.devices,
45
45
  notifications: own.notifications,
46
- status: own.status
46
+ status: own.status,
47
+ calls: (resource, key) => to(resource).calls(resource, key),
48
+ watchCalls: (resource, key, handlers) => to(resource).watchCalls(resource, key, handlers)
47
49
  };
48
50
  };
49
51
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/client",
3
- "version": "0.59.3",
3
+ "version": "0.60.1",
4
4
  "description": "Typed fetch client for the Protobase REST API.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.12",
34
- "@protobase/layout": "0.59.3",
35
- "@protobase/schema": "0.59.3",
34
+ "@protobase/layout": "0.60.1",
35
+ "@protobase/schema": "0.60.1",
36
36
  "@simplewebauthn/browser": "^13.3.0",
37
37
  "better-auth": "^1.7.12"
38
38
  }