@bunbase-ae/js 2.10.1-next.246.74820d0 → 2.10.1-next.252.6c5a17b
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/package.json +1 -1
- package/src/admin.ts +72 -0
- package/src/index.ts +3 -0
- package/src/realtime.ts +82 -2
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -400,6 +400,24 @@ export interface StatsResponse {
|
|
|
400
400
|
time_series: MinuteBucket[];
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
export interface TenantSummary {
|
|
404
|
+
tenant_id: string;
|
|
405
|
+
member_count: number;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface TenantMember {
|
|
409
|
+
user_id: string;
|
|
410
|
+
role: string;
|
|
411
|
+
created_at: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface AdminTenantMembership {
|
|
415
|
+
tenant_id: string;
|
|
416
|
+
user_id: string;
|
|
417
|
+
role: string;
|
|
418
|
+
created_at: number;
|
|
419
|
+
}
|
|
420
|
+
|
|
403
421
|
// ─── Sub-clients ──────────────────────────────────────────────────────────────
|
|
404
422
|
|
|
405
423
|
class AdminUsersClient {
|
|
@@ -1129,6 +1147,58 @@ class AdminNamedQueriesClient {
|
|
|
1129
1147
|
}
|
|
1130
1148
|
}
|
|
1131
1149
|
|
|
1150
|
+
// Tenant membership management — surfaces the /api/v1/admin/tenants/* endpoints
|
|
1151
|
+
// added in v2.5.2 (#277/#323) so operators don't need to hand-roll fetch calls
|
|
1152
|
+
// to onboard a tenant member.
|
|
1153
|
+
class AdminTenantsClient {
|
|
1154
|
+
constructor(private readonly http: HttpClient) {}
|
|
1155
|
+
|
|
1156
|
+
async list(): Promise<TenantSummary[]> {
|
|
1157
|
+
const res = await this.http.request<{ items: TenantSummary[] }>("GET", "/api/v1/admin/tenants");
|
|
1158
|
+
return res.items;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
async listMembers(tenantId: string): Promise<TenantMember[]> {
|
|
1162
|
+
const res = await this.http.request<{ items: TenantMember[] }>(
|
|
1163
|
+
"GET",
|
|
1164
|
+
`/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members`,
|
|
1165
|
+
);
|
|
1166
|
+
return res.items;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async addMember(
|
|
1170
|
+
tenantId: string,
|
|
1171
|
+
params: { userId: string; role?: string },
|
|
1172
|
+
): Promise<AdminTenantMembership> {
|
|
1173
|
+
const body: Record<string, unknown> = { user_id: params.userId };
|
|
1174
|
+
if (params.role !== undefined) body.role = params.role;
|
|
1175
|
+
return this.http.request<AdminTenantMembership>(
|
|
1176
|
+
"POST",
|
|
1177
|
+
`/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members`,
|
|
1178
|
+
{ body },
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async setMemberRole(
|
|
1183
|
+
tenantId: string,
|
|
1184
|
+
userId: string,
|
|
1185
|
+
role: string,
|
|
1186
|
+
): Promise<AdminTenantMembership> {
|
|
1187
|
+
return this.http.request<AdminTenantMembership>(
|
|
1188
|
+
"PATCH",
|
|
1189
|
+
`/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`,
|
|
1190
|
+
{ body: { role } },
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async removeMember(tenantId: string, userId: string): Promise<void> {
|
|
1195
|
+
await this.http.request<{ ok: boolean }>(
|
|
1196
|
+
"DELETE",
|
|
1197
|
+
`/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1132
1202
|
// ─── Main AdminClient ─────────────────────────────────────────────────────────
|
|
1133
1203
|
|
|
1134
1204
|
export class AdminClient {
|
|
@@ -1143,6 +1213,7 @@ export class AdminClient {
|
|
|
1143
1213
|
readonly queries: AdminNamedQueriesClient;
|
|
1144
1214
|
readonly logs: AdminLogsClient;
|
|
1145
1215
|
readonly system: AdminSystemClient;
|
|
1216
|
+
readonly tenants: AdminTenantsClient;
|
|
1146
1217
|
|
|
1147
1218
|
constructor(http: HttpClient) {
|
|
1148
1219
|
this.users = new AdminUsersClient(http);
|
|
@@ -1156,5 +1227,6 @@ export class AdminClient {
|
|
|
1156
1227
|
this.queries = new AdminNamedQueriesClient(http);
|
|
1157
1228
|
this.logs = new AdminLogsClient(http);
|
|
1158
1229
|
this.system = new AdminSystemClient(http);
|
|
1230
|
+
this.tenants = new AdminTenantsClient(http);
|
|
1159
1231
|
}
|
|
1160
1232
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
type AdminRecord,
|
|
8
8
|
type AdminSession,
|
|
9
9
|
type AdminStoredFile,
|
|
10
|
+
type AdminTenantMembership,
|
|
10
11
|
type AdminUser,
|
|
11
12
|
type BackupDestinationConfig,
|
|
12
13
|
type BackupFile,
|
|
@@ -38,6 +39,8 @@ export {
|
|
|
38
39
|
type StatsResponse,
|
|
39
40
|
type StorageBucket,
|
|
40
41
|
type TemplateName,
|
|
42
|
+
type TenantMember,
|
|
43
|
+
type TenantSummary,
|
|
41
44
|
type UpdateNamedQueryInput,
|
|
42
45
|
type UpdateUserParams,
|
|
43
46
|
type WebhookLogRow,
|
package/src/realtime.ts
CHANGED
|
@@ -88,6 +88,25 @@ export class RealtimeClient {
|
|
|
88
88
|
private ws: WebSocket | null = null;
|
|
89
89
|
// Map from channel key → { callbacks, options }
|
|
90
90
|
private channels = new Map<string, ChannelState>();
|
|
91
|
+
// Reverse map: server-resolved topic → user-supplied channel key. Populated
|
|
92
|
+
// from the subscribe ack (`{ type: "ack", channel: <resolved-topic>,
|
|
93
|
+
// requestedChannel: <user-key> }`) so dispatch can translate the topic on
|
|
94
|
+
// incoming change messages back to the key the caller used. Without this,
|
|
95
|
+
// three of four subscribe shapes silently drop every event because the
|
|
96
|
+
// resolved topic differs from the user key:
|
|
97
|
+
// - record:{collection}:{id} → record:{collection}:{id} (matches, unless tenant-scoped)
|
|
98
|
+
// - record:{collection}:{id} (tenant-scoped) → record:{collection}:tenant:{tid}:{id}
|
|
99
|
+
// - collection:{name}:mine → collection:{name}:user:{uid}
|
|
100
|
+
// - collection:{name} (tenant-scoped) → collection:{name}:tenant:{tid}
|
|
101
|
+
//
|
|
102
|
+
// The earlier #408 fix paired acks to subscribes via a FIFO queue. That broke
|
|
103
|
+
// whenever the response stream wasn't 1:1 with the request stream — most
|
|
104
|
+
// notably when the server replied with `{ type: "error" }` to a subscribe
|
|
105
|
+
// (e.g. `tenant_required` for a tenant-scoped collection on a JWT without a
|
|
106
|
+
// tenant claim) or when an unsubscribe ack arrived in between. Both desync
|
|
107
|
+
// the queue permanently. Replacing FIFO pairing with an explicit
|
|
108
|
+
// `requestedChannel` echo makes correlation direct and stateless.
|
|
109
|
+
private resolvedTopicToKey = new Map<string, string>();
|
|
91
110
|
private connected = false;
|
|
92
111
|
private intentionalClose = false;
|
|
93
112
|
private reconnectDelay = INITIAL_RECONNECT_MS;
|
|
@@ -281,6 +300,7 @@ export class RealtimeClient {
|
|
|
281
300
|
this.ws = null;
|
|
282
301
|
this.connected = false;
|
|
283
302
|
this.connectionState = "disconnected";
|
|
303
|
+
this.resolvedTopicToKey.clear();
|
|
284
304
|
}
|
|
285
305
|
|
|
286
306
|
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
@@ -291,6 +311,12 @@ export class RealtimeClient {
|
|
|
291
311
|
state.callbacks.delete(callback);
|
|
292
312
|
if (state.callbacks.size === 0) {
|
|
293
313
|
this.channels.delete(channel);
|
|
314
|
+
// Drop any reverse-map entries that pointed at this user key — leaving
|
|
315
|
+
// stale rows would route a future event for a recycled topic to a
|
|
316
|
+
// deleted subscription.
|
|
317
|
+
for (const [topic, key] of this.resolvedTopicToKey) {
|
|
318
|
+
if (key === channel) this.resolvedTopicToKey.delete(topic);
|
|
319
|
+
}
|
|
294
320
|
if (this.connected) this.send({ type: "unsubscribe", channel });
|
|
295
321
|
}
|
|
296
322
|
}
|
|
@@ -317,6 +343,9 @@ export class RealtimeClient {
|
|
|
317
343
|
this.intentionalClose = false;
|
|
318
344
|
this.connectionState = "connecting";
|
|
319
345
|
this.sawSuccessfulSubscribe = false;
|
|
346
|
+
// Clear any stale reverse-topic mappings — the server will reissue acks
|
|
347
|
+
// for every channel we re-send in onopen below.
|
|
348
|
+
this.resolvedTopicToKey.clear();
|
|
320
349
|
this.ws = new WebSocket(`${this.wsUrl}/realtime`);
|
|
321
350
|
|
|
322
351
|
this.ws.onopen = () => {
|
|
@@ -360,10 +389,52 @@ export class RealtimeClient {
|
|
|
360
389
|
|
|
361
390
|
// A subscribe ack proves we can actually hold a subscription on this
|
|
362
391
|
// connection — reset the unauth-bailout state. (Auth-event acks flow
|
|
363
|
-
// through the "auth" branch below and carry no `channel` field
|
|
392
|
+
// through the "auth" branch below and carry no `channel` field;
|
|
393
|
+
// unsubscribe acks carry `channel` but no `requestedChannel`, so they
|
|
394
|
+
// don't disturb the reverse map.)
|
|
364
395
|
if (msg.type === "ack" && (msg as { channel?: string }).channel) {
|
|
365
396
|
this.sawSuccessfulSubscribe = true;
|
|
366
397
|
this.unauthCloseTimestamps = [];
|
|
398
|
+
const ackChannel = (msg as unknown as { channel: string }).channel;
|
|
399
|
+
const requestedChannel = (msg as unknown as { requestedChannel?: string }).requestedChannel;
|
|
400
|
+
if (requestedChannel) {
|
|
401
|
+
// Direct correlation: the server echoed the user-supplied channel
|
|
402
|
+
// string back, so we can pair the resolved topic to the user key
|
|
403
|
+
// without any FIFO state.
|
|
404
|
+
this.resolvedTopicToKey.set(ackChannel, requestedChannel);
|
|
405
|
+
} else {
|
|
406
|
+
// Back-compat with older servers that don't echo requestedChannel:
|
|
407
|
+
// identity-map the resolved topic so dispatch still finds the
|
|
408
|
+
// channel when the user keyed by topic. Only the simple non-tenant
|
|
409
|
+
// `collection:{name}` shape works against an old server — that
|
|
410
|
+
// matches the pre-fix behavior, no regression here.
|
|
411
|
+
this.resolvedTopicToKey.set(ackChannel, ackChannel);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Subscribe-error: server returned `{ type: "error", requestedChannel }`
|
|
417
|
+
// for a subscribe that failed without closing the socket (e.g.
|
|
418
|
+
// `tenant_required`, `subscription_limit`, `invalid channel format`,
|
|
419
|
+
// RPC catch-all). Drop the channel from this.channels so the failed sub
|
|
420
|
+
// doesn't get re-sent on reconnect (where it would just fail again) and
|
|
421
|
+
// so a future subscribe of the same key starts clean. Surface to onError
|
|
422
|
+
// with channel context — currently the only way the caller can find out
|
|
423
|
+
// their subscribe didn't take.
|
|
424
|
+
if (msg.type === "error" && (msg as { requestedChannel?: string }).requestedChannel) {
|
|
425
|
+
const errMsg = msg as unknown as {
|
|
426
|
+
requestedChannel: string;
|
|
427
|
+
code?: string;
|
|
428
|
+
message?: string;
|
|
429
|
+
};
|
|
430
|
+
this.channels.delete(errMsg.requestedChannel);
|
|
431
|
+
for (const [topic, key] of this.resolvedTopicToKey) {
|
|
432
|
+
if (key === errMsg.requestedChannel) this.resolvedTopicToKey.delete(topic);
|
|
433
|
+
}
|
|
434
|
+
const summary = errMsg.code
|
|
435
|
+
? `${errMsg.code}: ${errMsg.message ?? ""}`
|
|
436
|
+
: (errMsg.message ?? "subscribe failed");
|
|
437
|
+
this.onError?.(`Realtime subscribe failed for "${errMsg.requestedChannel}": ${summary}`);
|
|
367
438
|
return;
|
|
368
439
|
}
|
|
369
440
|
|
|
@@ -405,7 +476,16 @@ export class RealtimeClient {
|
|
|
405
476
|
if (msg.type !== "change") return;
|
|
406
477
|
|
|
407
478
|
const changeMsg = msg as ServerChangeMessage;
|
|
408
|
-
|
|
479
|
+
// Translate the server's resolved topic back to the user-supplied
|
|
480
|
+
// channel key before dispatching. Server fanout sets msg.channel to the
|
|
481
|
+
// topic the publish landed on (e.g. `collection:posts:user:U` for
|
|
482
|
+
// :mine, `record:posts:tenant:T:01ABC` for tenant-scoped record subs),
|
|
483
|
+
// which differs from the key the caller passed to subscribe(). Fall
|
|
484
|
+
// through to identity lookup for unmapped topics — keeps backwards
|
|
485
|
+
// compatibility with older servers and the simple non-tenant
|
|
486
|
+
// collection-channel case where the topic matches the user key.
|
|
487
|
+
const userKey = this.resolvedTopicToKey.get(changeMsg.channel) ?? changeMsg.channel;
|
|
488
|
+
const state = this.channels.get(userKey);
|
|
409
489
|
if (!state) return;
|
|
410
490
|
|
|
411
491
|
const realtimeEvent: RealtimeEvent = {
|