@x12i/helpers 1.0.1 → 1.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.
@@ -0,0 +1,471 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ let _singleton = null;
5
+
6
+ function fileExists(p) {
7
+ try {
8
+ fs.accessSync(p, fs.constants.F_OK);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function parseDotEnv(contents) {
16
+ const out = {};
17
+ const lines = String(contents || "").split(/\r?\n/);
18
+ for (const rawLine of lines) {
19
+ const line = rawLine.trim();
20
+ if (!line || line.startsWith("#")) continue;
21
+
22
+ const eq = line.indexOf("=");
23
+ if (eq === -1) continue;
24
+ const key = line.slice(0, eq).trim();
25
+ let val = line.slice(eq + 1).trim();
26
+
27
+ if (
28
+ (val.startsWith('"') && val.endsWith('"')) ||
29
+ (val.startsWith("'") && val.endsWith("'"))
30
+ ) {
31
+ val = val.slice(1, -1);
32
+ }
33
+
34
+ val = val
35
+ .replace(/\\n/g, "\n")
36
+ .replace(/\\r/g, "\r")
37
+ .replace(/\\t/g, "\t");
38
+
39
+ if (key) out[key] = val;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function loadEnvFromFile(envPath, { override = false } = {}) {
45
+ const p = envPath ? path.resolve(envPath) : path.resolve(process.cwd(), ".env");
46
+ if (!fileExists(p)) return { loaded: false, path: p };
47
+
48
+ const parsed = parseDotEnv(fs.readFileSync(p, "utf8"));
49
+ for (const [k, v] of Object.entries(parsed)) {
50
+ if (!override && process.env[k] !== undefined) continue;
51
+ process.env[k] = v;
52
+ }
53
+ return { loaded: true, path: p };
54
+ }
55
+
56
+ function deepGet(obj, p) {
57
+ if (!obj || typeof obj !== "object") return undefined;
58
+ const segs = String(p || "").split(".").filter(Boolean);
59
+ let cur = obj;
60
+ for (const s of segs) {
61
+ if (cur == null) return undefined;
62
+ cur = cur[s];
63
+ }
64
+ return cur;
65
+ }
66
+
67
+ function matchesFilter(doc, filter) {
68
+ if (!filter || typeof filter !== "object") return true;
69
+ for (const [k, expected] of Object.entries(filter)) {
70
+ if (k === "_id" || k === "id") {
71
+ if (doc?._id !== expected && doc?.id !== expected) return false;
72
+ continue;
73
+ }
74
+ const actual = deepGet(doc, k);
75
+ if (actual !== expected) return false;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function applyUpdateOperators(doc, update) {
81
+ if (!update || typeof update !== "object") return doc;
82
+ const out = doc && typeof doc === "object" ? { ...doc } : {};
83
+
84
+ const isOperatorDoc = Object.keys(update).some((k) => k.startsWith("$"));
85
+ if (!isOperatorDoc) return { ...out, ...update };
86
+
87
+ if (update.$set && typeof update.$set === "object") {
88
+ for (const [k, v] of Object.entries(update.$set)) out[k] = v;
89
+ }
90
+
91
+ if (update.$unset && typeof update.$unset === "object") {
92
+ for (const k of Object.keys(update.$unset)) delete out[k];
93
+ }
94
+
95
+ if (update.$inc && typeof update.$inc === "object") {
96
+ for (const [k, v] of Object.entries(update.$inc)) {
97
+ const cur = Number(out[k] ?? 0);
98
+ const by = Number(v ?? 0);
99
+ out[k] = cur + by;
100
+ }
101
+ }
102
+
103
+ if (update.$push && typeof update.$push === "object") {
104
+ for (const [k, v] of Object.entries(update.$push)) {
105
+ const cur = out[k];
106
+ const arr = Array.isArray(cur) ? cur.slice() : [];
107
+ arr.push(v);
108
+ out[k] = arr;
109
+ }
110
+ }
111
+
112
+ if (update.$pull && typeof update.$pull === "object") {
113
+ for (const [k, v] of Object.entries(update.$pull)) {
114
+ const cur = out[k];
115
+ if (!Array.isArray(cur)) continue;
116
+ out[k] = cur.filter((item) => item !== v);
117
+ }
118
+ }
119
+
120
+ return out;
121
+ }
122
+
123
+ function envFirst(...keys) {
124
+ for (const k of keys) {
125
+ const v = process.env[k];
126
+ if (v != null && String(v).trim() !== "") return v;
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ function readServiceAccountFromEnvOrOptions(opts = {}) {
132
+ const inlineJson =
133
+ opts.serviceAccountJson ??
134
+ envFirst("FIREBASE_SERVICE_ACCOUNT_JSON", "GOOGLE_SERVICE_ACCOUNT_JSON");
135
+ const base64 =
136
+ opts.serviceAccountBase64 ??
137
+ envFirst("FIREBASE_SERVICE_ACCOUNT_BASE64", "GOOGLE_SERVICE_ACCOUNT_BASE64");
138
+ let jsonPath =
139
+ opts.serviceAccountPath ??
140
+ envFirst("FIREBASE_SERVICE_ACCOUNT_PATH", "GOOGLE_APPLICATION_CREDENTIALS");
141
+
142
+ // Smart default for this repo: if nothing set, use .secrets/firebase-service-account.json when it exists
143
+ if (!jsonPath) {
144
+ const defaultSecretsPath = path.resolve(process.cwd(), ".secrets", "firebase-service-account.json");
145
+ if (fileExists(defaultSecretsPath)) jsonPath = defaultSecretsPath;
146
+ }
147
+
148
+ if (inlineJson) {
149
+ return JSON.parse(inlineJson);
150
+ }
151
+ if (base64) {
152
+ const decoded = Buffer.from(String(base64), "base64").toString("utf8");
153
+ return JSON.parse(decoded);
154
+ }
155
+ if (jsonPath) {
156
+ const p = path.resolve(String(jsonPath));
157
+ const raw = fs.readFileSync(p, "utf8");
158
+ return JSON.parse(raw);
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function normalizeDatabaseUrl(url) {
164
+ const s = String(url || "").trim();
165
+ if (!s) return "";
166
+ return s.endsWith("/") ? s.slice(0, -1) : s;
167
+ }
168
+
169
+ function initFirebaseRtdb(options = {}) {
170
+ const {
171
+ envPath = ".env",
172
+ envOverride = false,
173
+ singleton = true,
174
+ appName,
175
+ } = options;
176
+
177
+ loadEnvFromFile(envPath, { override: envOverride });
178
+
179
+ const credentialJson = readServiceAccountFromEnvOrOptions(options);
180
+ if (!credentialJson) {
181
+ throw new Error(
182
+ "firebaseRtdb.init: missing service account credentials (set FIREBASE_SERVICE_ACCOUNT_PATH to .secrets/firebase-service-account.json, or set FIREBASE_SERVICE_ACCOUNT_JSON, or pass { serviceAccountPath/serviceAccountJson })"
183
+ );
184
+ }
185
+
186
+ const databaseURL = normalizeDatabaseUrl(
187
+ options.databaseURL ?? envFirst("FIREBASE_DATABASE_URL", "FIREBASE_DB_URL")
188
+ );
189
+ if (!databaseURL) {
190
+ throw new Error(
191
+ "firebaseRtdb.init: missing database URL (set FIREBASE_DATABASE_URL in .env or pass { databaseURL })"
192
+ );
193
+ }
194
+
195
+ // Lazy require so consumers that don't use Firebase don't pay cost on import.
196
+ const admin = require("firebase-admin");
197
+
198
+ const name = appName ?? envFirst("FIREBASE_APP_NAME") ?? undefined;
199
+ const existing = admin.apps.find((a) => a.name === (name || "[DEFAULT]"));
200
+ const app =
201
+ existing ||
202
+ admin.initializeApp(
203
+ {
204
+ credential: admin.credential.cert(credentialJson),
205
+ databaseURL,
206
+ },
207
+ name
208
+ );
209
+
210
+ const db = admin.database(app);
211
+ const client = createMongoLikeRtdbClient({ admin, app, db });
212
+
213
+ if (singleton) _singleton = client;
214
+ return client;
215
+ }
216
+
217
+ function getFirebaseRtdb() {
218
+ if (!_singleton) {
219
+ throw new Error(
220
+ "firebaseRtdb.get: not initialized. Call initFirebaseRtdb() first."
221
+ );
222
+ }
223
+ return _singleton;
224
+ }
225
+
226
+ function createMongoLikeRtdbClient({ admin, app, db }) {
227
+ function ref(p = "/") {
228
+ const clean = String(p || "/").replace(/^\/+/, "");
229
+ return clean ? db.ref(clean) : db.ref();
230
+ }
231
+
232
+ function collection(name) {
233
+ if (!name) throw new TypeError("collection(name) requires a name");
234
+ const basePath = String(name).replace(/^\/+|\/+$/g, "");
235
+ const baseRef = ref(basePath);
236
+
237
+ function doc(id) {
238
+ if (!id) throw new TypeError("doc(id) requires an id");
239
+ const idStr = String(id);
240
+ const r = baseRef.child(idStr);
241
+
242
+ return {
243
+ id: idStr,
244
+ ref: r,
245
+ async get() {
246
+ const snap = await r.get();
247
+ const val = snap.val();
248
+ if (val == null) return null;
249
+ return { _id: idStr, ...val };
250
+ },
251
+ async set(value, { merge = false } = {}) {
252
+ if (!merge) {
253
+ await r.set(value);
254
+ return { acknowledged: true, _id: idStr };
255
+ }
256
+ const cur = (await r.get()).val() || {};
257
+ await r.set({ ...cur, ...value });
258
+ return { acknowledged: true, _id: idStr };
259
+ },
260
+ async update(update) {
261
+ const cur = (await r.get()).val();
262
+ if (cur == null) return { acknowledged: true, matchedCount: 0, modifiedCount: 0 };
263
+ const next = applyUpdateOperators(cur, update);
264
+ await r.set(next);
265
+ return { acknowledged: true, matchedCount: 1, modifiedCount: 1 };
266
+ },
267
+ async delete() {
268
+ const cur = (await r.get()).exists();
269
+ await r.remove();
270
+ return { acknowledged: true, deletedCount: cur ? 1 : 0 };
271
+ },
272
+ };
273
+ }
274
+
275
+ async function _readAll({ query } = {}) {
276
+ if (query) {
277
+ const snap = await query.get();
278
+ const val = snap.val();
279
+ if (!val || typeof val !== "object") return [];
280
+ return Object.entries(val).map(([id, data]) => ({ _id: id, ...data }));
281
+ }
282
+
283
+ const snap = await baseRef.get();
284
+ const val = snap.val();
285
+ if (!val || typeof val !== "object") return [];
286
+ return Object.entries(val).map(([id, data]) => ({ _id: id, ...data }));
287
+ }
288
+
289
+ function _buildQuery(q) {
290
+ let queryRef = baseRef;
291
+ if (!q || typeof q !== "object") return queryRef;
292
+ if (q.orderByChild) queryRef = queryRef.orderByChild(String(q.orderByChild));
293
+ else if (q.orderByKey) queryRef = queryRef.orderByKey();
294
+ else if (q.orderByValue) queryRef = queryRef.orderByValue();
295
+
296
+ if (q.equalTo !== undefined) queryRef = queryRef.equalTo(q.equalTo);
297
+ if (q.startAt !== undefined) queryRef = queryRef.startAt(q.startAt);
298
+ if (q.endAt !== undefined) queryRef = queryRef.endAt(q.endAt);
299
+ if (q.limitToFirst !== undefined) queryRef = queryRef.limitToFirst(Number(q.limitToFirst));
300
+ if (q.limitToLast !== undefined) queryRef = queryRef.limitToLast(Number(q.limitToLast));
301
+
302
+ return queryRef;
303
+ }
304
+
305
+ return {
306
+ name: basePath,
307
+ ref: baseRef,
308
+ doc,
309
+
310
+ /**
311
+ * Mongo-ish query builder for RTDB native queries:
312
+ * col.query({ orderByChild: "email", equalTo: "a@b.com", limitToFirst: 10 }).find()
313
+ */
314
+ query(q) {
315
+ const built = _buildQuery(q);
316
+ return {
317
+ ref: built,
318
+ async find(filter, opts) {
319
+ return (await _readAll({ query: built })).filter((d) => matchesFilter(d, filter));
320
+ },
321
+ async findOne(filter, opts) {
322
+ const all = await _readAll({ query: built });
323
+ return all.find((d) => matchesFilter(d, filter)) || null;
324
+ },
325
+ };
326
+ },
327
+
328
+ async find(filter = {}, opts = {}) {
329
+ const { limit } = opts || {};
330
+ const docs = (await _readAll()).filter((d) => matchesFilter(d, filter));
331
+ return typeof limit === "number" ? docs.slice(0, limit) : docs;
332
+ },
333
+
334
+ async findOne(filter = {}, opts = {}) {
335
+ const { sortBy, sortDir = "asc" } = opts || {};
336
+ let docs = (await _readAll()).filter((d) => matchesFilter(d, filter));
337
+ if (sortBy) {
338
+ const dir = String(sortDir).toLowerCase() === "desc" ? -1 : 1;
339
+ docs = docs.sort((a, b) => {
340
+ const av = deepGet(a, sortBy);
341
+ const bv = deepGet(b, sortBy);
342
+ if (av === bv) return 0;
343
+ if (av == null) return 1;
344
+ if (bv == null) return -1;
345
+ return av > bv ? dir : -dir;
346
+ });
347
+ }
348
+ return docs[0] || null;
349
+ },
350
+
351
+ async insertOne(document, opts = {}) {
352
+ const { id } = opts || {};
353
+ const docToWrite = document && typeof document === "object" ? { ...document } : {};
354
+ delete docToWrite._id;
355
+ delete docToWrite.id;
356
+
357
+ if (id) {
358
+ const idStr = String(id);
359
+ await baseRef.child(idStr).set(docToWrite);
360
+ return { acknowledged: true, insertedId: idStr };
361
+ }
362
+
363
+ const newRef = baseRef.push();
364
+ await newRef.set(docToWrite);
365
+ return { acknowledged: true, insertedId: newRef.key };
366
+ },
367
+
368
+ async insertMany(documents, opts = {}) {
369
+ if (!Array.isArray(documents)) throw new TypeError("insertMany(documents) expects an array");
370
+ const ids = [];
371
+ for (const d of documents) {
372
+ // eslint-disable-next-line no-await-in-loop
373
+ const r = await this.insertOne(d, opts);
374
+ ids.push(r.insertedId);
375
+ }
376
+ return { acknowledged: true, insertedCount: ids.length, insertedIds: ids };
377
+ },
378
+
379
+ async updateOne(filter, update, opts = {}) {
380
+ const { upsert = false } = opts || {};
381
+ const found = await this.findOne(filter);
382
+ if (!found) {
383
+ if (!upsert) return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
384
+ const insertDoc = applyUpdateOperators({}, update);
385
+ const r = await this.insertOne(insertDoc);
386
+ return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: r.insertedId };
387
+ }
388
+
389
+ const next = applyUpdateOperators(found, update);
390
+ const { _id } = found;
391
+ const payload = { ...next };
392
+ delete payload._id;
393
+ await baseRef.child(String(_id)).set(payload);
394
+ return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
395
+ },
396
+
397
+ async updateMany(filter, update, opts = {}) {
398
+ const docs = await this.find(filter);
399
+ if (!docs.length) return { acknowledged: true, matchedCount: 0, modifiedCount: 0 };
400
+
401
+ const updates = {};
402
+ for (const d of docs) {
403
+ const next = applyUpdateOperators(d, update);
404
+ const payload = { ...next };
405
+ delete payload._id;
406
+ updates[String(d._id)] = payload;
407
+ }
408
+ await baseRef.update(updates);
409
+ return { acknowledged: true, matchedCount: docs.length, modifiedCount: docs.length };
410
+ },
411
+
412
+ async replaceOne(filter, replacement, opts = {}) {
413
+ const { upsert = false } = opts || {};
414
+ const found = await this.findOne(filter);
415
+ if (!found) {
416
+ if (!upsert) return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
417
+ const r = await this.insertOne(replacement);
418
+ return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: r.insertedId };
419
+ }
420
+ const payload = replacement && typeof replacement === "object" ? { ...replacement } : {};
421
+ delete payload._id;
422
+ delete payload.id;
423
+ await baseRef.child(String(found._id)).set(payload);
424
+ return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
425
+ },
426
+
427
+ async deleteOne(filter) {
428
+ const found = await this.findOne(filter);
429
+ if (!found) return { acknowledged: true, deletedCount: 0 };
430
+ await baseRef.child(String(found._id)).remove();
431
+ return { acknowledged: true, deletedCount: 1 };
432
+ },
433
+
434
+ async deleteMany(filter) {
435
+ const docs = await this.find(filter);
436
+ if (!docs.length) return { acknowledged: true, deletedCount: 0 };
437
+ const updates = {};
438
+ for (const d of docs) updates[String(d._id)] = null;
439
+ await baseRef.update(updates);
440
+ return { acknowledged: true, deletedCount: docs.length };
441
+ },
442
+
443
+ async countDocuments(filter = {}) {
444
+ const docs = await this.find(filter);
445
+ return docs.length;
446
+ },
447
+ };
448
+ }
449
+
450
+ return {
451
+ admin,
452
+ app,
453
+ db,
454
+ ref,
455
+ collection,
456
+ };
457
+ }
458
+
459
+ module.exports = {
460
+ initFirebaseRtdb,
461
+ getFirebaseRtdb,
462
+ // for advanced usage / tests
463
+ _internal: {
464
+ loadEnvFromFile,
465
+ parseDotEnv,
466
+ applyUpdateOperators,
467
+ matchesFilter,
468
+ createMongoLikeRtdbClient,
469
+ },
470
+ };
471
+
@@ -0,0 +1,3 @@
1
+ // Back-compat alias: earlier drafts referenced `json-mapper.js`.
2
+ module.exports = require("./objectsMapper.js");
3
+