@protobase/client 0.59.2 → 0.60.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.
@@ -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/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. */
@@ -112,6 +113,14 @@ export declare const createClient: (options?: ClientOptions) => {
112
113
  markRead: (id: string) => Promise<void>;
113
114
  markAllRead: (upTo: string) => Promise<void>;
114
115
  };
116
+ status: {
117
+ own: () => Promise<import("@protobase/schema/internal").PersonStatus | null>;
118
+ set: (status: import("@protobase/schema/internal").StatusInput) => Promise<import("@protobase/schema/internal").PersonStatus | null>;
119
+ clear: () => Promise<void>;
120
+ people: () => Promise<import("@protobase/schema/internal").PersonStatus[]>;
121
+ };
122
+ calls: (resource: ResourceRef, key: KeyValue | readonly KeyValue[]) => Promise<RecordCall[]>;
123
+ watchCalls: (resource: ResourceRef, key: KeyValue | readonly KeyValue[], handlers: RecordCallsHandlers) => () => void;
115
124
  };
116
125
  export type Client = ReturnType<typeof createClient>;
117
126
  //#endregion
package/dist/client.js CHANGED
@@ -6,7 +6,9 @@ import { followLive } from "./live.js";
6
6
  import { sharedLive } from "./shared-live.js";
7
7
  import { channelsClient } from "./channels.js";
8
8
  import { devicesClient } from "./devices.js";
9
+ import { statusClient } from "./status.js";
9
10
  import { notificationHistoryClient } from "./notification-history.js";
11
+ import { recordCallsClient } from "./record-calls.js";
10
12
  import { encodeKey } from "@protobase/schema/internal";
11
13
  //#region src/client.ts
12
14
  const nameOf = (resource) => typeof resource === "string" ? resource : resource.toModel().name;
@@ -16,6 +18,8 @@ const channelsUrl = (baseUrl = "/api/v1") => {
16
18
  return url.toString();
17
19
  };
18
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`;
19
23
  const toPage = (body, response, origin) => ({
20
24
  items: body.items.map((item) => withFileOrigin(item, origin)),
21
25
  nextPageToken: body.next_page_token,
@@ -430,6 +434,12 @@ const createClient = (options = {}) => {
430
434
  socket: options.webSocket ?? ((url, protocols) => new WebSocket(url, protocols)),
431
435
  retryDelay: (attempt) => Math.min(3e4, 1e3 * 2 ** attempt)
432
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);
433
443
  return {
434
444
  list,
435
445
  search,
@@ -459,9 +469,12 @@ const createClient = (options = {}) => {
459
469
  submitForm,
460
470
  live,
461
471
  channel,
462
- devices: devicesClient(send),
463
- notifications: notificationHistoryClient(send)
472
+ devices,
473
+ notifications,
474
+ status: statusClient(send),
475
+ calls,
476
+ watchCalls
464
477
  };
465
478
  };
466
479
  //#endregion
467
- export { createClient };
480
+ export { callsPath, createClient };
package/dist/index.d.ts CHANGED
@@ -5,10 +5,12 @@ 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";
13
+ import { Availability, PersonStatus, StatusClient, StatusInput } from "./status.js";
12
14
  import { ApiError, PreconditionFailedError, ProblemDetails, ProblemError, isApiError } from "./problem.js";
13
15
  import { fileUrl } from "./file-urls.js";
14
16
  import { routedClient } from "./routed-client.js";
@@ -27,4 +29,5 @@ import { AuthSession, AuthSessionOptions, createAuthSession, tokenExpiry } from
27
29
  import { createStaticSession } from "./static-session.js";
28
30
  import { AssistantClient, AssistantClientOptions, AssistantConnection, createAssistantClient } from "./assistant.js";
29
31
  import { RuntimeClient, RuntimeClientOptions, RuntimeLastUpdate, RuntimePolicy, RuntimeStatus, RuntimeUpdateStage, createRuntimeClient } from "./runtime.js";
30
- 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 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 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 Stored, type StoredFile, type StoredFileVariant, type TwoFactorMethod, type UploadOptions, type UploadProgress, type UploadResult, type UserInvitation, type UserInvitationSent, type UsersClient, type UsersFound, type WebDeviceRegistration, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, isApiError, listWire, routedClient, sourceMeta, tokenExpiry };
32
+ import { availabilities, heldStatus, maxStatusText, statusTextProblem } from "@protobase/schema/internal";
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 };
package/dist/index.js CHANGED
@@ -10,4 +10,5 @@ import { createAuthSession, tokenExpiry } from "./auth.js";
10
10
  import { createStaticSession } from "./static-session.js";
11
11
  import { createAssistantClient } from "./assistant.js";
12
12
  import { createRuntimeClient } from "./runtime.js";
13
- export { ApiError, AuthError, ChannelRefusal, PreconditionFailedError, createAssistantClient, createAuthSession, createClient, createRuntimeClient, createStaticSession, fileUrl, filterText, isApiError, listWire, routedClient, sourceMeta, tokenExpiry };
13
+ import { availabilities, heldStatus, maxStatusText, statusTextProblem } from "@protobase/schema/internal";
14
+ export { ApiError, AuthError, ChannelRefusal, PreconditionFailedError, 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 };
@@ -3,7 +3,7 @@ import { Client } from "./client.js";
3
3
  /**
4
4
  * One client over the app's own API and the sources it draws resources from: a call about a resource goes to the API
5
5
  * that serves it (`sourceOf` gives a source's client, or `undefined` for the app's own), and `meta`, `invoke`, the
6
- * public forms, `live` and the user's devices and notifications go to the app's own. A batch goes to the one API all its writes are for; one that writes to two cannot be atomic, so it is
6
+ * public forms, `live` and the user's devices, notifications and status go to the app's own. A batch goes to the one API all its writes are for; one that writes to two cannot be atomic, so it is
7
7
  * refused before anything is sent.
8
8
  */
9
9
  export declare const routedClient: (own: Client, sourceOf: (resource: string) => Client | undefined) => Client;
@@ -3,7 +3,7 @@ const nameOf = (resource) => typeof resource === "string" ? resource : resource.
3
3
  /**
4
4
  * One client over the app's own API and the sources it draws resources from: a call about a resource goes to the API
5
5
  * that serves it (`sourceOf` gives a source's client, or `undefined` for the app's own), and `meta`, `invoke`, the
6
- * public forms, `live` and the user's devices and notifications go to the app's own. A batch goes to the one API all its writes are for; one that writes to two cannot be atomic, so it is
6
+ * public forms, `live` and the user's devices, notifications and status go to the app's own. A batch goes to the one API all its writes are for; one that writes to two cannot be atomic, so it is
7
7
  * refused before anything is sent.
8
8
  */
9
9
  const routedClient = (own, sourceOf) => {
@@ -42,7 +42,10 @@ const routedClient = (own, sourceOf) => {
42
42
  live: (handlers) => own.live(handlers),
43
43
  channel: (resource, key) => own.channel(resource, key),
44
44
  devices: own.devices,
45
- notifications: own.notifications
45
+ notifications: own.notifications,
46
+ status: own.status,
47
+ calls: (resource, key) => to(resource).calls(resource, key),
48
+ watchCalls: (resource, key, handlers) => to(resource).watchCalls(resource, key, handlers)
46
49
  };
47
50
  };
48
51
  //#endregion
@@ -0,0 +1,19 @@
1
+ import { Transport } from "./transport.js";
2
+ import { Availability, PersonStatus, PersonStatus as PersonStatus$1, StatusInput, StatusInput as StatusInput$1 } from "@protobase/schema/internal";
3
+ //#region src/status.d.ts
4
+ /**
5
+ * People's own status (`/api/status`), in an app with `status: {}`: `own` the signed-in person's, `set` replaces it
6
+ * and answers it, `clear` removes it, and `people` the statuses of everyone who shares an organization with them, those
7
+ * without one left out. A status past its clear time is none. `set` rejects with an `ApiError` of slug
8
+ * `invalid-status-text` for a text with control characters or over `maxStatusText` characters, and
9
+ * `own-sign-in-only` for staff signed in as the person.
10
+ */
11
+ export declare const statusClient: (send: Transport) => {
12
+ own: () => Promise<PersonStatus | null>;
13
+ set: (status: StatusInput) => Promise<PersonStatus | null>;
14
+ clear: () => Promise<void>;
15
+ people: () => Promise<PersonStatus[]>;
16
+ };
17
+ export type StatusClient = ReturnType<typeof statusClient>;
18
+ //#endregion
19
+ export type { Availability, PersonStatus$1 as PersonStatus, StatusInput$1 as StatusInput };
package/dist/status.js ADDED
@@ -0,0 +1,36 @@
1
+ import { statusPath } from "@protobase/schema/internal";
2
+ //#region src/status.ts
3
+ /**
4
+ * People's own status (`/api/status`), in an app with `status: {}`: `own` the signed-in person's, `set` replaces it
5
+ * and answers it, `clear` removes it, and `people` the statuses of everyone who shares an organization with them, those
6
+ * without one left out. A status past its clear time is none. `set` rejects with an `ApiError` of slug
7
+ * `invalid-status-text` for a text with control characters or over `maxStatusText` characters, and
8
+ * `own-sign-in-only` for staff signed in as the person.
9
+ */
10
+ const statusClient = (send) => ({
11
+ own: async () => (await (await send({
12
+ method: "GET",
13
+ path: statusPath(""),
14
+ system: true
15
+ })).json()).status,
16
+ set: async (status) => (await (await send({
17
+ method: "PUT",
18
+ path: statusPath(""),
19
+ system: true,
20
+ body: status
21
+ })).json()).status,
22
+ clear: async () => {
23
+ await send({
24
+ method: "DELETE",
25
+ path: statusPath(""),
26
+ system: true
27
+ });
28
+ },
29
+ people: async () => (await (await send({
30
+ method: "GET",
31
+ path: statusPath("", "people"),
32
+ system: true
33
+ })).json()).statuses
34
+ });
35
+ //#endregion
36
+ export { statusClient };
package/dist/types.d.ts CHANGED
@@ -284,6 +284,7 @@ export type Meta = {
284
284
  bell?: true;
285
285
  webPushKey?: string;
286
286
  };
287
+ status?: true;
287
288
  locale?: 'en' | 'nl';
288
289
  files?: {
289
290
  trustedHosts: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/client",
3
- "version": "0.59.2",
3
+ "version": "0.60.0",
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.2",
35
- "@protobase/schema": "0.59.2",
34
+ "@protobase/layout": "0.60.0",
35
+ "@protobase/schema": "0.60.0",
36
36
  "@simplewebauthn/browser": "^13.3.0",
37
37
  "better-auth": "^1.7.12"
38
38
  }