@fyrestack/database 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1229 -0
- package/dist/index.d.cts +1071 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1071 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1179 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +2 -2
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/context.ts
|
|
3
|
+
/**
|
|
4
|
+
* Detect if we're running in Node.js environment
|
|
5
|
+
*/
|
|
6
|
+
const isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
7
|
+
/**
|
|
8
|
+
* Browser shim for AsyncLocalStorage.
|
|
9
|
+
* Note: This implementation has limited support for parallel async operations.
|
|
10
|
+
* In browsers, parallel context isolation may not work correctly.
|
|
11
|
+
* For full isolation, use Node.js with native AsyncLocalStorage.
|
|
12
|
+
*/
|
|
13
|
+
var AsyncLocalStorageShim = class {
|
|
14
|
+
constructor() {
|
|
15
|
+
this._store = void 0;
|
|
16
|
+
this._stack = [];
|
|
17
|
+
}
|
|
18
|
+
run(store, callback) {
|
|
19
|
+
this._stack.push(this._store);
|
|
20
|
+
this._store = store;
|
|
21
|
+
try {
|
|
22
|
+
const result = callback();
|
|
23
|
+
if (result && typeof result.then === "function") return result.finally(() => {
|
|
24
|
+
this._store = this._stack.pop();
|
|
25
|
+
});
|
|
26
|
+
this._store = this._stack.pop();
|
|
27
|
+
return result;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
this._store = this._stack.pop();
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
getStore() {
|
|
34
|
+
return this._store;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Get the AsyncLocalStorage implementation.
|
|
39
|
+
* Uses native AsyncLocalStorage in Node.js, falls back to shim in browsers.
|
|
40
|
+
*/
|
|
41
|
+
function getAsyncLocalStorageClass() {
|
|
42
|
+
if (isNode) try {
|
|
43
|
+
const dynamicRequire = eval("typeof require !== \"undefined\" ? require : undefined");
|
|
44
|
+
if (dynamicRequire) return dynamicRequire("async_hooks").AsyncLocalStorage;
|
|
45
|
+
} catch {}
|
|
46
|
+
return AsyncLocalStorageShim;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The async context storage that holds the current operation context.
|
|
50
|
+
* This propagates automatically through async/await chains.
|
|
51
|
+
*/
|
|
52
|
+
const AsyncLocalStorageClass = getAsyncLocalStorageClass();
|
|
53
|
+
const operationContext = new AsyncLocalStorageClass();
|
|
54
|
+
/**
|
|
55
|
+
* Get the current operation context, if any.
|
|
56
|
+
* Returns undefined when not inside a transaction or batch.
|
|
57
|
+
*/
|
|
58
|
+
function getCurrentContext() {
|
|
59
|
+
return operationContext.getStore();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Check if we're currently inside a transaction.
|
|
63
|
+
*/
|
|
64
|
+
function isInTransaction() {
|
|
65
|
+
return operationContext.getStore()?.type === "transaction";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check if we're currently inside a batch.
|
|
69
|
+
*/
|
|
70
|
+
function isInBatch() {
|
|
71
|
+
return operationContext.getStore()?.type === "batch";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get the current transaction, if inside one.
|
|
75
|
+
* Returns undefined when not in a transaction.
|
|
76
|
+
*/
|
|
77
|
+
function getCurrentTransaction() {
|
|
78
|
+
const ctx = operationContext.getStore();
|
|
79
|
+
return ctx?.type === "transaction" ? ctx.tx : void 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get the current batch, if inside one.
|
|
83
|
+
* Returns undefined when not in a batch.
|
|
84
|
+
*/
|
|
85
|
+
function getCurrentBatch() {
|
|
86
|
+
const ctx = operationContext.getStore();
|
|
87
|
+
return ctx?.type === "batch" ? ctx.batch : void 0;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Run a function within a transaction context.
|
|
91
|
+
* @internal Used by runTransaction()
|
|
92
|
+
*/
|
|
93
|
+
function runWithTransaction(tx, fn) {
|
|
94
|
+
const ctx = {
|
|
95
|
+
type: "transaction",
|
|
96
|
+
tx
|
|
97
|
+
};
|
|
98
|
+
return operationContext.run(ctx, fn);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Run a function within a batch context.
|
|
102
|
+
* @internal Used by writeBatch()
|
|
103
|
+
*/
|
|
104
|
+
function runWithBatch(batch, fn) {
|
|
105
|
+
const ctx = {
|
|
106
|
+
type: "batch",
|
|
107
|
+
batch
|
|
108
|
+
};
|
|
109
|
+
return operationContext.run(ctx, fn);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Type guard for transaction context.
|
|
113
|
+
*/
|
|
114
|
+
function isTransactionContext(ctx) {
|
|
115
|
+
return ctx?.type === "transaction";
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Type guard for batch context.
|
|
119
|
+
*/
|
|
120
|
+
function isBatchContext(ctx) {
|
|
121
|
+
return ctx?.type === "batch";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/errors.ts
|
|
126
|
+
/**
|
|
127
|
+
* FyreStack Custom Error Types
|
|
128
|
+
*
|
|
129
|
+
* Provides typed errors for better error handling and debugging.
|
|
130
|
+
*/
|
|
131
|
+
/**
|
|
132
|
+
* Base error class for all FyreStack errors.
|
|
133
|
+
*/
|
|
134
|
+
var FyreStackError = class extends Error {
|
|
135
|
+
constructor(message, code) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.name = "FyreStackError";
|
|
138
|
+
this.code = code;
|
|
139
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Error thrown when validation fails.
|
|
144
|
+
*/
|
|
145
|
+
var ValidationError = class ValidationError extends FyreStackError {
|
|
146
|
+
constructor(message, issues = []) {
|
|
147
|
+
super(message, "VALIDATION_ERROR");
|
|
148
|
+
this.name = "ValidationError";
|
|
149
|
+
this.issues = issues;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Create a ValidationError from Standard Schema validation issues.
|
|
153
|
+
*/
|
|
154
|
+
static fromStandardSchema(issues) {
|
|
155
|
+
const mappedIssues = issues.map((issue) => ({
|
|
156
|
+
path: issue.path?.map((p) => typeof p === "object" ? String(p.key) : String(p)) ?? [],
|
|
157
|
+
message: issue.message
|
|
158
|
+
}));
|
|
159
|
+
return new ValidationError(mappedIssues.length > 0 && mappedIssues[0] !== void 0 ? mappedIssues.length === 1 ? mappedIssues[0].message : `Validation failed with ${String(mappedIssues.length)} issues` : "Validation failed", mappedIssues);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* Error thrown when path parameters are invalid.
|
|
164
|
+
*/
|
|
165
|
+
var PathParamError = class extends FyreStackError {
|
|
166
|
+
constructor(paramName, paramValue, reason) {
|
|
167
|
+
super(`Invalid path parameter "${paramName}": ${reason}`, "PATH_PARAM_ERROR");
|
|
168
|
+
this.name = "PathParamError";
|
|
169
|
+
this.paramName = paramName;
|
|
170
|
+
this.paramValue = paramValue;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Error thrown when Firestore adapter is not initialized.
|
|
175
|
+
*/
|
|
176
|
+
var AdapterNotInitializedError = class extends FyreStackError {
|
|
177
|
+
constructor() {
|
|
178
|
+
super("Firestore adapter not initialized. Call setFirestoreAdapter() before using repositories.", "ADAPTER_NOT_INITIALIZED");
|
|
179
|
+
this.name = "AdapterNotInitializedError";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Error thrown when a model is missing required properties.
|
|
184
|
+
*/
|
|
185
|
+
var ModelError = class extends FyreStackError {
|
|
186
|
+
constructor(message) {
|
|
187
|
+
super(message, "MODEL_ERROR");
|
|
188
|
+
this.name = "ModelError";
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Error thrown when a document is not found.
|
|
193
|
+
*/
|
|
194
|
+
var NotFoundError = class extends FyreStackError {
|
|
195
|
+
constructor(documentPath) {
|
|
196
|
+
super(`Document not found: ${documentPath}`, "NOT_FOUND");
|
|
197
|
+
this.name = "NotFoundError";
|
|
198
|
+
this.documentPath = documentPath;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Error thrown when a Firestore operation fails.
|
|
203
|
+
*/
|
|
204
|
+
var FirestoreError = class FirestoreError extends FyreStackError {
|
|
205
|
+
constructor(message, originalError) {
|
|
206
|
+
super(message, "FIRESTORE_ERROR");
|
|
207
|
+
this.name = "FirestoreError";
|
|
208
|
+
this.originalError = originalError;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Wrap a Firestore error with context.
|
|
212
|
+
*/
|
|
213
|
+
static wrap(operation, error) {
|
|
214
|
+
return new FirestoreError(`Firestore ${operation} failed: ${error instanceof Error ? error.message : "Unknown error"}`, error);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Helper to check if a ValidationResult is valid.
|
|
219
|
+
*/
|
|
220
|
+
function isValidResult(result) {
|
|
221
|
+
return result.valid;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/firestore.types.ts
|
|
226
|
+
/**
|
|
227
|
+
* Abstract Firestore types for dependency injection.
|
|
228
|
+
* Allows switching between firebase-admin and client-side firebase.
|
|
229
|
+
*/
|
|
230
|
+
/**
|
|
231
|
+
* Type guard to check if a value is a ServerTimestampSentinel.
|
|
232
|
+
* Note: This checks the runtime structure, not the brand.
|
|
233
|
+
*/
|
|
234
|
+
function isServerTimestamp(value) {
|
|
235
|
+
return value !== null && typeof value === "object" && "__type__" in value && value.__type__ === "serverTimestamp";
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Global Firestore adapter instance holder
|
|
239
|
+
*/
|
|
240
|
+
let firestoreAdapter = null;
|
|
241
|
+
/**
|
|
242
|
+
* Set the Firestore adapter to use (call once at app initialization)
|
|
243
|
+
*/
|
|
244
|
+
function setFirestoreAdapter(adapter) {
|
|
245
|
+
firestoreAdapter = adapter;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Get the current Firestore adapter
|
|
249
|
+
* @throws AdapterNotInitializedError if adapter not initialized
|
|
250
|
+
*/
|
|
251
|
+
function getFirestoreAdapter() {
|
|
252
|
+
if (!firestoreAdapter) throw new AdapterNotInitializedError();
|
|
253
|
+
return firestoreAdapter;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Reset the Firestore adapter (for testing only)
|
|
257
|
+
* @internal
|
|
258
|
+
*/
|
|
259
|
+
function _resetFirestoreAdapter() {
|
|
260
|
+
firestoreAdapter = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/batch.ts
|
|
265
|
+
/**
|
|
266
|
+
* Batch Write Support for FyreStack Database
|
|
267
|
+
*
|
|
268
|
+
* Provides atomic write-only operations across multiple repositories.
|
|
269
|
+
* Context is automatically propagated using AsyncContext.
|
|
270
|
+
*/
|
|
271
|
+
/**
|
|
272
|
+
* Maximum operations per Firestore batch.
|
|
273
|
+
*/
|
|
274
|
+
const MAX_BATCH_SIZE = 500;
|
|
275
|
+
/**
|
|
276
|
+
* Run operations within a write batch.
|
|
277
|
+
*
|
|
278
|
+
* Batches provide:
|
|
279
|
+
* - Atomic writes (all succeed or all fail)
|
|
280
|
+
* - Better performance than individual writes
|
|
281
|
+
* - Up to 500 operations per batch
|
|
282
|
+
* - Works across multiple repositories
|
|
283
|
+
*
|
|
284
|
+
* The batch context is **automatically propagated** to all repository
|
|
285
|
+
* methods called within the batch function - no need to pass context explicitly.
|
|
286
|
+
*
|
|
287
|
+
* Note: Batches are write-only. Use `runTransaction` if you need reads.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```typescript
|
|
291
|
+
* // Bulk create users
|
|
292
|
+
* await writeBatch(async () => {
|
|
293
|
+
* for (const user of users) {
|
|
294
|
+
* await UserRepo.save({}, user);
|
|
295
|
+
* }
|
|
296
|
+
* });
|
|
297
|
+
* ```
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```typescript
|
|
301
|
+
* // Cross-repository batch write
|
|
302
|
+
* await writeBatch(async () => {
|
|
303
|
+
* await UserRepo.save({}, user);
|
|
304
|
+
* await ProfileRepo.save({ userId: user.id }, profile);
|
|
305
|
+
* await AuditRepo.save({}, auditLog);
|
|
306
|
+
* });
|
|
307
|
+
* ```
|
|
308
|
+
*
|
|
309
|
+
* @param fn - Async function to run within the batch
|
|
310
|
+
*/
|
|
311
|
+
async function writeBatch(fn) {
|
|
312
|
+
const batch = getFirestoreAdapter().batch();
|
|
313
|
+
await runWithBatch(batch, fn);
|
|
314
|
+
await batch.commit();
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Run operations with automatic batch chunking for large datasets.
|
|
318
|
+
*
|
|
319
|
+
* Automatically commits and creates new batches when exceeding 500 operations.
|
|
320
|
+
* Useful for migrations or bulk imports with thousands of records.
|
|
321
|
+
*
|
|
322
|
+
* The batch context is **automatically propagated** to all repository
|
|
323
|
+
* methods called within the function.
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* ```typescript
|
|
327
|
+
* // Process 10,000 records with automatic batching
|
|
328
|
+
* await writeChunkedBatch(async () => {
|
|
329
|
+
* for (const user of thousandsOfUsers) {
|
|
330
|
+
* await UserRepo.save({}, user);
|
|
331
|
+
* // Automatically commits every 500 operations
|
|
332
|
+
* }
|
|
333
|
+
* });
|
|
334
|
+
* ```
|
|
335
|
+
*
|
|
336
|
+
* @param fn - Function to run with automatic batch chunking
|
|
337
|
+
*/
|
|
338
|
+
async function writeChunkedBatch(fn) {
|
|
339
|
+
const adapter = getFirestoreAdapter();
|
|
340
|
+
const state = {
|
|
341
|
+
batch: adapter.batch(),
|
|
342
|
+
operationCount: 0
|
|
343
|
+
};
|
|
344
|
+
const flushIfNeeded = async () => {
|
|
345
|
+
if (state.operationCount >= MAX_BATCH_SIZE) {
|
|
346
|
+
await state.batch.commit();
|
|
347
|
+
state.batch = adapter.batch();
|
|
348
|
+
state.operationCount = 0;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const trackedBatch = {
|
|
352
|
+
set(docRef, data) {
|
|
353
|
+
state.batch.set(docRef, data);
|
|
354
|
+
state.operationCount++;
|
|
355
|
+
flushIfNeeded();
|
|
356
|
+
return trackedBatch;
|
|
357
|
+
},
|
|
358
|
+
update(docRef, data) {
|
|
359
|
+
state.batch.update(docRef, data);
|
|
360
|
+
state.operationCount++;
|
|
361
|
+
flushIfNeeded();
|
|
362
|
+
return trackedBatch;
|
|
363
|
+
},
|
|
364
|
+
delete(docRef) {
|
|
365
|
+
state.batch.delete(docRef);
|
|
366
|
+
state.operationCount++;
|
|
367
|
+
flushIfNeeded();
|
|
368
|
+
return trackedBatch;
|
|
369
|
+
},
|
|
370
|
+
async commit() {}
|
|
371
|
+
};
|
|
372
|
+
await runWithBatch(trackedBatch, fn);
|
|
373
|
+
if (state.operationCount > 0) await state.batch.commit();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/model.hooks.ts
|
|
378
|
+
/**
|
|
379
|
+
* Merge multiple repository hooks into a single hooks object.
|
|
380
|
+
* When multiple hooks define the same method, they are chained in order.
|
|
381
|
+
*/
|
|
382
|
+
function mergeRepositoryHooks(...hooksList) {
|
|
383
|
+
const filtered = hooksList.filter((h) => h !== void 0);
|
|
384
|
+
if (filtered.length === 0) return {};
|
|
385
|
+
if (filtered.length === 1) return filtered[0] ?? {};
|
|
386
|
+
const result = {};
|
|
387
|
+
const beforeSave = chainDataHooks(filtered.map((h) => h.beforeSave));
|
|
388
|
+
if (beforeSave) result.beforeSave = beforeSave;
|
|
389
|
+
const afterSave = chainVoidHooks(filtered.map((h) => h.afterSave));
|
|
390
|
+
if (afterSave) result.afterSave = afterSave;
|
|
391
|
+
const beforeUpdate = chainDataHooks(filtered.map((h) => h.beforeUpdate));
|
|
392
|
+
if (beforeUpdate) result.beforeUpdate = beforeUpdate;
|
|
393
|
+
const afterUpdate = chainVoidHooks(filtered.map((h) => h.afterUpdate));
|
|
394
|
+
if (afterUpdate) result.afterUpdate = afterUpdate;
|
|
395
|
+
const beforeDelete = chainVoidHooksNoData(filtered.map((h) => h.beforeDelete));
|
|
396
|
+
if (beforeDelete) result.beforeDelete = beforeDelete;
|
|
397
|
+
const afterDelete = chainVoidHooksNoData(filtered.map((h) => h.afterDelete));
|
|
398
|
+
if (afterDelete) result.afterDelete = afterDelete;
|
|
399
|
+
const afterGet = chainNullableDataHooks(filtered.map((h) => h.afterGet));
|
|
400
|
+
if (afterGet) result.afterGet = afterGet;
|
|
401
|
+
const afterFind = chainArrayDataHooks(filtered.map((h) => h.afterFind));
|
|
402
|
+
if (afterFind) result.afterFind = afterFind;
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Merge multiple model hooks into a single hooks object.
|
|
407
|
+
*/
|
|
408
|
+
function mergeModelHooks(...hooksList) {
|
|
409
|
+
const filtered = hooksList.filter((h) => h !== void 0);
|
|
410
|
+
if (filtered.length === 0) return {};
|
|
411
|
+
if (filtered.length === 1) return filtered[0] ?? {};
|
|
412
|
+
const result = {};
|
|
413
|
+
const onConstruct = chainModelDataHooks(filtered.map((h) => h.onConstruct));
|
|
414
|
+
if (onConstruct) result.onConstruct = onConstruct;
|
|
415
|
+
const onValidate = chainModelVoidHooks(filtered.map((h) => h.onValidate));
|
|
416
|
+
if (onValidate) result.onValidate = onValidate;
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Merge feature hooks (combines both model and repository hooks).
|
|
421
|
+
*/
|
|
422
|
+
function mergeFeatureHooks(...hooksList) {
|
|
423
|
+
const filtered = hooksList.filter((h) => h !== void 0);
|
|
424
|
+
if (filtered.length === 0) return {};
|
|
425
|
+
return {
|
|
426
|
+
model: mergeModelHooks(...filtered.map((h) => h.model)),
|
|
427
|
+
repository: mergeRepositoryHooks(...filtered.map((h) => h.repository))
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function chainDataHooks(hooks) {
|
|
431
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
432
|
+
if (filtered.length === 0) return void 0;
|
|
433
|
+
if (filtered.length === 1) return filtered[0];
|
|
434
|
+
return (data, context) => {
|
|
435
|
+
let result = data;
|
|
436
|
+
for (const hook of filtered) result = hook(result, context);
|
|
437
|
+
return result;
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function chainVoidHooks(hooks) {
|
|
441
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
442
|
+
if (filtered.length === 0) return void 0;
|
|
443
|
+
if (filtered.length === 1) return filtered[0];
|
|
444
|
+
return (data, context) => {
|
|
445
|
+
for (const hook of filtered) hook(data, context);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function chainVoidHooksNoData(hooks) {
|
|
449
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
450
|
+
if (filtered.length === 0) return void 0;
|
|
451
|
+
if (filtered.length === 1) return filtered[0];
|
|
452
|
+
return (context) => {
|
|
453
|
+
for (const hook of filtered) hook(context);
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function chainNullableDataHooks(hooks) {
|
|
457
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
458
|
+
if (filtered.length === 0) return void 0;
|
|
459
|
+
if (filtered.length === 1) return filtered[0];
|
|
460
|
+
return (data, context) => {
|
|
461
|
+
let result = data;
|
|
462
|
+
for (const hook of filtered) result = hook(result, context);
|
|
463
|
+
return result;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function chainArrayDataHooks(hooks) {
|
|
467
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
468
|
+
if (filtered.length === 0) return void 0;
|
|
469
|
+
if (filtered.length === 1) return filtered[0];
|
|
470
|
+
return (data, context) => {
|
|
471
|
+
let result = data;
|
|
472
|
+
for (const hook of filtered) result = hook(result, context);
|
|
473
|
+
return result;
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function chainModelDataHooks(hooks) {
|
|
477
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
478
|
+
if (filtered.length === 0) return void 0;
|
|
479
|
+
if (filtered.length === 1) return filtered[0];
|
|
480
|
+
return (props, context) => {
|
|
481
|
+
let result = props;
|
|
482
|
+
for (const hook of filtered) result = hook(result, context);
|
|
483
|
+
return result;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function chainModelVoidHooks(hooks) {
|
|
487
|
+
const filtered = hooks.filter((h) => h !== void 0);
|
|
488
|
+
if (filtered.length === 0) return void 0;
|
|
489
|
+
if (filtered.length === 1) return filtered[0];
|
|
490
|
+
return (data, context) => {
|
|
491
|
+
for (const hook of filtered) hook(data, context);
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/model.metadata.ts
|
|
497
|
+
/**
|
|
498
|
+
* Symbol used to store metadata on model classes.
|
|
499
|
+
* Using a symbol prevents conflicts with user-defined properties.
|
|
500
|
+
*/
|
|
501
|
+
const MODEL_METADATA = Symbol.for("fyrestack.model.metadata");
|
|
502
|
+
/**
|
|
503
|
+
* Get metadata from a model class.
|
|
504
|
+
*/
|
|
505
|
+
function getModelMetadata(ModelClass) {
|
|
506
|
+
if (ModelClass !== null && typeof ModelClass === "function" && MODEL_METADATA in ModelClass) return ModelClass[MODEL_METADATA];
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Get repository hooks from a model class.
|
|
510
|
+
*/
|
|
511
|
+
function getRepositoryHooks(ModelClass) {
|
|
512
|
+
return getModelMetadata(ModelClass)?.hooks?.repository;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Get model hooks from a model class.
|
|
516
|
+
*/
|
|
517
|
+
function getModelHooks(ModelClass) {
|
|
518
|
+
return getModelMetadata(ModelClass)?.hooks?.model;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Check if a model has any repository hooks registered.
|
|
522
|
+
*/
|
|
523
|
+
function hasRepositoryHooks(ModelClass) {
|
|
524
|
+
const hooks = getRepositoryHooks(ModelClass);
|
|
525
|
+
return hooks !== void 0 && Object.keys(hooks).length > 0;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Check if a model has a specific feature registered.
|
|
529
|
+
*/
|
|
530
|
+
function hasFeature(ModelClass, featureName) {
|
|
531
|
+
return getModelMetadata(ModelClass)?.features?.[featureName] !== void 0;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Get configuration for a specific feature.
|
|
535
|
+
*/
|
|
536
|
+
function getFeatureConfig(ModelClass, featureName) {
|
|
537
|
+
return getModelMetadata(ModelClass)?.features?.[featureName];
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Create metadata for a feature.
|
|
541
|
+
* Used by feature functions like withTimestamps() to build metadata.
|
|
542
|
+
*
|
|
543
|
+
* @param featureName - Unique name for the feature
|
|
544
|
+
* @param config - Feature-specific configuration (for introspection)
|
|
545
|
+
* @param hooks - Hooks to register
|
|
546
|
+
* @param existingMetadata - Existing metadata to merge with
|
|
547
|
+
*/
|
|
548
|
+
function createFeatureMetadata(featureName, config, hooks, existingMetadata) {
|
|
549
|
+
return {
|
|
550
|
+
hooks: mergeFeatureHooks(existingMetadata?.hooks, hooks),
|
|
551
|
+
features: {
|
|
552
|
+
...existingMetadata?.features,
|
|
553
|
+
[featureName]: config
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Default timestamp configuration.
|
|
559
|
+
*/
|
|
560
|
+
const DEFAULT_TIMESTAMPS = {
|
|
561
|
+
createdAt: "createdAt",
|
|
562
|
+
updatedAt: "updatedAt",
|
|
563
|
+
useServerTimestamp: true
|
|
564
|
+
};
|
|
565
|
+
/**
|
|
566
|
+
* Default audit configuration.
|
|
567
|
+
*/
|
|
568
|
+
const DEFAULT_AUDIT = {
|
|
569
|
+
createdBy: "createdBy",
|
|
570
|
+
updatedBy: "updatedBy"
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* Default status configuration.
|
|
574
|
+
*/
|
|
575
|
+
const DEFAULT_STATUS = {
|
|
576
|
+
field: "status",
|
|
577
|
+
values: [
|
|
578
|
+
"active",
|
|
579
|
+
"inactive",
|
|
580
|
+
"deleted"
|
|
581
|
+
],
|
|
582
|
+
defaultValue: "active",
|
|
583
|
+
deletedValue: "deleted",
|
|
584
|
+
deletedAt: "deletedAt"
|
|
585
|
+
};
|
|
586
|
+
/**
|
|
587
|
+
* Check if a model has timestamps feature.
|
|
588
|
+
*/
|
|
589
|
+
function hasTimestamps(ModelClass) {
|
|
590
|
+
return hasFeature(ModelClass, "timestamps");
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Get timestamps configuration from a model class.
|
|
594
|
+
*/
|
|
595
|
+
function getTimestampsConfig(ModelClass) {
|
|
596
|
+
return getFeatureConfig(ModelClass, "timestamps");
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Check if a model has audit feature.
|
|
600
|
+
*/
|
|
601
|
+
function hasAudit(ModelClass) {
|
|
602
|
+
return hasFeature(ModelClass, "audit");
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Get audit configuration from a model class.
|
|
606
|
+
*/
|
|
607
|
+
function getAuditConfig(ModelClass) {
|
|
608
|
+
return getFeatureConfig(ModelClass, "audit");
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Check if a model has status feature.
|
|
612
|
+
*/
|
|
613
|
+
function hasStatus(ModelClass) {
|
|
614
|
+
return hasFeature(ModelClass, "status");
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Get status configuration from a model class.
|
|
618
|
+
*/
|
|
619
|
+
function getStatusConfig(ModelClass) {
|
|
620
|
+
return getFeatureConfig(ModelClass, "status");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/model.types.ts
|
|
625
|
+
/**
|
|
626
|
+
* Symbol for storing the schema on model instances (internal use).
|
|
627
|
+
*/
|
|
628
|
+
const MODEL_SCHEMA = Symbol.for("fyrestack.model.schema");
|
|
629
|
+
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region src/model.ts
|
|
632
|
+
function model(...features) {
|
|
633
|
+
const innerStore = features.reduce((store, feature) => feature(store), getInitialInnerStore());
|
|
634
|
+
const modelSchema = innerStore.schema;
|
|
635
|
+
const modelMethods = innerStore.methods;
|
|
636
|
+
class Model {
|
|
637
|
+
constructor(data) {
|
|
638
|
+
this[MODEL_SCHEMA] = modelSchema;
|
|
639
|
+
let validatedProps = {};
|
|
640
|
+
if (data !== void 0 && data !== null) if ("~standard" in modelSchema) {
|
|
641
|
+
const result = modelSchema["~standard"].validate(data);
|
|
642
|
+
if ("issues" in result && result.issues) throw toValidationError(result.issues);
|
|
643
|
+
if ("value" in result) validatedProps = result.value;
|
|
644
|
+
} else validatedProps = data;
|
|
645
|
+
for (const [key, value] of Object.entries(validatedProps)) this[key] = value;
|
|
646
|
+
for (const [key, method] of Object.entries(modelMethods)) this[key] = method;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Validate the current model data against the schema.
|
|
650
|
+
* @returns ValidationResult with validated value or error
|
|
651
|
+
*/
|
|
652
|
+
validate() {
|
|
653
|
+
const schema = this[MODEL_SCHEMA];
|
|
654
|
+
const data = this.toObject();
|
|
655
|
+
if (!("~standard" in schema)) return {
|
|
656
|
+
valid: true,
|
|
657
|
+
value: data
|
|
658
|
+
};
|
|
659
|
+
const result = schema["~standard"].validate(data);
|
|
660
|
+
if ("issues" in result && result.issues) return {
|
|
661
|
+
valid: false,
|
|
662
|
+
error: toValidationError(result.issues)
|
|
663
|
+
};
|
|
664
|
+
if ("value" in result) return {
|
|
665
|
+
valid: true,
|
|
666
|
+
value: result.value
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
valid: true,
|
|
670
|
+
value: data
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Check if the current model data is valid.
|
|
675
|
+
* @returns true if valid, false otherwise
|
|
676
|
+
*/
|
|
677
|
+
isValid() {
|
|
678
|
+
return this.validate().valid;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Convert model instance to a plain object (excludes methods).
|
|
682
|
+
* Used for Firestore writes and serialization.
|
|
683
|
+
*/
|
|
684
|
+
toObject() {
|
|
685
|
+
const result = {};
|
|
686
|
+
for (const key of Object.keys(this)) {
|
|
687
|
+
const value = this[key];
|
|
688
|
+
if (typeof value !== "function") result[key] = value;
|
|
689
|
+
}
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const metadata = innerStore._metadata;
|
|
694
|
+
if (metadata) Model[MODEL_METADATA] = metadata;
|
|
695
|
+
return Model;
|
|
696
|
+
}
|
|
697
|
+
function getInitialInnerStore() {
|
|
698
|
+
return {
|
|
699
|
+
schema: {},
|
|
700
|
+
props: {},
|
|
701
|
+
methods: {}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Convert Standard Schema issues to ValidationIssues.
|
|
706
|
+
* Handles various path formats from different schema libraries.
|
|
707
|
+
*/
|
|
708
|
+
function toValidationError(issues) {
|
|
709
|
+
const mappedIssues = issues.map((issue) => {
|
|
710
|
+
let path = [];
|
|
711
|
+
if (Array.isArray(issue.path)) path = issue.path.map((p) => {
|
|
712
|
+
if (typeof p === "string" || typeof p === "number" || typeof p === "symbol") return String(p);
|
|
713
|
+
if (p !== null && typeof p === "object" && "key" in p) return String(p.key);
|
|
714
|
+
return String(p);
|
|
715
|
+
});
|
|
716
|
+
return {
|
|
717
|
+
path,
|
|
718
|
+
message: issue.message
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
return new ValidationError(mappedIssues.length > 0 && mappedIssues[0] !== void 0 ? mappedIssues.length === 1 ? mappedIssues[0].message : `Validation failed with ${String(mappedIssues.length)} issues` : "Validation failed", mappedIssues);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/model.with-schema.ts
|
|
726
|
+
function withSchema(schema) {
|
|
727
|
+
return (store) => ({
|
|
728
|
+
schema,
|
|
729
|
+
props: {},
|
|
730
|
+
methods: store.methods
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/model.with-timestamps.ts
|
|
736
|
+
/**
|
|
737
|
+
* Create repository hooks for timestamp management.
|
|
738
|
+
* This is the core implementation extracted into hooks.
|
|
739
|
+
*/
|
|
740
|
+
function createTimestampsHooks(config) {
|
|
741
|
+
return {
|
|
742
|
+
beforeSave(data, context) {
|
|
743
|
+
const timestamp = config.useServerTimestamp ? context.adapter.serverTimestamp() : /* @__PURE__ */ new Date();
|
|
744
|
+
if (config.createdAt !== false && (data[config.createdAt] === void 0 || data[config.createdAt] === null)) data[config.createdAt] = timestamp;
|
|
745
|
+
if (config.updatedAt !== false) data[config.updatedAt] = timestamp;
|
|
746
|
+
return data;
|
|
747
|
+
},
|
|
748
|
+
beforeUpdate(updates, context) {
|
|
749
|
+
if (config.updatedAt !== false) {
|
|
750
|
+
const timestamp = config.useServerTimestamp ? context.adapter.serverTimestamp() : /* @__PURE__ */ new Date();
|
|
751
|
+
updates[config.updatedAt] = timestamp;
|
|
752
|
+
}
|
|
753
|
+
return updates;
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Add automatic timestamp management to a model.
|
|
759
|
+
*
|
|
760
|
+
* This feature registers hooks that:
|
|
761
|
+
* - Set `createdAt` on save (if not already set)
|
|
762
|
+
* - Set `updatedAt` on save and update
|
|
763
|
+
*
|
|
764
|
+
* @example
|
|
765
|
+
* ```typescript
|
|
766
|
+
* // Basic usage with defaults (createdAt, updatedAt)
|
|
767
|
+
* const UserModel = model(
|
|
768
|
+
* withSchema(UserSchema),
|
|
769
|
+
* withTimestamps(),
|
|
770
|
+
* );
|
|
771
|
+
*
|
|
772
|
+
* // Custom field names
|
|
773
|
+
* const PostModel = model(
|
|
774
|
+
* withSchema(PostSchema),
|
|
775
|
+
* withTimestamps({ createdAt: 'publishedAt', updatedAt: 'modifiedAt' }),
|
|
776
|
+
* );
|
|
777
|
+
*
|
|
778
|
+
* // Disable one field
|
|
779
|
+
* const LogModel = model(
|
|
780
|
+
* withSchema(LogSchema),
|
|
781
|
+
* withTimestamps({ updatedAt: false }), // Only track creation
|
|
782
|
+
* );
|
|
783
|
+
*
|
|
784
|
+
* // Use client-side timestamps (for offline support)
|
|
785
|
+
* const OfflineModel = model(
|
|
786
|
+
* withSchema(OfflineSchema),
|
|
787
|
+
* withTimestamps({ useServerTimestamp: false }),
|
|
788
|
+
* );
|
|
789
|
+
* ```
|
|
790
|
+
*
|
|
791
|
+
* @param options - Optional configuration for field names and behavior
|
|
792
|
+
*/
|
|
793
|
+
function withTimestamps(options) {
|
|
794
|
+
const config = {
|
|
795
|
+
createdAt: options?.createdAt ?? DEFAULT_TIMESTAMPS.createdAt,
|
|
796
|
+
updatedAt: options?.updatedAt ?? DEFAULT_TIMESTAMPS.updatedAt,
|
|
797
|
+
useServerTimestamp: options?.useServerTimestamp ?? DEFAULT_TIMESTAMPS.useServerTimestamp
|
|
798
|
+
};
|
|
799
|
+
const hooks = createTimestampsHooks(config);
|
|
800
|
+
return (store) => {
|
|
801
|
+
const newMetadata = createFeatureMetadata("timestamps", config, { repository: hooks }, store._metadata);
|
|
802
|
+
return {
|
|
803
|
+
schema: store.schema,
|
|
804
|
+
props: store.props,
|
|
805
|
+
methods: store.methods,
|
|
806
|
+
_metadata: newMetadata
|
|
807
|
+
};
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/repository.helpers.ts
|
|
813
|
+
/**
|
|
814
|
+
* Composable Repository Implementation for Firestore
|
|
815
|
+
*/
|
|
816
|
+
function createTypedQueryBuilder(queryRef) {
|
|
817
|
+
return {
|
|
818
|
+
where(field, op, value) {
|
|
819
|
+
return createTypedQueryBuilder(queryRef.where(field, op, value));
|
|
820
|
+
},
|
|
821
|
+
orderBy(field, direction) {
|
|
822
|
+
return createTypedQueryBuilder(queryRef.orderBy(field, direction));
|
|
823
|
+
},
|
|
824
|
+
limit(count) {
|
|
825
|
+
return createTypedQueryBuilder(queryRef.limit(count));
|
|
826
|
+
},
|
|
827
|
+
startAt(...values) {
|
|
828
|
+
return createTypedQueryBuilder(queryRef.startAt(...values));
|
|
829
|
+
},
|
|
830
|
+
startAfter(...values) {
|
|
831
|
+
return createTypedQueryBuilder(queryRef.startAfter(...values));
|
|
832
|
+
},
|
|
833
|
+
endAt(...values) {
|
|
834
|
+
return createTypedQueryBuilder(queryRef.endAt(...values));
|
|
835
|
+
},
|
|
836
|
+
endBefore(...values) {
|
|
837
|
+
return createTypedQueryBuilder(queryRef.endBefore(...values));
|
|
838
|
+
},
|
|
839
|
+
_queryRef: queryRef
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function getQueryRef(builder) {
|
|
843
|
+
return builder._queryRef;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Regex for valid path parameter values.
|
|
847
|
+
* Allows alphanumeric characters, underscores, and hyphens.
|
|
848
|
+
*/
|
|
849
|
+
const VALID_PATH_PARAM_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
850
|
+
/**
|
|
851
|
+
* Validate a single path parameter value.
|
|
852
|
+
* @throws PathParamError if the value is invalid
|
|
853
|
+
*/
|
|
854
|
+
function validatePathParam(paramName, value) {
|
|
855
|
+
if (!value || value.length === 0) throw new PathParamError(paramName, value, "cannot be empty");
|
|
856
|
+
if (value.includes("/")) throw new PathParamError(paramName, value, "cannot contain forward slashes");
|
|
857
|
+
if (value.includes("..")) throw new PathParamError(paramName, value, "cannot contain path traversal (..)");
|
|
858
|
+
if (value.includes("\0")) throw new PathParamError(paramName, value, "cannot contain null bytes");
|
|
859
|
+
if (!VALID_PATH_PARAM_REGEX.test(value)) throw new PathParamError(paramName, value, "must only contain alphanumeric characters, underscores, or hyphens");
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Build a collection path from template and params.
|
|
863
|
+
* Validates all path parameters before building.
|
|
864
|
+
* @throws PathParamError if any parameter is invalid
|
|
865
|
+
*/
|
|
866
|
+
function buildCollectionPath(pathTemplate, params) {
|
|
867
|
+
let result = pathTemplate;
|
|
868
|
+
for (const [key, value] of Object.entries(params)) {
|
|
869
|
+
validatePathParam(key, value);
|
|
870
|
+
result = result.replace(`{${key}}`, value);
|
|
871
|
+
}
|
|
872
|
+
return result;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Extract model ID from model instance or string.
|
|
876
|
+
* @throws ModelError if the model doesn't have a valid ID
|
|
877
|
+
*/
|
|
878
|
+
function extractId(modelOrId) {
|
|
879
|
+
if (typeof modelOrId === "string") {
|
|
880
|
+
if (!modelOrId) throw new ModelError("Document ID cannot be empty");
|
|
881
|
+
return modelOrId;
|
|
882
|
+
}
|
|
883
|
+
if (modelOrId !== null && typeof modelOrId === "object" && "id" in modelOrId && typeof modelOrId.id === "string") {
|
|
884
|
+
if (!modelOrId.id) throw new ModelError("Model ID cannot be empty");
|
|
885
|
+
return modelOrId.id;
|
|
886
|
+
}
|
|
887
|
+
throw new ModelError("Model must have a string \"id\" property");
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Type guard to check if a model has a validate() method.
|
|
891
|
+
*/
|
|
892
|
+
function isValidatable(model$1) {
|
|
893
|
+
return "validate" in model$1 && typeof model$1.validate === "function";
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Type guard to check if a model has a toObject() method.
|
|
897
|
+
*/
|
|
898
|
+
function hasToObject(model$1) {
|
|
899
|
+
return "toObject" in model$1 && typeof model$1.toObject === "function";
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Validate a model instance before write operations.
|
|
903
|
+
* @throws ValidationError if validation fails
|
|
904
|
+
*/
|
|
905
|
+
function validateModel(model$1) {
|
|
906
|
+
if (model$1 === null || typeof model$1 !== "object") throw new ValidationError("Model must be an object");
|
|
907
|
+
if (isValidatable(model$1)) {
|
|
908
|
+
const result = model$1.validate();
|
|
909
|
+
if (!result.valid && result.error) throw result.error;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Convert model to plain object for Firestore.
|
|
914
|
+
* Uses toObject() if available, otherwise spreads the object.
|
|
915
|
+
*/
|
|
916
|
+
function modelToObject(model$1) {
|
|
917
|
+
if (model$1 !== null && typeof model$1 === "object") {
|
|
918
|
+
if (hasToObject(model$1)) return model$1.toObject();
|
|
919
|
+
const result = {};
|
|
920
|
+
for (const [key, value] of Object.entries(model$1)) if (typeof value !== "function") result[key] = value;
|
|
921
|
+
return result;
|
|
922
|
+
}
|
|
923
|
+
throw new ModelError("Model must be an object");
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
//#endregion
|
|
927
|
+
//#region src/repository.ts
|
|
928
|
+
function repository(config, ...features) {
|
|
929
|
+
const { path, model: ModelClass } = config;
|
|
930
|
+
const hooks = getRepositoryHooks(ModelClass);
|
|
931
|
+
class Repository {
|
|
932
|
+
getFirestore() {
|
|
933
|
+
return getFirestoreAdapter();
|
|
934
|
+
}
|
|
935
|
+
getCollectionRef(params) {
|
|
936
|
+
const collectionPath = buildCollectionPath(path, params);
|
|
937
|
+
return this.getFirestore().collection(collectionPath);
|
|
938
|
+
}
|
|
939
|
+
getDocumentRef(params, id) {
|
|
940
|
+
return this.getCollectionRef(params).doc(id);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Create hook context for repository hooks.
|
|
944
|
+
*/
|
|
945
|
+
createHookContext(params, id) {
|
|
946
|
+
const context = {
|
|
947
|
+
adapter: this.getFirestore(),
|
|
948
|
+
modelClass: ModelClass,
|
|
949
|
+
path,
|
|
950
|
+
params
|
|
951
|
+
};
|
|
952
|
+
if (id !== void 0) context.id = id;
|
|
953
|
+
return context;
|
|
954
|
+
}
|
|
955
|
+
async get(params, id) {
|
|
956
|
+
try {
|
|
957
|
+
const docRef = this.getDocumentRef(params, id);
|
|
958
|
+
const ctx = getCurrentContext();
|
|
959
|
+
const tx = isTransactionContext(ctx) ? ctx.tx : void 0;
|
|
960
|
+
const snapshot = tx ? await tx.get(docRef) : await docRef.get();
|
|
961
|
+
if (!snapshot.exists) return null;
|
|
962
|
+
const data = snapshot.data();
|
|
963
|
+
if (!data) return null;
|
|
964
|
+
let result = new ModelClass({
|
|
965
|
+
...data,
|
|
966
|
+
id: snapshot.id
|
|
967
|
+
});
|
|
968
|
+
if (hooks?.afterGet) {
|
|
969
|
+
const context = this.createHookContext(params, id);
|
|
970
|
+
result = hooks.afterGet(result, context);
|
|
971
|
+
}
|
|
972
|
+
return result;
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (error instanceof PathParamError || error instanceof ValidationError) throw error;
|
|
975
|
+
throw FirestoreError.wrap("get", error);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async find(params, queryFn) {
|
|
979
|
+
try {
|
|
980
|
+
const collectionRef = this.getCollectionRef(params);
|
|
981
|
+
let queryRef = collectionRef;
|
|
982
|
+
if (queryFn) queryRef = getQueryRef(queryFn(createTypedQueryBuilder(collectionRef)));
|
|
983
|
+
let results = (await queryRef.get()).docs.map((doc) => {
|
|
984
|
+
return new ModelClass({
|
|
985
|
+
...doc.data(),
|
|
986
|
+
id: doc.id
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
if (hooks?.afterFind) {
|
|
990
|
+
const context = this.createHookContext(params);
|
|
991
|
+
results = hooks.afterFind(results, context);
|
|
992
|
+
}
|
|
993
|
+
return results;
|
|
994
|
+
} catch (error) {
|
|
995
|
+
if (error instanceof PathParamError || error instanceof ValidationError) throw error;
|
|
996
|
+
throw FirestoreError.wrap("find", error);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
async save(params, model$1) {
|
|
1000
|
+
try {
|
|
1001
|
+
validateModel(model$1);
|
|
1002
|
+
const id = extractId(model$1);
|
|
1003
|
+
const docRef = this.getDocumentRef(params, id);
|
|
1004
|
+
let data = modelToObject(model$1);
|
|
1005
|
+
if (hooks?.beforeSave) {
|
|
1006
|
+
const context = this.createHookContext(params, id);
|
|
1007
|
+
data = hooks.beforeSave(data, context);
|
|
1008
|
+
}
|
|
1009
|
+
const ctx = getCurrentContext();
|
|
1010
|
+
if (isTransactionContext(ctx)) ctx.tx.set(docRef, data);
|
|
1011
|
+
else if (isBatchContext(ctx)) ctx.batch.set(docRef, data);
|
|
1012
|
+
else await docRef.set(data);
|
|
1013
|
+
if (hooks?.afterSave) {
|
|
1014
|
+
const context = this.createHookContext(params, id);
|
|
1015
|
+
hooks.afterSave(data, context);
|
|
1016
|
+
}
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (error instanceof PathParamError || error instanceof ValidationError || error instanceof ModelError) throw error;
|
|
1019
|
+
throw FirestoreError.wrap("save", error);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async update(params, modelOrId, updates) {
|
|
1023
|
+
try {
|
|
1024
|
+
const id = extractId(modelOrId);
|
|
1025
|
+
const docRef = this.getDocumentRef(params, id);
|
|
1026
|
+
let updateData = { ...updates };
|
|
1027
|
+
if (hooks?.beforeUpdate) {
|
|
1028
|
+
const context = this.createHookContext(params, id);
|
|
1029
|
+
updateData = hooks.beforeUpdate(updateData, context);
|
|
1030
|
+
}
|
|
1031
|
+
const ctx = getCurrentContext();
|
|
1032
|
+
if (isTransactionContext(ctx)) ctx.tx.update(docRef, updateData);
|
|
1033
|
+
else if (isBatchContext(ctx)) ctx.batch.update(docRef, updateData);
|
|
1034
|
+
else await docRef.update(updateData);
|
|
1035
|
+
if (hooks?.afterUpdate) {
|
|
1036
|
+
const context = this.createHookContext(params, id);
|
|
1037
|
+
hooks.afterUpdate(updateData, context);
|
|
1038
|
+
}
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
if (error instanceof PathParamError || error instanceof ModelError) throw error;
|
|
1041
|
+
throw FirestoreError.wrap("update", error);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
async delete(params, modelOrId) {
|
|
1045
|
+
try {
|
|
1046
|
+
const id = extractId(modelOrId);
|
|
1047
|
+
const docRef = this.getDocumentRef(params, id);
|
|
1048
|
+
if (hooks?.beforeDelete) {
|
|
1049
|
+
const context = this.createHookContext(params, id);
|
|
1050
|
+
hooks.beforeDelete(context);
|
|
1051
|
+
}
|
|
1052
|
+
const ctx = getCurrentContext();
|
|
1053
|
+
if (isTransactionContext(ctx)) ctx.tx.delete(docRef);
|
|
1054
|
+
else if (isBatchContext(ctx)) ctx.batch.delete(docRef);
|
|
1055
|
+
else await docRef.delete();
|
|
1056
|
+
if (hooks?.afterDelete) {
|
|
1057
|
+
const context = this.createHookContext(params, id);
|
|
1058
|
+
hooks.afterDelete(context);
|
|
1059
|
+
}
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
if (error instanceof PathParamError || error instanceof ModelError) throw error;
|
|
1062
|
+
throw FirestoreError.wrap("delete", error);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (features.length > 0) {
|
|
1067
|
+
const repoInstance = new Repository();
|
|
1068
|
+
const innerRepo = {
|
|
1069
|
+
model: ModelClass,
|
|
1070
|
+
path,
|
|
1071
|
+
methods: {
|
|
1072
|
+
get: repoInstance.get.bind(repoInstance),
|
|
1073
|
+
find: repoInstance.find.bind(repoInstance),
|
|
1074
|
+
save: repoInstance.save.bind(repoInstance),
|
|
1075
|
+
update: repoInstance.update.bind(repoInstance),
|
|
1076
|
+
delete: repoInstance.delete.bind(repoInstance)
|
|
1077
|
+
},
|
|
1078
|
+
getCollection: repoInstance["getCollectionRef"].bind(repoInstance),
|
|
1079
|
+
getDocument: repoInstance["getDocumentRef"].bind(repoInstance)
|
|
1080
|
+
};
|
|
1081
|
+
let currentMethods = innerRepo.methods;
|
|
1082
|
+
for (const feature of features) currentMethods = feature({
|
|
1083
|
+
...innerRepo,
|
|
1084
|
+
methods: currentMethods
|
|
1085
|
+
});
|
|
1086
|
+
const FinalRepository = class extends Repository {
|
|
1087
|
+
constructor() {
|
|
1088
|
+
super();
|
|
1089
|
+
for (const [key, method] of Object.entries(currentMethods)) if (!(key in this)) this[key] = method;
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
return FinalRepository;
|
|
1093
|
+
}
|
|
1094
|
+
return Repository;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/repository.with-methods.ts
|
|
1099
|
+
/**
|
|
1100
|
+
* Add custom methods to a repository
|
|
1101
|
+
*/
|
|
1102
|
+
function withMethods(methodsFactory) {
|
|
1103
|
+
return (repo) => {
|
|
1104
|
+
const newMethods = methodsFactory(repo);
|
|
1105
|
+
return {
|
|
1106
|
+
...repo.methods,
|
|
1107
|
+
...newMethods
|
|
1108
|
+
};
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region src/transaction.ts
|
|
1114
|
+
/**
|
|
1115
|
+
* Transaction Support for FyreStack Database
|
|
1116
|
+
*
|
|
1117
|
+
* Provides atomic read-write operations across multiple repositories.
|
|
1118
|
+
* Context is automatically propagated using AsyncContext.
|
|
1119
|
+
*/
|
|
1120
|
+
/**
|
|
1121
|
+
* Run operations within a Firestore transaction.
|
|
1122
|
+
*
|
|
1123
|
+
* Transactions provide:
|
|
1124
|
+
* - Atomic reads and writes across multiple repositories
|
|
1125
|
+
* - Automatic retry on contention (up to maxAttempts)
|
|
1126
|
+
* - Consistent view of data (all reads see same snapshot)
|
|
1127
|
+
* - All-or-nothing semantics (all writes succeed or all fail)
|
|
1128
|
+
*
|
|
1129
|
+
* The transaction context is **automatically propagated** to all repository
|
|
1130
|
+
* methods called within the transaction function - no need to pass context explicitly.
|
|
1131
|
+
*
|
|
1132
|
+
* @example
|
|
1133
|
+
* ```typescript
|
|
1134
|
+
* // Transfer funds between accounts atomically
|
|
1135
|
+
* const result = await runTransaction(async () => {
|
|
1136
|
+
* const fromAccount = await AccountRepo.get({}, fromId);
|
|
1137
|
+
* const toAccount = await AccountRepo.get({}, toId);
|
|
1138
|
+
*
|
|
1139
|
+
* if (!fromAccount || !toAccount) throw new Error('Account not found');
|
|
1140
|
+
* if (fromAccount.balance < amount) throw new Error('Insufficient funds');
|
|
1141
|
+
*
|
|
1142
|
+
* await AccountRepo.update({}, fromId, {
|
|
1143
|
+
* balance: fromAccount.balance - amount
|
|
1144
|
+
* });
|
|
1145
|
+
*
|
|
1146
|
+
* await AccountRepo.update({}, toId, {
|
|
1147
|
+
* balance: toAccount.balance + amount
|
|
1148
|
+
* });
|
|
1149
|
+
*
|
|
1150
|
+
* return { newBalance: fromAccount.balance - amount };
|
|
1151
|
+
* });
|
|
1152
|
+
* ```
|
|
1153
|
+
*
|
|
1154
|
+
* @example
|
|
1155
|
+
* ```typescript
|
|
1156
|
+
* // Cross-repository transaction
|
|
1157
|
+
* await runTransaction(async () => {
|
|
1158
|
+
* const order = await OrderRepo.get({}, orderId);
|
|
1159
|
+
* const user = await UserRepo.get({}, order.userId);
|
|
1160
|
+
*
|
|
1161
|
+
* await OrderRepo.update({}, orderId, { status: 'paid' });
|
|
1162
|
+
* await UserRepo.update({}, user.id, {
|
|
1163
|
+
* balance: user.balance - order.total
|
|
1164
|
+
* });
|
|
1165
|
+
* });
|
|
1166
|
+
* ```
|
|
1167
|
+
*
|
|
1168
|
+
* @param fn - Async function to run within the transaction
|
|
1169
|
+
* @param options - Transaction options (maxAttempts, etc.)
|
|
1170
|
+
* @returns Result of the transaction function
|
|
1171
|
+
*/
|
|
1172
|
+
async function runTransaction(fn, options) {
|
|
1173
|
+
return getFirestoreAdapter().runTransaction(async (tx) => {
|
|
1174
|
+
return runWithTransaction(tx, fn);
|
|
1175
|
+
}, options);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
//#endregion
|
|
1179
|
+
exports.AdapterNotInitializedError = AdapterNotInitializedError;
|
|
1180
|
+
exports.DEFAULT_AUDIT = DEFAULT_AUDIT;
|
|
1181
|
+
exports.DEFAULT_STATUS = DEFAULT_STATUS;
|
|
1182
|
+
exports.DEFAULT_TIMESTAMPS = DEFAULT_TIMESTAMPS;
|
|
1183
|
+
exports.FirestoreError = FirestoreError;
|
|
1184
|
+
exports.FyreStackError = FyreStackError;
|
|
1185
|
+
exports.MAX_BATCH_SIZE = MAX_BATCH_SIZE;
|
|
1186
|
+
exports.MODEL_METADATA = MODEL_METADATA;
|
|
1187
|
+
exports.MODEL_SCHEMA = MODEL_SCHEMA;
|
|
1188
|
+
exports.ModelError = ModelError;
|
|
1189
|
+
exports.NotFoundError = NotFoundError;
|
|
1190
|
+
exports.PathParamError = PathParamError;
|
|
1191
|
+
exports.ValidationError = ValidationError;
|
|
1192
|
+
exports._resetFirestoreAdapter = _resetFirestoreAdapter;
|
|
1193
|
+
exports.createFeatureMetadata = createFeatureMetadata;
|
|
1194
|
+
exports.getAuditConfig = getAuditConfig;
|
|
1195
|
+
exports.getCurrentBatch = getCurrentBatch;
|
|
1196
|
+
exports.getCurrentContext = getCurrentContext;
|
|
1197
|
+
exports.getCurrentTransaction = getCurrentTransaction;
|
|
1198
|
+
exports.getFeatureConfig = getFeatureConfig;
|
|
1199
|
+
exports.getFirestoreAdapter = getFirestoreAdapter;
|
|
1200
|
+
exports.getModelHooks = getModelHooks;
|
|
1201
|
+
exports.getModelMetadata = getModelMetadata;
|
|
1202
|
+
exports.getRepositoryHooks = getRepositoryHooks;
|
|
1203
|
+
exports.getStatusConfig = getStatusConfig;
|
|
1204
|
+
exports.getTimestampsConfig = getTimestampsConfig;
|
|
1205
|
+
exports.hasAudit = hasAudit;
|
|
1206
|
+
exports.hasFeature = hasFeature;
|
|
1207
|
+
exports.hasRepositoryHooks = hasRepositoryHooks;
|
|
1208
|
+
exports.hasStatus = hasStatus;
|
|
1209
|
+
exports.hasTimestamps = hasTimestamps;
|
|
1210
|
+
exports.isBatchContext = isBatchContext;
|
|
1211
|
+
exports.isInBatch = isInBatch;
|
|
1212
|
+
exports.isInTransaction = isInTransaction;
|
|
1213
|
+
exports.isServerTimestamp = isServerTimestamp;
|
|
1214
|
+
exports.isTransactionContext = isTransactionContext;
|
|
1215
|
+
exports.isValidResult = isValidResult;
|
|
1216
|
+
exports.mergeFeatureHooks = mergeFeatureHooks;
|
|
1217
|
+
exports.mergeModelHooks = mergeModelHooks;
|
|
1218
|
+
exports.mergeRepositoryHooks = mergeRepositoryHooks;
|
|
1219
|
+
exports.model = model;
|
|
1220
|
+
exports.repository = repository;
|
|
1221
|
+
exports.runTransaction = runTransaction;
|
|
1222
|
+
exports.runWithBatch = runWithBatch;
|
|
1223
|
+
exports.runWithTransaction = runWithTransaction;
|
|
1224
|
+
exports.setFirestoreAdapter = setFirestoreAdapter;
|
|
1225
|
+
exports.withMethods = withMethods;
|
|
1226
|
+
exports.withSchema = withSchema;
|
|
1227
|
+
exports.withTimestamps = withTimestamps;
|
|
1228
|
+
exports.writeBatch = writeBatch;
|
|
1229
|
+
exports.writeChunkedBatch = writeChunkedBatch;
|