@mitway/sdk 0.2.3 → 0.3.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
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  MitwayBaasClient: () => MitwayBaasClient,
28
28
  MitwayBaasError: () => MitwayBaasError,
29
29
  Realtime: () => Realtime,
30
+ RealtimeChannel: () => RealtimeChannel,
30
31
  TokenManager: () => TokenManager,
31
32
  createClient: () => createClient,
32
33
  default: () => index_default
@@ -891,40 +892,516 @@ var Database = class {
891
892
 
892
893
  // src/modules/realtime.ts
893
894
  var import_socket = require("socket.io-client");
895
+ var PRESENCE_HEARTBEAT_MS = 2e4;
896
+ function makeChannelError(code, message) {
897
+ const err = new Error(message);
898
+ err.code = code;
899
+ return err;
900
+ }
894
901
  var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
902
+ var RealtimeChannel = class {
903
+ constructor(topic, realtime, options = {}) {
904
+ this.topic = topic;
905
+ this.realtime = realtime;
906
+ this.options = options;
907
+ }
908
+ topic;
909
+ realtime;
910
+ bindings = [];
911
+ state = "closed";
912
+ statusCallback = null;
913
+ /** Local presence state mirror — populated from `presence_state` /
914
+ * `presence_join` / `presence_leave` events. Read via `presenceState()`. */
915
+ presence = {};
916
+ /** Latest state this client has tracked. Non-null means the heartbeat
917
+ * timer is active and we'll re-emit this state every TTL/2. */
918
+ trackedState = null;
919
+ presenceHeartbeat = null;
920
+ /** Timestamp of the most recently received broadcast on this channel —
921
+ * used as the `since` anchor on replay after reconnect. ISO-8601. */
922
+ lastBroadcastTimestamp = null;
923
+ /** Configuration from `channel(topic, opts)`. Frozen at construction. */
924
+ options;
925
+ /** Whether this channel was opened as `private: true`. */
926
+ get isPrivate() {
927
+ return this.options.config?.private === true;
928
+ }
929
+ /** The user-supplied presence key, if any. */
930
+ get presenceKey() {
931
+ return this.options.config?.presence?.key;
932
+ }
933
+ /** Internal — exposed for Realtime to drive resubscription after a
934
+ * network hiccup. Returns the current lifecycle state. */
935
+ _state() {
936
+ return this.state;
937
+ }
938
+ /** Internal — called by `Realtime` when Socket.IO reconnects after a
939
+ * drop. Re-runs the registration flow; the backend assigns fresh
940
+ * subscription_ids and Socket.IO rejoins the per-subscription rooms,
941
+ * so events resume without developer intervention. The user-provided
942
+ * statusCallback (from the original subscribe()) fires again with
943
+ * 'SUBSCRIBED' or 'CHANNEL_ERROR' so the app can reflect state. */
944
+ async _rejoinAfterReconnect() {
945
+ if (this.state === "closed") {
946
+ return;
947
+ }
948
+ for (const b of this.bindings) {
949
+ if (b.type === "postgres_changes") {
950
+ b.subscriptionId = void 0;
951
+ }
952
+ }
953
+ this.state = "joining";
954
+ try {
955
+ await this.registerAllBindings();
956
+ if (this.trackedState) {
957
+ const socket = this.realtime._getSocket();
958
+ const key = this.presenceKey;
959
+ socket?.emit(
960
+ "realtime:presence:track",
961
+ key !== void 0 ? { channel: this.topic, state: this.trackedState, key } : { channel: this.topic, state: this.trackedState }
962
+ );
963
+ }
964
+ if (this.lastBroadcastTimestamp && this.bindings.some((b) => b.type === "broadcast")) {
965
+ void this.replay({ since: this.lastBroadcastTimestamp }).catch(() => void 0);
966
+ }
967
+ this.state = "joined";
968
+ this.statusCallback?.("SUBSCRIBED");
969
+ } catch (err) {
970
+ this.state = "errored";
971
+ this.statusCallback?.(
972
+ "CHANNEL_ERROR",
973
+ makeChannelError("REJOIN_FAILED", err instanceof Error ? err.message : String(err))
974
+ );
975
+ }
976
+ }
977
+ // ── implementation signature (not in public type surface).
978
+ // The callback type is intentionally broad: each overload above pins a
979
+ // specific payload shape, but TypeScript overload resolution needs the
980
+ // implementation to accept the union of every narrow callback without
981
+ // the contravariance conflict (TS2394). `any` is the standard escape
982
+ // hatch for this exact pattern and is confined to this one line — the
983
+ // public surface users see is strictly typed via the overloads.
984
+ on(type, filter, callback) {
985
+ if (type === "postgres_changes") {
986
+ this.bindings.push({
987
+ type: "postgres_changes",
988
+ filter,
989
+ callback
990
+ });
991
+ } else if (type === "broadcast") {
992
+ this.bindings.push({
993
+ type: "broadcast",
994
+ filter,
995
+ callback
996
+ });
997
+ } else {
998
+ this.bindings.push({
999
+ type: "presence",
1000
+ filter,
1001
+ callback
1002
+ });
1003
+ }
1004
+ return this;
1005
+ }
1006
+ // -------------------------------------------------------------------------
1007
+ // Presence — track / untrack / state accessor
1008
+ // -------------------------------------------------------------------------
1009
+ /**
1010
+ * Register or refresh this client's presence entry on the channel. Safe
1011
+ * to call many times — state replaces (not merges). Starts a heartbeat
1012
+ * timer at TTL/2 so the entry stays alive while the socket is open.
1013
+ * The channel must be `subscribe()`d first (the server enforces this).
1014
+ */
1015
+ async track(state) {
1016
+ const socket = this.realtime._getSocket();
1017
+ if (!socket) {
1018
+ throw new MitwayBaasError("Socket not connected", 503, "NOT_CONNECTED");
1019
+ }
1020
+ this.trackedState = state;
1021
+ const key = this.presenceKey;
1022
+ socket.emit(
1023
+ "realtime:presence:track",
1024
+ key !== void 0 ? { channel: this.topic, state, key } : { channel: this.topic, state }
1025
+ );
1026
+ if (!this.presenceHeartbeat) {
1027
+ this.presenceHeartbeat = setInterval(() => {
1028
+ const s = this.realtime._getSocket();
1029
+ if (s && this.trackedState) {
1030
+ s.emit(
1031
+ "realtime:presence:track",
1032
+ key !== void 0 ? { channel: this.topic, state: this.trackedState, key } : { channel: this.topic, state: this.trackedState }
1033
+ );
1034
+ }
1035
+ }, PRESENCE_HEARTBEAT_MS);
1036
+ const h = this.presenceHeartbeat;
1037
+ h.unref?.();
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Remove this client's presence entry immediately, stopping the
1042
+ * heartbeat. Safe if the client never called `track`.
1043
+ */
1044
+ untrack() {
1045
+ this.trackedState = null;
1046
+ if (this.presenceHeartbeat) {
1047
+ clearInterval(this.presenceHeartbeat);
1048
+ this.presenceHeartbeat = null;
1049
+ }
1050
+ const socket = this.realtime._getSocket();
1051
+ socket?.emit("realtime:presence:untrack", { channel: this.topic });
1052
+ }
1053
+ /** Snapshot of the current presence state on this channel. Keys are
1054
+ * opaque client identifiers (server-assigned). Re-read inside your
1055
+ * `.on('presence', { event: 'sync' }, ...)` handler. */
1056
+ presenceState() {
1057
+ return this.presence;
1058
+ }
1059
+ /**
1060
+ * Register all bindings with the server:
1061
+ * * For each `broadcast` binding, ensure we're subscribed to the topic
1062
+ * (one `realtime:subscribe` for the channel as a whole).
1063
+ * * For each `postgres_changes` binding, emit
1064
+ * `realtime:postgres_changes:subscribe` and record the assigned
1065
+ * subscription_id.
1066
+ *
1067
+ * `statusCallback` is invoked with `'SUBSCRIBED'` when every binding
1068
+ * has ack'd, or with `'CHANNEL_ERROR' | 'TIMED_OUT'` on failure.
1069
+ */
1070
+ subscribe(statusCallback) {
1071
+ this.statusCallback = statusCallback ?? null;
1072
+ if (this.state === "joining" || this.state === "joined") {
1073
+ return this;
1074
+ }
1075
+ this.state = "joining";
1076
+ void this.realtime.connect().then(
1077
+ async () => {
1078
+ try {
1079
+ await this.registerAllBindings();
1080
+ this.state = "joined";
1081
+ this.statusCallback?.("SUBSCRIBED");
1082
+ } catch (err) {
1083
+ this.state = "errored";
1084
+ const message = err instanceof Error ? err.message : String(err);
1085
+ this.statusCallback?.(
1086
+ "CHANNEL_ERROR",
1087
+ makeChannelError("SUBSCRIBE_FAILED", message)
1088
+ );
1089
+ }
1090
+ },
1091
+ (err) => {
1092
+ this.state = "errored";
1093
+ this.statusCallback?.(
1094
+ "CHANNEL_ERROR",
1095
+ makeChannelError("CONNECT_FAILED", err.message)
1096
+ );
1097
+ }
1098
+ );
1099
+ return this;
1100
+ }
1101
+ /**
1102
+ * Tear down every binding: unsubscribe from the broadcast topic (if
1103
+ * any broadcast bindings exist) and remove every postgres_changes
1104
+ * subscription by id. Safe to call when state is already closed.
1105
+ */
1106
+ async unsubscribe() {
1107
+ if (this.state === "closed") {
1108
+ return;
1109
+ }
1110
+ const socket = this.realtime._getSocket();
1111
+ if (this.presenceHeartbeat) {
1112
+ clearInterval(this.presenceHeartbeat);
1113
+ this.presenceHeartbeat = null;
1114
+ }
1115
+ if (!socket) {
1116
+ this.trackedState = null;
1117
+ this.state = "closed";
1118
+ return;
1119
+ }
1120
+ if (this.trackedState) {
1121
+ socket.emit("realtime:presence:untrack", { channel: this.topic });
1122
+ this.trackedState = null;
1123
+ }
1124
+ const pcBindings = this.bindings.filter(
1125
+ (b) => b.type === "postgres_changes"
1126
+ );
1127
+ for (const b of pcBindings) {
1128
+ if (b.subscriptionId) {
1129
+ socket.emit("realtime:postgres_changes:unsubscribe", {
1130
+ subscription_id: b.subscriptionId
1131
+ });
1132
+ b.subscriptionId = void 0;
1133
+ }
1134
+ }
1135
+ if (this.bindings.some((b) => b.type === "broadcast" || b.type === "presence")) {
1136
+ socket.emit("realtime:unsubscribe", { channel: this.topic });
1137
+ }
1138
+ this.realtime._detachChannel(this);
1139
+ this.state = "closed";
1140
+ this.statusCallback?.("CLOSED");
1141
+ }
1142
+ /**
1143
+ * Publish a broadcast event to the topic. Broadcasts are always
1144
+ * ephemeral — the server fans out to every subscribed socket in memory,
1145
+ * with no DB persistence or webhook fan-out. For audited / durable
1146
+ * events, write to your own application table and enable
1147
+ * `postgres_changes` on it; the SDK will surface the INSERT as a
1148
+ * `postgres_changes` event without a separate channel.send call.
1149
+ *
1150
+ * The returned promise always resolves with the server ack, so callers
1151
+ * can `await channel.send(...)` to confirm delivery + get the server-
1152
+ * assigned `message_id`. There's no performance cost — Socket.IO piggy-
1153
+ * backs the ack on the same frame. Callers that don't need it just
1154
+ * don't await.
1155
+ */
1156
+ async send(args) {
1157
+ if (args.type !== "broadcast") {
1158
+ throw new MitwayBaasError(
1159
+ 'Only "broadcast" sends are supported \u2014 DB changes flow via your DB writes, not channel.send()',
1160
+ 400,
1161
+ "UNSUPPORTED_SEND_TYPE"
1162
+ );
1163
+ }
1164
+ const RESERVED_BROADCAST_NAMES = /* @__PURE__ */ new Set([
1165
+ "postgres_changes",
1166
+ "presence_state",
1167
+ "presence_join",
1168
+ "presence_leave"
1169
+ ]);
1170
+ if (RESERVED_BROADCAST_NAMES.has(args.event)) {
1171
+ throw new MitwayBaasError(
1172
+ `"${args.event}" is a reserved event name \u2014 pick a different name for broadcast events`,
1173
+ 400,
1174
+ "RESERVED_EVENT_NAME"
1175
+ );
1176
+ }
1177
+ const socket = this.realtime._getSocket();
1178
+ if (!socket) {
1179
+ throw new MitwayBaasError("Socket not connected", 503, "NOT_CONNECTED");
1180
+ }
1181
+ const self = this.options.config?.broadcast?.self;
1182
+ const wirePayload = {
1183
+ channel: this.topic,
1184
+ event: args.event,
1185
+ payload: args.payload
1186
+ };
1187
+ if (self === false) {
1188
+ wirePayload.self = false;
1189
+ }
1190
+ return await new Promise((resolve) => {
1191
+ socket.emit(
1192
+ "realtime:publish",
1193
+ wirePayload,
1194
+ (ack) => {
1195
+ resolve(ack);
1196
+ }
1197
+ );
1198
+ });
1199
+ }
1200
+ /**
1201
+ * Replay SQL-originated broadcasts on this topic since the given
1202
+ * timestamp. Delivered only to this socket (same envelope format as
1203
+ * live broadcasts; the SDK routes them through `.on('broadcast', ...)`
1204
+ * bindings just like the real-time path). Backend caps the window at
1205
+ * 24 h and the limit at 1000.
1206
+ */
1207
+ async replay(args) {
1208
+ const socket = this.realtime._getSocket();
1209
+ if (!socket) {
1210
+ throw new MitwayBaasError("Socket not connected", 503, "NOT_CONNECTED");
1211
+ }
1212
+ socket.emit("realtime:broadcast:replay", {
1213
+ channel: this.topic,
1214
+ since: args.since,
1215
+ limit: args.limit,
1216
+ private: this.isPrivate
1217
+ });
1218
+ }
1219
+ /** Internal — called by Realtime's event router on every incoming event. */
1220
+ _dispatch(event, envelope) {
1221
+ if (event === "postgres_changes") {
1222
+ const pcEvent = envelope;
1223
+ for (const b of this.bindings) {
1224
+ if (b.type !== "postgres_changes") {
1225
+ continue;
1226
+ }
1227
+ if (!b.subscriptionId || !pcEvent.ids.includes(b.subscriptionId)) {
1228
+ continue;
1229
+ }
1230
+ const matchesEvent = b.filter.event === "*" || b.filter.event === pcEvent.data.eventType;
1231
+ if (!matchesEvent) {
1232
+ continue;
1233
+ }
1234
+ try {
1235
+ b.callback(pcEvent.data);
1236
+ } catch {
1237
+ }
1238
+ }
1239
+ return;
1240
+ }
1241
+ if (event === "presence_state" || event === "presence_join" || event === "presence_leave") {
1242
+ const e = envelope;
1243
+ if (e.channel !== this.topic) {
1244
+ return;
1245
+ }
1246
+ if (event === "presence_state" && e.state) {
1247
+ this.presence = { ...e.state };
1248
+ this.firePresence({ event: "sync", state: this.presence });
1249
+ } else if (event === "presence_join" && e.joins) {
1250
+ Object.assign(this.presence, e.joins);
1251
+ this.firePresence({ event: "join", joins: e.joins });
1252
+ } else if (event === "presence_leave" && e.leaves) {
1253
+ for (const key of Object.keys(e.leaves)) {
1254
+ delete this.presence[key];
1255
+ }
1256
+ this.firePresence({ event: "leave", leaves: e.leaves });
1257
+ }
1258
+ return;
1259
+ }
1260
+ const meta = envelope.meta;
1261
+ if (meta?.timestamp) {
1262
+ if (!this.lastBroadcastTimestamp || meta.timestamp > this.lastBroadcastTimestamp) {
1263
+ this.lastBroadcastTimestamp = meta.timestamp;
1264
+ }
1265
+ }
1266
+ for (const b of this.bindings) {
1267
+ if (b.type !== "broadcast") {
1268
+ continue;
1269
+ }
1270
+ if (b.filter.event !== event) {
1271
+ continue;
1272
+ }
1273
+ const { meta: _meta, ...payload } = envelope;
1274
+ try {
1275
+ b.callback({ type: "broadcast", event, payload });
1276
+ } catch {
1277
+ }
1278
+ }
1279
+ }
1280
+ firePresence(payload) {
1281
+ for (const b of this.bindings) {
1282
+ if (b.type !== "presence") {
1283
+ continue;
1284
+ }
1285
+ if (b.filter.event !== payload.event) {
1286
+ continue;
1287
+ }
1288
+ try {
1289
+ if (payload.event === "sync") {
1290
+ b.callback();
1291
+ } else if (payload.event === "join") {
1292
+ b.callback(payload);
1293
+ } else {
1294
+ b.callback(payload);
1295
+ }
1296
+ } catch {
1297
+ }
1298
+ }
1299
+ }
1300
+ async registerAllBindings() {
1301
+ const socket = this.realtime._getSocket();
1302
+ if (!socket) {
1303
+ throw new Error("Socket not available");
1304
+ }
1305
+ const needsRoomJoin = this.bindings.some(
1306
+ (b) => b.type === "broadcast" || b.type === "presence"
1307
+ );
1308
+ if (needsRoomJoin) {
1309
+ await new Promise((resolve, reject) => {
1310
+ socket.emit(
1311
+ "realtime:subscribe",
1312
+ { channel: this.topic, private: this.isPrivate },
1313
+ (ack) => {
1314
+ if (ack.status === "ok") {
1315
+ resolve();
1316
+ } else {
1317
+ reject(new Error(ack.error?.message ?? "subscribe failed"));
1318
+ }
1319
+ }
1320
+ );
1321
+ });
1322
+ }
1323
+ const pcBindings = this.bindings.filter(
1324
+ (b) => b.type === "postgres_changes"
1325
+ );
1326
+ await Promise.all(
1327
+ pcBindings.map(
1328
+ (b) => new Promise((resolve, reject) => {
1329
+ socket.emit(
1330
+ "realtime:postgres_changes:subscribe",
1331
+ {
1332
+ event: b.filter.event,
1333
+ schema: b.filter.schema ?? "public",
1334
+ table: b.filter.table,
1335
+ filter: b.filter.filter
1336
+ },
1337
+ (ack) => {
1338
+ if (ack.status === "ok" && ack.subscription_id) {
1339
+ b.subscriptionId = ack.subscription_id;
1340
+ resolve();
1341
+ } else {
1342
+ reject(
1343
+ new Error(ack.error?.message ?? "postgres_changes subscribe failed")
1344
+ );
1345
+ }
1346
+ }
1347
+ );
1348
+ })
1349
+ )
1350
+ );
1351
+ }
1352
+ };
895
1353
  var Realtime = class {
896
1354
  socket = null;
897
1355
  baseUrl;
898
1356
  options;
899
1357
  anonKey;
900
1358
  tokenManager;
901
- listeners = /* @__PURE__ */ new Map();
902
- reserved = {
903
- connect: /* @__PURE__ */ new Set(),
904
- disconnect: /* @__PURE__ */ new Set(),
905
- connect_error: /* @__PURE__ */ new Set(),
906
- error: /* @__PURE__ */ new Set()
907
- };
1359
+ channels = /* @__PURE__ */ new Map();
908
1360
  connecting = null;
909
- subscribedChannels = /* @__PURE__ */ new Set();
1361
+ /** Flips to `true` once the initial handshake resolves. Differentiates
1362
+ * the first `connect` event (part of `openSocket`) from subsequent
1363
+ * reconnect events (which should trigger auto-resubscribe). */
1364
+ firstConnected = false;
910
1365
  constructor(baseUrl, tokenManager, anonKey, options = {}) {
911
1366
  this.baseUrl = baseUrl;
912
1367
  this.tokenManager = tokenManager;
913
1368
  this.anonKey = anonKey;
914
1369
  this.options = options;
915
1370
  }
916
- // -----------------------------------------------------------------
917
- // Connection lifecycle
918
- // -----------------------------------------------------------------
919
1371
  get isConnected() {
920
1372
  return this.socket?.connected === true;
921
1373
  }
922
1374
  get socketId() {
923
1375
  return this.socket?.id;
924
1376
  }
925
- /** Explicitly open the connection. Safe to call multiple times; only
926
- * opens one socket per instance. Subsequent calls during connection
927
- * return the same in-flight promise. */
1377
+ /**
1378
+ * Get (or create) a channel for `topic`. Channels are cached so multiple
1379
+ * `.channel('same')` calls return the same instance.
1380
+ *
1381
+ * The optional `opts` argument lets the caller configure the channel:
1382
+ * * `config.private` — enable subscribe-side authorization against
1383
+ * `realtime.authorize_subscribe(...)` on the tenant DB.
1384
+ * * `config.broadcast.self` — `false` excludes the sender from the
1385
+ * fan-out (defaults to `true`).
1386
+ * * `config.presence.key` — stable presence key to group multiple
1387
+ * tabs of the same user under one entry.
1388
+ *
1389
+ * `channel.send()` always resolves with the server ack (see its own
1390
+ * docstring); there is no separate opt-in needed.
1391
+ *
1392
+ * Options are locked in when the channel is first created; subsequent
1393
+ * `.channel('same')` calls with different opts are ignored. Pass a
1394
+ * different topic to get a different-configured channel.
1395
+ */
1396
+ channel(topic, opts) {
1397
+ const existing = this.channels.get(topic);
1398
+ if (existing) {
1399
+ return existing;
1400
+ }
1401
+ const channel = new RealtimeChannel(topic, this, opts);
1402
+ this.channels.set(topic, channel);
1403
+ return channel;
1404
+ }
928
1405
  connect() {
929
1406
  if (this.isConnected) {
930
1407
  return Promise.resolve();
@@ -935,6 +1412,28 @@ var Realtime = class {
935
1412
  this.connecting = this.openSocket();
936
1413
  return this.connecting;
937
1414
  }
1415
+ /**
1416
+ * Close the socket. Channels are left as-is so they can re-subscribe
1417
+ * on the next `connect()` — useful for auth token refresh flows.
1418
+ */
1419
+ disconnect() {
1420
+ if (!this.socket) {
1421
+ return;
1422
+ }
1423
+ this.socket.disconnect();
1424
+ this.socket = null;
1425
+ this.firstConnected = false;
1426
+ }
1427
+ // -------------------------------------------------------------------------
1428
+ // internals used by RealtimeChannel
1429
+ // -------------------------------------------------------------------------
1430
+ /* istanbul ignore next — tested via channel integration */
1431
+ _getSocket() {
1432
+ return this.socket;
1433
+ }
1434
+ _detachChannel(channel) {
1435
+ this.channels.delete(channel.topic);
1436
+ }
938
1437
  openSocket() {
939
1438
  const token = this.tokenManager.getAccessToken() ?? this.anonKey;
940
1439
  if (!token) {
@@ -956,13 +1455,22 @@ var Realtime = class {
956
1455
  });
957
1456
  this.socket = socket;
958
1457
  socket.onAny((event, ...args) => this.dispatch(event, args));
959
- socket.on("connect", () => this.emitReserved("connect"));
960
- socket.on("disconnect", (reason) => this.emitReserved("disconnect", reason));
961
- socket.on("connect_error", (err) => this.emitReserved("connect_error", err));
1458
+ socket.on("connect", () => {
1459
+ if (!this.firstConnected) {
1460
+ return;
1461
+ }
1462
+ for (const channel of this.channels.values()) {
1463
+ const state = channel._state();
1464
+ if (state === "joined" || state === "errored") {
1465
+ void channel._rejoinAfterReconnect();
1466
+ }
1467
+ }
1468
+ });
962
1469
  return new Promise((resolve, reject) => {
963
1470
  const timer = setTimeout(() => {
964
1471
  socket.off("connect", onConnect);
965
1472
  socket.off("connect_error", onConnectError);
1473
+ this.connecting = null;
966
1474
  reject(
967
1475
  new MitwayBaasError(
968
1476
  `Realtime connection timeout after ${timeoutMs}ms`,
@@ -970,7 +1478,6 @@ var Realtime = class {
970
1478
  "CONNECTION_TIMEOUT"
971
1479
  )
972
1480
  );
973
- this.connecting = null;
974
1481
  }, timeoutMs);
975
1482
  const clear = () => {
976
1483
  clearTimeout(timer);
@@ -980,128 +1487,31 @@ var Realtime = class {
980
1487
  const onConnect = () => {
981
1488
  clear();
982
1489
  this.connecting = null;
1490
+ this.firstConnected = true;
983
1491
  resolve();
984
1492
  };
985
1493
  const onConnectError = (err) => {
986
1494
  clear();
987
1495
  this.connecting = null;
988
- reject(
989
- new MitwayBaasError(err.message, 0, "CONNECTION_FAILED")
990
- );
1496
+ reject(new MitwayBaasError(err.message, 0, "CONNECTION_FAILED"));
991
1497
  };
992
1498
  socket.once("connect", onConnect);
993
1499
  socket.once("connect_error", onConnectError);
994
1500
  });
995
1501
  }
996
- /** Close the socket and clear in-memory subscription state. Reserved
997
- * listeners survive so callers can reconnect later via `connect()`. */
998
- disconnect() {
999
- if (!this.socket) {
1000
- return;
1001
- }
1002
- this.socket.disconnect();
1003
- this.socket = null;
1004
- this.subscribedChannels.clear();
1005
- }
1006
- // -----------------------------------------------------------------
1007
- // Subscribe / Unsubscribe / Publish
1008
- // -----------------------------------------------------------------
1009
- async subscribe(channel) {
1010
- await this.connect();
1011
- const socket = this.socket;
1012
- if (!socket) {
1013
- return {
1014
- ok: false,
1015
- channel,
1016
- error: { code: "NOT_CONNECTED", message: "Socket is not connected" }
1017
- };
1018
- }
1019
- const result = await new Promise((resolve) => {
1020
- socket.emit("realtime:subscribe", { channel }, (ack) => {
1021
- resolve(ack);
1022
- });
1023
- });
1024
- if (result.ok) {
1025
- this.subscribedChannels.add(channel);
1026
- }
1027
- return result;
1028
- }
1029
- /** Fire-and-forget. No ack from the server. */
1030
- unsubscribe(channel) {
1031
- this.subscribedChannels.delete(channel);
1032
- this.socket?.emit("realtime:unsubscribe", { channel });
1033
- }
1034
- /** Publish via the Socket.IO transport. Subject to RLS INSERT policy
1035
- * on `realtime.messages` (disabled by default — the developer must
1036
- * add a policy before clients can publish). Returns immediately; any
1037
- * server rejection comes through the `error` reserved event. */
1038
- publish(channel, event, payload) {
1039
- this.socket?.emit("realtime:publish", { channel, event, payload });
1040
- }
1041
- // TypeScript overload impl signature must be assignable from every
1042
- // public overload. The public overloads take arg lists of different
1043
- // shapes (ConnectionListener: 0 args, DisconnectListener: 1 string
1044
- // arg, RealtimeListener: 2 args), so the implementation uses the
1045
- // widest possible signature. This matches the pattern in socket.io
1046
- // itself and is the standard TypeScript overload idiom.
1047
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1048
- on(event, cb) {
1049
- if (isReserved(event)) {
1050
- this.reserved[event].add(cb);
1051
- return;
1052
- }
1053
- if (!this.listeners.has(event)) {
1054
- this.listeners.set(event, /* @__PURE__ */ new Set());
1055
- }
1056
- this.listeners.get(event).add(cb);
1057
- }
1058
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1059
- off(event, cb) {
1060
- if (isReserved(event)) {
1061
- this.reserved[event].delete(cb);
1062
- return;
1063
- }
1064
- this.listeners.get(event)?.delete(cb);
1065
- }
1066
- // -----------------------------------------------------------------
1067
- // Internals
1068
- // -----------------------------------------------------------------
1069
1502
  dispatch(event, args) {
1070
- if (isReserved(event)) {
1503
+ if (event === "postgres_changes") {
1504
+ const envelope2 = args[0] ?? {};
1505
+ this.channels.forEach((ch) => ch._dispatch("postgres_changes", envelope2));
1071
1506
  return;
1072
1507
  }
1073
- if (event === "realtime:error") {
1074
- const err = args[0] ?? {};
1075
- this.reserved.error.forEach(
1076
- (cb) => cb(err, {
1077
- message_id: "",
1078
- sender_type: "system",
1079
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1080
- })
1081
- );
1082
- return;
1083
- }
1084
- const set = this.listeners.get(event);
1085
- if (!set || set.size === 0) {
1508
+ if (event === "connect" || event === "disconnect" || event === "connect_error" || event === "error" || event === "realtime:error" || event === "realtime:shutdown") {
1086
1509
  return;
1087
1510
  }
1088
1511
  const envelope = args[0] ?? {};
1089
- const { meta, ...payload } = envelope;
1090
- const metaOrStub = meta ?? {
1091
- message_id: "",
1092
- sender_type: "system",
1093
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1094
- };
1095
- set.forEach((cb) => cb(payload, metaOrStub));
1096
- }
1097
- emitReserved(event, ...args) {
1098
- const set = this.reserved[event];
1099
- set.forEach((cb) => cb(...args));
1512
+ this.channels.forEach((ch) => ch._dispatch(event, envelope));
1100
1513
  }
1101
1514
  };
1102
- function isReserved(event) {
1103
- return event === "connect" || event === "disconnect" || event === "connect_error" || event === "error";
1104
- }
1105
1515
 
1106
1516
  // src/client.ts
1107
1517
  var MitwayBaasClient = class {
@@ -1146,6 +1556,7 @@ var index_default = MitwayBaasClient;
1146
1556
  MitwayBaasClient,
1147
1557
  MitwayBaasError,
1148
1558
  Realtime,
1559
+ RealtimeChannel,
1149
1560
  TokenManager,
1150
1561
  createClient
1151
1562
  });