@iebh/tera-fy 2.3.8 → 2.3.10

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,765 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ /* eslint-disable no-unused-vars */
3
+ import { isEmpty, cloneDeep, random, sample, throttle, isEqual } from 'lodash-es';
4
+ import { doc as FirestoreDocRef, getDoc as FirestoreGetDoc, onSnapshot as FirestoreOnSnapshot, setDoc as FirestoreSetDoc, updateDoc as FirestoreUpdateDoc, } from 'firebase/firestore';
5
+ // @ts-expect-error No declaration file for marshal
6
+ import marshal from '@momsfriendlydevco/marshal';
7
+ import { nanoid } from 'nanoid';
8
+ import PromiseThrottle from 'p-throttle';
9
+ import PromiseRetry from 'p-retry';
10
+ import { FirebaseError } from 'firebase/app';
11
+ /**
12
+ * @class Syncro
13
+ * TERA Isomorphic Syncro class
14
+ * Slurp an entity from Supabase and hold it in Firebase/Firestore as a "floating" entity which periodically gets flushed back to Supabase and eventually completely cleaned up
15
+ * This class tries to be as independent as possible to help with adapting it to various front-end TERA-fy plugin frameworks
16
+ */
17
+ export default class Syncro {
18
+ /**
19
+ * Firebase instance in use
20
+ *
21
+ * @type {FirebaseApp}
22
+ */
23
+ static firebase;
24
+ /**
25
+ * Firestore instance in use
26
+ *
27
+ * @type {Firestore}
28
+ */
29
+ static firestore;
30
+ /**
31
+ * Supabasey instance in use
32
+ *
33
+ * @type {Supabasey}
34
+ */
35
+ static supabasey;
36
+ /**
37
+ * The current user session, should be unique for the user + browser tab
38
+ * Used by the heartbeat system
39
+ *
40
+ * @type {String}
41
+ */
42
+ static session;
43
+ /**
44
+ * OPTIONAL SyncroEntries from './entities.ts' if its required
45
+ * This only gets populated if `config.forceLocalInit` is truthy and we've mounted at least one Syncro
46
+ *
47
+ * @type {Record<string, any>}
48
+ */
49
+ static SyncroEntities;
50
+ /**
51
+ * This instances fully formed string path
52
+ *
53
+ * @type {String}
54
+ */
55
+ path;
56
+ /**
57
+ * The Firestore docHandle when calling various Firestore functions
58
+ *
59
+ * @type {DocumentReference | undefined}
60
+ */
61
+ docRef;
62
+ /**
63
+ * The reactive object managed by this Syncro instance
64
+ * The nature of this varies by framework and what `getReactive()` provides
65
+ *
66
+ * @type {any}
67
+ */
68
+ value;
69
+ /**
70
+ * Default throttle to apply for writes
71
+ *
72
+ * @type {Number} Throttle time in milliseconds
73
+ */
74
+ throttle = 250;
75
+ /**
76
+ * Various Misc config for the Syncro instance
77
+ *
78
+ * @type {Object}
79
+ * @property {Number} heartbeatInterval Time in milliseconds between heartbeat beacons
80
+ * @property {String} syncroRegistryUrl The prefix Sync worker URL, used to populate Syncros and determine their active status
81
+ * @property {Object} context Additional named parameters to pass to callbacks like initState
82
+ */
83
+ config = {
84
+ heartbeatInterval: 120_000, //~= 120s / 2m
85
+ syncroRegistryUrl: 'https://tera-tools.com/api/sync',
86
+ context: {},
87
+ };
88
+ /**
89
+ * Whether the next heartbeat should be marked as 'dirty'
90
+ * This indicates that at least one change has occurred since the last heartbeat and the server should perform a flush (but not a clean)
91
+ * This flag is only transmitted once in the next heartbeat before being reset
92
+ *
93
+ * @see markDirty()
94
+ * @type {Boolean}
95
+ */
96
+ isDirty = false;
97
+ /**
98
+ * @interface
99
+ * Debugging printer for this instance
100
+ * Defaults to doing nothing
101
+ *
102
+ * @param {*...} [msg] The message to output
103
+ */
104
+ debug(...msg) { }
105
+ /**
106
+ * @interface
107
+ * Debugging printer specifically for error messages
108
+ * Defaults to using console.log()
109
+ *
110
+ * @param {*...} [msg] The message to output
111
+ */
112
+ debugError(...msg) {
113
+ console.warn(`[Syncro ${this.path}]`, ...msg);
114
+ }
115
+ /**
116
+ * Instance constructor
117
+ *
118
+ * @param {String} path Mount path for the Syncro. Should be in the form `${ENTITY}::${ID}(::${RELATION})?`
119
+ * @param {Object} [options] Additional instance setters (mutates instance directly), note that the `config` subkey is merged with the existing config rather than assigned
120
+ */
121
+ constructor(path, options) {
122
+ this.path = path;
123
+ Object.assign(this, {
124
+ ...options,
125
+ config: {
126
+ ...this.config,
127
+ ...options?.config,
128
+ },
129
+ });
130
+ if (!Syncro.session) // Assign a random session ID if we don't already have one
131
+ Syncro.session = `syncro_${nanoid()}`;
132
+ }
133
+ /**
134
+ * Instance destruction trigger
135
+ * This will unsubscribe from various facilities and release the object for cleanup
136
+ *
137
+ * @returns {Promise} A promise which resolves when the operation has completed
138
+ */
139
+ destroy() {
140
+ this.debug('Destroy!');
141
+ return Promise.resolve()
142
+ .then(() => Promise.all(this._destroyActions // eslint-disable @typescript-eslint/await-thenable
143
+ .map(fn => fn())))
144
+ .then(() => this._destroyActions = []); // Reset list of actions to perform when terminating
145
+ }
146
+ /**
147
+ * Actions to preform when we are destroying this instance
148
+ * This is an array of function callbacks to execute in parallel when `destroy()` is called
149
+ *
150
+ * @type {Array<Function<Promise>>}
151
+ */
152
+ _destroyActions = [];
153
+ /**
154
+ * Function to return whatever the local framework uses as a reactive object
155
+ * This should respond with an object of mandatory functions to watch for changes and re-merge them
156
+ *
157
+ * @param {Object} value Initial value of the reactive
158
+ *
159
+ * @returns {ReactiveWrapper} A reactive object prototype
160
+ * @property {Object} doc The reactive object
161
+ * @property {Function} setState Function used to overwrite the default state, called as `(newState:Object)`
162
+ * @property {Function} getState Function used to fetch the current snapshot state, called as `()`
163
+ * @property {Function} watch Function used to set up state watchers, should call its callback when a change is detected, called as `(cb:Function)`
164
+ */
165
+ getReactive(value) {
166
+ console.warn('Syncro.getReactive has not been subclassed, assuming a POJO response');
167
+ const doc = { ...value };
168
+ return {
169
+ doc,
170
+ setState(state) {
171
+ // Shallow copy all sub keys into existing object (keeping the object pointer)
172
+ Object.entries(state || {})
173
+ .forEach(([k, v]) => doc[k] = v);
174
+ },
175
+ getState() {
176
+ return cloneDeep(doc);
177
+ },
178
+ watch(cb) {
179
+ // Stub
180
+ },
181
+ };
182
+ }
183
+ ;
184
+ /**
185
+ * Returns the split entity + ID relationship from a given session path
186
+ * This function checks for valid UUID format strings + that the entity is a known/supported entity (see `knownEntities`)
187
+ * NOTE: When used by itself (i.e. ignoring response) this function can also act as a guard that a path is valid
188
+ *
189
+ * INPUT: `widgets::UUID` -> `{entity:'widgets', id:UUID}`
190
+ * INPUT: `widgets::UUID::thing` -> `{entity:'widgets', id:UUID, relation:'thing'}`
191
+ *
192
+ * @param {String} path The input session path of the form `${ENTITY}::${ID}`
193
+ * @param {Object} [options] Additional options to mutate behaviour
194
+ * @param {Boolean} [options.allowAsterisk=false] Whether to allow the meta asterisk character when recognising paths, this is used by the SyncroKeyed class
195
+ *
196
+ * @returns {PathSplitResult} An object composed of the session path components
197
+ */
198
+ static pathSplit(path, options) {
199
+ const settings = {
200
+ allowAsterisk: false,
201
+ ...options,
202
+ };
203
+ const pathMatcher = new RegExp(
204
+ // Compose the patch matching expression - note double escapes for backslashes to avoid encoding as raw string values
205
+ '^'
206
+ + '(?<entity>\\w+?)' // Any alpha-numeric sequence as the entity name (non-greedy capture)
207
+ + '::' // Followed by '::'
208
+ + '(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})' // Followed by a valid lower-case UUID
209
+ + '(?:::(?<relation>[' // Followed by an optional ansi relation
210
+ + '\\w' // Which is any alpha-numeric sequence...
211
+ + '\\-' // ... Including UUID characters
212
+ + (settings.allowAsterisk ? '\\*' : '') // ... and (optionally) an asterisk
213
+ + ']+?))?'
214
+ + '$');
215
+ const extracted = { ...pathMatcher.exec(path)?.groups };
216
+ if (!extracted || !extracted.entity || !extracted.id)
217
+ throw new Error(`Invalid session path syntax "${path}"`);
218
+ if (Syncro.SyncroEntities && !(extracted.entity in Syncro.SyncroEntities))
219
+ throw new Error(`Unsupported entity "${path}" -> Entity="${extracted.entity}"`);
220
+ return {
221
+ entity: extracted.entity,
222
+ id: extracted.id,
223
+ relation: extracted.relation,
224
+ fsCollection: extracted.entity,
225
+ fsId: extracted.relation
226
+ ? `${extracted.id}::${extracted.relation}`
227
+ : extracted.id,
228
+ };
229
+ }
230
+ /**
231
+ * Convert local POJO -> Firestore compatible object
232
+ * This applies the following mutations to the incoming object:
233
+ *
234
+ * 1. Arrays are converted to Objects (Firestore cannot store nested arrays)
235
+ * 2. All non-POJO objects (e.g. Dates) to a symmetric object
236
+ *
237
+ * @param {Object} snapshot The current state to convert
238
+ * @returns {Object} A Firebase compatible object
239
+ */
240
+ static toFirestore(snapshot = {}) {
241
+ return marshal.serialize(snapshot, {
242
+ ...marshalBaseConfig,
243
+ stringify: false,
244
+ });
245
+ }
246
+ /**
247
+ * Convert local Firestore compatible object -> local POJO
248
+ * This reverses the mutations listed in `toFirestore()`
249
+ *
250
+ * @param {Object} snapshot The raw Firebase state to convert
251
+ * @returns {Object} A JavaScript POJO representing the converted state
252
+ */
253
+ static fromFirestore(snapshot = {}) {
254
+ return marshal.deserialize(snapshot, {
255
+ ...marshalBaseConfig,
256
+ destringify: false,
257
+ });
258
+ }
259
+ /**
260
+ * Convert a raw POJO into Firestore field layout
261
+ * Field structures are usually consumed by the Firestore ReST API and need converting before being used
262
+ * NOTE: This does not serialize the incoming data so you likely want to use this as `toFirestoreFields(toFirestore(data))`
263
+ *
264
+ * @see https://stackoverflow.com/a/62304377
265
+ * @param {Object} data The raw value to convert
266
+ * @returns {Object} A Firestore compatible, typed data structure
267
+ */
268
+ static toFirestoreFields(data) {
269
+ const result = {};
270
+ for (const [key, value] of Object.entries(data)) {
271
+ const type = typeof value;
272
+ if (type === 'string') { // eslint-disable-line unicorn/prefer-switch
273
+ result[key] = { stringValue: value };
274
+ }
275
+ else if (type === 'number') {
276
+ result[key] = { doubleValue: value };
277
+ }
278
+ else if (type === 'boolean') {
279
+ result[key] = { booleanValue: value };
280
+ }
281
+ else if (value === null) {
282
+ result[key] = { nullValue: null };
283
+ }
284
+ else if (Array.isArray(value)) {
285
+ // Need to handle the inner item structure correctly
286
+ result[key] = { arrayValue: { values: value.map(item => {
287
+ const field = Syncro.toFirestoreFields({ item });
288
+ return field.item; // Extract the typed value from the temporary {item: ...} structure
289
+ }) } };
290
+ }
291
+ else if (type === 'object') {
292
+ result[key] = { mapValue: { fields: Syncro.toFirestoreFields(value) } };
293
+ }
294
+ }
295
+ return result;
296
+ }
297
+ /**
298
+ * Convert a Firestore field dump into a native POJO
299
+ * Field structures are usually provided by the Firestore ReST API and need de-typing back into a native document
300
+ * NOTE: This does not deserialize the result so you likely want to use this as `fromFirestore(fromFirestoreFields(response.fields))`
301
+ *
302
+ * @see https://stackoverflow.com/a/62304377
303
+ * @param {Object} fields The raw Snapshot to convert
304
+ * @returns {Object} A JavaScript POJO representing the converted state
305
+ */
306
+ static fromFirestoreFields(fields = {}) {
307
+ const result = {};
308
+ for (const key in fields) {
309
+ const value = fields[key];
310
+ const isDocumentType = [
311
+ 'stringValue', 'booleanValue', 'doubleValue',
312
+ 'integerValue', 'timestampValue', 'mapValue', 'arrayValue', 'nullValue', // Added nullValue
313
+ ].find(t => t === Object.keys(value)[0]); // Check the first key of the value object
314
+ if (isDocumentType) {
315
+ switch (isDocumentType) {
316
+ case 'mapValue':
317
+ result[key] = Syncro.fromFirestoreFields(value.mapValue.fields || {});
318
+ break;
319
+ case 'arrayValue': {
320
+ const list = value.arrayValue.values;
321
+ result[key] = !!list ? list.map((l) => Syncro.fromFirestoreFields(l)) : [];
322
+ break;
323
+ }
324
+ case 'nullValue':
325
+ result[key] = null;
326
+ break;
327
+ default:
328
+ result[key] = value[isDocumentType];
329
+ }
330
+ }
331
+ else {
332
+ // This case might not be standard Firestore field structure, but handle recursively
333
+ result[key] = Syncro.fromFirestoreFields(value);
334
+ }
335
+ }
336
+ return result;
337
+ }
338
+ /**
339
+ * Perform a one-off fetch of a given Syncro path
340
+ *
341
+ * @param {String} path The Syncro entity + ID path. Takes the form `ENTITY::ID`
342
+ *
343
+ * @returns {Promise<Object|Null>} An eventual snapshot of the given path, if the entity doesn't exist null is returned
344
+ */
345
+ static getSnapshot(path) {
346
+ const { fsCollection, fsId } = Syncro.pathSplit(path);
347
+ return Promise.resolve()
348
+ .then(async () => FirestoreGetDoc(// Set up binding and wait for it to come ready
349
+ FirestoreDocRef(Syncro.firestore, fsCollection, fsId)))
350
+ .then(doc => doc.exists() // Use exists() method
351
+ ? Syncro.fromFirestore(doc.data())
352
+ : null);
353
+ }
354
+ /**
355
+ * Perform a one-off set/merge operation against
356
+ *
357
+ * @param {String} path The Syncro entity + ID path. Takes the form `ENTITY::ID`
358
+ * @param {Object} state The new state to set/merge
359
+ *
360
+ * @param {Object} [options] Additional options to mutate behaviour
361
+ * @param {'merge'|'set'} [options.method='merge'] How to apply the new state. 'merge' (merge in partial data to an existing Syncro), 'set' (overwrite the entire Syncro state)
362
+ *
363
+ * @returns {Promise<*>} The state object after it has been applied
364
+ */
365
+ static setSnapshot(path, state, options) {
366
+ const settings = {
367
+ method: 'merge',
368
+ ...options,
369
+ };
370
+ const { fsCollection, fsId } = Syncro.pathSplit(path);
371
+ const docRef = FirestoreDocRef(Syncro.firestore, fsCollection, fsId);
372
+ const firestoreData = Syncro.toFirestore(state);
373
+ return Promise.resolve()
374
+ .then(() => {
375
+ if (settings.method === 'merge') {
376
+ return FirestoreUpdateDoc(docRef, firestoreData);
377
+ }
378
+ else { // method === 'set'
379
+ return FirestoreSetDoc(docRef, firestoreData); // Default set overwrites
380
+ }
381
+ })
382
+ .then(() => state);
383
+ }
384
+ /**
385
+ * Mount the remote Firestore document against this Syncro instance
386
+ *
387
+ * @param {Object} [options] Additional options to mutate behaviour
388
+ * @param {Object} [options.initialState] State to use if no state is already loaded, overrides the entities own `initState` function fetcher
389
+ * @param {Number} [options.retries=3] Number of times to retry if a mounted Syncro fails its sanity checks
390
+ * @returns {Promise<Syncro>} A promise which resolves as this syncro instance when completed
391
+ */
392
+ mount(options) {
393
+ const settings = {
394
+ initialState: null,
395
+ retries: 5,
396
+ retryMinTime: 250,
397
+ ...options,
398
+ };
399
+ const { fsCollection, fsId, entity } = Syncro.pathSplit(this.path);
400
+ let reactive; // Eventual response from reactive() with the initial value
401
+ let doc; // Eventual Firebase document
402
+ return PromiseRetry(async () => {
403
+ await this.setHeartbeat(false); // Disable any existing heartbeat
404
+ // Set up binding and wait for it to come ready
405
+ this.docRef = FirestoreDocRef(Syncro.firestore, fsCollection, fsId);
406
+ // Initialize state
407
+ let initialState = await this.getFirestoreState();
408
+ // If we have a project that has `users` (written by the invite system)
409
+ // but lacks core data like `name` 9implying a full sync hasn't happened),
410
+ // we assume the document is corrupted/partial.
411
+ if (this.path.startsWith('projects::') &&
412
+ initialState &&
413
+ !isEmpty(initialState) &&
414
+ initialState.users &&
415
+ (!initialState.type && !initialState.name && !initialState.created)) {
416
+ this.debugError('Zombie state detected (Partial document). Forcing repair...');
417
+ // Force the backend to wipe and re-sync this entity
418
+ const repairRes = await fetch(`${this.config.syncroRegistryUrl}/${this.path}?drop=1&force=1`);
419
+ if (repairRes.ok) {
420
+ this.debug('Repair signal sent successfully. Reloading local state...');
421
+ // Fetch the newly corrected state from Firestore
422
+ initialState = await this.getFirestoreState();
423
+ }
424
+ else {
425
+ console.error('[Syncro] Self-healing failed', repairRes.statusText);
426
+ }
427
+ }
428
+ // Construct a reactive component
429
+ reactive = this.getReactive(initialState);
430
+ if (!reactive.doc || !reactive.setState || !reactive.getState || !reactive.watch)
431
+ throw new Error('Syncro.getReactive() requires a returned `doc`, `setState()`, `getState()` + `watch()`');
432
+ // Accept throttling for reactiveWrapper if present
433
+ if (reactive.throttle) { // Wanting to throttle - handed either truthy or an object of throttle settings
434
+ let throttleSettings = {
435
+ limit: 2,
436
+ interval: 100, // i.e. 2 calls within 100ms, otherwise throttle
437
+ strict: false,
438
+ ...(typeof reactive.throttle == 'object' && reactive.throttle), // Import throttle settings if any
439
+ };
440
+ // Wrap original reactive.setState() in a throttle function
441
+ let originalSetState = reactive.setState;
442
+ reactive.setState = PromiseThrottle({
443
+ limit: throttleSettings.limit,
444
+ interval: throttleSettings.interval,
445
+ onDelay: () => this.debug('Throttling excessive Syncro.setState() writes'),
446
+ })(originalSetState);
447
+ }
448
+ this.value = doc = reactive.doc;
449
+ this.debug('Initial state', { doc });
450
+ // Subscribe to remote updates
451
+ const unsubscribe = FirestoreOnSnapshot(this.docRef, snapshot => {
452
+ const snapshotData = Syncro.fromFirestore(snapshot.data());
453
+ this.debug('Incoming snapshot', { snapshotData });
454
+ reactive.setState(snapshotData);
455
+ });
456
+ this._destroyActions.push(unsubscribe);
457
+ // Optionally create the doc if it has no content
458
+ if (!isEmpty(doc)) { // Doc already has content - skip
459
+ // Do nothing
460
+ }
461
+ else if (settings.initialState) { // Provided an initialState
462
+ this.debug('Populate initial Syncro state (from provided initialState)');
463
+ await this.setFirestoreState(settings.initialState, { method: 'set' });
464
+ }
465
+ else {
466
+ // Doc is empty (or was missing).
467
+ this.debug(`Populate initial Syncro state (from "${entity}" Syncro worker)`);
468
+ const response = await fetch(`${this.config.syncroRegistryUrl}/${this.path}`);
469
+ if (!response.ok) {
470
+ throw new Error(`Failed to check Syncro "${fsCollection}::${fsId}" status - ${response.statusText}`);
471
+ }
472
+ }
473
+ // Setup local state watcher
474
+ reactive.watch(throttle((newState) => {
475
+ this.debug('Local change', { newState });
476
+ this.markDirty();
477
+ this.setFirestoreState(newState, { method: 'merge' }); // eslint-disable-line @typescript-eslint/no-floating-promises
478
+ }, this.throttle));
479
+ await this.setHeartbeat(true, {
480
+ immediate: true,
481
+ });
482
+ return this;
483
+ }, {
484
+ retries: settings.retries,
485
+ minTimeout: settings.retryMinTime,
486
+ randomize: true,
487
+ factor: 3,
488
+ onFailedAttempt: async (e) => {
489
+ this.debugError(`[Attempt ${e.attemptNumber}/${e.attemptNumber + e.retriesLeft - 1}] to mount syncro`, e);
490
+ await this.destroy();
491
+ },
492
+ });
493
+ }
494
+ /**
495
+ * Merge a single or multiple values into a Syncro data object
496
+ * NOTE: Default behaviour is to flush (if any changes apply), use direct object mutation or disable with `flush:false` to disable
497
+ *
498
+ * @param {String|Object} key Either the single named key to set OR the object to merge
499
+ * @param {*} [value] The value to set if `key` is a string
500
+ *
501
+ * @param {Object} [options] Additional options to mutate behaviour
502
+ * @param {Boolean} [options.delta=true] Only merge keys that differ, skipping flush if no changes are made
503
+ * @param {Boolean} [options.flush=true] Send a flush signal that Firebase should sync to Supabase on changes
504
+ * @param {Boolean} [options.forceFlush=false] Flush even if no changes were made
505
+ * @param {Boolean} [options.flushDestroy=false] Destroy the Syncro after flushing
506
+ *
507
+ * @returns {Promise<Syncro>} A promise which resolves with this Syncro instance on completion
508
+ */
509
+ async set(key, value, options) {
510
+ // Argument mangling - [key, value, settings] -> changes{}, settings {{{
511
+ let changes;
512
+ if (typeof key == 'string') { // Called as (key:String, value:*, options?:Object)
513
+ changes[key] = value;
514
+ }
515
+ else if (typeof key == 'object') { // Called as (changes:Object, options?:Object)
516
+ [changes, options] = [key, value];
517
+ }
518
+ else {
519
+ throw new Error('Unknown call signature for set() - call with string+value or object');
520
+ }
521
+ // }}}
522
+ const settings = {
523
+ delta: true,
524
+ flush: true,
525
+ forceFlush: false,
526
+ flushDestroy: false,
527
+ ...options,
528
+ };
529
+ // Perform merge
530
+ let hasChanges = false;
531
+ if (settings.delta) { // Merge changes lazily
532
+ Object.entries(changes)
533
+ .filter(([k, v]) => !isEqual(this.value[k], v))
534
+ .forEach(([k, v]) => {
535
+ hasChanges = true;
536
+ this.value[k] = v;
537
+ });
538
+ }
539
+ else {
540
+ hasChanges = true;
541
+ Object.assign(this.value, changes);
542
+ }
543
+ // Optionally perform flush
544
+ if ((settings.forceFlush || hasChanges)
545
+ && settings.flush) {
546
+ await this.flush({ destroy: settings.flushDestroy });
547
+ }
548
+ return this;
549
+ }
550
+ /**
551
+ * Schedule Syncro heartbeats
552
+ * This populates the `sync` presence meta-information
553
+ *
554
+ * @param {Boolean} [enable=true] Whether to enable heartbeats
555
+ *
556
+ * @param {Object} [options] Additional options to mutate behaviour
557
+ * @param {Boolean} [options.immediate=false] Fire a heartbeat as soon as this function is called, this is only really useful on mount
558
+ * @returns {Promise|Void} A promise that resolves when completed (if `{immediate:true}`) or void
559
+ */
560
+ setHeartbeat(enable = true, options) {
561
+ const settings = {
562
+ immediate: true,
563
+ ...options,
564
+ };
565
+ // Clear existing heartbeat timer, if there is one
566
+ clearTimeout(this._heartbeatTimer);
567
+ if (enable) {
568
+ const heartbeatAction = async () => {
569
+ // Perform the heartbeat
570
+ await this.heartbeat();
571
+ // If we're enabled - schedule the next heartbeat timer
572
+ if (enable)
573
+ this.setHeartbeat(true); // eslint-disable-line @typescript-eslint/no-floating-promises
574
+ };
575
+ this._heartbeatTimer = setTimeout(heartbeatAction, this.config.heartbeatInterval);
576
+ if (settings.immediate)
577
+ return this.heartbeat(); // Return the promise from immediate heartbeat
578
+ }
579
+ }
580
+ /**
581
+ * Perform one heartbeat pulse to the server to indicate presence within this Syncro
582
+ * This function is automatically called by a timer if `setHeartbeat(true)` (the default behaviour)
583
+ *
584
+ * @returns {Promise} A promise which resolves when the operation has completed
585
+ */
586
+ async heartbeat() {
587
+ this.debug('heartbeat!');
588
+ try {
589
+ const response = await fetch(`${this.config.syncroRegistryUrl}/${this.path}/heartbeat`, {
590
+ method: 'post',
591
+ headers: {
592
+ 'Content-Type': 'application/json'
593
+ },
594
+ body: JSON.stringify({
595
+ session: Syncro.session,
596
+ ...(this.isDirty && { dirty: true }),
597
+ }),
598
+ });
599
+ if (!response.ok) {
600
+ console.warn(this.path, `Heartbeat failed - ${response.statusText}`, { response });
601
+ }
602
+ this.isDirty = false; // Reset the dirty flag if it is set
603
+ }
604
+ catch (error) {
605
+ console.warn(this.path, 'Heartbeat fetch error', error);
606
+ // Decide if isDirty should be reset on network error
607
+ }
608
+ }
609
+ /**
610
+ * Utility function to directly set this documents firestore state
611
+ *
612
+ * @param {Object} state The state to set / merge
613
+ * @param {Object} [options] Additional options to mutate behaviour
614
+ * @param {'merge'|'set'} [options.method='merge'] How to apply the new state. 'merge' (merge in partial data to an existing Syncro), 'set' (overwrite the entire Syncro state)
615
+ *
616
+ * @param {number} retries How many tries to take before erroring
617
+ * @returns {Promise} A promise which resolves when the operation has completed
618
+ */
619
+ async setFirestoreState(state, options, retries = 0) {
620
+ const settings = {
621
+ method: 'merge',
622
+ ...options,
623
+ };
624
+ if (!this.docRef)
625
+ throw new Error('mount() must be called before setting Firestore state');
626
+ const firestoreData = Syncro.toFirestore(state);
627
+ try {
628
+ if (settings.method === 'merge') {
629
+ return await FirestoreUpdateDoc(this.docRef, firestoreData);
630
+ }
631
+ else { // method === 'set'
632
+ return await FirestoreSetDoc(this.docRef, firestoreData);
633
+ }
634
+ }
635
+ catch (e) {
636
+ if (e instanceof FirebaseError && e.code === 'not-found') {
637
+ if (retries < 3) {
638
+ console.warn('Firebase syncro document does not exist during document update, reinitializing...');
639
+ // Reinitialize the firestore syncro document
640
+ const response = await fetch(`${this.config.syncroRegistryUrl}/${this.path}?force=1`);
641
+ if (!response.ok) {
642
+ console.error('Failed to reinitialize Syncro');
643
+ }
644
+ // Retry the request
645
+ return await this.setFirestoreState(state, options, retries + 1);
646
+ }
647
+ else {
648
+ console.warn('Max retries exceeded while trying to recover firestore syncro document, throwing error');
649
+ }
650
+ }
651
+ console.error(`Error during Firestore operation (${settings.method}) on doc: ${this.docRef.path}`, e);
652
+ throw e;
653
+ }
654
+ }
655
+ /**
656
+ * Utility method to fetch the Firestore state for this Syncro
657
+ * NOTE: This directly extracts the state of the Firestore, not its wrapping doc object returned by `FirestoreGetDoc`
658
+ *
659
+ * @returns {Promise<Object>} A promise which resolves to the Firestore state
660
+ */
661
+ getFirestoreState() {
662
+ if (!this.docRef)
663
+ throw new Error('mount() must be called before getting Firestore state');
664
+ return FirestoreGetDoc(this.docRef)
665
+ .then(doc => Syncro.fromFirestore(doc.data() ?? {})); // Handle undefined data with default {}
666
+ }
667
+ /**
668
+ * Set the Syncro dirty flag which gets passed to the server on the next heartbeat
669
+ *
670
+ * @see isDirty
671
+ * @returns {Syncro} This chainable Syncro instance
672
+ */
673
+ markDirty() {
674
+ this.isDirty = true;
675
+ return this;
676
+ }
677
+ /**
678
+ * Force the server to flush state
679
+ * This is only really useful for debugging as this happens automatically anyway
680
+ *
681
+ * @param {Object} [options] Additional options to mutate behaviour
682
+ * @param {Boolean} [options.destroy=false] Instruct the server to also dispose of the Syncro state
683
+ *
684
+ * @returns {Promise} A promise which resolves when the operation has completed
685
+ */
686
+ flush(options) {
687
+ const settings = {
688
+ destroy: false,
689
+ ...options,
690
+ };
691
+ return fetch(`${this.config.syncroRegistryUrl}/${this.path}/flush` + (settings.destroy ? '?destroy=1' : ''))
692
+ .then(response => response.ok
693
+ ? null
694
+ : Promise.reject(response.statusText || 'An error occurred'));
695
+ }
696
+ /**
697
+ * Timer handle for heartbeats
698
+ *
699
+ * @type {any}
700
+ */
701
+ _heartbeatTimer; // Using any for simplicity with NodeJS.Timeout / number
702
+ }
703
+ /**
704
+ * Build a chaotic random tree structure based on dice rolls
705
+ * This function is mainly used for sync testing
706
+ *
707
+ * @param {Number} [depth=0] The current depth we are starting at, changes the nature of branches based on probability
708
+ *
709
+ * @returns {*} The current branch contents
710
+ */
711
+ export function randomBranch(depth = 0) {
712
+ const dice = // Roll a dice to pick the content
713
+ depth == 0 ? 10 // first roll is always '10'
714
+ : random(0, 11 - depth, false); // Subsequent rolls bias downwards based on depth (to avoid recursion)
715
+ return (dice == 0 ? false
716
+ : dice == 1 ? true
717
+ : dice == 2 ? random(1, 10_000)
718
+ : dice == 3 ? (new Date(random(1_000_000_000_000, 1_777_777_777_777))).toISOString()
719
+ : dice == 5 ? Array.from({ length: random(1, 10) }, () => random(1, 10)) // Added return type hint
720
+ : dice == 6 ? null
721
+ : dice < 8 ? Array.from({ length: random(1, 10) }, () => randomBranch(depth + 1)) // Added return type hint
722
+ : Object.fromEntries(Array.from({ length: random(1, 5) })
723
+ .map((v, k) => [
724
+ sample(['foo', 'bar', 'baz', 'quz', 'flarp', 'corge', 'grault', 'garply', 'waldo', 'fred', 'plugh', 'thud'])
725
+ + `_${k}`,
726
+ randomBranch(depth + 1),
727
+ ])));
728
+ }
729
+ /**
730
+ * Syncro specific version of the base NPM:@momsfriendlydevco/marshal config
731
+ * Designed to be used when calling marshal.serialize() + marshal.deserialize()
732
+ */
733
+ const marshalBaseConfig = {
734
+ circular: false,
735
+ clone: true, // Clone away from original so we don't trigger a loop within Firebase
736
+ modules: [
737
+ {
738
+ id: `~array`,
739
+ recursive: true,
740
+ test: (v) => Array.isArray(v),
741
+ serialize: (v) => ({ _: '~array', ...v }),
742
+ deserialize: (v) => {
743
+ const arr = Array.from({ length: Object.keys(v).length - 1 });
744
+ Object.entries(v)
745
+ .filter(([k]) => k !== '_')
746
+ .forEach(([k, val]) => arr[+k] = val); // Changed v to val to avoid shadowing
747
+ return arr;
748
+ },
749
+ },
750
+ {
751
+ id: '~function',
752
+ test: (v) => typeof v == 'function',
753
+ serialize: (v, path) => {
754
+ console.warn('Marshal Warning: Stripping function from path', path.join('.'));
755
+ throw new Error('Function serializing is forbidden');
756
+ },
757
+ deserialize: (v, path) => {
758
+ console.warn('Marshal Warning: Stripping function from path', path.join('.'));
759
+ },
760
+ },
761
+ ...marshal.settings.modules // Use default Marshal modules excepting...
762
+ .filter((mod) => mod.id != '~function') // Remove the Marshal function module as this upsets Cloudflare workers by using the `eval` built-in
763
+ ],
764
+ };
765
+ //# sourceMappingURL=syncro.js.map