alchemymvc 1.4.0-alpha.9 → 1.4.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/lib/app/behaviour/sluggable_behaviour.js +72 -1
- package/lib/app/conduit/electron_conduit.js +1 -1
- package/lib/app/conduit/socket_conduit.js +62 -8
- package/lib/app/controller/alchemy_info_controller.js +2 -2
- package/lib/app/datasource/mongo_datasource.js +84 -104
- package/lib/app/element/al_time.js +126 -0
- package/lib/app/helper/enum_values.js +9 -0
- package/lib/app/helper/router_helper.js +37 -5
- package/lib/app/helper/socket_helper.js +71 -14
- package/lib/app/helper/syncable.js +253 -30
- package/lib/app/helper_datasource/00-nosql_datasource.js +403 -53
- package/lib/app/helper_datasource/05-fallback_datasource.js +5 -2
- package/lib/app/helper_datasource/idb_datasource.js +75 -59
- package/lib/app/helper_datasource/indexed_db.js +41 -33
- package/lib/app/helper_datasource/read_operational_context.js +0 -17
- package/lib/app/helper_field/00-objectid_field.js +16 -0
- package/lib/app/helper_field/11-date_field.js +13 -0
- package/lib/app/helper_field/datetime_field.js +13 -0
- package/lib/app/helper_field/enum_field.js +27 -0
- package/lib/app/helper_field/geopoint_field.js +61 -0
- package/lib/app/helper_field/html_field.js +14 -1
- package/lib/app/helper_field/integer_field.js +14 -0
- package/lib/app/helper_field/local_date_field.js +13 -0
- package/lib/app/helper_field/local_date_time_field.js +13 -0
- package/lib/app/helper_field/local_time_field.js +13 -0
- package/lib/app/helper_field/password_field.js +24 -0
- package/lib/app/helper_field/schema_field.js +85 -2
- package/lib/app/helper_field/url_field.js +22 -0
- package/lib/app/helper_model/00-base_criteria.js +67 -6
- package/lib/app/helper_model/05-criteria_expressions.js +23 -4
- package/lib/app/helper_model/10-model_criteria.js +39 -18
- package/lib/app/helper_model/document.js +12 -7
- package/lib/app/helper_model/field_config.js +9 -2
- package/lib/app/helper_model/model.js +9 -8
- package/lib/app/model/system_task_history_model.js +11 -1
- package/lib/class/conduit.js +112 -12
- package/lib/class/datasource.js +30 -2
- package/lib/class/document.js +1 -1
- package/lib/class/field.js +170 -7
- package/lib/class/inode_file.js +2 -2
- package/lib/class/model.js +12 -11
- package/lib/class/operational_context.js +37 -0
- package/lib/class/route.js +34 -3
- package/lib/class/router.js +14 -7
- package/lib/class/schema.js +1 -1
- package/lib/class/schema_client.js +141 -14
- package/lib/class/session.js +1 -1
- package/lib/class/task.js +30 -1
- package/lib/class/task_service.js +83 -14
- package/lib/core/alchemy.js +78 -4
- package/lib/core/alchemy_functions.js +7 -11
- package/lib/core/alchemy_load_functions.js +37 -5
- package/lib/core/client_alchemy.js +9 -1
- package/lib/core/middleware.js +52 -20
- package/lib/core/setting.js +75 -6
- package/lib/scripts/create_settings.js +44 -0
- package/lib/scripts/setup_ai_devmode.js +324 -0
- package/lib/scripts/setup_devwatch.js +0 -0
- package/lib/stages/00-load_core.js +1 -1
- package/lib/stages/50-routes.js +6 -1
- package/lib/stages/90-server.js +0 -1
- package/package.json +18 -18
|
@@ -29,14 +29,26 @@ var Route = Function.inherits('Alchemy.Helper.Router', function Route(renderer)
|
|
|
29
29
|
*
|
|
30
30
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
31
31
|
* @since 1.1.0
|
|
32
|
-
* @version 1.
|
|
32
|
+
* @version 1.4.0
|
|
33
33
|
*
|
|
34
34
|
* @type {RURL}
|
|
35
35
|
*/
|
|
36
36
|
Router.setProperty(function current_url() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
|
|
38
|
+
let renderer = this.view_render;
|
|
39
|
+
|
|
40
|
+
if (renderer?.variables) {
|
|
41
|
+
try {
|
|
42
|
+
let url = renderer.variables.get('__url');
|
|
43
|
+
if (url) {
|
|
44
|
+
return url.clone();
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Ignore
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Blast.isBrowser) {
|
|
40
52
|
|
|
41
53
|
if (hawkejs?.scene?.opening_url?.url) {
|
|
42
54
|
return Blast.Classes.RURL.parse(hawkejs.scene.opening_url.url);
|
|
@@ -270,7 +282,27 @@ Router.setMethod(function applyDirective(element, name, options) {
|
|
|
270
282
|
}
|
|
271
283
|
|
|
272
284
|
if (method_attribute && config.methods?.[0]) {
|
|
273
|
-
element.
|
|
285
|
+
let existing_method = element.getAttribute(method_attribute);
|
|
286
|
+
|
|
287
|
+
// Assume the user knows what they're doing when there is an existing method
|
|
288
|
+
if (!existing_method) {
|
|
289
|
+
let found_method = false;
|
|
290
|
+
|
|
291
|
+
// There is no method, look for the best one (we prefer a post)
|
|
292
|
+
for (let method of config.methods) {
|
|
293
|
+
method = method.toLowerCase();
|
|
294
|
+
|
|
295
|
+
if (method == 'post' || method == 'put') {
|
|
296
|
+
element.setAttribute(method_attribute, method);
|
|
297
|
+
found_method = true;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!found_method) {
|
|
303
|
+
element.setAttribute(method_attribute, config.methods[0]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
274
306
|
}
|
|
275
307
|
|
|
276
308
|
if (disable_ajax) {
|
|
@@ -16,7 +16,7 @@ function isStream(obj) {
|
|
|
16
16
|
*
|
|
17
17
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
18
18
|
* @since 0.2.0
|
|
19
|
-
* @version 1.
|
|
19
|
+
* @version 1.4.0
|
|
20
20
|
*
|
|
21
21
|
* @param {string} type
|
|
22
22
|
*/
|
|
@@ -43,12 +43,21 @@ var Linkup = Blast.Collection.Function.inherits('Informer', function ClientLinku
|
|
|
43
43
|
// The initial submitted data
|
|
44
44
|
this.initialData = data;
|
|
45
45
|
|
|
46
|
+
// Has this linkup been destroyed?
|
|
47
|
+
this.destroyed = false;
|
|
48
|
+
|
|
46
49
|
// Make the linkup store itself
|
|
47
50
|
client.linkups[this.id] = this;
|
|
48
51
|
|
|
49
52
|
// The parent server
|
|
50
53
|
this.client = client;
|
|
51
54
|
|
|
55
|
+
// Listen for socket disconnection
|
|
56
|
+
this._onClientClose = () => {
|
|
57
|
+
this._markDisconnected();
|
|
58
|
+
};
|
|
59
|
+
client.on('close', this._onClientClose);
|
|
60
|
+
|
|
52
61
|
if (server_object) {
|
|
53
62
|
this.submit('ready');
|
|
54
63
|
} else {
|
|
@@ -146,23 +155,51 @@ Linkup.setMethod(function createStream() {
|
|
|
146
155
|
*
|
|
147
156
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
148
157
|
* @since 0.2.0
|
|
149
|
-
* @version 1.
|
|
158
|
+
* @version 1.4.0
|
|
150
159
|
*/
|
|
151
160
|
Linkup.setMethod(function destroy() {
|
|
161
|
+
if (this.destroyed) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
152
165
|
this.submit('__destroy__');
|
|
153
166
|
this._destroy();
|
|
154
167
|
this.removeAllListeners();
|
|
155
168
|
});
|
|
156
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Mark the linkup as disconnected due to socket close
|
|
172
|
+
*
|
|
173
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
174
|
+
* @since 1.4.0
|
|
175
|
+
* @version 1.4.0
|
|
176
|
+
*/
|
|
177
|
+
Linkup.setMethod(function _markDisconnected() {
|
|
178
|
+
if (this.destroyed) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Emit the close event so listeners can handle disconnect
|
|
183
|
+
this.emit('close');
|
|
184
|
+
});
|
|
185
|
+
|
|
157
186
|
/**
|
|
158
187
|
* Make sure the linkup is removed
|
|
159
188
|
*
|
|
160
189
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
161
190
|
* @since 1.3.10
|
|
162
|
-
* @version 1.
|
|
191
|
+
* @version 1.4.0
|
|
163
192
|
*/
|
|
164
193
|
Linkup.setMethod(function _destroy() {
|
|
194
|
+
this.destroyed = true;
|
|
165
195
|
delete this.client.linkups[this.id];
|
|
196
|
+
|
|
197
|
+
// Remove the close listener from the client
|
|
198
|
+
if (this._onClientClose) {
|
|
199
|
+
this.client.removeListener('close', this._onClientClose);
|
|
200
|
+
this._onClientClose = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
166
203
|
this.emit('destroyed');
|
|
167
204
|
});
|
|
168
205
|
|
|
@@ -352,7 +389,7 @@ Client.setMethod(function createStream() {
|
|
|
352
389
|
*
|
|
353
390
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
354
391
|
* @since 0.2.0
|
|
355
|
-
* @version 1.
|
|
392
|
+
* @version 1.4.0
|
|
356
393
|
*
|
|
357
394
|
* @param {Function} callback
|
|
358
395
|
*/
|
|
@@ -487,7 +524,8 @@ Client.setMethod(function connect(address, data, callback) {
|
|
|
487
524
|
});
|
|
488
525
|
|
|
489
526
|
server.on('error', function(err) {
|
|
490
|
-
|
|
527
|
+
log.error('Socket error:', err);
|
|
528
|
+
that.emit('error', err);
|
|
491
529
|
});
|
|
492
530
|
|
|
493
531
|
// Listen for cookies
|
|
@@ -547,16 +585,22 @@ Client.setMethod(function connect(address, data, callback) {
|
|
|
547
585
|
}
|
|
548
586
|
}
|
|
549
587
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
} catch (err) {
|
|
555
|
-
console.log('ERROR UNDRYING PACKET:', err, packet);
|
|
556
|
-
return;
|
|
588
|
+
try {
|
|
589
|
+
if (packet.data && typeof packet.data == 'object') {
|
|
590
|
+
packet.data = JSON.undry(packet.data);
|
|
557
591
|
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
log.error('Error undrying response data:', err);
|
|
558
594
|
|
|
559
|
-
|
|
595
|
+
// Still call the callback with an error instead of silently dropping
|
|
596
|
+
let undry_error = new Error('Failed to deserialize response data: ' + err.message);
|
|
597
|
+
undry_error.cause = err;
|
|
598
|
+
that.callbacks[packet.respond_to](undry_error, null);
|
|
599
|
+
delete that.callbacks[packet.respond_to];
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (packet.noData) {
|
|
560
604
|
that.callbacks[packet.respond_to](packet.err, packet.stream);
|
|
561
605
|
} else if (packet.stream) {
|
|
562
606
|
that.callbacks[packet.respond_to](packet.err, packet.data, packet.stream);
|
|
@@ -578,7 +622,20 @@ Client.setMethod(function connect(address, data, callback) {
|
|
|
578
622
|
packet.data = JSON.undry(packet.data);
|
|
579
623
|
}
|
|
580
624
|
} catch (err) {
|
|
581
|
-
|
|
625
|
+
log.error('Error undrying packet data:', err);
|
|
626
|
+
|
|
627
|
+
// If the sender expects a response, send back an error
|
|
628
|
+
if (packet.respond) {
|
|
629
|
+
let response_packet = {
|
|
630
|
+
err: new Error('Failed to deserialize packet data: ' + err.message),
|
|
631
|
+
respond_to: packet.id,
|
|
632
|
+
data: null
|
|
633
|
+
};
|
|
634
|
+
server.emit('response', response_packet);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Emit error event so callers can be aware
|
|
638
|
+
that.emit('packet_error', err, packet);
|
|
582
639
|
return;
|
|
583
640
|
}
|
|
584
641
|
|
|
@@ -15,7 +15,7 @@ const SESSION_KEY = 'Syncables',
|
|
|
15
15
|
*
|
|
16
16
|
* @param {string} type
|
|
17
17
|
*/
|
|
18
|
-
const Syncable = Function.inherits('Alchemy.Base', function Syncable(type) {
|
|
18
|
+
const Syncable = Function.inherits('Alchemy.Base', 'Alchemy.Syncable', function Syncable(type) {
|
|
19
19
|
|
|
20
20
|
if (!type) {
|
|
21
21
|
throw new Error('Each Syncable must have a type');
|
|
@@ -44,39 +44,35 @@ if (Blast.isNode) {
|
|
|
44
44
|
*
|
|
45
45
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
46
46
|
* @since 1.3.10
|
|
47
|
-
* @version 1.
|
|
47
|
+
* @version 1.4.0
|
|
48
48
|
*
|
|
49
49
|
* @param {Conduit}
|
|
50
50
|
* @param {Linkup}
|
|
51
51
|
* @param {Object}
|
|
52
52
|
*/
|
|
53
|
-
Syncable.setStatic(function handleLink(conduit, linkup, config) {
|
|
53
|
+
Syncable.setStatic(async function handleLink(conduit, linkup, config) {
|
|
54
54
|
|
|
55
55
|
let syncables = conduit.session(SESSION_KEY),
|
|
56
|
-
syncable
|
|
57
|
-
error_msg;
|
|
58
|
-
|
|
59
|
-
if (!syncables) {
|
|
60
|
-
error_msg = 'No syncables found';
|
|
61
|
-
} else {
|
|
56
|
+
syncable;
|
|
62
57
|
|
|
58
|
+
if (syncables) {
|
|
63
59
|
let type_map = syncables.get(config.type);
|
|
64
60
|
|
|
65
|
-
if (
|
|
66
|
-
error_msg = 'No syncables found for type: ' + config.type;
|
|
67
|
-
} else {
|
|
61
|
+
if (type_map) {
|
|
68
62
|
syncable = type_map.get(config.id);
|
|
69
|
-
|
|
70
|
-
if (!syncable) {
|
|
71
|
-
error_msg = 'No syncable found for id: ' + config.id;
|
|
72
|
-
}
|
|
73
63
|
}
|
|
74
64
|
}
|
|
75
65
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
// If syncable not found, try to recreate it
|
|
67
|
+
if (!syncable) {
|
|
68
|
+
syncable = await this.tryRecreate(conduit, config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!syncable) {
|
|
72
|
+
linkup.submit('error', {
|
|
73
|
+
code: 'SYNCABLE_NOT_FOUND',
|
|
74
|
+
message: 'No syncable found for type: ' + config.type + ', id: ' + config.id,
|
|
75
|
+
});
|
|
80
76
|
linkup.destroy();
|
|
81
77
|
return;
|
|
82
78
|
}
|
|
@@ -84,6 +80,65 @@ if (Blast.isNode) {
|
|
|
84
80
|
syncable.attachClient(conduit, linkup, config);
|
|
85
81
|
}, false);
|
|
86
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Try to recreate a syncable that wasn't found in the session.
|
|
85
|
+
* This is called when a client reconnects after a server restart.
|
|
86
|
+
*
|
|
87
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
88
|
+
* @since 1.4.0
|
|
89
|
+
* @version 1.4.0
|
|
90
|
+
*
|
|
91
|
+
* @param {Conduit} conduit
|
|
92
|
+
* @param {Object} config Contains type, id, and version from the client
|
|
93
|
+
*
|
|
94
|
+
* @return {Syncable} The recreated syncable, or null if recreation failed
|
|
95
|
+
*/
|
|
96
|
+
Syncable.setStatic(async function tryRecreate(conduit, config) {
|
|
97
|
+
|
|
98
|
+
// Find the Syncable class for this type.
|
|
99
|
+
// For Specialized syncables, config.type is the class's type_path (includes namespace prefix).
|
|
100
|
+
// For ad-hoc syncables, config.type is a custom string and won't match any class.
|
|
101
|
+
let SyncableClass = this.getDescendant(config.type);
|
|
102
|
+
|
|
103
|
+
if (!SyncableClass) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check if this class supports recreation
|
|
108
|
+
if (typeof SyncableClass.recreate !== 'function') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
let syncable = await SyncableClass.recreate(conduit, config);
|
|
114
|
+
|
|
115
|
+
if (syncable) {
|
|
116
|
+
// Make sure it's registered
|
|
117
|
+
syncable.registerClient(conduit);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return syncable;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log.error('Failed to recreate syncable:', err);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}, false);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Recreate a syncable instance after server restart.
|
|
129
|
+
* Override this in subclasses to enable automatic recreation.
|
|
130
|
+
*
|
|
131
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
132
|
+
* @since 1.4.0
|
|
133
|
+
* @version 1.4.0
|
|
134
|
+
*
|
|
135
|
+
* @param {Conduit} conduit
|
|
136
|
+
* @param {Object} config Contains type, id, and version from the client
|
|
137
|
+
*
|
|
138
|
+
* @return {Syncable} The recreated syncable, or null if recreation not possible
|
|
139
|
+
*/
|
|
140
|
+
// Base class does not implement recreate - subclasses must opt-in
|
|
141
|
+
|
|
87
142
|
/**
|
|
88
143
|
* Attach a client
|
|
89
144
|
*
|
|
@@ -205,7 +260,7 @@ if (Blast.isBrowser) {
|
|
|
205
260
|
*
|
|
206
261
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
207
262
|
* @since 1.3.10
|
|
208
|
-
* @version 1.
|
|
263
|
+
* @version 1.4.0
|
|
209
264
|
*/
|
|
210
265
|
Syncable.setMethod(function startSyncLink() {
|
|
211
266
|
|
|
@@ -214,6 +269,27 @@ if (Blast.isBrowser) {
|
|
|
214
269
|
return;
|
|
215
270
|
}
|
|
216
271
|
|
|
272
|
+
// Prevent concurrent connection attempts
|
|
273
|
+
if (this._connecting) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
this._connecting = true;
|
|
278
|
+
|
|
279
|
+
// Clean up existing broken link if any
|
|
280
|
+
if (this.c2s_link) {
|
|
281
|
+
if (!this.c2s_link.destroyed) {
|
|
282
|
+
this.c2s_link.destroy();
|
|
283
|
+
}
|
|
284
|
+
this.c2s_link = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Clear any pending reconnect
|
|
288
|
+
if (this._reconnect_timeout) {
|
|
289
|
+
clearTimeout(this._reconnect_timeout);
|
|
290
|
+
this._reconnect_timeout = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
217
293
|
alchemy.enableWebsockets();
|
|
218
294
|
|
|
219
295
|
let data = {
|
|
@@ -223,6 +299,8 @@ if (Blast.isBrowser) {
|
|
|
223
299
|
};
|
|
224
300
|
|
|
225
301
|
let link = this.c2s_link = alchemy.linkup('syncablelink', data, () => {
|
|
302
|
+
this._connecting = false;
|
|
303
|
+
this._reconnect_attempts = 0; // Reset on successful connection
|
|
226
304
|
this.emit('ready');
|
|
227
305
|
});
|
|
228
306
|
|
|
@@ -234,6 +312,74 @@ if (Blast.isBrowser) {
|
|
|
234
312
|
|
|
235
313
|
this.version = data.version;
|
|
236
314
|
});
|
|
315
|
+
|
|
316
|
+
// Handle socket disconnection
|
|
317
|
+
link.on('close', () => {
|
|
318
|
+
this._connecting = false;
|
|
319
|
+
this.emit('disconnected');
|
|
320
|
+
this._scheduleReconnect();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Handle server errors
|
|
324
|
+
link.on('error', (err) => {
|
|
325
|
+
this._connecting = false;
|
|
326
|
+
|
|
327
|
+
// Use error codes when available, fall back to string matching for backwards compatibility
|
|
328
|
+
let code = err?.code;
|
|
329
|
+
let message = err?.message || '';
|
|
330
|
+
|
|
331
|
+
if (code === 'SYNCABLE_NOT_FOUND' || message.includes('No syncable found')) {
|
|
332
|
+
// Server doesn't recognize us - stop trying to reconnect
|
|
333
|
+
// This happens after server restart. The only recovery is a page reload
|
|
334
|
+
// to get fresh syncables from the server.
|
|
335
|
+
this._released = true;
|
|
336
|
+
CLIENT_MAP.delete(this.id);
|
|
337
|
+
this.emit('needs_resync');
|
|
338
|
+
} else if (code === 'PERMISSION_DENIED' || message === 'Permission denied') {
|
|
339
|
+
// User doesn't have permission - stop trying to reconnect
|
|
340
|
+
this._released = true;
|
|
341
|
+
this.emit('permission_denied', err?.required_permission);
|
|
342
|
+
} else {
|
|
343
|
+
// Unknown error - log it and emit for consumers
|
|
344
|
+
console.warn('[Syncable] Link error:', err);
|
|
345
|
+
this.emit('error', err);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Schedule a reconnection attempt with exponential backoff and jitter
|
|
352
|
+
*
|
|
353
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
354
|
+
* @since 1.4.0
|
|
355
|
+
* @version 1.4.0
|
|
356
|
+
*/
|
|
357
|
+
Syncable.setMethod(function _scheduleReconnect() {
|
|
358
|
+
|
|
359
|
+
// Don't schedule if already pending
|
|
360
|
+
if (this._reconnect_timeout) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Don't reconnect if explicitly released
|
|
365
|
+
if (this._released) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Increment attempt counter
|
|
370
|
+
this._reconnect_attempts = (this._reconnect_attempts || 0) + 1;
|
|
371
|
+
|
|
372
|
+
// Calculate delay with exponential backoff: 5s, 7.5s, 11.25s, ... max 60s
|
|
373
|
+
let base_delay = 5000;
|
|
374
|
+
let delay = Math.min(base_delay * Math.pow(1.5, this._reconnect_attempts - 1), 60000);
|
|
375
|
+
|
|
376
|
+
// Add jitter (0-1000ms) to prevent thundering herd
|
|
377
|
+
delay += Math.random() * 1000;
|
|
378
|
+
|
|
379
|
+
this._reconnect_timeout = setTimeout(() => {
|
|
380
|
+
this._reconnect_timeout = null;
|
|
381
|
+
this.startSyncLink();
|
|
382
|
+
}, delay);
|
|
237
383
|
});
|
|
238
384
|
}
|
|
239
385
|
|
|
@@ -449,7 +595,7 @@ Syncable.setStatic(function setStateProperty(name, options) {
|
|
|
449
595
|
*
|
|
450
596
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
451
597
|
* @since 1.3.10
|
|
452
|
-
* @version 1.
|
|
598
|
+
* @version 1.4.0
|
|
453
599
|
*
|
|
454
600
|
* @param {Object} data
|
|
455
601
|
*
|
|
@@ -457,14 +603,34 @@ Syncable.setStatic(function setStateProperty(name, options) {
|
|
|
457
603
|
*/
|
|
458
604
|
Syncable.setStatic(function unDry(data) {
|
|
459
605
|
|
|
460
|
-
let result
|
|
606
|
+
let result,
|
|
607
|
+
old_instance;
|
|
461
608
|
|
|
462
609
|
// Try to reuse the same instance on the client side
|
|
463
610
|
if (Blast.isBrowser) {
|
|
464
611
|
result = CLIENT_MAP.get(data.id);
|
|
465
612
|
|
|
466
613
|
if (result) {
|
|
467
|
-
|
|
614
|
+
if (result.c2s_link && !result.c2s_link.destroyed) {
|
|
615
|
+
// Server version is older - client has newer data, keep client instance
|
|
616
|
+
if (data.version < result.version) {
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Server version is same or newer - update client state from server
|
|
621
|
+
// We update the state even if versions match because nested Syncables
|
|
622
|
+
// may have changed (their unDry already ran and updated them)
|
|
623
|
+
if (data.state) {
|
|
624
|
+
result.state = data.state;
|
|
625
|
+
}
|
|
626
|
+
result.version = data.version;
|
|
627
|
+
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Link is broken - need to replace the instance
|
|
632
|
+
old_instance = result;
|
|
633
|
+
CLIENT_MAP.delete(data.id);
|
|
468
634
|
}
|
|
469
635
|
}
|
|
470
636
|
|
|
@@ -478,7 +644,20 @@ Syncable.setStatic(function unDry(data) {
|
|
|
478
644
|
result.queues = clone.queues;
|
|
479
645
|
|
|
480
646
|
if (Blast.isBrowser) {
|
|
647
|
+
CLIENT_MAP.set(result.id, result);
|
|
481
648
|
result.startSyncLink();
|
|
649
|
+
|
|
650
|
+
// Now that the new instance is ready, clean up the old one
|
|
651
|
+
if (old_instance) {
|
|
652
|
+
// Mark the old instance as replaced
|
|
653
|
+
old_instance._replaced = true;
|
|
654
|
+
|
|
655
|
+
// Emit 'replaced' event with the new instance so listeners can switch
|
|
656
|
+
old_instance.emit('replaced', result);
|
|
657
|
+
|
|
658
|
+
// Release the old instance (cancels pending reconnects)
|
|
659
|
+
old_instance.release();
|
|
660
|
+
}
|
|
482
661
|
}
|
|
483
662
|
|
|
484
663
|
return result;
|
|
@@ -692,12 +871,13 @@ Syncable.setMethod(function sendUpdates() {
|
|
|
692
871
|
*
|
|
693
872
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
694
873
|
* @since 1.3.10
|
|
695
|
-
* @version 1.
|
|
874
|
+
* @version 1.4.0
|
|
696
875
|
*
|
|
697
876
|
* @param {string} property
|
|
698
877
|
*/
|
|
699
878
|
Syncable.setMethod(function emitPropertyChange(property) {
|
|
700
|
-
|
|
879
|
+
// Use state directly to support properties without explicit getters
|
|
880
|
+
let value = this.state[property];
|
|
701
881
|
this.emit('property_change_' + property, value, null);
|
|
702
882
|
});
|
|
703
883
|
|
|
@@ -706,14 +886,15 @@ Syncable.setMethod(function emitPropertyChange(property) {
|
|
|
706
886
|
*
|
|
707
887
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
708
888
|
* @since 1.3.10
|
|
709
|
-
* @version 1.
|
|
889
|
+
* @version 1.4.0
|
|
710
890
|
*
|
|
711
891
|
* @param {string} property
|
|
712
892
|
* @param {Function} callback
|
|
713
893
|
*/
|
|
714
894
|
Syncable.setMethod(function watchProperty(property, callback) {
|
|
715
895
|
this.on('property_change_' + property, callback);
|
|
716
|
-
|
|
896
|
+
// Use state directly to support properties without explicit getters
|
|
897
|
+
let value = this.state[property];
|
|
717
898
|
callback(value);
|
|
718
899
|
});
|
|
719
900
|
|
|
@@ -975,10 +1156,25 @@ Syncable.setMethod(function setProperty(key, value) {
|
|
|
975
1156
|
*
|
|
976
1157
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
977
1158
|
* @since 1.3.10
|
|
978
|
-
* @version 1.
|
|
1159
|
+
* @version 1.4.0
|
|
979
1160
|
*/
|
|
980
1161
|
Syncable.setMethod(function release() {
|
|
981
1162
|
|
|
1163
|
+
// Mark as released to prevent auto-reconnection
|
|
1164
|
+
this._released = true;
|
|
1165
|
+
|
|
1166
|
+
// Cancel any pending reconnection
|
|
1167
|
+
if (this._reconnect_timeout) {
|
|
1168
|
+
clearTimeout(this._reconnect_timeout);
|
|
1169
|
+
this._reconnect_timeout = null;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Cancel any pending update
|
|
1173
|
+
if (this[UPDATE_ID]) {
|
|
1174
|
+
clearTimeout(this[UPDATE_ID]);
|
|
1175
|
+
this[UPDATE_ID] = null;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
982
1178
|
if (this.c2s_link) {
|
|
983
1179
|
this.c2s_link.destroy();
|
|
984
1180
|
this.c2s_link = null;
|
|
@@ -987,4 +1183,31 @@ Syncable.setMethod(function release() {
|
|
|
987
1183
|
if (Blast.isBrowser) {
|
|
988
1184
|
CLIENT_MAP.delete(this.id);
|
|
989
1185
|
}
|
|
990
|
-
|
|
1186
|
+
|
|
1187
|
+
this.emit('released');
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The Specialized Syncable class:
|
|
1192
|
+
* A syncable subclass that automatically derives its type from the class's type_path or type_name.
|
|
1193
|
+
* Use this as the base class for syncables that need to support recreation after server restart.
|
|
1194
|
+
*
|
|
1195
|
+
* @constructor
|
|
1196
|
+
*
|
|
1197
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1198
|
+
* @since 1.4.0
|
|
1199
|
+
* @version 1.4.0
|
|
1200
|
+
*/
|
|
1201
|
+
const Specialized = Function.inherits('Alchemy.Syncable', function Specialized() {
|
|
1202
|
+
|
|
1203
|
+
let type = this.constructor.type_path || this.constructor.type_name;
|
|
1204
|
+
|
|
1205
|
+
if (!type) {
|
|
1206
|
+
throw new Error('Specialized Syncable subclasses must have a type_path or type_name (set automatically by Alchemy.Base)');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
Specialized.super.call(this, type);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
// Make it abstract - it should not be instantiated directly
|
|
1213
|
+
Specialized.makeAbstractClass();
|