@iebh/tera-fy 1.15.9 → 2.0.0
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 +26 -0
- package/api.md +621 -488
- package/dist/plugin.vue2.es2019.js +2294 -1
- package/dist/terafy.es2019.js +2 -2
- package/dist/terafy.js +2 -2
- package/lib/syncro.js +452 -0
- package/lib/terafy.client.js +152 -24
- package/lib/terafy.server.js +61 -21
- package/package.json +4 -1
- package/plugins/firebase.js +122 -0
- package/plugins/vue2.js +82 -94
- package/plugins/vue3.js +38 -142
package/lib/syncro.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
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
|
+
* This instances fully formed string path
|
|
48
|
+
*
|
|
49
|
+
* @type {String}
|
|
50
|
+
*/
|
|
51
|
+
path;
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The Firestore docHandle when calling various Firestore functions
|
|
56
|
+
*
|
|
57
|
+
* @type {FirestoreRef}
|
|
58
|
+
*/
|
|
59
|
+
docRef;
|
|
60
|
+
|
|
61
|
+
|
|
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 {*}
|
|
67
|
+
*/
|
|
68
|
+
value;
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Default throttle to apply for writes
|
|
73
|
+
* Time in milliseconds
|
|
74
|
+
*
|
|
75
|
+
* @type {Number}
|
|
76
|
+
*/
|
|
77
|
+
throttle = 250;
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @interface
|
|
82
|
+
* Debugging printer for this instance
|
|
83
|
+
* Defaults to doing nothing
|
|
84
|
+
*
|
|
85
|
+
* @param {*...} [msg] The message to output
|
|
86
|
+
*/
|
|
87
|
+
debug(...msg) {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Instance constructor
|
|
92
|
+
*
|
|
93
|
+
* @param {String} path Mount path for the Syncro. Should be in the form `${ENTITY}::${ID}(::${RELATION})?`
|
|
94
|
+
* @param {Object} [options] Additional instance setters (mutates instance directly)
|
|
95
|
+
*/
|
|
96
|
+
constructor(path, options) {
|
|
97
|
+
this.path = path;
|
|
98
|
+
Object.assign(this, options);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Function to return whatever the local framework uses as a reactive object
|
|
104
|
+
* This should respond with an object of mandatory functions to watch for changes and remerge them
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} value Initial value of the reactive
|
|
107
|
+
*
|
|
108
|
+
* @returns {Object} A reactive object prototype
|
|
109
|
+
* @property {Object} doc The reactive object
|
|
110
|
+
* @property {Function} setState Function used to overwrite the default state, called as `(newState:Object)`
|
|
111
|
+
* @property {Function} getState Function used to fetch the current snapshot state, called as `()`
|
|
112
|
+
* @property {Function} watch Function used to set up state watchers, should call its callback when a change is detected, called as `(cb:Function)`
|
|
113
|
+
*/
|
|
114
|
+
getReactive(value) {
|
|
115
|
+
console.warn('Syncro.getReactive has not been subclassed, assuming a POJO response');
|
|
116
|
+
let doc = {...value};
|
|
117
|
+
return {
|
|
118
|
+
doc,
|
|
119
|
+
setState(state) {
|
|
120
|
+
// Shallow copy all sub keys into existing object (keeping the object pointer)
|
|
121
|
+
Object.entries(state || {})
|
|
122
|
+
.forEach(([k, v]) => doc[k] = v)
|
|
123
|
+
},
|
|
124
|
+
getState() {
|
|
125
|
+
return cloneDeep(doc);
|
|
126
|
+
},
|
|
127
|
+
watch(cb) {
|
|
128
|
+
// Stub
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns the split entity + ID relationship from a given session path
|
|
136
|
+
* This funciton checks for valid UUID format strings + that the entity is a known/supported entity (see `knownEntities`)
|
|
137
|
+
* NOTE: When used by itself (i.e. ignoring response) this function can also act as a guard that a path is valid
|
|
138
|
+
*
|
|
139
|
+
* INPUT: `widgets::UUID` -> `{entity:'widgets', id:UUID}`
|
|
140
|
+
* INPUT: `widgets::UUID::thing` -> `{entity:'widgets', id:UUID, relation:'thing'}`
|
|
141
|
+
*
|
|
142
|
+
* @param {String} path The input session path of the form `${ENTITY}::${ID}`
|
|
143
|
+
*
|
|
144
|
+
* @returns {Object} An object composed of the session path components
|
|
145
|
+
* @property {String} fbEntity The top level Firebase collection to store within
|
|
146
|
+
* @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
|
|
147
|
+
* @property {String} entity A valid entity name (in plural form e.g. 'projects')
|
|
148
|
+
* @property {String} id A valid UUID ID
|
|
149
|
+
* @property {String} [relation] A string representing a sub-relationship. Usually a short string alias
|
|
150
|
+
*/
|
|
151
|
+
static pathSplit(path) {
|
|
152
|
+
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 };
|
|
153
|
+
|
|
154
|
+
if (!extracted) throw new Error(`Invalid session path syntax "${path}"`);
|
|
155
|
+
if (!extracted.entity in syncEntities) throw new Error(`Unsupported entity "${path}" -> Entity="${extracted.entity}"`);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...extracted,
|
|
159
|
+
fsCollection: extracted.entity,
|
|
160
|
+
fsId: extracted.relation
|
|
161
|
+
? `${extracted.id}::${extracted.relation}`
|
|
162
|
+
: extracted.id,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Convert local POJO -> Firestore compatible object
|
|
169
|
+
* This applies the following mutations to the incoming object:
|
|
170
|
+
*
|
|
171
|
+
* 1. Arrays are converted to Objects (Firestore cannot store nested arrays)
|
|
172
|
+
* 2. All non-POJO objects (e.g. Dates) to a symetric object
|
|
173
|
+
*
|
|
174
|
+
* @FIXME: Pretty sure we can drop the fromEntities() + key serializer in future
|
|
175
|
+
*
|
|
176
|
+
* @param {Object} snapshot The current state to convert
|
|
177
|
+
* @returns {Object} A Firebase compatible object
|
|
178
|
+
*/
|
|
179
|
+
static toFirestore(snapshot = {}) {
|
|
180
|
+
return marshal.serialize(this.cleanPojo(snapshot), {
|
|
181
|
+
clone: true, // Clone away from the original Vue Reactive so we dont mangle it while traversing
|
|
182
|
+
modules: [
|
|
183
|
+
marshalFlattenArrays,
|
|
184
|
+
...marshal.settings.modules,
|
|
185
|
+
],
|
|
186
|
+
stringify: false,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Convert local Firestore compatible object -> local POJO
|
|
193
|
+
* This reverses the mutations listed in `toFirestore()`
|
|
194
|
+
*
|
|
195
|
+
* @FIXME: Pretty sure we can drop the fromEntities() + key serializer in future
|
|
196
|
+
*
|
|
197
|
+
* @param {Object} snapshot The raw Firebase state to convert
|
|
198
|
+
* @returns {Object} A JavaScript POJO representing the converted state
|
|
199
|
+
*/
|
|
200
|
+
static fromFirestore(snapshot = {}) {
|
|
201
|
+
return marshal.deserialize(this.cleanPojo(snapshot), {
|
|
202
|
+
modules: [
|
|
203
|
+
marshalFlattenArrays,
|
|
204
|
+
...marshal.settings.modules,
|
|
205
|
+
],
|
|
206
|
+
destringify: false,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Perform a one-off fetch of a given Syncro path
|
|
213
|
+
*
|
|
214
|
+
* @param {String} path The Syncro entity + ID path. Takes the form `ENTITY::ID`
|
|
215
|
+
*
|
|
216
|
+
* @returns {Promise<Object|Null>} An eventual snapshot of the given path, if the entity doesn't exist null is returned
|
|
217
|
+
*/
|
|
218
|
+
static getSnapshot(path) {
|
|
219
|
+
let {fsCollection, fsId} = Syncro.pathSplit(path);
|
|
220
|
+
|
|
221
|
+
return Promise.resolve()
|
|
222
|
+
.then(async ()=> { // Set up binding and wait for it to come ready
|
|
223
|
+
let docRef = FirestoreDocRef(Syncro.firestore, fsCollection, fsId);
|
|
224
|
+
return FirestoreGetDoc(docRef);
|
|
225
|
+
})
|
|
226
|
+
.then(doc => doc
|
|
227
|
+
? Syncro.fromFirestore(snapshot.data())
|
|
228
|
+
: null
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Perform a one-off set/merge operation against
|
|
235
|
+
*
|
|
236
|
+
* @param {String} path The Syncro entity + ID path. Takes the form `ENTITY::ID`
|
|
237
|
+
* @param {Object} state The new state to set/merge
|
|
238
|
+
*
|
|
239
|
+
* @param {Object} [options] Additional options to mutate behaviour
|
|
240
|
+
* @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)
|
|
241
|
+
*
|
|
242
|
+
* @returns {Promise<*>} The state object after it has been applied
|
|
243
|
+
*/
|
|
244
|
+
static setSnapshot(path, state, options) {
|
|
245
|
+
let settings = {
|
|
246
|
+
method: 'merge',
|
|
247
|
+
...options,
|
|
248
|
+
};
|
|
249
|
+
let {fsCollection, fsId} = Syncro.pathSplit(path);
|
|
250
|
+
|
|
251
|
+
return Promise.resolve()
|
|
252
|
+
.then(()=> // Set up binding and wait for it to come ready
|
|
253
|
+
(settings.method == 'merge' ? 'FirestoreUpdateDoc' : 'FirestoreSetDoc')(
|
|
254
|
+
FirestoreDocRef(this.firestore, fsCollection, fsId),
|
|
255
|
+
Syncro.toFirestore(state),
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
.then(()=> state)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
static cleanPojo(state) {
|
|
263
|
+
return Object.fromEntries(
|
|
264
|
+
Object.entries(state)
|
|
265
|
+
.filter(([k]) => // Ignore top level hidden fields
|
|
266
|
+
!/^[\$_]/.test(k) // Starts with '_' or '$'
|
|
267
|
+
&& k !== 'metadata' // Is the Firestore forbidden 'metadata' key
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Wrap a Supabase query so it works more like a classic-JS promise
|
|
275
|
+
*
|
|
276
|
+
* 1. Flatten non-promise responses into thennables
|
|
277
|
+
* 2. The query is forced to respond as a promise (prevents accidental query chaining)
|
|
278
|
+
* 3. The response data object is forced as a POJO (if any data is returned, otherwise void)
|
|
279
|
+
* 4. Error responses throw with a logical error message rather than a weird object return
|
|
280
|
+
* 5. Translate various error messages to something logical
|
|
281
|
+
*
|
|
282
|
+
* @param {SupabaseQuery} query A Supabase query object or method to execute
|
|
283
|
+
* @returns {Object} The data response as a plain JavaScript Object
|
|
284
|
+
*/
|
|
285
|
+
static wrapSupabase(query) {
|
|
286
|
+
return Promise.resolve(query)
|
|
287
|
+
.then(res => {
|
|
288
|
+
if (res?.error) {
|
|
289
|
+
if (/JSON object requested, multiple \(or no\) rows returned$/.test(res.error.message)) {
|
|
290
|
+
console.warn('Supabase query threw record not found against query', query.url.search);
|
|
291
|
+
console.warn('Supabase raw error', res);
|
|
292
|
+
throw new Error('NOT-FOUND');
|
|
293
|
+
} else {
|
|
294
|
+
console.warn('Supabase query threw', res.error.message);
|
|
295
|
+
throw new Error(`${res.error?.code || 'UnknownError'}: ${res.error?.message || 'Unknown Supabase error'}`);
|
|
296
|
+
}
|
|
297
|
+
} else if (res.data) { // Do we have output data
|
|
298
|
+
return res.data;
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Mount the remote Firestore document against this instances local `value` getReactive
|
|
306
|
+
*
|
|
307
|
+
* @returns {Promise<Sync>} A promise which resolves as this sync instance when completed
|
|
308
|
+
*/
|
|
309
|
+
mount() {
|
|
310
|
+
let {fsCollection, fsId, entity, id, relation} = Syncro.pathSplit(this.path);
|
|
311
|
+
let reactive; // Eventual response from reactive() with the intitial value
|
|
312
|
+
let doc; // Eventual Firebase document
|
|
313
|
+
|
|
314
|
+
return Promise.resolve()
|
|
315
|
+
.then(async ()=> { // Set up binding and wait for it to come ready
|
|
316
|
+
this.docRef = FirestoreDocRef(Syncro.firestore, fsCollection, fsId);
|
|
317
|
+
|
|
318
|
+
// Initalize state
|
|
319
|
+
let initialState = Syncro.fromFirestore(
|
|
320
|
+
(await FirestoreGetDoc(this.docRef))
|
|
321
|
+
.data()
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Construct a reactive component
|
|
325
|
+
reactive = this.getReactive(initialState);
|
|
326
|
+
if (!reactive.doc || !reactive.setState || !reactive.getState || !reactive.watch) throw new Error('Syncro.getReactive() requires a returned `doc`, `setState()`, `getState()` + `watch()`');
|
|
327
|
+
this.value = doc = reactive.doc;
|
|
328
|
+
|
|
329
|
+
this.debug('Initial state', {doc});
|
|
330
|
+
|
|
331
|
+
// Subscribe to remote updates
|
|
332
|
+
FirestoreOnSnapshot(this.docRef, snapshot => {
|
|
333
|
+
let snapshotData = Syncro.fromFirestore(snapshot.data());
|
|
334
|
+
this.debug('Incoming snapshot', {snapshotData});
|
|
335
|
+
reactive.setState(snapshotData);
|
|
336
|
+
});
|
|
337
|
+
})
|
|
338
|
+
.then(()=> { // Optionally create the doc if it has no content
|
|
339
|
+
if (!isEmpty(doc)) return; // Doc already has some content at least
|
|
340
|
+
|
|
341
|
+
this.debug('Populate initial state');
|
|
342
|
+
|
|
343
|
+
// Extract base data + add document and return new hook
|
|
344
|
+
return Promise.resolve()
|
|
345
|
+
.then(()=> syncEntities[entity] || Promise.reject(`Unknown Sync entity "${entity}"`))
|
|
346
|
+
.then(()=> syncEntities[entity].initState({
|
|
347
|
+
supabase: Syncro.supabase,
|
|
348
|
+
entity,
|
|
349
|
+
id,
|
|
350
|
+
relation,
|
|
351
|
+
}))
|
|
352
|
+
.then(state => FirestoreSetDoc(
|
|
353
|
+
this.docRef,
|
|
354
|
+
Syncro.toFirestore(state),
|
|
355
|
+
)) // Send new base state to Firestore
|
|
356
|
+
})
|
|
357
|
+
.then(()=> { // Setup local state watcher
|
|
358
|
+
reactive.watch(throttle(newState => {
|
|
359
|
+
this.debug('Local change', {newState});
|
|
360
|
+
FirestoreUpdateDoc(
|
|
361
|
+
this.docRef,
|
|
362
|
+
Syncro.toFirestore(newState),
|
|
363
|
+
);
|
|
364
|
+
}, this.throttle));
|
|
365
|
+
})
|
|
366
|
+
.then(()=> this)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Entities we support syncro paths on, each needs to correspond with a Firebase/Firestore collection name
|
|
374
|
+
*
|
|
375
|
+
* @type {Object} An object lookup of entities
|
|
376
|
+
*
|
|
377
|
+
* @property {String} singular The singular noun for the item
|
|
378
|
+
* @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
|
|
379
|
+
*/
|
|
380
|
+
export const syncEntities = {
|
|
381
|
+
projects: {
|
|
382
|
+
singular: 'project',
|
|
383
|
+
initState({supabase, id}) {
|
|
384
|
+
return Syncro.wrapSupabase(supabase.from('projects')
|
|
385
|
+
.select('data')
|
|
386
|
+
.limit(1)
|
|
387
|
+
.eq('id', id)
|
|
388
|
+
)
|
|
389
|
+
.then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro project "${id}" not found`))
|
|
390
|
+
.then(item => item.data); // Bind to 'data' JSONB column
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
project_namespaces: {
|
|
394
|
+
singular: 'project namespace',
|
|
395
|
+
initState({supabase, id, relation}) {
|
|
396
|
+
if (!relation) throw new Error('Project namespace relation missing, path should resemble "project_namespaces::${PROJECT}::${RELATION}"');
|
|
397
|
+
return Syncro.wrapSupabase(supabase.from('project_namespaces')
|
|
398
|
+
.select('data')
|
|
399
|
+
.limit(1)
|
|
400
|
+
.eq('project', id)
|
|
401
|
+
.eq('name', relation)
|
|
402
|
+
)
|
|
403
|
+
.then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro project ("${id}") namespace ("${relation}") not found`))
|
|
404
|
+
.then(item => item.data);
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
test: {
|
|
408
|
+
singular: 'test',
|
|
409
|
+
initState({supabase, id}) {
|
|
410
|
+
return Syncro.wrapSupabase(supabase.from('test')
|
|
411
|
+
.select('data')
|
|
412
|
+
.limit(1)
|
|
413
|
+
.eq('id', id)
|
|
414
|
+
)
|
|
415
|
+
.then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro test item "${id}" not found`))
|
|
416
|
+
.then(item => item.data);
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
users: {
|
|
420
|
+
singular: 'user',
|
|
421
|
+
initState({supabase, id}) {
|
|
422
|
+
return Syncro.wrapSupabase(supabase.from('users')
|
|
423
|
+
.select('data')
|
|
424
|
+
.limit(1)
|
|
425
|
+
.eq('id', id)
|
|
426
|
+
)
|
|
427
|
+
.then(rows => rows.length == 1 ? rows[0] : Promise.reject(`Syncro user "${id}" not found`))
|
|
428
|
+
.then(item => item.data);
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* NPM:@momsfriendlydevco/marshal Compatible module for flattening arrays
|
|
436
|
+
* @type {MarshalModule}
|
|
437
|
+
*/
|
|
438
|
+
const marshalFlattenArrays = {
|
|
439
|
+
id: `~array`,
|
|
440
|
+
recursive: true,
|
|
441
|
+
test: v => Array.isArray(v),
|
|
442
|
+
serialize: v => ({_: '~array', ...v}),
|
|
443
|
+
deserialize: v => {
|
|
444
|
+
let arr = Array.from({length: Object.keys(v).length - 1});
|
|
445
|
+
|
|
446
|
+
Object.entries(v)
|
|
447
|
+
.filter(([k]) => k !== '_')
|
|
448
|
+
.forEach(([k, v]) => arr[+k] = v);
|
|
449
|
+
|
|
450
|
+
return arr;
|
|
451
|
+
},
|
|
452
|
+
};
|
package/lib/terafy.client.js
CHANGED
|
@@ -105,6 +105,7 @@ export default class TeraFy {
|
|
|
105
105
|
// Session
|
|
106
106
|
'getUser',
|
|
107
107
|
'requireUser',
|
|
108
|
+
'getCredentials',
|
|
108
109
|
|
|
109
110
|
// Projects
|
|
110
111
|
'bindProject',
|
|
@@ -114,11 +115,17 @@ export default class TeraFy {
|
|
|
114
115
|
'requireProject',
|
|
115
116
|
'selectProject',
|
|
116
117
|
|
|
118
|
+
// Project namespaces
|
|
119
|
+
// 'mountNamespace', // Handled by this library
|
|
120
|
+
// 'unmountNamespace', // Handled by this library
|
|
121
|
+
'getNamespace',
|
|
122
|
+
'setNamespace',
|
|
123
|
+
'listNamespaces',
|
|
124
|
+
|
|
117
125
|
// Project State
|
|
118
126
|
'getProjectState',
|
|
119
127
|
'setProjectState',
|
|
120
128
|
'setProjectStateDefaults',
|
|
121
|
-
'setProjectStateFlush',
|
|
122
129
|
'setProjectStateRefresh',
|
|
123
130
|
'saveProjectState',
|
|
124
131
|
'replaceProjectState',
|
|
@@ -164,6 +171,16 @@ export default class TeraFy {
|
|
|
164
171
|
plugins = [];
|
|
165
172
|
|
|
166
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Active namespaces we are subscribed to
|
|
176
|
+
* Each key is the namespace name with the value as the local reactive \ observer \ object equivelent
|
|
177
|
+
* The key string is always of the form `${ENTITY}::${ID}` e.g. `projects:1234`
|
|
178
|
+
*
|
|
179
|
+
* @type {Object<Object>}
|
|
180
|
+
*/
|
|
181
|
+
namespaces = {};
|
|
182
|
+
|
|
183
|
+
|
|
167
184
|
// Messages - send(), sendRaw(), rpc(), acceptMessage() {{{
|
|
168
185
|
|
|
169
186
|
/**
|
|
@@ -303,6 +320,68 @@ export default class TeraFy {
|
|
|
303
320
|
|
|
304
321
|
// }}}
|
|
305
322
|
|
|
323
|
+
// Project namespace - mountNamespace(), unmountNamespace() {{{
|
|
324
|
+
/**
|
|
325
|
+
* Make a namespace available locally
|
|
326
|
+
* This generally creates whatever framework flavoured reactive/observer/object is supported locally - generally with writes automatically synced with the master state
|
|
327
|
+
*
|
|
328
|
+
* @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
|
|
329
|
+
*
|
|
330
|
+
* @returns {Promise<Reactive>} A promise which resolves to the reactive object
|
|
331
|
+
*/
|
|
332
|
+
mountNamespace(name) {
|
|
333
|
+
if (!/^[\w-]+$/.test(name)) throw new Error('Namespaces must be alphanumeric + hyphens + underscores');
|
|
334
|
+
if (this.namespaces[name]) return Promise.resolve(this.namespaces[name]); // Already mounted
|
|
335
|
+
|
|
336
|
+
return Promise.resolve()
|
|
337
|
+
.then(()=> this._mountNamespace(name))
|
|
338
|
+
.then(()=> this.namespaces[name] || Promise.reject(`teraFy.mountNamespace('${name}') resolved but no namespace has been mounted`))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @interface
|
|
344
|
+
* Actual namespace mounting function designed to be overriden by plugins
|
|
345
|
+
*
|
|
346
|
+
* @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
|
|
347
|
+
*
|
|
348
|
+
* @returns {Promise} A promise which resolves when the mount operation has completed
|
|
349
|
+
*/
|
|
350
|
+
_mountNamespace(name) {
|
|
351
|
+
console.warn('teraFy._mountNamespace() has not been overriden by a TERA-fy plugin, load one to add this functionality for your preferred framework');
|
|
352
|
+
throw new Error('teraFy._mountNamespace() is not supported');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Release a locally mounted namespace
|
|
358
|
+
* This function will remove the namespace from `namespaces`, cleaning up any memory / subscription hooks
|
|
359
|
+
*
|
|
360
|
+
* @interface
|
|
361
|
+
*
|
|
362
|
+
* @param {String} name The name of the namespace to unmount
|
|
363
|
+
*
|
|
364
|
+
* @returns {Promise} A promise which resolves when the operation has completed
|
|
365
|
+
*/
|
|
366
|
+
unmountNamespace(name) {
|
|
367
|
+
if (!this.namespaces[name]) return Promise.resolve(); // Already unmounted
|
|
368
|
+
return this._unmountNamespace(name);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @interface
|
|
374
|
+
* Actual namespace unmounting function designed to be overriden by plugins
|
|
375
|
+
*
|
|
376
|
+
* @param {String} name The name of the namespace to unmount
|
|
377
|
+
*
|
|
378
|
+
* @returns {Promise} A promise which resolves when the operation has completed
|
|
379
|
+
*/
|
|
380
|
+
_unmountNamespace(name) {
|
|
381
|
+
console.warn('teraFy.unbindNamespace() has not been overriden by a TERA-fy plugin, load one to add this functionality for your preferred framework');
|
|
382
|
+
}
|
|
383
|
+
// }}}
|
|
384
|
+
|
|
306
385
|
// Project state - createProjectStatePatch(), applyProjectStatePatchLocal() {{{
|
|
307
386
|
/**
|
|
308
387
|
* Create + transmit a new project state patch base on the current and previous states
|
|
@@ -838,19 +917,35 @@ export default class TeraFy {
|
|
|
838
917
|
* @param {Object} source Initalized source object to extend from
|
|
839
918
|
*/
|
|
840
919
|
mixin(target, source) {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
920
|
+
// Iterate through the source object upwards extracting each prototype
|
|
921
|
+
let prototypeStack = [];
|
|
922
|
+
let node = source;
|
|
923
|
+
do {
|
|
924
|
+
prototypeStack.unshift(node);
|
|
925
|
+
} while (node = Object.getPrototypeOf(node)); // Walk upwards until we hit null (no more inherited classes)
|
|
926
|
+
|
|
927
|
+
// Iterate through stacks inheriting each prop into the target
|
|
928
|
+
prototypeStack.forEach(stack =>
|
|
929
|
+
Object.getOwnPropertyNames(stack)
|
|
930
|
+
.filter(prop =>
|
|
931
|
+
!['constructor', 'init', 'prototype', 'name'].includes(prop) // Ignore forbidden properties
|
|
932
|
+
&& !prop.startsWith('__') // Ignore double underscore meta properties
|
|
933
|
+
)
|
|
934
|
+
.forEach(prop => {
|
|
935
|
+
if (typeof source[prop] == 'function') { // Inheriting function - glue onto object as non-editable, non-enumerable property
|
|
936
|
+
Object.defineProperty(
|
|
937
|
+
target,
|
|
938
|
+
prop,
|
|
939
|
+
{
|
|
940
|
+
enumerable: false,
|
|
941
|
+
value: source[prop].bind(target), // Rebind functions
|
|
942
|
+
},
|
|
943
|
+
);
|
|
944
|
+
} else { // Everything else, just glue onto the object
|
|
945
|
+
target[prop] = source[prop];
|
|
946
|
+
}
|
|
947
|
+
})
|
|
948
|
+
)
|
|
854
949
|
}
|
|
855
950
|
|
|
856
951
|
|
|
@@ -955,6 +1050,14 @@ export default class TeraFy {
|
|
|
955
1050
|
*/
|
|
956
1051
|
|
|
957
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* Provide an object of credentials for 3rd party services like Firebase/Supabase
|
|
1055
|
+
*
|
|
1056
|
+
* @functions getCredentials
|
|
1057
|
+
* @returns {Object} An object containing 3rd party service credentials
|
|
1058
|
+
*/
|
|
1059
|
+
|
|
1060
|
+
|
|
958
1061
|
/**
|
|
959
1062
|
* Require a user login to TERA
|
|
960
1063
|
* If there is no user OR they are not logged in a prompt is shown to go and do so
|
|
@@ -1038,6 +1141,40 @@ export default class TeraFy {
|
|
|
1038
1141
|
*/
|
|
1039
1142
|
|
|
1040
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* Get a one-off snapshot of a namespace without mounting it
|
|
1146
|
+
* This can be used for simpler apps which don't have their own reactive / observer equivelent
|
|
1147
|
+
*
|
|
1148
|
+
* @function getNamespace
|
|
1149
|
+
* @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
|
|
1150
|
+
*
|
|
1151
|
+
* @returns {Promise<Object>} A promise which resolves to the namespace POJO state
|
|
1152
|
+
*/
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Set (or merge by default) a one-off snapshot over an existing namespace
|
|
1157
|
+
* This can be used for simpler apps which don't have their own reactive / observer equivelent and just want to quickly set something
|
|
1158
|
+
*
|
|
1159
|
+
* @function setNamespace
|
|
1160
|
+
* @param {String} name The name of the namespace
|
|
1161
|
+
* @param {Object} state The state to merge
|
|
1162
|
+
* @param {Object} [options] Additional options to mutate behaviour
|
|
1163
|
+
* @param {'merge'|'set'} [options.method='merge'] How to handle the state. 'merge' (merge a partial state over the existing namespace state), 'set' (completely overwrite the existing namespace)
|
|
1164
|
+
*
|
|
1165
|
+
* @returns {Promise<Object>} A promise which resolves to the namespace POJO state
|
|
1166
|
+
*/
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Return a list of namespaces available to the current project
|
|
1171
|
+
*
|
|
1172
|
+
* @function listNamespaces
|
|
1173
|
+
* @returns {Promise<Array<Object>>} Collection of available namespaces for the current project
|
|
1174
|
+
* @property {String} name The name of the namespace
|
|
1175
|
+
*/
|
|
1176
|
+
|
|
1177
|
+
|
|
1041
1178
|
/**
|
|
1042
1179
|
* Return the current, full snapshot state of the active project
|
|
1043
1180
|
*
|
|
@@ -1081,15 +1218,6 @@ export default class TeraFy {
|
|
|
1081
1218
|
*/
|
|
1082
1219
|
|
|
1083
1220
|
|
|
1084
|
-
/**
|
|
1085
|
-
* Force copying local changes to the server
|
|
1086
|
-
* This is only ever needed when saving large quantities of data that need to be immediately available
|
|
1087
|
-
*
|
|
1088
|
-
* @function setProjectStateFlush
|
|
1089
|
-
* @returns {Promise} A promise which resolves when the operation has completed
|
|
1090
|
-
*/
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
1221
|
/**
|
|
1094
1222
|
* Force refetching the remote project state into local
|
|
1095
1223
|
* This is only ever needed when saving large quantities of data that need to be immediately available
|
|
@@ -1428,9 +1556,9 @@ export default class TeraFy {
|
|
|
1428
1556
|
*
|
|
1429
1557
|
* @param {Object} [options] Additional options to mutate behaviour
|
|
1430
1558
|
* @param {String} [options.body] Optional additional body text
|
|
1559
|
+
* @param {Boolean} [options.isHtml=false] If truthy, treat the body as HTML
|
|
1431
1560
|
* @param {String} [options.value] Current or default value to display pre-filled
|
|
1432
1561
|
* @param {String} [options.title='Input required'] The dialog title to display
|
|
1433
|
-
* @param {Boolean} [options.bodyHtml=false] If truthy, treat the body as HTML
|
|
1434
1562
|
* @param {String} [options.placeholder] Optional placeholder text
|
|
1435
1563
|
* @param {Boolean} [options.required=true] Treat nullish or empty inputs as a cancel operation
|
|
1436
1564
|
*
|