@iebh/tera-fy 2.3.10 → 2.3.12

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