alchemymvc 1.4.0-alpha.8 → 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.
Files changed (62) hide show
  1. package/lib/app/behaviour/sluggable_behaviour.js +72 -1
  2. package/lib/app/conduit/electron_conduit.js +1 -1
  3. package/lib/app/conduit/socket_conduit.js +62 -8
  4. package/lib/app/controller/alchemy_info_controller.js +2 -2
  5. package/lib/app/datasource/mongo_datasource.js +84 -104
  6. package/lib/app/element/al_time.js +126 -0
  7. package/lib/app/helper/enum_values.js +9 -0
  8. package/lib/app/helper/router_helper.js +37 -5
  9. package/lib/app/helper/socket_helper.js +71 -14
  10. package/lib/app/helper/syncable.js +253 -30
  11. package/lib/app/helper_datasource/00-nosql_datasource.js +403 -53
  12. package/lib/app/helper_datasource/05-fallback_datasource.js +5 -2
  13. package/lib/app/helper_datasource/idb_datasource.js +75 -59
  14. package/lib/app/helper_datasource/indexed_db.js +41 -33
  15. package/lib/app/helper_datasource/read_operational_context.js +0 -17
  16. package/lib/app/helper_field/00-objectid_field.js +16 -0
  17. package/lib/app/helper_field/11-date_field.js +13 -0
  18. package/lib/app/helper_field/datetime_field.js +13 -0
  19. package/lib/app/helper_field/enum_field.js +27 -0
  20. package/lib/app/helper_field/geopoint_field.js +61 -0
  21. package/lib/app/helper_field/html_field.js +14 -1
  22. package/lib/app/helper_field/integer_field.js +14 -0
  23. package/lib/app/helper_field/local_date_field.js +13 -0
  24. package/lib/app/helper_field/local_date_time_field.js +13 -0
  25. package/lib/app/helper_field/local_time_field.js +13 -0
  26. package/lib/app/helper_field/password_field.js +24 -0
  27. package/lib/app/helper_field/schema_field.js +85 -2
  28. package/lib/app/helper_field/url_field.js +22 -0
  29. package/lib/app/helper_model/00-base_criteria.js +67 -6
  30. package/lib/app/helper_model/05-criteria_expressions.js +23 -4
  31. package/lib/app/helper_model/10-model_criteria.js +39 -18
  32. package/lib/app/helper_model/document.js +12 -7
  33. package/lib/app/helper_model/field_config.js +9 -2
  34. package/lib/app/helper_model/model.js +9 -8
  35. package/lib/app/model/system_task_history_model.js +11 -1
  36. package/lib/class/conduit.js +114 -14
  37. package/lib/class/datasource.js +30 -2
  38. package/lib/class/document.js +1 -1
  39. package/lib/class/field.js +170 -7
  40. package/lib/class/inode_file.js +2 -2
  41. package/lib/class/model.js +21 -15
  42. package/lib/class/operational_context.js +37 -0
  43. package/lib/class/route.js +34 -3
  44. package/lib/class/router.js +14 -7
  45. package/lib/class/schema.js +1 -1
  46. package/lib/class/schema_client.js +141 -14
  47. package/lib/class/session.js +1 -1
  48. package/lib/class/task.js +30 -1
  49. package/lib/class/task_service.js +83 -14
  50. package/lib/core/alchemy.js +78 -4
  51. package/lib/core/alchemy_functions.js +13 -13
  52. package/lib/core/alchemy_load_functions.js +37 -5
  53. package/lib/core/client_alchemy.js +9 -1
  54. package/lib/core/middleware.js +52 -20
  55. package/lib/core/setting.js +75 -6
  56. package/lib/scripts/create_settings.js +44 -0
  57. package/lib/scripts/setup_ai_devmode.js +324 -0
  58. package/lib/scripts/setup_devwatch.js +0 -0
  59. package/lib/stages/00-load_core.js +1 -1
  60. package/lib/stages/50-routes.js +6 -1
  61. package/lib/stages/90-server.js +0 -1
  62. 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.2.5
32
+ * @version 1.4.0
33
33
  *
34
34
  * @type {RURL}
35
35
  */
36
36
  Router.setProperty(function current_url() {
37
- if (this.view_render?.variables?.__url) {
38
- return this.view_render.variables.__url.clone();
39
- } else if (Blast.isBrowser) {
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.setAttribute(method_attribute, config.methods[0]);
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.3.10
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.3.10
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.3.10
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.3.12
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
- console.log('Socket error:', err);
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
- try {
551
- if (packet.data && typeof packet.data == 'object') {
552
- packet.data = JSON.undry(packet.data);
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
- if (packet.noData) {
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
- console.log('ERROR UNDRYING PACKET:', err, packet);
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.3.10
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 (!type_map) {
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
- if (error_msg) {
77
- let err = new Error(error_msg);
78
- console.log('ERROR:', err);
79
- linkup.emit('error', err);
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.3.10
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.3.10
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
- return result;
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.3.10
874
+ * @version 1.4.0
696
875
  *
697
876
  * @param {string} property
698
877
  */
699
878
  Syncable.setMethod(function emitPropertyChange(property) {
700
- let value = this[property];
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.3.10
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
- let value = this[property];
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.3.10
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();