postbase 0.1.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.
Files changed (126) hide show
  1. package/.github/workflows/test.yml +74 -0
  2. package/CLA.md +60 -0
  3. package/CONTRIBUTORS.md +35 -0
  4. package/LICENSE +661 -0
  5. package/README.md +211 -0
  6. package/admin/404.html +33 -0
  7. package/admin/README.md +21 -0
  8. package/admin/index.html +15 -0
  9. package/admin/jsconfig.json +20 -0
  10. package/admin/lib/postbase.js +222 -0
  11. package/admin/package-lock.json +3746 -0
  12. package/admin/package.json +27 -0
  13. package/admin/public/assets/img/admin-ui.png +0 -0
  14. package/admin/public/assets/img/blank-profile-picture-960_720.webp +0 -0
  15. package/admin/public/assets/img/chart-active-users.png +0 -0
  16. package/admin/public/assets/img/icon-transparent.png +0 -0
  17. package/admin/src/App.jsx +48 -0
  18. package/admin/src/auth.js +11 -0
  19. package/admin/src/common/formatDateTime.js +18 -0
  20. package/admin/src/components/AuthPanel.jsx +88 -0
  21. package/admin/src/components/Header.jsx +67 -0
  22. package/admin/src/main.jsx +6 -0
  23. package/admin/src/pages/Dashboard.jsx +24 -0
  24. package/admin/src/pages/Home.jsx +52 -0
  25. package/admin/src/pages/Login.jsx +10 -0
  26. package/admin/src/pages/authentication/Users.jsx +199 -0
  27. package/admin/src/pages/firestore/Database.jsx +29 -0
  28. package/admin/src/pages/storage/files.jsx +29 -0
  29. package/admin/src/postbase.js +15 -0
  30. package/admin/src/styles.css +3 -0
  31. package/admin/tailwind.config.cjs +11 -0
  32. package/admin/template.env +2 -0
  33. package/admin/vite.config.js +21 -0
  34. package/assets/img/HomePageScreenshot.png +0 -0
  35. package/assets/img/better-auth-logo-dark.136b122f.png +0 -0
  36. package/assets/img/better-auth-logo-light.4b03f444.png +0 -0
  37. package/assets/img/expresjs.png +0 -0
  38. package/assets/img/icon-transparent.png +0 -0
  39. package/assets/img/icon.png +0 -0
  40. package/assets/img/letsencrypt-logo-horizontal.png +0 -0
  41. package/assets/img/logo.png +0 -0
  42. package/assets/img/node.js_logo.png +0 -0
  43. package/assets/img/nodejsLight.svg +39 -0
  44. package/assets/img/postgres.png +0 -0
  45. package/backend/README.md +49 -0
  46. package/backend/admin/auth.js +9 -0
  47. package/backend/app.js +68 -0
  48. package/backend/auth.js +92 -0
  49. package/backend/env.js +12 -0
  50. package/backend/lib/postbase/adminClient.js +520 -0
  51. package/backend/lib/postbase/compat/admin.js +44 -0
  52. package/backend/lib/postbase/db.js +17 -0
  53. package/backend/lib/postbase/genericRouter.js +603 -0
  54. package/backend/lib/postbase/local-storage.js +56 -0
  55. package/backend/lib/postbase/metadataCache.js +32 -0
  56. package/backend/lib/postbase/middlewares/auth.js +57 -0
  57. package/backend/lib/postbase/migrations/1765239687559_rtdb-nodes.js +93 -0
  58. package/backend/lib/postbase/package-lock.json +5873 -0
  59. package/backend/lib/postbase/package.json +19 -0
  60. package/backend/lib/postbase/rtdb/router.js +190 -0
  61. package/backend/lib/postbase/rtdb/rulesEngine.js +63 -0
  62. package/backend/lib/postbase/rtdb/ws.js +84 -0
  63. package/backend/lib/postbase/rulesEngine.js +62 -0
  64. package/backend/lib/postbase/storage.js +130 -0
  65. package/backend/lib/postbase/tests/README.md +22 -0
  66. package/backend/lib/postbase/tests/db.js +9 -0
  67. package/backend/lib/postbase/tests/rtdb.rest.test.js +46 -0
  68. package/backend/lib/postbase/tests/rtdb.ws.test.js +113 -0
  69. package/backend/lib/postbase/tests/rules.js +26 -0
  70. package/backend/lib/postbase/tests/testServer.js +46 -0
  71. package/backend/lib/postbase/websocket.js +131 -0
  72. package/backend/local.js +6 -0
  73. package/backend/main.js +20 -0
  74. package/backend/middlewares/auth_middleware.js +10 -0
  75. package/backend/migrations/1762137399366-init.sql +98 -0
  76. package/backend/migrations/1762137399367_init_jsonb_schema.js +68 -0
  77. package/backend/migrations/1762149999999_enable_realtime_changes.js +48 -0
  78. package/backend/migrations/1765224247654_rtdb-nodes.js +93 -0
  79. package/backend/package-lock.json +2374 -0
  80. package/backend/package.json +27 -0
  81. package/backend/postbase_db_rules.js +128 -0
  82. package/backend/postbase_rtdb_rules.js +27 -0
  83. package/backend/postbase_storage_rules.js +45 -0
  84. package/backend/template.env +10 -0
  85. package/backend-systemd/README.md +39 -0
  86. package/backend-systemd/your_website.com.service +12 -0
  87. package/frontend/404.html +33 -0
  88. package/frontend/README.md +25 -0
  89. package/frontend/index.html +15 -0
  90. package/frontend/jsconfig.json +20 -0
  91. package/frontend/lib/postbase/auth.js +132 -0
  92. package/frontend/lib/postbase/compat/firebase/app.js +3 -0
  93. package/frontend/lib/postbase/compat/firebase/auth.js +115 -0
  94. package/frontend/lib/postbase/compat/firebase/database.js +11 -0
  95. package/frontend/lib/postbase/compat/firebase/firestore/lite.js +61 -0
  96. package/frontend/lib/postbase/compat/firebase/storage.js +10 -0
  97. package/frontend/lib/postbase/db.js +657 -0
  98. package/frontend/lib/postbase/package-lock.json +6284 -0
  99. package/frontend/lib/postbase/package.json +17 -0
  100. package/frontend/lib/postbase/rtdb.js +108 -0
  101. package/frontend/lib/postbase/storage.js +293 -0
  102. package/frontend/lib/postbase/tests/rtdb.client.test.js +88 -0
  103. package/frontend/lib/postbase/tests/waitFor.js +13 -0
  104. package/frontend/lib/postbase/utils.js +1 -0
  105. package/frontend/package-lock.json +2977 -0
  106. package/frontend/package.json +24 -0
  107. package/frontend/src/App.jsx +38 -0
  108. package/frontend/src/auth.js +52 -0
  109. package/frontend/src/components/AuthPanel.jsx +85 -0
  110. package/frontend/src/components/Header.jsx +54 -0
  111. package/frontend/src/main.jsx +5 -0
  112. package/frontend/src/pages/Dashboard.jsx +24 -0
  113. package/frontend/src/pages/Home.jsx +178 -0
  114. package/frontend/src/pages/Login.jsx +10 -0
  115. package/frontend/src/postbase.js +14 -0
  116. package/frontend/src/styles.css +1 -0
  117. package/frontend/tailwind.config.cjs +11 -0
  118. package/frontend/template.env +2 -0
  119. package/frontend/vite.config.js +18 -0
  120. package/git/hooks/README.md +31 -0
  121. package/git/hooks/post-receive +26 -0
  122. package/nginx/README.md +84 -0
  123. package/nginx/apt/www.your_website.com.conf +80 -0
  124. package/nginx/homebrew/www.your_website.com.conf +80 -0
  125. package/nginx/letsencrypt/README +14 -0
  126. package/package.json +8 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "postbase",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "jest"
7
+ },
8
+ "devDependencies": {
9
+ "better-auth": "^1.3.34",
10
+ "express": "^4.21.2",
11
+ "ws": "^8.20.0",
12
+ "@types/jest": "^30.0.0",
13
+ "jest": "^30.2.0",
14
+ "node-pg-migrate": "^8.0.3",
15
+ "supertest": "^7.1.4"
16
+ }
17
+ }
@@ -0,0 +1,108 @@
1
+ // frontend/lib/postbase/rtdbClient.js
2
+ export class RtdbClient {
3
+ constructor({ restUrl, wsUrl, getAuthToken }) {
4
+ this.restUrl = restUrl.replace(/\/+$/, '');
5
+ this.wsUrl = wsUrl;
6
+ this.ws = null;
7
+ this.listeners = new Map();
8
+ this.getAuthToken = getAuthToken; // TODO: use this
9
+ }
10
+
11
+ async connect() {
12
+ this.ws = new WebSocket(this.wsUrl);
13
+
14
+ await new Promise((resolve, reject) => {
15
+ this.ws.addEventListener("open", resolve, { once: true });
16
+ this.ws.addEventListener("error", reject, { once: true });
17
+ });
18
+
19
+ this.ws.addEventListener("message", evt => {
20
+ let msg;
21
+ try { msg = JSON.parse(evt.data); }
22
+ catch { return; }
23
+
24
+ const { path, field, value } = msg;
25
+ const key = field ? `${path}/${field}` : path;
26
+
27
+ if (this.listeners.has(key)) {
28
+ for (const cb of this.listeners.get(key)) cb(value);
29
+ }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Firebase-style ON()
35
+ */
36
+ on(path, callback) {
37
+ if (!this.listeners.has(path)) {
38
+ this.listeners.set(path, new Set());
39
+
40
+ const parts = path.split("/");
41
+ const isField = parts.length > 2;
42
+
43
+ let type = "sub";
44
+ let payload = { type, path };
45
+
46
+ if (isField) {
47
+ const field = parts.pop();
48
+ const basePath = parts.join("/");
49
+ payload = { type, path: basePath, field };
50
+ }
51
+
52
+ // prefix listener if path does NOT include a leaf key
53
+ if (path !== "" && !isField && !path.includes("/")) {
54
+ payload = { type, path, prefix: true };
55
+ }
56
+
57
+ this.ws.send(JSON.stringify(payload));
58
+ }
59
+
60
+ this.listeners.get(path).add(callback);
61
+
62
+ // Return unsubscribe function (like Firebase)
63
+ return () => this.off(path, callback);
64
+ }
65
+
66
+ /**
67
+ * Firebase-style OFF()
68
+ */
69
+ off(path, callback) {
70
+ if (!this.listeners.has(path)) return;
71
+
72
+ if (callback)
73
+ this.listeners.get(path).delete(callback);
74
+
75
+ if (!callback || this.listeners.get(path).size === 0) {
76
+ this.listeners.delete(path);
77
+
78
+ this.ws.send(JSON.stringify({
79
+ type: "unsub",
80
+ path
81
+ }));
82
+ }
83
+ }
84
+
85
+ // -------------------
86
+ // Write / update
87
+ // -------------------
88
+ async set(path, value) {
89
+ return fetch(`${this.restUrl}/rtdb/${path}`, {
90
+ method: 'PUT',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify(value),
93
+ }).then(r => r.json());
94
+ }
95
+
96
+ async get(path) {
97
+ return fetch(`${this.restUrl}/rtdb/${path}`)
98
+ .then(r => r.json());
99
+ }
100
+
101
+ async update(path, value) {
102
+ return fetch(`${this.restUrl}/rtdb/${path}`, {
103
+ method: 'PATCH',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify(value),
106
+ }).then(res => res.json());
107
+ }
108
+ }
@@ -0,0 +1,293 @@
1
+ // client-storage-sdk.js
2
+ // Minimal Firebase-like client storage SDK for uploading files to an Express backend.
3
+ //
4
+ // Usage:
5
+ // const storage = createClientStorage('https://api.example.com', () => authToken);
6
+ // const ref = storage.ref('users/123/profile.jpg');
7
+ // const task = ref.put(file, { contentType: 'image/jpeg' });
8
+ // task.on(firebase.storage.TaskEvent.STATE_CHANGED, snapshot => { ... }, error => { ... }, () => { ... });
9
+
10
+ export const TaskEvent = {
11
+ STATE_CHANGED: 'state_changed'
12
+ };
13
+
14
+ export const TaskState = {
15
+ RUNNING: 'running',
16
+ PAUSED: 'paused',
17
+ SUCCESS: 'success',
18
+ ERROR: 'error'
19
+ };
20
+
21
+ class UploadSnapshot {
22
+ constructor(bytesTransferred, totalBytes, state, ref, serverResponse = null) {
23
+ this.bytesTransferred = bytesTransferred;
24
+ this.totalBytes = totalBytes;
25
+ this.state = state;
26
+ this.ref = ref;
27
+ this.serverResponse = serverResponse;
28
+ }
29
+ }
30
+
31
+ class UploadTask {
32
+ constructor(ref, file, metadata, baseUrl, getAuthToken) {
33
+ this.ref = ref;
34
+ this.file = file;
35
+ this.metadata = metadata || {};
36
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
37
+ this.getAuthToken = getAuthToken;
38
+ this._xhr = null;
39
+ this._listeners = []; // to support .on with (snapshotCallback, errorCb, completeCb)
40
+ this._state = TaskState.PAUSED;
41
+ this._bytesTransferred = 0;
42
+ this._totalBytes = file.size;
43
+ this._serverResponse = null;
44
+ this._error = null;
45
+ this._aborted = false;
46
+ // start immediately
47
+ this._start();
48
+ }
49
+
50
+ _emitSnapshot() {
51
+ const snap = new UploadSnapshot(this._bytesTransferred, this._totalBytes, this._state, this.ref, this._serverResponse);
52
+ for (const l of this._listeners) {
53
+ if (typeof l.snapshot === 'function') {
54
+ try { l.snapshot(snap); } catch (e) { console.error(e); }
55
+ }
56
+ }
57
+ }
58
+
59
+ _emitError(err) {
60
+ this._error = err;
61
+ for (const l of this._listeners) {
62
+ if (typeof l.error === 'function') {
63
+ try { l.error(err); } catch (e) { console.error(e); }
64
+ }
65
+ }
66
+ }
67
+
68
+ _emitComplete() {
69
+ for (const l of this._listeners) {
70
+ if (typeof l.complete === 'function') {
71
+ try { l.complete(); } catch (e) { console.error(e); }
72
+ }
73
+ }
74
+ }
75
+
76
+ on(event, snapshotCb, errorCb, completeCb) {
77
+ if (event !== TaskEvent.STATE_CHANGED) {
78
+ throw new Error('Only STATE_CHANGED is supported');
79
+ }
80
+ const listener = { snapshot: snapshotCb, error: errorCb, complete: completeCb };
81
+ this._listeners.push(listener);
82
+ // immediately call snapshot with current state
83
+ if (snapshotCb) {
84
+ try { snapshotCb(new UploadSnapshot(this._bytesTransferred, this._totalBytes, this._state, this.ref, this._serverResponse)); } catch (e) {/* ignore */ }
85
+ }
86
+ // return unsubscribe
87
+ return () => {
88
+ const i = this._listeners.indexOf(listener);
89
+ if (i >= 0) this._listeners.splice(i, 1);
90
+ };
91
+ }
92
+
93
+ async _start() {
94
+ // Start upload (or resume). Implemented with XHR for progress and abort.
95
+ this._state = TaskState.RUNNING;
96
+ this._aborted = false;
97
+ this._xhr = new XMLHttpRequest();
98
+ const url = `${this.baseUrl}/upload?path=${encodeURIComponent(this.ref.fullPath)}`;
99
+ this._xhr.open('POST', url, true);
100
+
101
+ // Auth header if provided
102
+ const token = this.getAuthToken && await this.getAuthToken();
103
+ if (token) {
104
+ this._xhr.setRequestHeader('Authorization', `Bearer ${token}`);
105
+ }
106
+
107
+ // We can send metadata as header, or as part of formdata
108
+ const form = new FormData();
109
+ form.append('file', this.file);
110
+ form.append('metadata', JSON.stringify(this.metadata));
111
+
112
+ this._xhr.upload.onprogress = (ev) => {
113
+ if (ev.lengthComputable) {
114
+ this._bytesTransferred = ev.loaded;
115
+ this._totalBytes = ev.total;
116
+ this._emitSnapshot();
117
+ }
118
+ };
119
+
120
+ this._xhr.onerror = (ev) => {
121
+ this._state = TaskState.ERROR;
122
+ this._emitSnapshot();
123
+ const err = new Error('Upload failed (network error)');
124
+ this._emitError(err);
125
+ };
126
+
127
+ this._xhr.onabort = () => {
128
+ // considered paused if not intentionally cancelled
129
+ if (this._aborted) {
130
+ // paused or canceled - state already set by caller
131
+ this._emitSnapshot();
132
+ } else {
133
+ this._state = TaskState.PAUSED;
134
+ this._emitSnapshot();
135
+ }
136
+ };
137
+
138
+ this._xhr.onload = () => {
139
+ if (this._xhr.status >= 200 && this._xhr.status < 300) {
140
+ // success
141
+ this._state = TaskState.SUCCESS;
142
+ try {
143
+ this._serverResponse = JSON.parse(this._xhr.responseText || '{}');
144
+ } catch (e) {
145
+ this._serverResponse = { raw: this._xhr.responseText };
146
+ }
147
+ // set bytesTransferred to total
148
+ this._bytesTransferred = this._totalBytes;
149
+ this._emitSnapshot();
150
+ this._emitComplete();
151
+ } else {
152
+ this._state = TaskState.ERROR;
153
+ const err = new Error(`Upload failed: ${this._xhr.status} ${this._xhr.statusText}`);
154
+ err.status = this._xhr.status;
155
+ try { err.body = JSON.parse(this._xhr.responseText || ''); } catch (e) { }
156
+ this._emitError(err);
157
+ }
158
+ };
159
+
160
+ this._xhr.send(form);
161
+ this._emitSnapshot();
162
+ }
163
+
164
+ pause() {
165
+ if (!this._xhr) return;
166
+ if (this._state !== TaskState.RUNNING) return;
167
+ this._aborted = true;
168
+ try { this._xhr.abort(); } catch (e) { }
169
+ this._state = TaskState.PAUSED;
170
+ this._emitSnapshot();
171
+ }
172
+
173
+ resume() {
174
+ if (this._state !== TaskState.PAUSED) return;
175
+ // restart upload from scratch
176
+ this._start();
177
+ }
178
+
179
+ cancel() {
180
+ if (!this._xhr) return;
181
+ this._aborted = true;
182
+ try { this._xhr.abort(); } catch (e) { }
183
+ this._state = TaskState.ERROR;
184
+ const err = new Error('Upload canceled by user');
185
+ err.code = 'storage/canceled';
186
+ this._emitError(err);
187
+ }
188
+
189
+ // snapshot.ref.getDownloadURL()
190
+ snapshotRefGetDownloadURL() {
191
+ // server response should include publicUrl or downloadUrl
192
+ if (this._serverResponse && this._serverResponse.publicUrl) {
193
+ return Promise.resolve(this._serverResponse.publicUrl);
194
+ }
195
+ // fallback to an endpoint that returns metadata including url
196
+ const url = `${this.baseUrl}/metadata?path=${encodeURIComponent(this.ref.fullPath)}`;
197
+ const token = this.getAuthToken && this.getAuthToken();
198
+ const headers = {};
199
+ if (token) headers['Authorization'] = `Bearer ${token}`;
200
+ return fetch(url, { headers }).then(res => {
201
+ if (!res.ok) throw new Error(`Failed to get metadata: ${res.status}`);
202
+ return res.json();
203
+ }).then(json => {
204
+ if (json.publicUrl) return json.publicUrl;
205
+ throw new Error('No publicUrl in response');
206
+ });
207
+ }
208
+ }
209
+
210
+ export class StorageRef {
211
+ constructor(fullPath, baseUrl, getAuthToken) {
212
+ this.fullPath = fullPath.replace(/^\/+/, '');
213
+ this.baseUrl = baseUrl;
214
+ this.getAuthToken = getAuthToken;
215
+ }
216
+
217
+ put(file, metadata) {
218
+ const task = new UploadTask(this, file, metadata, this.baseUrl, this.getAuthToken);
219
+
220
+ // still allow event-based tracking
221
+ task.snapshot = {
222
+ ref: {
223
+ getDownloadURL: () => task.snapshotRefGetDownloadURL()
224
+ }
225
+ };
226
+
227
+ // Backward compatability for Firebase Storage
228
+ this.getDownloadURL = () => task.snapshotRefGetDownloadURL();
229
+
230
+ // Wrap the task in a Promise that resolves when upload completes
231
+ return new Promise((resolve, reject) => {
232
+ task.on(
233
+ TaskEvent.STATE_CHANGED,
234
+ null,
235
+ (err) => reject(err),
236
+ () => {
237
+ const snap = new UploadSnapshot(
238
+ task._bytesTransferred,
239
+ task._totalBytes,
240
+ task._state,
241
+ task.ref,
242
+ task._serverResponse
243
+ );
244
+ resolve(snap);
245
+ }
246
+ );
247
+ });
248
+ }
249
+
250
+ delete() {
251
+ const url = `${this.baseUrl}/delete?path=${encodeURIComponent(this.fullPath)}`;
252
+ const token = this.getAuthToken && this.getAuthToken();
253
+ const headers = { 'Content-Type': 'application/json' };
254
+ if (token) headers['Authorization'] = `Bearer ${token}`;
255
+ return fetch(url, { method: 'DELETE', headers }).then(res => {
256
+ if (!res.ok) throw new Error(`Failed to delete file: ${res.status}`);
257
+ return res.json().catch(() => ({}));
258
+ });
259
+ }
260
+ }
261
+
262
+ export function createClientStorage({
263
+ baseUrl = '/api/storage',
264
+ getAuthToken = null
265
+ } = {}) {
266
+ baseUrl = baseUrl.replace(/\/+$/, '');
267
+ return {
268
+ ref: (path) => new StorageRef(path, baseUrl, getAuthToken),
269
+
270
+ refFromFile: (fileUrl) => {
271
+ if (typeof fileUrl !== 'string' || !/^https?:\/\//i.test(fileUrl)) {
272
+ throw new Error('refFromFile() expects a valid HTTP or HTTPS URL');
273
+ }
274
+
275
+ try {
276
+ const parsed = new URL(fileUrl);
277
+ // Extract path after `/files/` or fallback to pathname
278
+ let path = parsed.pathname;
279
+ // Common convention: https://api.example.com/files/<path>
280
+ const filesIdx = path.indexOf('/files/');
281
+ if (filesIdx >= 0) {
282
+ path = path.slice(filesIdx + '/files/'.length);
283
+ } else {
284
+ // Remove leading slash if not using /files/ convention
285
+ path = path.replace(/^\/+/, '');
286
+ }
287
+ return new StorageRef(path, baseUrl, getAuthToken);
288
+ } catch (err) {
289
+ throw new Error(`Invalid URL: ${fileUrl}`);
290
+ }
291
+ }
292
+ };
293
+ }
@@ -0,0 +1,88 @@
1
+ // tests/rtdb.client.integration.test.js
2
+ import request from 'supertest';
3
+ import { createAuthClient as createBetterAuthClient } from 'better-auth/client';
4
+ import { phoneNumberClient } from "better-auth/client/plugins"
5
+ import { startTestServer } from '../../../../backend/lib/postbase/tests/testServer';
6
+ import { RtdbClient } from '../rtdb';
7
+ import { delay } from '../utils.js';
8
+ import { createAuthClient } from '../auth';
9
+
10
+ let srv;
11
+ let client;
12
+
13
+ beforeAll(async () => {
14
+ srv = await startTestServer();
15
+
16
+ const betterAuthClient = createBetterAuthClient({
17
+ baseURL: srv.url + '/auth',
18
+ plugins: [
19
+ phoneNumberClient()
20
+ ]
21
+ });
22
+
23
+ const auth = createAuthClient(betterAuthClient);
24
+
25
+ client = new RtdbClient({
26
+ restUrl: srv.url,
27
+ wsUrl: srv.wsUrl,
28
+ getAuthToken: auth.getBetterAuthToken,
29
+ });
30
+
31
+ await client.connect();
32
+ });
33
+
34
+ afterEach(async () => {
35
+ client.off('users/u2');
36
+ client.off('users/u4/status');
37
+ });
38
+
39
+ afterAll(() => {
40
+ if (srv.wss) srv.wss.close();
41
+ if (srv.server) srv.server.close();
42
+ });
43
+
44
+ test('set + get', async () => {
45
+ await client.set('users/u1', { online: true });
46
+ const v = await client.get('users/u1');
47
+ expect(v.online).toBe(true);
48
+ });
49
+
50
+ test('on fires on change (whole path)', async () => {
51
+ const received = [];
52
+ const unsub = await client.on('users/u2', (val) => received.push(val));
53
+ await client.set('users/u2', { online: true });
54
+ // wait briefly for ws
55
+ await new Promise(r => setTimeout(r, 100));
56
+ expect(received.length).toBe(1);
57
+ expect(received[0].online).toBe(true);
58
+ unsub();
59
+ });
60
+
61
+ test("field listener fires only when changed", async () => {
62
+ const msgs = [];
63
+
64
+ const unsub = await client.on("users/u4/status", v => msgs.push(v));
65
+
66
+ await request(srv.url)
67
+ .put("/rtdb/users/u4")
68
+ .send({ status: "offline" });
69
+
70
+ await delay(50);
71
+ expect(msgs).toContain("offline");
72
+
73
+ msgs.length = 0;
74
+
75
+ await request(srv.url)
76
+ .patch("/rtdb/users/u4")
77
+ .send({ status: "offline" });
78
+
79
+ await request(srv.url)
80
+ .patch("/rtdb/users/u4")
81
+ .send({ status: "online" });
82
+
83
+ await delay(50);
84
+ expect(msgs).toContain("online");
85
+
86
+ unsub();
87
+ });
88
+
@@ -0,0 +1,13 @@
1
+ export function waitFor(fn, timeout = 500) {
2
+ return new Promise((resolve, reject) => {
3
+ const start = Date.now();
4
+ const tick = () => {
5
+ if (fn()) return resolve();
6
+ if (Date.now() - start > timeout) {
7
+ return reject(new Error('timeout'));
8
+ }
9
+ setTimeout(tick, 10);
10
+ };
11
+ tick();
12
+ });
13
+ }
@@ -0,0 +1 @@
1
+ export const delay = ms => new Promise(res => setTimeout(res, ms));