@giaeulate/baas-sdk 1.3.0 → 1.4.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/CHANGELOG.md +12 -0
- package/dist/cli.js +32 -0
- package/dist/index.cjs +308 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -8
- package/dist/index.d.ts +51 -8
- package/dist/index.js +308 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 — 2026-07-01
|
|
4
|
+
|
|
5
|
+
Backward-compatible additions tracking the golang-baas gap-fix release (refresh tokens, realtime broadcast/presence + server-side filters, per-bucket storage caps). All existing signatures unchanged.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Auto token refresh** — `HttpClient` retries once on a 401 via `POST /api/auth/refresh` (concurrent requests share a single in-flight refresh), then replays the original request. `onForceLogout` fires only when the refresh itself fails.
|
|
10
|
+
- **Auth**: `auth.refresh()` (manual refresh), `setRefreshToken`/`getRefreshToken`; `login()` stores the refresh token and `logout()` clears both tokens.
|
|
11
|
+
- **Realtime**: `subscribe(table, action, cb, filter?)` server-side value filters (`eq/neq/gt/gte/lt/lte/in/like`, applied per-subscription — shared filter goes server-side, otherwise each subscriber filters client-side); `subscribeBroadcast(channel, cb)` + `broadcast(channel, payload)` (ephemeral, no DB/RLS); `subscribePresence(channel, state, cb)` (sync/join/leave). All re-join automatically on reconnect.
|
|
12
|
+
- **Storage**: `createBucket(name, isPublic?, maxBytes?)` and `updateBucket(id, { isPublic?, maxBytes? })` per-bucket upload caps; `StorageBucket.max_bytes`.
|
|
13
|
+
- **CLI**: `baas gen types [--out file]` generates TypeScript interfaces from the live schema (`GET /api/schemas/types`).
|
|
14
|
+
|
|
3
15
|
## 1.3.0 — 2026-06-04
|
|
4
16
|
|
|
5
17
|
Backward-compatible additions + two dead-route fixes. Validated end-to-end (33 live checks) against a running golang-baas server.
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
5
|
+
|
|
3
6
|
// src/cli/config.ts
|
|
4
7
|
import { readFileSync, existsSync } from "fs";
|
|
5
8
|
import { resolve } from "path";
|
|
@@ -297,8 +300,10 @@ Usage:
|
|
|
297
300
|
baas db list Alias of status
|
|
298
301
|
baas db push Apply pending migrations and record them
|
|
299
302
|
baas db baseline Mark existing files as applied without running them
|
|
303
|
+
baas gen types Generate TypeScript types from the live schema
|
|
300
304
|
|
|
301
305
|
Options:
|
|
306
|
+
--out <file> gen types: write to a file instead of stdout
|
|
302
307
|
--dir <path> Migrations directory (default: cwd or BAAS_MIGRATIONS_DIR)
|
|
303
308
|
--url <url> BaaS URL (default: BAAS_URL / .env)
|
|
304
309
|
--token <key> API key (default: BAAS_API_KEY / .env)
|
|
@@ -332,6 +337,33 @@ async function main() {
|
|
|
332
337
|
console.log(HELP);
|
|
333
338
|
return;
|
|
334
339
|
}
|
|
340
|
+
if (group === "gen") {
|
|
341
|
+
if (sub !== "types") {
|
|
342
|
+
console.error(`Unknown gen subcommand: ${sub ?? "(none)"}
|
|
343
|
+
`);
|
|
344
|
+
console.log(HELP);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
const cfg2 = resolveConfig(argv);
|
|
348
|
+
if (!cfg2.baasUrl || !cfg2.token) {
|
|
349
|
+
console.error("ERROR: missing BaaS URL or token (--url/--token, env, or .env).");
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
353
|
+
const res = await fetch(`${cfg2.baasUrl.replace(/\/$/, "")}/api/schemas/types`, {
|
|
354
|
+
headers: { Authorization: `Bearer ${cfg2.token}` }
|
|
355
|
+
});
|
|
356
|
+
const text = await res.text();
|
|
357
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
|
|
358
|
+
const out = getArg(argv, "out");
|
|
359
|
+
if (out) {
|
|
360
|
+
writeFileSync2(out, text);
|
|
361
|
+
console.error(`Wrote ${out}`);
|
|
362
|
+
} else {
|
|
363
|
+
process.stdout.write(text);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
335
367
|
if (group !== "db") {
|
|
336
368
|
console.error(`Unknown command: ${group}
|
|
337
369
|
`);
|
package/dist/index.cjs
CHANGED
|
@@ -32,8 +32,10 @@ var HttpClient = class {
|
|
|
32
32
|
apiKey = "";
|
|
33
33
|
keyType;
|
|
34
34
|
token = null;
|
|
35
|
+
refreshToken = null;
|
|
35
36
|
environment = "prod";
|
|
36
37
|
_onForceLogout = null;
|
|
38
|
+
_refreshing = null;
|
|
37
39
|
constructor(url, apiKey, options) {
|
|
38
40
|
let baseUrl = url || (typeof window !== "undefined" ? window.location.origin : "http://localhost:8080");
|
|
39
41
|
this.url = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
@@ -41,6 +43,7 @@ var HttpClient = class {
|
|
|
41
43
|
this.keyType = options?.keyType ?? "service_role";
|
|
42
44
|
this.warnIfUnsafeKey();
|
|
43
45
|
this.token = typeof localStorage !== "undefined" ? this.cleanValue(localStorage.getItem("baas_token")) : null;
|
|
46
|
+
this.refreshToken = typeof localStorage !== "undefined" ? this.cleanValue(localStorage.getItem("baas_refresh_token")) : null;
|
|
44
47
|
}
|
|
45
48
|
/** SECURITY: a service_role key bypasses RLS and grants admin over every
|
|
46
49
|
* tenant. If it ends up in a browser bundle, anyone can extract it. Warn
|
|
@@ -59,12 +62,59 @@ var HttpClient = class {
|
|
|
59
62
|
*/
|
|
60
63
|
setToken(token) {
|
|
61
64
|
this.token = token;
|
|
65
|
+
if (typeof localStorage === "undefined") return;
|
|
62
66
|
if (token) {
|
|
63
67
|
localStorage.setItem("baas_token", token);
|
|
64
68
|
} else {
|
|
65
69
|
localStorage.removeItem("baas_token");
|
|
66
70
|
}
|
|
67
71
|
}
|
|
72
|
+
/** Persist the rotating refresh token (used by auto-refresh on 401). In the
|
|
73
|
+
* browser the server also sets an HttpOnly `baas_refresh` cookie, so storing
|
|
74
|
+
* it here is mainly for non-cookie clients (React Native). */
|
|
75
|
+
setRefreshToken(token) {
|
|
76
|
+
this.refreshToken = token;
|
|
77
|
+
if (typeof localStorage === "undefined") return;
|
|
78
|
+
if (token) {
|
|
79
|
+
localStorage.setItem("baas_refresh_token", token);
|
|
80
|
+
} else {
|
|
81
|
+
localStorage.removeItem("baas_refresh_token");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
getRefreshToken() {
|
|
85
|
+
const ls = typeof localStorage !== "undefined" ? localStorage.getItem("baas_refresh_token") : null;
|
|
86
|
+
return this.cleanValue(this.refreshToken || ls);
|
|
87
|
+
}
|
|
88
|
+
/** Attempts a single token refresh, deduped across concurrent 401s. Sends the
|
|
89
|
+
* stored refresh token (falls back to the HttpOnly cookie in browsers). */
|
|
90
|
+
tryRefresh() {
|
|
91
|
+
if (!this._refreshing) {
|
|
92
|
+
this._refreshing = this.doRefresh().finally(() => {
|
|
93
|
+
this._refreshing = null;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return this._refreshing;
|
|
97
|
+
}
|
|
98
|
+
async doRefresh() {
|
|
99
|
+
try {
|
|
100
|
+
const body = {};
|
|
101
|
+
const rt = this.getRefreshToken();
|
|
102
|
+
if (rt) body.refresh_token = rt;
|
|
103
|
+
const res = await fetch(`${this.url}/api/auth/refresh`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json", apikey: this.apiKey },
|
|
106
|
+
credentials: "include",
|
|
107
|
+
body: JSON.stringify(body)
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) return false;
|
|
110
|
+
const data = await res.json().catch(() => null);
|
|
111
|
+
if (data?.token) this.setToken(data.token);
|
|
112
|
+
if (data?.refresh_token) this.setRefreshToken(data.refresh_token);
|
|
113
|
+
return Boolean(data?.token);
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
68
118
|
/**
|
|
69
119
|
* Register a callback invoked on forced logout (401).
|
|
70
120
|
* Use this in React Native instead of the default window.location redirect.
|
|
@@ -105,7 +155,7 @@ var HttpClient = class {
|
|
|
105
155
|
/**
|
|
106
156
|
* Core HTTP request method - DRY principle
|
|
107
157
|
*/
|
|
108
|
-
async request(endpoint, options = {}) {
|
|
158
|
+
async request(endpoint, options = {}, _isRetry = false) {
|
|
109
159
|
const { method = "GET", body, headers: customHeaders, skipAuth = false } = options;
|
|
110
160
|
const headers = { ...this.getHeaders(), ...customHeaders };
|
|
111
161
|
const fetchOptions = {
|
|
@@ -123,6 +173,9 @@ var HttpClient = class {
|
|
|
123
173
|
const data = await res.json().catch(() => null);
|
|
124
174
|
if (!res.ok) {
|
|
125
175
|
if (res.status === 401 && !skipAuth) {
|
|
176
|
+
if (!_isRetry && await this.tryRefresh()) {
|
|
177
|
+
return this.request(endpoint, options, true);
|
|
178
|
+
}
|
|
126
179
|
this.forceLogout();
|
|
127
180
|
}
|
|
128
181
|
if (res.status === 403 && data?.error === "ip_not_allowed") {
|
|
@@ -167,7 +220,11 @@ var HttpClient = class {
|
|
|
167
220
|
}
|
|
168
221
|
logout() {
|
|
169
222
|
this.token = null;
|
|
170
|
-
|
|
223
|
+
this.refreshToken = null;
|
|
224
|
+
if (typeof localStorage !== "undefined") {
|
|
225
|
+
localStorage.removeItem("baas_token");
|
|
226
|
+
localStorage.removeItem("baas_refresh_token");
|
|
227
|
+
}
|
|
171
228
|
}
|
|
172
229
|
forceLogout() {
|
|
173
230
|
this.logout();
|
|
@@ -408,17 +465,27 @@ function createAuthModule(client) {
|
|
|
408
465
|
throw new Error(data?.error || `Login failed: ${res.status}`);
|
|
409
466
|
}
|
|
410
467
|
if (data.token) {
|
|
411
|
-
client.
|
|
412
|
-
localStorage.setItem("baas_token", data.token);
|
|
468
|
+
client.setToken(data.token);
|
|
413
469
|
}
|
|
470
|
+
if (data.refresh_token) {
|
|
471
|
+
client.setRefreshToken(data.refresh_token);
|
|
472
|
+
}
|
|
473
|
+
return data;
|
|
474
|
+
},
|
|
475
|
+
/** Manually refresh the access token via the rotating refresh token. The
|
|
476
|
+
* HttpClient also does this automatically on a 401. */
|
|
477
|
+
async refresh() {
|
|
478
|
+
const rt = client.refreshToken;
|
|
479
|
+
const data = await post("/api/auth/refresh", rt ? { refresh_token: rt } : {});
|
|
480
|
+
if (data?.token) client.setToken(data.token);
|
|
481
|
+
if (data?.refresh_token) client.setRefreshToken(data.refresh_token);
|
|
414
482
|
return data;
|
|
415
483
|
},
|
|
416
484
|
async logout() {
|
|
417
485
|
try {
|
|
418
486
|
await post("/api/logout");
|
|
419
487
|
} finally {
|
|
420
|
-
client.
|
|
421
|
-
localStorage.removeItem("baas_token");
|
|
488
|
+
client.logout();
|
|
422
489
|
}
|
|
423
490
|
},
|
|
424
491
|
// Anonymous sign-in (Supabase parity). Response carries access_token (+ a
|
|
@@ -703,11 +770,11 @@ function createStorageModule(client) {
|
|
|
703
770
|
async deleteFile(fileId) {
|
|
704
771
|
return del(`/api/storage/files/${fileId}`);
|
|
705
772
|
},
|
|
706
|
-
async createBucket(name, isPublic = false) {
|
|
707
|
-
return post("/api/storage/buckets", { name, is_public: isPublic });
|
|
773
|
+
async createBucket(name, isPublic = false, maxBytes) {
|
|
774
|
+
return post("/api/storage/buckets", { name, is_public: isPublic, max_bytes: maxBytes });
|
|
708
775
|
},
|
|
709
776
|
async updateBucket(bucketId, opts) {
|
|
710
|
-
return put(`/api/storage/buckets/${bucketId}`, { is_public: opts.isPublic });
|
|
777
|
+
return put(`/api/storage/buckets/${bucketId}`, { is_public: opts.isPublic, max_bytes: opts.maxBytes });
|
|
711
778
|
},
|
|
712
779
|
async listBuckets() {
|
|
713
780
|
return get("/api/storage/buckets");
|
|
@@ -924,9 +991,18 @@ function createEmailModule(client) {
|
|
|
924
991
|
async getConfig() {
|
|
925
992
|
return get("/api/email/config");
|
|
926
993
|
},
|
|
994
|
+
async listConfigs() {
|
|
995
|
+
return get("/api/email/configs");
|
|
996
|
+
},
|
|
927
997
|
async saveConfig(config) {
|
|
928
998
|
return post("/api/email/config", config);
|
|
929
999
|
},
|
|
1000
|
+
async setDefaultConfig(id) {
|
|
1001
|
+
return post(`/api/email/configs/${id}/default`);
|
|
1002
|
+
},
|
|
1003
|
+
async deleteConfig(id) {
|
|
1004
|
+
return del(`/api/email/configs/${id}`);
|
|
1005
|
+
},
|
|
930
1006
|
async createTemplate(template) {
|
|
931
1007
|
return post("/api/email/templates", template);
|
|
932
1008
|
},
|
|
@@ -1078,6 +1154,9 @@ function createAuditModule(client) {
|
|
|
1078
1154
|
},
|
|
1079
1155
|
async purge(olderThanDays) {
|
|
1080
1156
|
return del(`/api/audit/purge?older_than_days=${olderThanDays}`);
|
|
1157
|
+
},
|
|
1158
|
+
async clearAll() {
|
|
1159
|
+
return del(`/api/audit/clear`);
|
|
1081
1160
|
}
|
|
1082
1161
|
};
|
|
1083
1162
|
}
|
|
@@ -1178,6 +1257,16 @@ var RealtimeService = class {
|
|
|
1178
1257
|
}
|
|
1179
1258
|
socket = null;
|
|
1180
1259
|
subscribers = /* @__PURE__ */ new Map();
|
|
1260
|
+
// The server-side value filter currently REGISTERED per table (e.g.
|
|
1261
|
+
// { status: 'eq.pending' }). The server keeps one filter per (connection,table),
|
|
1262
|
+
// so this is only set when every subscriber on the table shares the same filter;
|
|
1263
|
+
// otherwise the table runs unfiltered server-side and each subscriber's filter is
|
|
1264
|
+
// applied client-side in handleEvent (see computeServerFilter).
|
|
1265
|
+
filters = /* @__PURE__ */ new Map();
|
|
1266
|
+
// Ephemeral Broadcast / Presence channel handlers.
|
|
1267
|
+
broadcastHandlers = /* @__PURE__ */ new Map();
|
|
1268
|
+
presenceHandlers = /* @__PURE__ */ new Map();
|
|
1269
|
+
presenceStates = /* @__PURE__ */ new Map();
|
|
1181
1270
|
reconnectAttempts = 0;
|
|
1182
1271
|
maxReconnectAttempts = 5;
|
|
1183
1272
|
reconnectInterval = 3e3;
|
|
@@ -1206,6 +1295,12 @@ var RealtimeService = class {
|
|
|
1206
1295
|
for (const table of this.subscribers.keys()) {
|
|
1207
1296
|
this.sendSub("subscribe", table);
|
|
1208
1297
|
}
|
|
1298
|
+
for (const channel of this.broadcastHandlers.keys()) {
|
|
1299
|
+
this.send({ event: "broadcast_subscribe", channel });
|
|
1300
|
+
}
|
|
1301
|
+
for (const channel of this.presenceHandlers.keys()) {
|
|
1302
|
+
this.send({ event: "presence_subscribe", channel, state: this.presenceStates.get(channel) ?? {} });
|
|
1303
|
+
}
|
|
1209
1304
|
resolve();
|
|
1210
1305
|
};
|
|
1211
1306
|
socket.onmessage = (event) => {
|
|
@@ -1241,17 +1336,15 @@ var RealtimeService = class {
|
|
|
1241
1336
|
/**
|
|
1242
1337
|
* Subscribe to changes on a specific table
|
|
1243
1338
|
*/
|
|
1244
|
-
async subscribe(table, action, callback) {
|
|
1339
|
+
async subscribe(table, action, callback, filter) {
|
|
1245
1340
|
await this.connect();
|
|
1246
1341
|
const isNewTable = !this.subscribers.has(table);
|
|
1247
1342
|
if (isNewTable) {
|
|
1248
1343
|
this.subscribers.set(table, /* @__PURE__ */ new Set());
|
|
1249
1344
|
}
|
|
1250
|
-
const sub = { action, callback };
|
|
1345
|
+
const sub = { action, callback, filter: filter && Object.keys(filter).length > 0 ? filter : void 0 };
|
|
1251
1346
|
this.subscribers.get(table).add(sub);
|
|
1252
|
-
if (
|
|
1253
|
-
this.sendSub("subscribe", table);
|
|
1254
|
-
}
|
|
1347
|
+
if (table !== "*") this.syncServerFilter(table, isNewTable);
|
|
1255
1348
|
return {
|
|
1256
1349
|
unsubscribe: () => {
|
|
1257
1350
|
const tableSubs = this.subscribers.get(table);
|
|
@@ -1259,39 +1352,194 @@ var RealtimeService = class {
|
|
|
1259
1352
|
tableSubs.delete(sub);
|
|
1260
1353
|
if (tableSubs.size === 0) {
|
|
1261
1354
|
this.subscribers.delete(table);
|
|
1355
|
+
this.filters.delete(table);
|
|
1262
1356
|
if (table !== "*") this.sendSub("unsubscribe", table);
|
|
1357
|
+
} else if (table !== "*") {
|
|
1358
|
+
this.syncServerFilter(table, false);
|
|
1263
1359
|
}
|
|
1264
1360
|
}
|
|
1265
1361
|
}
|
|
1266
1362
|
};
|
|
1267
1363
|
}
|
|
1268
|
-
/**
|
|
1364
|
+
/**
|
|
1365
|
+
* Recompute the effective server-side filter for a table and (re)register it
|
|
1366
|
+
* when it changed (or when force is set, e.g. a brand-new table). The server
|
|
1367
|
+
* stores exactly one filter per (connection, table): a plain `subscribe`
|
|
1368
|
+
* clears it, a filtered `subscribe` replaces it.
|
|
1369
|
+
*/
|
|
1370
|
+
syncServerFilter(table, force) {
|
|
1371
|
+
const desired = this.computeServerFilter(table);
|
|
1372
|
+
const current = this.filters.get(table);
|
|
1373
|
+
const changed = JSON.stringify(desired ?? null) !== JSON.stringify(current ?? null);
|
|
1374
|
+
if (!force && !changed) return;
|
|
1375
|
+
if (desired) this.filters.set(table, desired);
|
|
1376
|
+
else this.filters.delete(table);
|
|
1377
|
+
this.sendSub("subscribe", table);
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* The server-side filter safe to apply for a table: the shared filter iff
|
|
1381
|
+
* EVERY subscriber requested the identical (non-empty) filter. If any
|
|
1382
|
+
* subscriber is unfiltered or filters conflict, returns undefined so the
|
|
1383
|
+
* server delivers all rows and each subscriber filters client-side.
|
|
1384
|
+
*/
|
|
1385
|
+
computeServerFilter(table) {
|
|
1386
|
+
const subs = this.subscribers.get(table);
|
|
1387
|
+
if (!subs || subs.size === 0) return void 0;
|
|
1388
|
+
let chosen;
|
|
1389
|
+
for (const sub of subs) {
|
|
1390
|
+
const key = sub.filter && Object.keys(sub.filter).length > 0 ? JSON.stringify(sub.filter) : "";
|
|
1391
|
+
if (key === "") return void 0;
|
|
1392
|
+
if (chosen === void 0) chosen = key;
|
|
1393
|
+
else if (chosen !== key) return void 0;
|
|
1394
|
+
}
|
|
1395
|
+
return chosen ? JSON.parse(chosen) : void 0;
|
|
1396
|
+
}
|
|
1397
|
+
/** Send a subscribe/unsubscribe control frame to the server (with any filter). */
|
|
1269
1398
|
sendSub(event, table) {
|
|
1399
|
+
const frame = { event, table };
|
|
1400
|
+
if (event === "subscribe") {
|
|
1401
|
+
const filter = this.filters.get(table);
|
|
1402
|
+
if (filter) frame.filter = filter;
|
|
1403
|
+
}
|
|
1404
|
+
this.send(frame);
|
|
1405
|
+
}
|
|
1406
|
+
/** Send an arbitrary control frame if the socket is open. */
|
|
1407
|
+
send(frame) {
|
|
1270
1408
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
1271
|
-
this.socket.send(JSON.stringify(
|
|
1409
|
+
this.socket.send(JSON.stringify(frame));
|
|
1272
1410
|
}
|
|
1273
1411
|
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Subscribe to an ephemeral Broadcast channel (no DB, no RLS — UI sync/chat).
|
|
1414
|
+
*/
|
|
1415
|
+
async subscribeBroadcast(channel, callback) {
|
|
1416
|
+
await this.connect();
|
|
1417
|
+
const isNew = !this.broadcastHandlers.has(channel);
|
|
1418
|
+
if (isNew) this.broadcastHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
1419
|
+
const handlers = this.broadcastHandlers.get(channel);
|
|
1420
|
+
handlers.add(callback);
|
|
1421
|
+
if (isNew) this.send({ event: "broadcast_subscribe", channel });
|
|
1422
|
+
return {
|
|
1423
|
+
unsubscribe: () => {
|
|
1424
|
+
handlers.delete(callback);
|
|
1425
|
+
if (handlers.size === 0) {
|
|
1426
|
+
this.broadcastHandlers.delete(channel);
|
|
1427
|
+
this.send({ event: "broadcast_unsubscribe", channel });
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
/** Publish a message to a Broadcast channel. */
|
|
1433
|
+
broadcast(channel, payload) {
|
|
1434
|
+
this.send({ event: "broadcast", channel, payload });
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Join a Presence channel. The callback receives sync/join/leave events with
|
|
1438
|
+
* the current member list. Returns a leave() handle.
|
|
1439
|
+
*/
|
|
1440
|
+
async subscribePresence(channel, state, callback) {
|
|
1441
|
+
await this.connect();
|
|
1442
|
+
const isNew = !this.presenceHandlers.has(channel);
|
|
1443
|
+
if (isNew) this.presenceHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
1444
|
+
this.presenceStates.set(channel, state ?? {});
|
|
1445
|
+
const handlers = this.presenceHandlers.get(channel);
|
|
1446
|
+
handlers.add(callback);
|
|
1447
|
+
if (isNew) this.send({ event: "presence_subscribe", channel, state: state ?? {} });
|
|
1448
|
+
return {
|
|
1449
|
+
leave: () => {
|
|
1450
|
+
handlers.delete(callback);
|
|
1451
|
+
if (handlers.size === 0) {
|
|
1452
|
+
this.presenceHandlers.delete(channel);
|
|
1453
|
+
this.presenceStates.delete(channel);
|
|
1454
|
+
this.send({ event: "presence_unsubscribe", channel });
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1274
1459
|
/**
|
|
1275
1460
|
* Handle incoming CDC events from the server
|
|
1276
1461
|
*/
|
|
1277
1462
|
handleEvent(payload) {
|
|
1278
|
-
const
|
|
1279
|
-
if (
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1463
|
+
const kind = payload.type;
|
|
1464
|
+
if (kind === "broadcast") {
|
|
1465
|
+
const handlers = this.broadcastHandlers.get(payload.channel);
|
|
1466
|
+
if (handlers) for (const cb of handlers) cb(payload.payload);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (kind === "presence") {
|
|
1470
|
+
const handlers = this.presenceHandlers.get(payload.channel);
|
|
1471
|
+
if (handlers) for (const cb of handlers) cb(payload);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
this.dispatchToSubs(this.subscribers.get(payload.table), payload);
|
|
1475
|
+
this.dispatchToSubs(this.subscribers.get("*"), payload);
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Fire matching subscribers. Each subscriber's own filter is re-checked
|
|
1479
|
+
* client-side: when subscribers on a table disagree, the server delivers all
|
|
1480
|
+
* rows unfiltered, so client-side filtering is what keeps each callback scoped.
|
|
1481
|
+
*/
|
|
1482
|
+
dispatchToSubs(subs, payload) {
|
|
1483
|
+
if (!subs) return;
|
|
1484
|
+
for (const sub of subs) {
|
|
1485
|
+
if (sub.action !== "*" && sub.action.toLowerCase() !== payload.action.toLowerCase()) continue;
|
|
1486
|
+
if (sub.filter && !this.matchFilter(payload.record, sub.filter)) continue;
|
|
1487
|
+
sub.callback(payload);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
/** Evaluate a PostgREST-style filter against a record (mirrors hub_filter.go). */
|
|
1491
|
+
matchFilter(record, filter) {
|
|
1492
|
+
if (record == null) return false;
|
|
1493
|
+
for (const col of Object.keys(filter)) {
|
|
1494
|
+
const spec = filter[col];
|
|
1495
|
+
let op = "eq";
|
|
1496
|
+
let val = spec;
|
|
1497
|
+
const i = spec.indexOf(".");
|
|
1498
|
+
if (i > 0) {
|
|
1499
|
+
const cand = spec.slice(0, i);
|
|
1500
|
+
if (["eq", "neq", "gt", "gte", "lt", "lte", "in", "like"].includes(cand)) {
|
|
1501
|
+
op = cand;
|
|
1502
|
+
val = spec.slice(i + 1);
|
|
1283
1503
|
}
|
|
1284
1504
|
}
|
|
1505
|
+
const actualRaw = record[col];
|
|
1506
|
+
if (actualRaw === void 0) return false;
|
|
1507
|
+
const actual = actualRaw === null ? "" : String(actualRaw);
|
|
1508
|
+
if (!this.evalPred(op, actual, val)) return false;
|
|
1285
1509
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1510
|
+
return true;
|
|
1511
|
+
}
|
|
1512
|
+
evalPred(op, actual, val) {
|
|
1513
|
+
switch (op) {
|
|
1514
|
+
case "eq":
|
|
1515
|
+
return actual === val;
|
|
1516
|
+
case "neq":
|
|
1517
|
+
return actual !== val;
|
|
1518
|
+
case "in":
|
|
1519
|
+
return val.split(",").includes(actual);
|
|
1520
|
+
case "like":
|
|
1521
|
+
return this.likeMatch(actual, val);
|
|
1522
|
+
case "gt":
|
|
1523
|
+
case "gte":
|
|
1524
|
+
case "lt":
|
|
1525
|
+
case "lte": {
|
|
1526
|
+
const a = parseFloat(actual);
|
|
1527
|
+
const b = parseFloat(val);
|
|
1528
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
1529
|
+
if (op === "gt") return a > b;
|
|
1530
|
+
if (op === "gte") return a >= b;
|
|
1531
|
+
if (op === "lt") return a < b;
|
|
1532
|
+
return a <= b;
|
|
1292
1533
|
}
|
|
1534
|
+
default:
|
|
1535
|
+
return false;
|
|
1293
1536
|
}
|
|
1294
1537
|
}
|
|
1538
|
+
/** SQL LIKE with '%' (any run) and '_' (any single char), anchored, case-sensitive. */
|
|
1539
|
+
likeMatch(s, pattern) {
|
|
1540
|
+
const re = "^" + pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".") + "$";
|
|
1541
|
+
return new RegExp(re).test(s);
|
|
1542
|
+
}
|
|
1295
1543
|
/**
|
|
1296
1544
|
* Attempt to reconnect on connection loss
|
|
1297
1545
|
*/
|
|
@@ -1329,8 +1577,17 @@ function createRealtimeModule(client) {
|
|
|
1329
1577
|
typeof WebSocket !== "undefined" ? WebSocket : void 0
|
|
1330
1578
|
);
|
|
1331
1579
|
return {
|
|
1332
|
-
async subscribe(table, action, callback) {
|
|
1333
|
-
return service.subscribe(table, action, callback);
|
|
1580
|
+
async subscribe(table, action, callback, filter) {
|
|
1581
|
+
return service.subscribe(table, action, callback, filter);
|
|
1582
|
+
},
|
|
1583
|
+
async subscribeBroadcast(channel, callback) {
|
|
1584
|
+
return service.subscribeBroadcast(channel, callback);
|
|
1585
|
+
},
|
|
1586
|
+
broadcast(channel, payload) {
|
|
1587
|
+
service.broadcast(channel, payload);
|
|
1588
|
+
},
|
|
1589
|
+
async subscribePresence(channel, state, callback) {
|
|
1590
|
+
return service.subscribePresence(channel, state, callback);
|
|
1334
1591
|
}
|
|
1335
1592
|
};
|
|
1336
1593
|
}
|
|
@@ -1353,6 +1610,9 @@ function createApiKeysModule(client) {
|
|
|
1353
1610
|
async delete(keyId) {
|
|
1354
1611
|
return del(`/api/api-keys/${keyId}`);
|
|
1355
1612
|
},
|
|
1613
|
+
async setBrand(keyId, emailConfigId) {
|
|
1614
|
+
return post(`/api/api-keys/${keyId}/brand`, { email_config_id: emailConfigId });
|
|
1615
|
+
},
|
|
1356
1616
|
async getInstanceToken() {
|
|
1357
1617
|
return get("/api/api-keys/instance");
|
|
1358
1618
|
},
|
|
@@ -1671,8 +1931,8 @@ var BaasClient = class extends HttpClient {
|
|
|
1671
1931
|
async deleteStorageFile(fileId) {
|
|
1672
1932
|
return this.storage.deleteFile(fileId);
|
|
1673
1933
|
}
|
|
1674
|
-
async createStorageBucket(name, isPublic) {
|
|
1675
|
-
return this.storage.createBucket(name, isPublic);
|
|
1934
|
+
async createStorageBucket(name, isPublic, maxBytes) {
|
|
1935
|
+
return this.storage.createBucket(name, isPublic, maxBytes);
|
|
1676
1936
|
}
|
|
1677
1937
|
async listStorageBuckets() {
|
|
1678
1938
|
return this.storage.listBuckets();
|
|
@@ -1703,6 +1963,9 @@ var BaasClient = class extends HttpClient {
|
|
|
1703
1963
|
async deleteApiKey(keyId) {
|
|
1704
1964
|
return this.apiKeys.delete(keyId);
|
|
1705
1965
|
}
|
|
1966
|
+
async setApiKeyBrand(keyId, emailConfigId) {
|
|
1967
|
+
return this.apiKeys.setBrand(keyId, emailConfigId);
|
|
1968
|
+
}
|
|
1706
1969
|
async getInstanceToken() {
|
|
1707
1970
|
return this.apiKeys.getInstanceToken();
|
|
1708
1971
|
}
|
|
@@ -1777,9 +2040,18 @@ var BaasClient = class extends HttpClient {
|
|
|
1777
2040
|
async getEmailConfig() {
|
|
1778
2041
|
return this.email.getConfig();
|
|
1779
2042
|
}
|
|
2043
|
+
async listEmailConfigs() {
|
|
2044
|
+
return this.email.listConfigs();
|
|
2045
|
+
}
|
|
1780
2046
|
async saveEmailConfig(config) {
|
|
1781
2047
|
return this.email.saveConfig(config);
|
|
1782
2048
|
}
|
|
2049
|
+
async setDefaultEmailConfig(id) {
|
|
2050
|
+
return this.email.setDefaultConfig(id);
|
|
2051
|
+
}
|
|
2052
|
+
async deleteEmailConfig(id) {
|
|
2053
|
+
return this.email.deleteConfig(id);
|
|
2054
|
+
}
|
|
1783
2055
|
async createEmailTemplate(template) {
|
|
1784
2056
|
return this.email.createTemplate(template);
|
|
1785
2057
|
}
|
|
@@ -1928,9 +2200,12 @@ var BaasClient = class extends HttpClient {
|
|
|
1928
2200
|
async purgeAuditLogs(olderThanDays) {
|
|
1929
2201
|
return this.audit.purge(olderThanDays);
|
|
1930
2202
|
}
|
|
2203
|
+
async clearAllAuditLogs() {
|
|
2204
|
+
return this.audit.clearAll();
|
|
2205
|
+
}
|
|
1931
2206
|
// Realtime shortcuts
|
|
1932
|
-
subscribe(table, action, callback) {
|
|
1933
|
-
return this.realtime.subscribe(table, action, callback);
|
|
2207
|
+
subscribe(table, action, callback, filter) {
|
|
2208
|
+
return this.realtime.subscribe(table, action, callback, filter);
|
|
1934
2209
|
}
|
|
1935
2210
|
// Environment shortcuts
|
|
1936
2211
|
async getEnvironmentStatus() {
|