@iebh/tera-fy 1.15.9 → 2.0.1

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/lib/syncro.js ADDED
@@ -0,0 +1,592 @@
1
+ import {
2
+ isEmpty,
3
+ cloneDeep,
4
+ throttle,
5
+ } from 'lodash-es';
6
+ import {
7
+ doc as FirestoreDocRef,
8
+ getDoc as FirestoreGetDoc,
9
+ onSnapshot as FirestoreOnSnapshot,
10
+ setDoc as FirestoreSetDoc,
11
+ updateDoc as FirestoreUpdateDoc,
12
+ } from 'firebase/firestore';
13
+ import marshal from '@momsfriendlydevco/marshal';
14
+
15
+
16
+ /**
17
+ * TERA Isomorphic Syncro class
18
+ * This class tries to be as independent as possible to help with adapting it to various front-end TERA-fy plugin frameworks
19
+ */
20
+ export default class Syncro {
21
+
22
+ /**
23
+ * Firebase instance in use
24
+ *
25
+ * @type {Firebase}
26
+ */
27
+ static firebase;
28
+
29
+
30
+ /**
31
+ * Firestore instance in use
32
+ *
33
+ * @type {Firestore}
34
+ */
35
+ static firestore;
36
+
37
+
38
+ /**
39
+ * Supabase instance in use
40
+ *
41
+ * @type {SupabaseClient}
42
+ */
43
+ static supabase;
44
+
45
+
46
+ /**
47
+ * The current user session, should be unique for the user + browser tab
48
+ * Used by the heartbeat system
49
+ *
50
+ * @type {String}
51
+ */
52
+ static session;
53
+
54
+
55
+ /**
56
+ * This instances fully formed string path
57
+ *
58
+ * @type {String}
59
+ */
60
+ path;
61
+
62
+
63
+ /**
64
+ * The Firestore docHandle when calling various Firestore functions
65
+ *
66
+ * @type {FirestoreRef}
67
+ */
68
+ docRef;
69
+
70
+
71
+ /**
72
+ * The reactive object managed by this Syncro instance
73
+ * The nature of this varies by framework and what `getReactive()` provides
74
+ *
75
+ * @type {*}
76
+ */
77
+ value;
78
+
79
+
80
+ /**
81
+ * Default throttle to apply for writes
82
+ *
83
+ * @type {Number} Throttle time in milliseconds
84
+ */
85
+ throttle = 250;
86
+
87
+
88
+ /**
89
+ * Time between heartbeats
90
+ *
91
+ * @type {Number} Heartbeat time in milliseconds
92
+ */
93
+ heartbeatInterval = 30_000; //~= 30s
94
+
95
+
96
+ /**
97
+ * @interface
98
+ * Debugging printer for this instance
99
+ * Defaults to doing nothing
100
+ *
101
+ * @param {*...} [msg] The message to output
102
+ */
103
+ debug(...msg) {}
104
+
105
+
106
+ /**
107
+ * Instance constructor
108
+ *
109
+ * @param {String} path Mount path for the Syncro. Should be in the form `${ENTITY}::${ID}(::${RELATION})?`
110
+ * @param {Object} [options] Additional instance setters (mutates instance directly)
111
+ */
112
+ constructor(path, options) {
113
+ this.path = path;
114
+ Object.assign(this, options);
115
+
116
+ if (!Syncro.session) // Assign a random session ID if we don't already have one
117
+ Syncro.session = `syncro_${crypto.randomUUID()}`;
118
+ }
119
+
120
+
121
+ /**
122
+ * Instance destruction trigger
123
+ * This will unsubscribe from various facilities and release the object for cleanup
124
+ *
125
+ * @returns {Promise} A promise which resolves when the operation has completed
126
+ */
127
+ destroy() {
128
+ this.debug('Destroy!');
129
+ return Promise.all(this._destroyActions
130
+ .map(fn => fn())
131
+ )
132
+ .then(()=> this._destroyActions = []) // Reset list of actions to perform when terminating
133
+ }
134
+
135
+
136
+ /**
137
+ * Actions to preform when we are destroying this instance
138
+ * This is an array of function callbacks to execute in parallel when `destroy()` is called
139
+ *
140
+ * @type {Array<Function>}
141
+ */
142
+ _destroyActions = [];
143
+
144
+
145
+ /**
146
+ * Function to return whatever the local framework uses as a reactive object
147
+ * This should respond with an object of mandatory functions to watch for changes and remerge them
148
+ *
149
+ * @param {Object} value Initial value of the reactive
150
+ *
151
+ * @returns {Object} A reactive object prototype
152
+ * @property {Object} doc The reactive object
153
+ * @property {Function} setState Function used to overwrite the default state, called as `(newState:Object)`
154
+ * @property {Function} getState Function used to fetch the current snapshot state, called as `()`
155
+ * @property {Function} watch Function used to set up state watchers, should call its callback when a change is detected, called as `(cb:Function)`
156
+ */
157
+ getReactive(value) {
158
+ console.warn('Syncro.getReactive has not been subclassed, assuming a POJO response');
159
+ let doc = {...value};
160
+ return {
161
+ doc,
162
+ setState(state) {
163
+ // Shallow copy all sub keys into existing object (keeping the object pointer)
164
+ Object.entries(state || {})
165
+ .forEach(([k, v]) => doc[k] = v)
166
+ },
167
+ getState() {
168
+ return cloneDeep(doc);
169
+ },
170
+ watch(cb) {
171
+ // Stub
172
+ },
173
+ };
174
+ };
175
+
176
+
177
+ /**
178
+ * Returns the split entity + ID relationship from a given session path
179
+ * This funciton checks for valid UUID format strings + that the entity is a known/supported entity (see `knownEntities`)
180
+ * NOTE: When used by itself (i.e. ignoring response) this function can also act as a guard that a path is valid
181
+ *
182
+ * INPUT: `widgets::UUID` -> `{entity:'widgets', id:UUID}`
183
+ * INPUT: `widgets::UUID::thing` -> `{entity:'widgets', id:UUID, relation:'thing'}`
184
+ *
185
+ * @param {String} path The input session path of the form `${ENTITY}::${ID}`
186
+ *
187
+ * @returns {Object} An object composed of the session path components
188
+ * @property {String} fbEntity The top level Firebase collection to store within
189
+ * @property {String} fsId The top level Firebase ID of the collection to store as, this is either just a copy of the ID or a combination of id + relation
190
+ * @property {String} entity A valid entity name (in plural form e.g. 'projects')
191
+ * @property {String} id A valid UUID ID
192
+ * @property {String} [relation] A string representing a sub-relationship. Usually a short string alias
193
+ */
194
+ static pathSplit(path) {
195
+ let extracted = { .../^(?<entity>\w+?)::(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:::(?<relation>\w+?))?$/.exec(path)?.groups };
196
+
197
+ if (!extracted) throw new Error(`Invalid session path syntax "${path}"`);
198
+ if (!(extracted.entity in syncEntities)) throw new Error(`Unsupported entity "${path}" -> Entity="${extracted.entity}"`);
199
+
200
+ return {
201
+ ...extracted,
202
+ fsCollection: extracted.entity,
203
+ fsId: extracted.relation
204
+ ? `${extracted.id}::${extracted.relation}`
205
+ : extracted.id,
206
+ };
207
+ }
208
+
209
+
210
+ /**
211
+ * Convert local POJO -> Firestore compatible object
212
+ * This applies the following mutations to the incoming object:
213
+ *
214
+ * 1. Arrays are converted to Objects (Firestore cannot store nested arrays)
215
+ * 2. All non-POJO objects (e.g. Dates) to a symetric object
216
+ *
217
+ * @FIXME: Pretty sure we can drop the fromEntities() + key serializer in future
218
+ *
219
+ * @param {Object} snapshot The current state to convert
220
+ * @returns {Object} A Firebase compatible object
221
+ */
222
+ static toFirestore(snapshot = {}) {
223
+ return marshal.serialize(snapshot, {
224
+ clone: true, // Clone away from the original Vue Reactive so we dont mangle it while traversing
225
+ modules: [
226
+ marshalFlattenArrays,
227
+ ...marshal.settings.modules,
228
+ ],
229
+ stringify: false,
230
+ });
231
+ }
232
+
233
+
234
+ /**
235
+ * Convert local Firestore compatible object -> local POJO
236
+ * This reverses the mutations listed in `toFirestore()`
237
+ *
238
+ * @FIXME: Pretty sure we can drop the fromEntities() + key serializer in future
239
+ *
240
+ * @param {Object} snapshot The raw Firebase state to convert
241
+ * @returns {Object} A JavaScript POJO representing the converted state
242
+ */
243
+ static fromFirestore(snapshot = {}) {
244
+ return marshal.deserialize(snapshot, {
245
+ clone: true, // Clone away from original so we don't trigger a loop within Firebase
246
+ modules: [
247
+ marshalFlattenArrays,
248
+ ...marshal.settings.modules,
249
+ ],
250
+ destringify: false,
251
+ });
252
+ }
253
+
254
+
255
+ /**
256
+ * Convert a Firestore field dump into a native POJO
257
+ * Field structures are usually provided by the Firestore ReST API and need de-typing back into a native document
258
+ * NOTE: This does not deserialize the result so you likely want to use this as `fromFirestore(fromFirestoreFields(response.fields))`
259
+ *
260
+ * @see https://stackoverflow.com/a/62304377
261
+ * @param {Object} fields The raw Snapshot to convert
262
+ * @returns {Object} A JavaScript POJO representing the converted state
263
+ */
264
+ static fromFirestoreFields(fields = {}) {
265
+ let result = {};
266
+ for (let key in fields) {
267
+ let value = fields[key];
268
+ let isDocumentType = [
269
+ 'stringValue', 'booleanValue', 'doubleValue',
270
+ 'integerValue', 'timestampValue', 'mapValue', 'arrayValue',
271
+ ].find(t => t === key);
272
+
273
+ if (isDocumentType) {
274
+ let item = ['stringValue', 'booleanValue', 'doubleValue', 'integerValue', 'timestampValue']
275
+ .find(t => t === key)
276
+
277
+ if (item) {
278
+ return value;
279
+ } else if ('mapValue' == key) {
280
+ return Syncro.fromFirestoreFields(value.fields || {});
281
+ } else if ('arrayValue' == key) {
282
+ let list = value.values;
283
+ return !!list ? list.map(l => Syncro.fromFirestoreFields(l)) : [];
284
+ }
285
+ } else {
286
+ result[key] = Syncro.fromFirestoreFields(value)
287
+ }
288
+ }
289
+ return result;
290
+ }
291
+
292
+
293
+ /**
294
+ * Perform a one-off fetch of a given Syncro path
295
+ *
296
+ * @param {String} path The Syncro entity + ID path. Takes the form `ENTITY::ID`
297
+ *
298
+ * @returns {Promise<Object|Null>} An eventual snapshot of the given path, if the entity doesn't exist null is returned
299
+ */
300
+ static getSnapshot(path) {
301
+ let {fsCollection, fsId} = Syncro.pathSplit(path);
302
+
303
+ return Promise.resolve()
304
+ .then(async ()=> { // Set up binding and wait for it to come ready
305
+ let docRef = FirestoreDocRef(Syncro.firestore, fsCollection, fsId);
306
+ return FirestoreGetDoc(docRef);
307
+ })
308
+ .then(doc => doc
309
+ ? Syncro.fromFirestore(snapshot.data())
310
+ : null
311
+ )
312
+ }
313
+
314
+
315
+ /**
316
+ * Perform a one-off set/merge operation against
317
+ *
318
+ * @param {String} path The Syncro entity + ID path. Takes the form `ENTITY::ID`
319
+ * @param {Object} state The new state to set/merge
320
+ *
321
+ * @param {Object} [options] Additional options to mutate behaviour
322
+ * @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)
323
+ *
324
+ * @returns {Promise<*>} The state object after it has been applied
325
+ */
326
+ static setSnapshot(path, state, options) {
327
+ let settings = {
328
+ method: 'merge',
329
+ ...options,
330
+ };
331
+ let {fsCollection, fsId} = Syncro.pathSplit(path);
332
+
333
+ return Promise.resolve()
334
+ .then(()=> // Set up binding and wait for it to come ready
335
+ (settings.method == 'merge' ? 'FirestoreUpdateDoc' : 'FirestoreSetDoc')(
336
+ FirestoreDocRef(Syncro.firestore, fsCollection, fsId),
337
+ Syncro.toFirestore(state),
338
+ )
339
+ )
340
+ .then(()=> state)
341
+ }
342
+
343
+
344
+ /**
345
+ * Wrap a Supabase query so it works more like a classic-JS promise
346
+ *
347
+ * 1. Flatten non-promise responses into thennables
348
+ * 2. The query is forced to respond as a promise (prevents accidental query chaining)
349
+ * 3. The response data object is forced as a POJO (if any data is returned, otherwise void)
350
+ * 4. Error responses throw with a logical error message rather than a weird object return
351
+ * 5. Translate various error messages to something logical
352
+ *
353
+ * @param {SupabaseQuery} query A Supabase query object or method to execute
354
+ * @returns {Object} The data response as a plain JavaScript Object
355
+ */
356
+ static wrapSupabase(query) {
357
+ return Promise.resolve(query)
358
+ .then(res => {
359
+ if (res?.error) {
360
+ if (/JSON object requested, multiple \(or no\) rows returned$/.test(res.error.message)) {
361
+ console.warn('Supabase query threw record not found against query', query.url.search);
362
+ console.warn('Supabase raw error', res);
363
+ throw new Error('NOT-FOUND');
364
+ } else {
365
+ console.warn('Supabase query threw', res.error.message);
366
+ throw new Error(`${res.error?.code || 'UnknownError'}: ${res.error?.message || 'Unknown Supabase error'}`);
367
+ }
368
+ } else if (res.data) { // Do we have output data
369
+ return res.data;
370
+ }
371
+ })
372
+ }
373
+
374
+
375
+ /**
376
+ * Mount the remote Firestore document against this instances local `value` getReactive
377
+ *
378
+ * @returns {Promise<Sync>} A promise which resolves as this sync instance when completed
379
+ */
380
+ mount() {
381
+ let {fsCollection, fsId, entity, id, relation} = Syncro.pathSplit(this.path);
382
+ let reactive; // Eventual response from reactive() with the intitial value
383
+ let doc; // Eventual Firebase document
384
+
385
+ return Promise.resolve()
386
+ .then(()=> this.setHeartbeat(false)) // Disable any existing heartbeat - this only really applies if we're changing path for some reason
387
+ .then(async ()=> { // Set up binding and wait for it to come ready
388
+ this.docRef = FirestoreDocRef(Syncro.firestore, fsCollection, fsId);
389
+
390
+ // Initalize state
391
+ let initialState = Syncro.fromFirestore(
392
+ (await FirestoreGetDoc(this.docRef))
393
+ .data()
394
+ );
395
+
396
+ // Construct a reactive component
397
+ reactive = this.getReactive(initialState);
398
+ if (!reactive.doc || !reactive.setState || !reactive.getState || !reactive.watch) throw new Error('Syncro.getReactive() requires a returned `doc`, `setState()`, `getState()` + `watch()`');
399
+ this.value = doc = reactive.doc;
400
+
401
+ this.debug('Initial state', {doc});
402
+
403
+ // Subscribe to remote updates
404
+ this._destroyActions.push( // Add the unsubscribe handle to the list of destroyAction promises we call on `destroy()`
405
+ FirestoreOnSnapshot(this.docRef, snapshot => {
406
+ let snapshotData = Syncro.fromFirestore(snapshot.data());
407
+ this.debug('Incoming snapshot', {snapshotData});
408
+ reactive.setState(snapshotData);
409
+ })
410
+ );
411
+ })
412
+ .then(()=> { // Optionally create the doc if it has no content
413
+ if (!isEmpty(doc)) return; // Doc already has some content at least?
414
+
415
+ this.debug('Populate initial state');
416
+
417
+ // Extract base data + add document and return new hook
418
+ return Promise.resolve()
419
+ .then(()=> syncEntities[entity] || Promise.reject(`Unknown Sync entity "${entity}"`))
420
+ .then(()=> syncEntities[entity].initState({
421
+ supabase: Syncro.supabase,
422
+ entity,
423
+ id,
424
+ relation,
425
+ }))
426
+ .then(state => FirestoreSetDoc(
427
+ this.docRef,
428
+ Syncro.toFirestore(state),
429
+ )) // Send new base state to Firestore
430
+ })
431
+ .then(()=> { // Setup local state watcher
432
+ reactive.watch(throttle(newState => {
433
+ this.debug('Local change', {newState});
434
+ FirestoreUpdateDoc(
435
+ this.docRef,
436
+ Syncro.toFirestore(newState),
437
+ );
438
+ }, this.throttle));
439
+ })
440
+ .then(()=> this.setHeartbeat(true))
441
+ .then(()=> this)
442
+ }
443
+
444
+
445
+ /**
446
+ * Schedule Syncro heartbeats
447
+ * This populates the `sync` presence meta-information
448
+ *
449
+ * @param {Boolean} [enable=true] Whether to enable heartbeating
450
+ */
451
+ setHeartbeat(enable = true) {
452
+ clearTimeout(this._heartbeatTimer);
453
+
454
+ this._heartbeatTimer = enable && setTimeout(async ()=> {
455
+ this.debug('heartbeat!');
456
+ let timestamp = (new Date()).toISOString();
457
+ await FirestoreSetDoc(
458
+ FirestoreDocRef(Syncro.firestore, 'presence', this.path),
459
+ {
460
+ latest: timestamp,
461
+ sessions: {
462
+ [this.session]: timestamp,
463
+ },
464
+ },
465
+ {merge: true},
466
+ );
467
+
468
+ // If we're enabled - set the next heartbeat timer
469
+ this.setHeartbeat(true);
470
+ }, this.heartbeatInterval);
471
+ }
472
+
473
+
474
+ /**
475
+ * Timer handle for heartbeats
476
+ *
477
+ * @type {Handle}
478
+ */
479
+ _heartbeatTimer;
480
+ }
481
+
482
+
483
+ /**
484
+ * Entities we support syncro paths on, each needs to correspond with a Firebase/Firestore collection name
485
+ *
486
+ * @type {Object} An object lookup of entities
487
+ *
488
+ * @property {String} singular The singular noun for the item
489
+ * @property {Function} initState Function called to initialize state when Firestore has no existing document. Called as `({supabase:SupabaseClient, entity:String, id:String, relation?:string})` and expected to return the initial data object state
490
+ * @property {Function} flushState Function called to flush state from Firebase to Supabase. Called the same as `initState` + `{state:Object}`
491
+ */
492
+ export const syncEntities = {
493
+ projects: {
494
+ singular: 'project',
495
+ initState({supabase, id}) {
496
+ return Syncro.wrapSupabase(supabase.from('projects')
497
+ .select('data')
498
+ .limit(1)
499
+ .eq('id', id)
500
+ )
501
+ .then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro project "${id}" not found`))
502
+ .then(item => item.data); // Bind to 'data' JSONB column
503
+ },
504
+ flushState({supabase, state, fsId}) {
505
+ return Syncro.wrapSupabase(supabase.rpc('syncro_merge_data', {
506
+ table_name: 'projects',
507
+ entity_id: fsId,
508
+ new_data: state,
509
+ }))
510
+ },
511
+ },
512
+ project_namespaces: {
513
+ singular: 'project namespace',
514
+ initState({supabase, id, relation}) {
515
+ if (!relation) throw new Error('Project namespace relation missing, path should resemble "project_namespaces::${PROJECT}::${RELATION}"');
516
+ return Syncro.wrapSupabase(supabase.from('project_namespaces')
517
+ .select('data')
518
+ .limit(1)
519
+ .eq('project', id)
520
+ .eq('name', relation)
521
+ )
522
+ .then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro project ("${id}") namespace ("${relation}") not found`))
523
+ .then(item => item.data);
524
+ },
525
+ flushState({supabase, state, id, relation}) {
526
+ return Syncro.wrapSupabase(supabase.from('project_namespaces')
527
+ .update(state)
528
+ .eq('project', id)
529
+ .eq('name', relation)
530
+ )
531
+ },
532
+ },
533
+ test: {
534
+ singular: 'test',
535
+ initState({supabase, id}) {
536
+ return Syncro.wrapSupabase(supabase.from('test')
537
+ .select('data')
538
+ .limit(1)
539
+ .eq('id', id)
540
+ )
541
+ .then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro test item "${id}" not found`))
542
+ .then(item => item.data);
543
+ },
544
+ flushState({supabase, state, fsId}) {
545
+ return Syncro.wrapSupabase(supabase.rpc('syncro_merge_data', {
546
+ table_name: 'test',
547
+ entity_id: fsId,
548
+ new_data: state,
549
+ }))
550
+ },
551
+ },
552
+ users: {
553
+ singular: 'user',
554
+ initState({supabase, id}) {
555
+ return Syncro.wrapSupabase(supabase.from('users')
556
+ .select('data')
557
+ .limit(1)
558
+ .eq('id', id)
559
+ )
560
+ .then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro user "${id}" not found`))
561
+ .then(item => item.data);
562
+ },
563
+ flushState({supabase, state, fsId}) {
564
+ return Syncro.wrapSupabase(supabase.rpc('syncro_merge_data', {
565
+ table_name: 'users',
566
+ entity_id: fsId,
567
+ new_data: state,
568
+ }))
569
+ },
570
+ },
571
+ };
572
+
573
+
574
+ /**
575
+ * NPM:@momsfriendlydevco/marshal Compatible module for flattening arrays
576
+ * @type {MarshalModule}
577
+ */
578
+ const marshalFlattenArrays = {
579
+ id: `~array`,
580
+ recursive: true,
581
+ test: v => Array.isArray(v),
582
+ serialize: v => ({_: '~array', ...v}),
583
+ deserialize: v => {
584
+ let arr = Array.from({length: Object.keys(v).length - 1});
585
+
586
+ Object.entries(v)
587
+ .filter(([k]) => k !== '_')
588
+ .forEach(([k, v]) => arr[+k] = v);
589
+
590
+ return arr;
591
+ },
592
+ };