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