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.
- package/.github/workflows/test.yml +74 -0
- package/CLA.md +60 -0
- package/CONTRIBUTORS.md +35 -0
- package/LICENSE +661 -0
- package/README.md +211 -0
- package/admin/404.html +33 -0
- package/admin/README.md +21 -0
- package/admin/index.html +15 -0
- package/admin/jsconfig.json +20 -0
- package/admin/lib/postbase.js +222 -0
- package/admin/package-lock.json +3746 -0
- package/admin/package.json +27 -0
- package/admin/public/assets/img/admin-ui.png +0 -0
- package/admin/public/assets/img/blank-profile-picture-960_720.webp +0 -0
- package/admin/public/assets/img/chart-active-users.png +0 -0
- package/admin/public/assets/img/icon-transparent.png +0 -0
- package/admin/src/App.jsx +48 -0
- package/admin/src/auth.js +11 -0
- package/admin/src/common/formatDateTime.js +18 -0
- package/admin/src/components/AuthPanel.jsx +88 -0
- package/admin/src/components/Header.jsx +67 -0
- package/admin/src/main.jsx +6 -0
- package/admin/src/pages/Dashboard.jsx +24 -0
- package/admin/src/pages/Home.jsx +52 -0
- package/admin/src/pages/Login.jsx +10 -0
- package/admin/src/pages/authentication/Users.jsx +199 -0
- package/admin/src/pages/firestore/Database.jsx +29 -0
- package/admin/src/pages/storage/files.jsx +29 -0
- package/admin/src/postbase.js +15 -0
- package/admin/src/styles.css +3 -0
- package/admin/tailwind.config.cjs +11 -0
- package/admin/template.env +2 -0
- package/admin/vite.config.js +21 -0
- package/assets/img/HomePageScreenshot.png +0 -0
- package/assets/img/better-auth-logo-dark.136b122f.png +0 -0
- package/assets/img/better-auth-logo-light.4b03f444.png +0 -0
- package/assets/img/expresjs.png +0 -0
- package/assets/img/icon-transparent.png +0 -0
- package/assets/img/icon.png +0 -0
- package/assets/img/letsencrypt-logo-horizontal.png +0 -0
- package/assets/img/logo.png +0 -0
- package/assets/img/node.js_logo.png +0 -0
- package/assets/img/nodejsLight.svg +39 -0
- package/assets/img/postgres.png +0 -0
- package/backend/README.md +49 -0
- package/backend/admin/auth.js +9 -0
- package/backend/app.js +68 -0
- package/backend/auth.js +92 -0
- package/backend/env.js +12 -0
- package/backend/lib/postbase/adminClient.js +520 -0
- package/backend/lib/postbase/compat/admin.js +44 -0
- package/backend/lib/postbase/db.js +17 -0
- package/backend/lib/postbase/genericRouter.js +603 -0
- package/backend/lib/postbase/local-storage.js +56 -0
- package/backend/lib/postbase/metadataCache.js +32 -0
- package/backend/lib/postbase/middlewares/auth.js +57 -0
- package/backend/lib/postbase/migrations/1765239687559_rtdb-nodes.js +93 -0
- package/backend/lib/postbase/package-lock.json +5873 -0
- package/backend/lib/postbase/package.json +19 -0
- package/backend/lib/postbase/rtdb/router.js +190 -0
- package/backend/lib/postbase/rtdb/rulesEngine.js +63 -0
- package/backend/lib/postbase/rtdb/ws.js +84 -0
- package/backend/lib/postbase/rulesEngine.js +62 -0
- package/backend/lib/postbase/storage.js +130 -0
- package/backend/lib/postbase/tests/README.md +22 -0
- package/backend/lib/postbase/tests/db.js +9 -0
- package/backend/lib/postbase/tests/rtdb.rest.test.js +46 -0
- package/backend/lib/postbase/tests/rtdb.ws.test.js +113 -0
- package/backend/lib/postbase/tests/rules.js +26 -0
- package/backend/lib/postbase/tests/testServer.js +46 -0
- package/backend/lib/postbase/websocket.js +131 -0
- package/backend/local.js +6 -0
- package/backend/main.js +20 -0
- package/backend/middlewares/auth_middleware.js +10 -0
- package/backend/migrations/1762137399366-init.sql +98 -0
- package/backend/migrations/1762137399367_init_jsonb_schema.js +68 -0
- package/backend/migrations/1762149999999_enable_realtime_changes.js +48 -0
- package/backend/migrations/1765224247654_rtdb-nodes.js +93 -0
- package/backend/package-lock.json +2374 -0
- package/backend/package.json +27 -0
- package/backend/postbase_db_rules.js +128 -0
- package/backend/postbase_rtdb_rules.js +27 -0
- package/backend/postbase_storage_rules.js +45 -0
- package/backend/template.env +10 -0
- package/backend-systemd/README.md +39 -0
- package/backend-systemd/your_website.com.service +12 -0
- package/frontend/404.html +33 -0
- package/frontend/README.md +25 -0
- package/frontend/index.html +15 -0
- package/frontend/jsconfig.json +20 -0
- package/frontend/lib/postbase/auth.js +132 -0
- package/frontend/lib/postbase/compat/firebase/app.js +3 -0
- package/frontend/lib/postbase/compat/firebase/auth.js +115 -0
- package/frontend/lib/postbase/compat/firebase/database.js +11 -0
- package/frontend/lib/postbase/compat/firebase/firestore/lite.js +61 -0
- package/frontend/lib/postbase/compat/firebase/storage.js +10 -0
- package/frontend/lib/postbase/db.js +657 -0
- package/frontend/lib/postbase/package-lock.json +6284 -0
- package/frontend/lib/postbase/package.json +17 -0
- package/frontend/lib/postbase/rtdb.js +108 -0
- package/frontend/lib/postbase/storage.js +293 -0
- package/frontend/lib/postbase/tests/rtdb.client.test.js +88 -0
- package/frontend/lib/postbase/tests/waitFor.js +13 -0
- package/frontend/lib/postbase/utils.js +1 -0
- package/frontend/package-lock.json +2977 -0
- package/frontend/package.json +24 -0
- package/frontend/src/App.jsx +38 -0
- package/frontend/src/auth.js +52 -0
- package/frontend/src/components/AuthPanel.jsx +85 -0
- package/frontend/src/components/Header.jsx +54 -0
- package/frontend/src/main.jsx +5 -0
- package/frontend/src/pages/Dashboard.jsx +24 -0
- package/frontend/src/pages/Home.jsx +178 -0
- package/frontend/src/pages/Login.jsx +10 -0
- package/frontend/src/postbase.js +14 -0
- package/frontend/src/styles.css +1 -0
- package/frontend/tailwind.config.cjs +11 -0
- package/frontend/template.env +2 -0
- package/frontend/vite.config.js +18 -0
- package/git/hooks/README.md +31 -0
- package/git/hooks/post-receive +26 -0
- package/nginx/README.md +84 -0
- package/nginx/apt/www.your_website.com.conf +80 -0
- package/nginx/homebrew/www.your_website.com.conf +80 -0
- package/nginx/letsencrypt/README +14 -0
- package/package.json +8 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
// // Minimal client SDK for the generic CRUD API.
|
|
2
|
+
// // Usage example:
|
|
3
|
+
// import { getDB } from './postbase.js';
|
|
4
|
+
// const db = getDB({ baseUrl: 'https://api.example.com/api' });
|
|
5
|
+
// const posts = db.collection('posts');
|
|
6
|
+
// await posts.add({ title: 'hi' });
|
|
7
|
+
// const doc = await posts.doc('123').get();
|
|
8
|
+
//
|
|
9
|
+
// // You can also use references
|
|
10
|
+
// const userRef = db.collection('users').doc('alovelace');
|
|
11
|
+
// await userRef.set({ name: 'Ada Lovelace' });
|
|
12
|
+
|
|
13
|
+
// await db.collection('reviews').doc('r1').set({
|
|
14
|
+
// rating: 5,
|
|
15
|
+
// reviewer: userRef
|
|
16
|
+
// });
|
|
17
|
+
|
|
18
|
+
// const review = await db.collection('reviews').doc('r1').get();
|
|
19
|
+
|
|
20
|
+
// console.log(review.reviewer instanceof DocumentReference); // ✅ true
|
|
21
|
+
// const user = await review.reviewer.get();
|
|
22
|
+
// console.log(user.name); // "Ada Lovelace"
|
|
23
|
+
|
|
24
|
+
function toJsonOrThrow(res) {
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
return res.json().then(j => { throw j; });
|
|
27
|
+
}
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getDB({
|
|
32
|
+
baseUrl = '/api/db',
|
|
33
|
+
defaultHeaders = {},
|
|
34
|
+
getAuthToken = null, // 👈 optional async token resolver
|
|
35
|
+
} = {}) {
|
|
36
|
+
return new Database(baseUrl.replace(/\/$/, ''), defaultHeaders, getAuthToken);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class Database {
|
|
40
|
+
constructor(baseUrl, defaultHeaders, getAuthToken) {
|
|
41
|
+
this.baseUrl = baseUrl;
|
|
42
|
+
this.defaultHeaders = defaultHeaders;
|
|
43
|
+
this.getAuthToken = getAuthToken;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
collection(name) {
|
|
47
|
+
return new CollectionReference(this, name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getHeaders() {
|
|
51
|
+
const headers = { ...this.defaultHeaders };
|
|
52
|
+
if (typeof this.getAuthToken === 'function') {
|
|
53
|
+
try {
|
|
54
|
+
const token = await this.getAuthToken();
|
|
55
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.warn('getAuthToken failed', err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class CollectionReference {
|
|
65
|
+
constructor(db, name, parentPath = null) {
|
|
66
|
+
this.db = db;
|
|
67
|
+
this.name = name;
|
|
68
|
+
this.parentPath = parentPath; // e.g., "users/u1"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get fullPath() {
|
|
72
|
+
return this.parentPath ? `${this.parentPath}/${encodeURIComponent(this.name)}` : encodeURIComponent(this.name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
doc(id) {
|
|
76
|
+
return new DocumentReference(this.db, this.name, id, this.parentPath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
collection(subName) {
|
|
80
|
+
return new CollectionReference(this.db, subName, this.fullPath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async add(data) {
|
|
84
|
+
const url = `${this.db.baseUrl}/${this.fullPath}`;
|
|
85
|
+
const headers = await this.db.getHeaders();
|
|
86
|
+
const res = await fetch(url, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
89
|
+
body: JSON.stringify(serializeRefs(data))
|
|
90
|
+
});
|
|
91
|
+
const json = await toJsonOrThrow(res);
|
|
92
|
+
const _data = json.data;
|
|
93
|
+
if (_data && _data.hasOwnProperty('id')) {
|
|
94
|
+
return this.doc(_data.id);
|
|
95
|
+
}
|
|
96
|
+
return _data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Directly get all docs (no filters) */
|
|
100
|
+
async get() {
|
|
101
|
+
const query = new QueryBuilder(this);
|
|
102
|
+
const data = await query.get();
|
|
103
|
+
const { docs } = data;
|
|
104
|
+
// If there are no query filters, wrap in QuerySnapshot
|
|
105
|
+
if (query._filters.length === 0 && query._order.length === 0 && !query._limit) {
|
|
106
|
+
return new QuerySnapshot(docs);
|
|
107
|
+
}
|
|
108
|
+
// Otherwise, return array of DocumentSnapshot as before
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Start a query builder chain */
|
|
113
|
+
where(field, op, value) {
|
|
114
|
+
return new QueryBuilder(this).where(field, op, value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Optional syntactic sugar — allow orderBy/limit without where() */
|
|
118
|
+
orderBy(field, dir = 'asc') {
|
|
119
|
+
return new QueryBuilder(this).orderBy(field, dir);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
limit(n) {
|
|
123
|
+
return new QueryBuilder(this).limit(n);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Recursively convert DocumentReference instances to JSON-safe { _type: 'ref', path } objects
|
|
130
|
+
*/
|
|
131
|
+
function serializeRefs(obj) {
|
|
132
|
+
if (obj instanceof DocumentReference) {
|
|
133
|
+
return { _type: 'ref', path: obj.fullPath };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
//NEW: If Timestamp instance, send canonical structure
|
|
137
|
+
if (obj instanceof Timestamp) {
|
|
138
|
+
return obj.toString();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(obj)) return obj.map(serializeRefs);
|
|
142
|
+
|
|
143
|
+
if (obj && typeof obj === 'object') {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
146
|
+
out[k] = serializeRefs(v);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return obj;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Recursively restore { _type:'ref', path } objects back to DocumentReference instances.
|
|
156
|
+
*/
|
|
157
|
+
function deserializeRefs(db, obj) {
|
|
158
|
+
if (Array.isArray(obj)) {
|
|
159
|
+
return obj.map(v => deserializeRefs(db, v));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (obj && typeof obj === 'object') {
|
|
163
|
+
// Detect PostgreSQL TIMESTAMPTZ returned as strings
|
|
164
|
+
if (isIsoDateString(obj)) {
|
|
165
|
+
return Timestamp.fromPostgres(obj);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (obj._type === 'ref' && obj.path) {
|
|
169
|
+
const parts = obj.path.split('/');
|
|
170
|
+
let ref = db.collection(parts[0]).doc(parts[1]);
|
|
171
|
+
for (let i = 2; i < parts.length; i += 2) {
|
|
172
|
+
ref = ref.collection(parts[i]).doc(parts[i + 1]);
|
|
173
|
+
}
|
|
174
|
+
return ref;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const out = {};
|
|
178
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
179
|
+
out[k] = deserializeRefs(db, v);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//If a primitive ISO string is received, convert it
|
|
185
|
+
if (isIsoDateString(obj)) {
|
|
186
|
+
return Timestamp.fromPostgres(obj);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return obj;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class DocumentsSnapshot {
|
|
193
|
+
constructor(docs) {
|
|
194
|
+
this.docs = docs; // array of DocumentSnapshot
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
forEach(callback) {
|
|
198
|
+
this.docs.forEach(callback);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
map(callback) {
|
|
202
|
+
return this.docs.map(callback);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get empty() {
|
|
206
|
+
return this.docs.length === 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get size() {
|
|
210
|
+
return this.docs.length;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
class DocumentSnapshot {
|
|
215
|
+
constructor(id, data, path, db) {
|
|
216
|
+
this.id = decodeURIComponent(id);
|
|
217
|
+
this._path = path.split('/').map(decodeURIComponent).join('/');
|
|
218
|
+
this._db = db;
|
|
219
|
+
this._data = data;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
data() {
|
|
223
|
+
return this._data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
get ref() {
|
|
227
|
+
const parts = this._path.split('/');
|
|
228
|
+
let ref = this._db.collection(parts[0]).doc(parts[1]);
|
|
229
|
+
for (let i = 2; i < parts.length; i += 2) {
|
|
230
|
+
ref = ref.collection(parts[i]).doc(parts[i + 1]);
|
|
231
|
+
}
|
|
232
|
+
return ref;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
get path() {
|
|
236
|
+
return this._path;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get exists() {
|
|
240
|
+
return !!this._data;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
class DocumentReference {
|
|
245
|
+
constructor(db, collectionName, id, parentPath = null) {
|
|
246
|
+
this.db = db;
|
|
247
|
+
this.collectionName = collectionName;
|
|
248
|
+
this.id = id;
|
|
249
|
+
this.parentPath = parentPath;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Full document path, e.g. "users/u1/posts/p2" */
|
|
253
|
+
get fullPath() {
|
|
254
|
+
const base = this.parentPath ? `${this.parentPath}/${encodeURIComponent(this.collectionName)}` : encodeURIComponent(this.collectionName);
|
|
255
|
+
return `${base}/${encodeURIComponent(this.id)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Allow chaining subcollections under this document */
|
|
259
|
+
collection(subName) {
|
|
260
|
+
return new CollectionReference(this.db, subName, this.fullPath);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async get() {
|
|
264
|
+
const url = `${this.db.baseUrl}/${this.fullPath}`;
|
|
265
|
+
const headers = await this.db.getHeaders();
|
|
266
|
+
const res = await fetch(url, { headers });
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
const errorData = await res.json();
|
|
269
|
+
console.error(`HTTP error! Status: ${res.status}, Details: ${JSON.stringify(errorData)}`);
|
|
270
|
+
if (errorData.hasOwnProperty('error')) {
|
|
271
|
+
return new DocumentSnapshot(this.id, null, this.fullPath, this.db);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const json = await toJsonOrThrow(res);
|
|
275
|
+
const data = deserializeRefs(this.db, json.data || {});
|
|
276
|
+
return new DocumentSnapshot(this.id, data, this.fullPath, this.db);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async set(data, opts = {}) {
|
|
280
|
+
const url = `${this.db.baseUrl}/${this.fullPath}`;
|
|
281
|
+
const headers = await this.db.getHeaders();
|
|
282
|
+
|
|
283
|
+
// If merge=true, fetch existing data first and merge locally
|
|
284
|
+
let finalData = data;
|
|
285
|
+
if (opts.merge) {
|
|
286
|
+
try {
|
|
287
|
+
const existing = await this.get();
|
|
288
|
+
finalData = { ...(existing.data() || {}), ...data };
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// If doc doesn't exist, just create it
|
|
291
|
+
finalData = data;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const res = await fetch(url, {
|
|
296
|
+
method: 'PUT',
|
|
297
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
298
|
+
body: JSON.stringify(serializeRefs(finalData)),
|
|
299
|
+
});
|
|
300
|
+
const json = await toJsonOrThrow(res);
|
|
301
|
+
return json.data;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async update(data) {
|
|
305
|
+
const url = `${this.db.baseUrl}/${this.fullPath}`;
|
|
306
|
+
const headers = await this.db.getHeaders();
|
|
307
|
+
const res = await fetch(url, {
|
|
308
|
+
method: 'PATCH',
|
|
309
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
310
|
+
body: JSON.stringify(serializeRefs(data)),
|
|
311
|
+
});
|
|
312
|
+
const json = await toJsonOrThrow(res);
|
|
313
|
+
return json.data;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async delete() {
|
|
317
|
+
const url = `${this.db.baseUrl}/${this.fullPath}`;
|
|
318
|
+
const headers = await this.db.getHeaders();
|
|
319
|
+
const res = await fetch(url, { method: 'DELETE', headers });
|
|
320
|
+
const json = await toJsonOrThrow(res);
|
|
321
|
+
return json.data;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
onSnapshot(callback, errorCallback) {
|
|
325
|
+
// Reuse the existing real-time query system
|
|
326
|
+
const q = new QueryBuilder(this.collection(this.id));
|
|
327
|
+
q._filters.push({ field: "__id", op: "==", value: this.id });
|
|
328
|
+
|
|
329
|
+
return q.onSnapshot((querySnap) => {
|
|
330
|
+
const docSnap = querySnap.docs[0] ||
|
|
331
|
+
new DocumentSnapshot(this.id, null, this.fullPath, this.db);
|
|
332
|
+
callback(docSnap);
|
|
333
|
+
}, errorCallback);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* Query builder helpers */
|
|
338
|
+
export function query(collectionRef, ...clauses) {
|
|
339
|
+
// returns a structured query object for backend: { filters: [...], order: [...], limit, offset }
|
|
340
|
+
const q = new QueryBuilder(collectionRef);
|
|
341
|
+
for (const c of clauses) {
|
|
342
|
+
if (c instanceof QueryBuilder) q.mergeFrom(c);
|
|
343
|
+
}
|
|
344
|
+
return q;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
class QuerySnapshot {
|
|
348
|
+
constructor(docs) {
|
|
349
|
+
this.docs = docs; // array of DocumentSnapshot
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Optional helper like Firestore
|
|
353
|
+
forEach(callback) {
|
|
354
|
+
this.docs.forEach(callback);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
map(callback) {
|
|
358
|
+
return this.docs.map(callback);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
get empty() {
|
|
362
|
+
return this.docs.length === 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
get size() {
|
|
366
|
+
return this.docs.length;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
class QueryBuilder {
|
|
371
|
+
constructor(collectionRef) {
|
|
372
|
+
this.collectionRef = collectionRef;
|
|
373
|
+
this._filters = [];
|
|
374
|
+
this._order = [];
|
|
375
|
+
this._limit = undefined;
|
|
376
|
+
this._offset = undefined;
|
|
377
|
+
|
|
378
|
+
// New cursor fields
|
|
379
|
+
this._startAt = undefined;
|
|
380
|
+
this._startAfter = undefined;
|
|
381
|
+
this._endAt = undefined;
|
|
382
|
+
this._endBefore = undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
where(field, op, value) {
|
|
386
|
+
let _value = value;
|
|
387
|
+
if (typeof value === 'object'
|
|
388
|
+
&& value !== null
|
|
389
|
+
&& value.hasOwnProperty('_type')
|
|
390
|
+
&& value._type === 'timestamp'
|
|
391
|
+
&& 'toString' in value) {
|
|
392
|
+
_value = value.toString();
|
|
393
|
+
} else if (value instanceof DocumentReference) {
|
|
394
|
+
_value = { _type: 'ref', path: value.fullPath };
|
|
395
|
+
}
|
|
396
|
+
this._filters.push({ field, op, value: _value });
|
|
397
|
+
return this;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
orderBy(field, dir = 'asc') {
|
|
401
|
+
this._order.push({ field, dir });
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
limit(n) {
|
|
406
|
+
this._limit = n;
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
offset(n) {
|
|
411
|
+
this._offset = n;
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 🔽 Pagination methods (Firestore-like)
|
|
416
|
+
startAt(cursor) {
|
|
417
|
+
this._startAt = this._normalizeCursor(cursor);
|
|
418
|
+
return this;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
startAfter(cursor) {
|
|
422
|
+
this._startAfter = this._normalizeCursor(cursor);
|
|
423
|
+
return this;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
endAt(cursor) {
|
|
427
|
+
this._endAt = this._normalizeCursor(cursor);
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
endBefore(cursor) {
|
|
432
|
+
this._endBefore = this._normalizeCursor(cursor);
|
|
433
|
+
return this;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Internal helper — Firestore allows passing either value or doc snapshot
|
|
437
|
+
_normalizeCursor(cursor) {
|
|
438
|
+
if (!cursor) return null;
|
|
439
|
+
if (typeof cursor === 'object' && cursor.id) {
|
|
440
|
+
// Likely a document snapshot or data with an id
|
|
441
|
+
return { _type: 'cursorRef', path: cursor._path || `${this.collectionRef.fullPath}/${cursor.id}` };
|
|
442
|
+
}
|
|
443
|
+
// For raw scalar values (like numeric sort fields)
|
|
444
|
+
return cursor;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
build() {
|
|
448
|
+
const out = {};
|
|
449
|
+
if (this._filters.length) out.filters = this._filters;
|
|
450
|
+
if (this._order.length) out.order = this._order;
|
|
451
|
+
if (typeof this._limit !== 'undefined') out.limit = this._limit;
|
|
452
|
+
if (typeof this._offset !== 'undefined') out.offset = this._offset;
|
|
453
|
+
|
|
454
|
+
if (this.collectionRef.parentPath) {
|
|
455
|
+
// parentPath = "messages/A"
|
|
456
|
+
const parts = this.collectionRef.parentPath.split('/');
|
|
457
|
+
const parentTable = parts[0];
|
|
458
|
+
const parentId = parts[1];
|
|
459
|
+
out.parent = {
|
|
460
|
+
collectionName: parentTable,
|
|
461
|
+
id: parentId,
|
|
462
|
+
path: `${this.collectionRef.parentPath}`
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async get() {
|
|
470
|
+
const url = `${this.collectionRef.db.baseUrl}/${this.collectionRef.fullPath}/query`;
|
|
471
|
+
const headers = await this.collectionRef.db.getHeaders();
|
|
472
|
+
|
|
473
|
+
const queryBody = this.build();
|
|
474
|
+
const res = await fetch(url, {
|
|
475
|
+
method: 'POST',
|
|
476
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
477
|
+
body: JSON.stringify(queryBody),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
if (!res.ok) {
|
|
481
|
+
const errorData = await res.json();
|
|
482
|
+
console.error(`HTTP error! Status: ${res.status}, Details: ${JSON.stringify(errorData)}`);
|
|
483
|
+
if (errorData.hasOwnProperty('error')) {
|
|
484
|
+
return new DocumentsSnapshot([]);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const json = await toJsonOrThrow(res);
|
|
489
|
+
|
|
490
|
+
// Map each document to a DocumentSnapshot
|
|
491
|
+
const docs = (json.data || []).map((doc) => {
|
|
492
|
+
const data = deserializeRefs(this.collectionRef.db, doc.data || doc || {});
|
|
493
|
+
return new DocumentSnapshot(doc.id, data, `${this.collectionRef.fullPath}/${doc.id}`, this.collectionRef.db);
|
|
494
|
+
});
|
|
495
|
+
return new DocumentsSnapshot(docs);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
onSnapshot(callback, errorCallback) {
|
|
499
|
+
const self = this;
|
|
500
|
+
|
|
501
|
+
const wsUrl = this.collectionRef.db.baseUrl
|
|
502
|
+
.replace(/^https/, 'wss')
|
|
503
|
+
.replace(/^http/, 'ws')
|
|
504
|
+
+ `/${this.collectionRef.fullPath}/stream`;
|
|
505
|
+
|
|
506
|
+
//console.log(`Requesting a new web socket connection`, wsUrl);
|
|
507
|
+
const ws = new WebSocket(wsUrl);
|
|
508
|
+
|
|
509
|
+
ws.onopen = (event) => {
|
|
510
|
+
//console.log('ws.onopen', event);
|
|
511
|
+
const queryBody = this.build();
|
|
512
|
+
//console.log('Sending query...', queryBody);
|
|
513
|
+
ws.send(JSON.stringify(queryBody)); // send query definition once
|
|
514
|
+
//console.log('Sent query success');
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
ws.onmessage = (event) => {
|
|
518
|
+
//console.log('ws.onmessage');
|
|
519
|
+
try {
|
|
520
|
+
const msg = JSON.parse(event.data);
|
|
521
|
+
if (msg.type === 'init' || msg.type === 'change') {
|
|
522
|
+
let data = msg.data;
|
|
523
|
+
if (!Array.isArray(msg.data)) {
|
|
524
|
+
// nested data found, meaning we have op and id as well
|
|
525
|
+
data = [msg.data];
|
|
526
|
+
}
|
|
527
|
+
const docs = (data || []).map(doc => {
|
|
528
|
+
const data = deserializeRefs(this.collectionRef.db, doc.data || doc);
|
|
529
|
+
if (msg.data.hasOwnProperty('op')) {
|
|
530
|
+
data._data = msg.data;
|
|
531
|
+
}
|
|
532
|
+
return new DocumentSnapshot(
|
|
533
|
+
doc.id,
|
|
534
|
+
data,
|
|
535
|
+
`${this.collectionRef.fullPath}/${doc.id}`,
|
|
536
|
+
this.collectionRef.db
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
callback(new QuerySnapshot(docs), msg.type);
|
|
540
|
+
} else if (msg.type === 'error') {
|
|
541
|
+
errorCallback(msg);
|
|
542
|
+
}
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (typeof errorCallback === 'function') errorCallback(err);
|
|
545
|
+
else console.error('onSnapshot parsing error:', err);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
ws.onerror = (err) => {
|
|
550
|
+
//console.log('ws.onerror');
|
|
551
|
+
if (typeof errorCallback === 'function') errorCallback(err);
|
|
552
|
+
else console.error('onSnapshot websocket error:', err);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
ws.onclose = event => {
|
|
556
|
+
//console.log('ws.onclose', event);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
return () => ws.close(); // return unsubscribe function
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
mergeFrom(other) {
|
|
563
|
+
this._filters.push(...(other._filters || []));
|
|
564
|
+
this._order.push(...(other._order || []));
|
|
565
|
+
if (other._limit) this._limit = other._limit;
|
|
566
|
+
if (other._offset) this._offset = other._offset;
|
|
567
|
+
if (other._startAt) this._startAt = other._startAt;
|
|
568
|
+
if (other._startAfter) this._startAfter = other._startAfter;
|
|
569
|
+
if (other._endAt) this._endAt = other._endAt;
|
|
570
|
+
if (other._endBefore) this._endBefore = other._endBefore;
|
|
571
|
+
return this;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/* Basic helpers similar to Firestore types */
|
|
576
|
+
|
|
577
|
+
function isIsoDateString(v) {
|
|
578
|
+
return (
|
|
579
|
+
typeof v === 'string' &&
|
|
580
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(v)
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export class Timestamp {
|
|
585
|
+
constructor(seconds, nanoseconds) {
|
|
586
|
+
this._type = 'timestamp';
|
|
587
|
+
this.seconds = seconds;
|
|
588
|
+
this.nanoseconds = nanoseconds;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Create from JS Date */
|
|
592
|
+
static fromDate(date) {
|
|
593
|
+
const millis = date.getTime();
|
|
594
|
+
const seconds = Math.floor(millis / 1000);
|
|
595
|
+
const nanoseconds = (millis % 1000) * 1e6;
|
|
596
|
+
return new Timestamp(seconds, nanoseconds);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Now */
|
|
600
|
+
static now() {
|
|
601
|
+
return Timestamp.fromDate(new Date());
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Create from Firestore-style seconds + nanos */
|
|
605
|
+
static fromSeconds(seconds, nanoseconds = 0) {
|
|
606
|
+
return new Timestamp(seconds, nanoseconds);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Create from Postgres ISO datetime string */
|
|
610
|
+
static fromPostgres(isoString) {
|
|
611
|
+
const date = new Date(isoString);
|
|
612
|
+
|
|
613
|
+
if (isNaN(date.getTime())) {
|
|
614
|
+
throw new Error("Invalid Postgres ISO datetime: " + isoString);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Parse fractional seconds manually (Postgres can include microseconds)
|
|
618
|
+
const match = isoString.match(/\.(\d+)(?=Z|[+-]\d\d:?\d\d$)/);
|
|
619
|
+
let nanos = 0;
|
|
620
|
+
|
|
621
|
+
if (match) {
|
|
622
|
+
let fractional = match[1]; // e.g., "789123"
|
|
623
|
+
if (fractional.length > 9) {
|
|
624
|
+
fractional = fractional.slice(0, 9); // trim to nanoseconds
|
|
625
|
+
}
|
|
626
|
+
nanos = parseInt((fractional + "000000000").slice(0, 9), 10);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const seconds = Math.floor(date.getTime() / 1000);
|
|
630
|
+
|
|
631
|
+
return new Timestamp(seconds, nanos);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Convert back to JS Date */
|
|
635
|
+
toDate() {
|
|
636
|
+
return new Date(this.seconds * 1000 + Math.floor(this.nanoseconds / 1e6));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Milliseconds since epoch */
|
|
640
|
+
toMillis() {
|
|
641
|
+
return this.seconds * 1000 + Math.floor(this.nanoseconds / 1e6);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** ISO string */
|
|
645
|
+
toString() {
|
|
646
|
+
return this.toDate().toISOString();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export const FieldValue = {
|
|
651
|
+
increment: (by = 1) => ({ _op: 'increment', by }),
|
|
652
|
+
serverTimestamp: () => ({ _op: 'serverTimestamp' }),
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
export const FieldPath = (path) => ({ _fieldPath: path });
|
|
656
|
+
|
|
657
|
+
export const documentId = () => "__id";
|