@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.
- package/CHANGELOG.md +21 -0
- package/dist/lib/syncro/entities.d.ts +9 -3
- package/dist/lib/syncro/entities.js +103 -115
- package/dist/lib/syncro/entities.js.map +1 -1
- package/dist/lib/syncro/keyed (Rhino's conflicted copy 2026-05-10).js +287 -0
- package/dist/lib/syncro/keyed.js +1 -0
- package/dist/lib/syncro/keyed.js (Rhino's conflicted copy 2026-05-10).map +1 -0
- package/dist/lib/syncro/keyed.js.map +1 -1
- package/dist/lib/syncro/syncro (Rhino's conflicted copy 2026-05-10).js +765 -0
- package/dist/lib/syncro/syncro.d (Rhino's conflicted copy 2026-05-10).ts +336 -0
- package/dist/lib/syncro/syncro.d.ts +7 -0
- package/dist/lib/syncro/syncro.js +6 -0
- package/dist/lib/syncro/syncro.js (Rhino's conflicted copy 2026-05-10).map +1 -0
- package/dist/lib/syncro/syncro.js.map +1 -1
- package/dist/plugin.vue2.es2019 (Rhino's conflicted copy 2026-05-10).js +1271 -0
- package/dist/plugin.vue2.es2019.js +1 -1
- package/lib/syncro/entities.ts +126 -131
- package/lib/syncro/keyed.ts +1 -0
- package/lib/syncro/syncro.ts +9 -0
- package/package.json +1 -1
|
@@ -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
|