@yuneta/gobj-ui 7.25.5 → 7.25.6

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/README.md CHANGED
@@ -2154,6 +2154,20 @@ else.
2154
2154
  - In: `EV_SHOW` (`{subpath}` — the tail it owns is `<treedb>[/<topic>]` or
2155
2155
  `<treedb>/diagram`), `EV_HIDE`, `EV_TRANSPORT_STATE`, `EV_REFRESH`,
2156
2156
  `EV_LANGUAGE_CHANGED`, `EV_MT_COMMAND_ANSWER`.
2157
+ - A **drop** of the transport — `EV_TRANSPORT_STATE {connected: false}`, or a
2158
+ failed answer that arrives while the transport is out of `ST_SESSION` — ends
2159
+ the load or the write it cut, and the model is asked again on the next
2160
+ `{connected: true}`. A write cut this way is shown as
2161
+ `the connection dropped during the write` (an i18n key the consumer's
2162
+ locales carry). Each request carries its round in `__md_command__`
2163
+ (`round` / `write`), so an answer of what the drop cut is ignored with a
2164
+ warning.
2165
+
2166
+ ```js
2167
+ // the host forwards its session edges; nothing else is needed
2168
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
2169
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
2170
+ ```
2157
2171
  - Out: `EV_POSITION_CHANGED` (`{subpath}` — the host writes the url; this view
2158
2172
  navigates nothing itself), `EV_RECORD_WRITTEN` (whoever owns the Apply needs
2159
2173
  to know the yuno has not re-read its schema yet), `EV_SCHEMA_CHECKED`
@@ -72101,6 +72101,12 @@ function write_options(op) {
72101
72101
  * `update-node` / `delete-node` on the treedb service, and it may
72102
72102
  * be a routing adapter two hops away (the agent console's
72103
72103
  * C_AGENT_TREEDB_LINK) — this view cannot tell, and must not.
72104
+ * A DROP of it (the host's EV_TRANSPORT_STATE, or a failure that
72105
+ * arrives while the transport is out of session) ends the load or
72106
+ * the write it cut, and the model is asked again when the session
72107
+ * is back. Every request carries its round (`round` for a load,
72108
+ * `write` for a write) in `__md_command__`, so an answer of what
72109
+ * was cut is ignored instead of counted in the next one.
72104
72110
  *
72105
72111
  * Copyright (c) 2026, ArtGins.
72106
72112
  * All Rights Reserved.
@@ -72133,6 +72139,7 @@ var PRIVATE_DATA$1 = {
72133
72139
  model: null,
72134
72140
  pending: 0,
72135
72141
  load_error: "",
72142
+ load_round: 0,
72136
72143
  treedb_id: "",
72137
72144
  topic_name: "",
72138
72145
  diagram: false,
@@ -72146,7 +72153,9 @@ var PRIVATE_DATA$1 = {
72146
72153
  save_queue: null,
72147
72154
  save_return: "",
72148
72155
  save_wrote: false,
72156
+ write_tag: 0,
72149
72157
  connected: false,
72158
+ reload_on_open: false,
72150
72159
  pending_seg: null,
72151
72160
  import_plan: null,
72152
72161
  import_pane: null,
@@ -72317,6 +72326,7 @@ function request_model(gobj) {
72317
72326
  };
72318
72327
  priv.load_error = "";
72319
72328
  priv.pending = 0;
72329
+ priv.load_round++;
72320
72330
  (0, _yuneta_gobj_js.gobj_change_state)(gobj, "ST_LOADING");
72321
72331
  show_notice(gobj, "loading the schemas");
72322
72332
  for (let topic_name of [
@@ -72326,7 +72336,7 @@ function request_model(gobj) {
72326
72336
  ]) if (remote_command(gobj, "nodes", {
72327
72337
  topic_name,
72328
72338
  options: { list_dict: true }
72329
- }) === 0) priv.pending++;
72339
+ }, { round: priv.load_round }) === 0) priv.pending++;
72330
72340
  if (priv.pending === 0) {
72331
72341
  priv.load_error = (0, i18next.t)("cannot reach the treedb");
72332
72342
  (0, _yuneta_gobj_js.gobj_change_state)(gobj, "ST_IDLE");
@@ -72344,7 +72354,7 @@ function request_model(gobj) {
72344
72354
  * answer says which topic it is about, since the command is
72345
72355
  * `nodes` for all three.
72346
72356
  ***************************************************************/
72347
- function remote_command(gobj, command, kw) {
72357
+ function remote_command(gobj, command, kw, tag) {
72348
72358
  let remote = (0, _yuneta_gobj_js.gobj_read_pointer_attr)(gobj, "gobj_remote_yuno");
72349
72359
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
72350
72360
  if (!remote) {
@@ -72355,11 +72365,11 @@ function remote_command(gobj, command, kw) {
72355
72365
  service: treedb_name,
72356
72366
  treedb_name
72357
72367
  }, kw || {});
72358
- full_kw.__md_command__ = {
72368
+ full_kw.__md_command__ = Object.assign({
72359
72369
  purpose: PURPOSE,
72360
72370
  topic_name: kw && kw.topic_name || "",
72361
72371
  record_id: kw && kw.record_id || ""
72362
- };
72372
+ }, tag || {});
72363
72373
  delete full_kw.record_id;
72364
72374
  let ret = (0, _yuneta_gobj_js.gobj_command)(remote, command, full_kw, gobj);
72365
72375
  if (ret) {
@@ -74590,12 +74600,61 @@ function run_next_write(gobj) {
74590
74600
  let write = priv.save_queue[0];
74591
74601
  let command = write_command(write.op);
74592
74602
  let options = write_options(write.op);
74603
+ priv.write_tag++;
74593
74604
  if (remote_command(gobj, command, {
74594
74605
  topic_name: write.topic_name,
74595
74606
  record: write.record,
74596
74607
  options,
74597
74608
  record_id: write.record.id || ""
74598
- }) < 0) return end_writes(gobj, (0, i18next.t)("cannot reach the treedb"));
74609
+ }, { write: priv.write_tag }) < 0) return end_writes(gobj, (0, i18next.t)("cannot reach the treedb"));
74610
+ return 0;
74611
+ }
74612
+ /***************************************************************
74613
+ * Does the transport say it is in session? The state the library
74614
+ * reads off every transport (C_IEVENT_CLI and the routing adapters
74615
+ * name it the same).
74616
+ ***************************************************************/
74617
+ function transport_in_session(gobj) {
74618
+ let remote = (0, _yuneta_gobj_js.gobj_read_pointer_attr)(gobj, "gobj_remote_yuno");
74619
+ return !!remote && (0, _yuneta_gobj_js.gobj_current_state)(remote) === "ST_SESSION";
74620
+ }
74621
+ /***************************************************************
74622
+ * Is this answer of the load in flight, or of the write in
74623
+ * flight? Each request carries its round in `__md_command__`, and
74624
+ * one the drop abandoned (or a deadline settled) can still come
74625
+ * back: counted, it ended the next load with the old data or
74626
+ * moved the next write's queue.
74627
+ ***************************************************************/
74628
+ function is_current_load(gobj, md) {
74629
+ return (0, _yuneta_gobj_js.gobj_current_state)(gobj) === "ST_LOADING" && (0, _yuneta_gobj_js.kw_get_int)(gobj, md, "round", 0, 0) === gobj.priv.load_round;
74630
+ }
74631
+ function is_current_write(gobj, md) {
74632
+ return (0, _yuneta_gobj_js.gobj_current_state)(gobj) === "ST_SAVING" && (0, _yuneta_gobj_js.kw_get_int)(gobj, md, "write", 0, 0) === gobj.priv.write_tag;
74633
+ }
74634
+ /***************************************************************
74635
+ * The transport dropped under a load or a write: its answers
74636
+ * will never come. The load goes back to the screen it replaced
74637
+ * (or to "not connected" when there was none), the write ends
74638
+ * with the drop said -- whether it landed is unknown -- and both
74639
+ * mark the model to be asked again when the session is back.
74640
+ * Bumping the round makes an answer of what was cut stale.
74641
+ ***************************************************************/
74642
+ function transport_dropped(gobj) {
74643
+ let priv = gobj.priv;
74644
+ let state = (0, _yuneta_gobj_js.gobj_current_state)(gobj);
74645
+ priv.reload_on_open = true;
74646
+ if (state === "ST_SAVING") {
74647
+ priv.write_tag++;
74648
+ return end_writes(gobj, "the connection dropped during the write");
74649
+ }
74650
+ if (state === "ST_LOADING") {
74651
+ priv.load_round++;
74652
+ priv.pending = 0;
74653
+ priv.load_error = "";
74654
+ if (priv.model) return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74655
+ (0, _yuneta_gobj_js.gobj_change_state)(gobj, "ST_IDLE");
74656
+ render(gobj);
74657
+ }
74599
74658
  return 0;
74600
74659
  }
74601
74660
  /***************************************************************
@@ -74750,8 +74809,15 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74750
74809
  let kw_command = (0, _yuneta_gobj_js.kw_get_dict)(gobj, __command__, "kw", {}, 0);
74751
74810
  let md = (0, _yuneta_gobj_js.is_object)(kw_command.__md_command__) ? kw_command.__md_command__ : kw_command;
74752
74811
  if ((0, _yuneta_gobj_js.kw_get_str)(gobj, md, "purpose", "", 0) !== PURPOSE) return 0;
74812
+ if (result < 0 && !transport_in_session(gobj) && ((0, _yuneta_gobj_js.gobj_current_state)(gobj) === "ST_LOADING" || (0, _yuneta_gobj_js.gobj_current_state)(gobj) === "ST_SAVING")) {
74813
+ if (command === "nodes" && is_current_load(gobj, md) || command !== "nodes" && is_current_write(gobj, md)) return transport_dropped(gobj);
74814
+ }
74753
74815
  if (command === "nodes") {
74754
74816
  let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, md, "topic_name", "", 0);
74817
+ if (!is_current_load(gobj, md)) {
74818
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: 'nodes' of '${topic_name}' answered for a load that is over: ignored`);
74819
+ return 0;
74820
+ }
74755
74821
  priv.pending--;
74756
74822
  if (result < 0) priv.load_error = comment || (0, i18next.t)("cannot load the schemas");
74757
74823
  else if (priv.records && Array.isArray(priv.records[topic_name])) priv.records[topic_name] = Array.isArray(data) ? data : [];
@@ -74769,6 +74835,10 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74769
74835
  }
74770
74836
  if (command === "update-node" || command === "delete-node") {
74771
74837
  let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, md, "topic_name", "", 0);
74838
+ if (!is_current_write(gobj, md)) {
74839
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: '${command}' of '${topic_name}' answered for a write that is over (result ${result}): ignored`);
74840
+ return 0;
74841
+ }
74772
74842
  if (result < 0) return end_writes(gobj, comment || (0, i18next.t)("the treedb refused the write"));
74773
74843
  priv.save_wrote = true;
74774
74844
  if (command === "delete-node") forget_record(gobj, topic_name, (0, _yuneta_gobj_js.kw_get_str)(gobj, md, "record_id", "", 0));
@@ -74828,14 +74898,28 @@ function ac_hide(gobj, event, kw, src) {
74828
74898
  return 0;
74829
74899
  }
74830
74900
  /***************************************************************
74831
- * The session came up or went down. The model stays either way
74832
- * — a schema does not change under you — but a first load may
74833
- * have had no transport to run on.
74901
+ * The session came up or went down. The model stays -- a schema
74902
+ * does not change under you -- unless the drop cut a load or a
74903
+ * write (transport_dropped()): then it is asked again on the way
74904
+ * back, as it is when a first load had no transport to run on.
74905
+ *
74906
+ * Nothing else ends a load or a write the drop cut: their answers
74907
+ * will never come, and waiting in ST_LOADING skipped the reload
74908
+ * itself (M-1 of the independent review of 7.25.4).
74834
74909
  ***************************************************************/
74835
74910
  function ac_transport_state(gobj, event, kw, src) {
74836
74911
  let priv = gobj.priv;
74912
+ let state = (0, _yuneta_gobj_js.gobj_current_state)(gobj);
74837
74913
  priv.connected = !!(kw && kw.connected);
74838
- if (priv.connected && !priv.model && (0, _yuneta_gobj_js.gobj_current_state)(gobj) !== "ST_LOADING") return request_model(gobj);
74914
+ if (!priv.connected) {
74915
+ if (state === "ST_LOADING" || state === "ST_SAVING") return transport_dropped(gobj);
74916
+ render_toolbar(gobj);
74917
+ return 0;
74918
+ }
74919
+ if ((!priv.model || priv.reload_on_open) && state !== "ST_LOADING" && state !== "ST_SAVING") {
74920
+ priv.reload_on_open = false;
74921
+ return request_model(gobj);
74922
+ }
74839
74923
  render_toolbar(gobj);
74840
74924
  return 0;
74841
74925
  }
@@ -72096,6 +72096,12 @@ function write_options(op) {
72096
72096
  * `update-node` / `delete-node` on the treedb service, and it may
72097
72097
  * be a routing adapter two hops away (the agent console's
72098
72098
  * C_AGENT_TREEDB_LINK) — this view cannot tell, and must not.
72099
+ * A DROP of it (the host's EV_TRANSPORT_STATE, or a failure that
72100
+ * arrives while the transport is out of session) ends the load or
72101
+ * the write it cut, and the model is asked again when the session
72102
+ * is back. Every request carries its round (`round` for a load,
72103
+ * `write` for a write) in `__md_command__`, so an answer of what
72104
+ * was cut is ignored instead of counted in the next one.
72099
72105
  *
72100
72106
  * Copyright (c) 2026, ArtGins.
72101
72107
  * All Rights Reserved.
@@ -72128,6 +72134,7 @@ var PRIVATE_DATA$1 = {
72128
72134
  model: null,
72129
72135
  pending: 0,
72130
72136
  load_error: "",
72137
+ load_round: 0,
72131
72138
  treedb_id: "",
72132
72139
  topic_name: "",
72133
72140
  diagram: false,
@@ -72141,7 +72148,9 @@ var PRIVATE_DATA$1 = {
72141
72148
  save_queue: null,
72142
72149
  save_return: "",
72143
72150
  save_wrote: false,
72151
+ write_tag: 0,
72144
72152
  connected: false,
72153
+ reload_on_open: false,
72145
72154
  pending_seg: null,
72146
72155
  import_plan: null,
72147
72156
  import_pane: null,
@@ -72312,6 +72321,7 @@ function request_model(gobj) {
72312
72321
  };
72313
72322
  priv.load_error = "";
72314
72323
  priv.pending = 0;
72324
+ priv.load_round++;
72315
72325
  gobj_change_state(gobj, "ST_LOADING");
72316
72326
  show_notice(gobj, "loading the schemas");
72317
72327
  for (let topic_name of [
@@ -72321,7 +72331,7 @@ function request_model(gobj) {
72321
72331
  ]) if (remote_command(gobj, "nodes", {
72322
72332
  topic_name,
72323
72333
  options: { list_dict: true }
72324
- }) === 0) priv.pending++;
72334
+ }, { round: priv.load_round }) === 0) priv.pending++;
72325
72335
  if (priv.pending === 0) {
72326
72336
  priv.load_error = t("cannot reach the treedb");
72327
72337
  gobj_change_state(gobj, "ST_IDLE");
@@ -72339,7 +72349,7 @@ function request_model(gobj) {
72339
72349
  * answer says which topic it is about, since the command is
72340
72350
  * `nodes` for all three.
72341
72351
  ***************************************************************/
72342
- function remote_command(gobj, command, kw) {
72352
+ function remote_command(gobj, command, kw, tag) {
72343
72353
  let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
72344
72354
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
72345
72355
  if (!remote) {
@@ -72350,11 +72360,11 @@ function remote_command(gobj, command, kw) {
72350
72360
  service: treedb_name,
72351
72361
  treedb_name
72352
72362
  }, kw || {});
72353
- full_kw.__md_command__ = {
72363
+ full_kw.__md_command__ = Object.assign({
72354
72364
  purpose: PURPOSE,
72355
72365
  topic_name: kw && kw.topic_name || "",
72356
72366
  record_id: kw && kw.record_id || ""
72357
- };
72367
+ }, tag || {});
72358
72368
  delete full_kw.record_id;
72359
72369
  let ret = gobj_command(remote, command, full_kw, gobj);
72360
72370
  if (ret) {
@@ -74585,12 +74595,61 @@ function run_next_write(gobj) {
74585
74595
  let write = priv.save_queue[0];
74586
74596
  let command = write_command(write.op);
74587
74597
  let options = write_options(write.op);
74598
+ priv.write_tag++;
74588
74599
  if (remote_command(gobj, command, {
74589
74600
  topic_name: write.topic_name,
74590
74601
  record: write.record,
74591
74602
  options,
74592
74603
  record_id: write.record.id || ""
74593
- }) < 0) return end_writes(gobj, t("cannot reach the treedb"));
74604
+ }, { write: priv.write_tag }) < 0) return end_writes(gobj, t("cannot reach the treedb"));
74605
+ return 0;
74606
+ }
74607
+ /***************************************************************
74608
+ * Does the transport say it is in session? The state the library
74609
+ * reads off every transport (C_IEVENT_CLI and the routing adapters
74610
+ * name it the same).
74611
+ ***************************************************************/
74612
+ function transport_in_session(gobj) {
74613
+ let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
74614
+ return !!remote && gobj_current_state(remote) === "ST_SESSION";
74615
+ }
74616
+ /***************************************************************
74617
+ * Is this answer of the load in flight, or of the write in
74618
+ * flight? Each request carries its round in `__md_command__`, and
74619
+ * one the drop abandoned (or a deadline settled) can still come
74620
+ * back: counted, it ended the next load with the old data or
74621
+ * moved the next write's queue.
74622
+ ***************************************************************/
74623
+ function is_current_load(gobj, md) {
74624
+ return gobj_current_state(gobj) === "ST_LOADING" && kw_get_int(gobj, md, "round", 0, 0) === gobj.priv.load_round;
74625
+ }
74626
+ function is_current_write(gobj, md) {
74627
+ return gobj_current_state(gobj) === "ST_SAVING" && kw_get_int(gobj, md, "write", 0, 0) === gobj.priv.write_tag;
74628
+ }
74629
+ /***************************************************************
74630
+ * The transport dropped under a load or a write: its answers
74631
+ * will never come. The load goes back to the screen it replaced
74632
+ * (or to "not connected" when there was none), the write ends
74633
+ * with the drop said -- whether it landed is unknown -- and both
74634
+ * mark the model to be asked again when the session is back.
74635
+ * Bumping the round makes an answer of what was cut stale.
74636
+ ***************************************************************/
74637
+ function transport_dropped(gobj) {
74638
+ let priv = gobj.priv;
74639
+ let state = gobj_current_state(gobj);
74640
+ priv.reload_on_open = true;
74641
+ if (state === "ST_SAVING") {
74642
+ priv.write_tag++;
74643
+ return end_writes(gobj, "the connection dropped during the write");
74644
+ }
74645
+ if (state === "ST_LOADING") {
74646
+ priv.load_round++;
74647
+ priv.pending = 0;
74648
+ priv.load_error = "";
74649
+ if (priv.model) return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74650
+ gobj_change_state(gobj, "ST_IDLE");
74651
+ render(gobj);
74652
+ }
74594
74653
  return 0;
74595
74654
  }
74596
74655
  /***************************************************************
@@ -74745,8 +74804,15 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74745
74804
  let kw_command = kw_get_dict(gobj, __command__, "kw", {}, 0);
74746
74805
  let md = is_object(kw_command.__md_command__) ? kw_command.__md_command__ : kw_command;
74747
74806
  if (kw_get_str(gobj, md, "purpose", "", 0) !== PURPOSE) return 0;
74807
+ if (result < 0 && !transport_in_session(gobj) && (gobj_current_state(gobj) === "ST_LOADING" || gobj_current_state(gobj) === "ST_SAVING")) {
74808
+ if (command === "nodes" && is_current_load(gobj, md) || command !== "nodes" && is_current_write(gobj, md)) return transport_dropped(gobj);
74809
+ }
74748
74810
  if (command === "nodes") {
74749
74811
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
74812
+ if (!is_current_load(gobj, md)) {
74813
+ log_warning(`${gobj_short_name(gobj)}: 'nodes' of '${topic_name}' answered for a load that is over: ignored`);
74814
+ return 0;
74815
+ }
74750
74816
  priv.pending--;
74751
74817
  if (result < 0) priv.load_error = comment || t("cannot load the schemas");
74752
74818
  else if (priv.records && Array.isArray(priv.records[topic_name])) priv.records[topic_name] = Array.isArray(data) ? data : [];
@@ -74764,6 +74830,10 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74764
74830
  }
74765
74831
  if (command === "update-node" || command === "delete-node") {
74766
74832
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
74833
+ if (!is_current_write(gobj, md)) {
74834
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' answered for a write that is over (result ${result}): ignored`);
74835
+ return 0;
74836
+ }
74767
74837
  if (result < 0) return end_writes(gobj, comment || t("the treedb refused the write"));
74768
74838
  priv.save_wrote = true;
74769
74839
  if (command === "delete-node") forget_record(gobj, topic_name, kw_get_str(gobj, md, "record_id", "", 0));
@@ -74823,14 +74893,28 @@ function ac_hide(gobj, event, kw, src) {
74823
74893
  return 0;
74824
74894
  }
74825
74895
  /***************************************************************
74826
- * The session came up or went down. The model stays either way
74827
- * — a schema does not change under you — but a first load may
74828
- * have had no transport to run on.
74896
+ * The session came up or went down. The model stays -- a schema
74897
+ * does not change under you -- unless the drop cut a load or a
74898
+ * write (transport_dropped()): then it is asked again on the way
74899
+ * back, as it is when a first load had no transport to run on.
74900
+ *
74901
+ * Nothing else ends a load or a write the drop cut: their answers
74902
+ * will never come, and waiting in ST_LOADING skipped the reload
74903
+ * itself (M-1 of the independent review of 7.25.4).
74829
74904
  ***************************************************************/
74830
74905
  function ac_transport_state(gobj, event, kw, src) {
74831
74906
  let priv = gobj.priv;
74907
+ let state = gobj_current_state(gobj);
74832
74908
  priv.connected = !!(kw && kw.connected);
74833
- if (priv.connected && !priv.model && gobj_current_state(gobj) !== "ST_LOADING") return request_model(gobj);
74909
+ if (!priv.connected) {
74910
+ if (state === "ST_LOADING" || state === "ST_SAVING") return transport_dropped(gobj);
74911
+ render_toolbar(gobj);
74912
+ return 0;
74913
+ }
74914
+ if ((!priv.model || priv.reload_on_open) && state !== "ST_LOADING" && state !== "ST_SAVING") {
74915
+ priv.reload_on_open = false;
74916
+ return request_model(gobj);
74917
+ }
74834
74918
  render_toolbar(gobj);
74835
74919
  return 0;
74836
74920
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.25.5",
3
+ "version": "7.25.6",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -70,6 +70,12 @@
70
70
  * `update-node` / `delete-node` on the treedb service, and it may
71
71
  * be a routing adapter two hops away (the agent console's
72
72
  * C_AGENT_TREEDB_LINK) — this view cannot tell, and must not.
73
+ * A DROP of it (the host's EV_TRANSPORT_STATE, or a failure that
74
+ * arrives while the transport is out of session) ends the load or
75
+ * the write it cut, and the model is asked again when the session
76
+ * is back. Every request carries its round (`round` for a load,
77
+ * `write` for a write) in `__md_command__`, so an answer of what
78
+ * was cut is ignored instead of counted in the next one.
73
79
  *
74
80
  * Copyright (c) 2026, ArtGins.
75
81
  * All Rights Reserved.
@@ -108,6 +114,7 @@ import {
108
114
  clean_name,
109
115
  msg_iev_get_stack,
110
116
  kw_get_str,
117
+ kw_get_int,
111
118
  kw_get_dict,
112
119
  is_object,
113
120
  is_array,
@@ -198,6 +205,7 @@ let PRIVATE_DATA = {
198
205
  model: null, /* build_schema_model() of the above */
199
206
  pending: 0, /* `nodes` requests still in flight */
200
207
  load_error: "",
208
+ load_round: 0, /* which load an answer belongs to: echoed as `round` */
201
209
 
202
210
  /* Where the operator is. The url carries it; these mirror it. */
203
211
  treedb_id: "",
@@ -220,7 +228,9 @@ let PRIVATE_DATA = {
220
228
  save_queue: null,
221
229
  save_return: "",
222
230
  save_wrote: false,
231
+ write_tag: 0, /* which write an answer belongs to: echoed as `write` */
223
232
  connected: false,
233
+ reload_on_open: false, /* a drop cut a load or a write: ask the model again */
224
234
 
225
235
  /* A position that arrived while a write was in flight */
226
236
  pending_seg: null,
@@ -458,6 +468,7 @@ function request_model(gobj)
458
468
  priv.records = {treedbs: [], topics: [], cols: []};
459
469
  priv.load_error = "";
460
470
  priv.pending = 0;
471
+ priv.load_round++;
461
472
  gobj_change_state(gobj, "ST_LOADING");
462
473
  show_notice(gobj, "loading the schemas");
463
474
 
@@ -465,7 +476,7 @@ function request_model(gobj)
465
476
  if(remote_command(gobj, "nodes", {
466
477
  topic_name: topic_name,
467
478
  options: {list_dict: true}
468
- }) === 0) {
479
+ }, {round: priv.load_round}) === 0) {
469
480
  priv.pending++;
470
481
  }
471
482
  }
@@ -487,7 +498,7 @@ function request_model(gobj)
487
498
  * answer says which topic it is about, since the command is
488
499
  * `nodes` for all three.
489
500
  ***************************************************************/
490
- function remote_command(gobj, command, kw)
501
+ function remote_command(gobj, command, kw, tag)
491
502
  {
492
503
  let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
493
504
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
@@ -512,11 +523,11 @@ function remote_command(gobj, command, kw)
512
523
  * whichever one is under this view — and `record_id` is here and
513
524
  * NOT at the top level because a delete answers with nothing, and a
514
525
  * parameter the treedb does not know has no business travelling. */
515
- full_kw.__md_command__ = {
526
+ full_kw.__md_command__ = Object.assign({
516
527
  purpose: PURPOSE,
517
528
  topic_name: (kw && kw.topic_name) || "",
518
529
  record_id: (kw && kw.record_id) || ""
519
- };
530
+ }, tag || {});
520
531
  delete full_kw.record_id;
521
532
 
522
533
  let ret = gobj_command(remote, command, full_kw, gobj);
@@ -2362,17 +2373,80 @@ function run_next_write(gobj)
2362
2373
  let command = write_command(write.op);
2363
2374
  let options = write_options(write.op);
2364
2375
 
2376
+ priv.write_tag++;
2365
2377
  if(remote_command(gobj, command, {
2366
2378
  topic_name: write.topic_name,
2367
2379
  record: write.record,
2368
2380
  options: options,
2369
2381
  record_id: write.record.id || ""
2370
- }) < 0) {
2382
+ }, {write: priv.write_tag}) < 0) {
2371
2383
  return end_writes(gobj, t("cannot reach the treedb"));
2372
2384
  }
2373
2385
  return 0;
2374
2386
  }
2375
2387
 
2388
+ /***************************************************************
2389
+ * Does the transport say it is in session? The state the library
2390
+ * reads off every transport (C_IEVENT_CLI and the routing adapters
2391
+ * name it the same).
2392
+ ***************************************************************/
2393
+ function transport_in_session(gobj)
2394
+ {
2395
+ let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
2396
+
2397
+ return !!remote && gobj_current_state(remote) === "ST_SESSION";
2398
+ }
2399
+
2400
+ /***************************************************************
2401
+ * Is this answer of the load in flight, or of the write in
2402
+ * flight? Each request carries its round in `__md_command__`, and
2403
+ * one the drop abandoned (or a deadline settled) can still come
2404
+ * back: counted, it ended the next load with the old data or
2405
+ * moved the next write's queue.
2406
+ ***************************************************************/
2407
+ function is_current_load(gobj, md)
2408
+ {
2409
+ return gobj_current_state(gobj) === "ST_LOADING" &&
2410
+ kw_get_int(gobj, md, "round", 0, 0) === gobj.priv.load_round;
2411
+ }
2412
+
2413
+ function is_current_write(gobj, md)
2414
+ {
2415
+ return gobj_current_state(gobj) === "ST_SAVING" &&
2416
+ kw_get_int(gobj, md, "write", 0, 0) === gobj.priv.write_tag;
2417
+ }
2418
+
2419
+ /***************************************************************
2420
+ * The transport dropped under a load or a write: its answers
2421
+ * will never come. The load goes back to the screen it replaced
2422
+ * (or to "not connected" when there was none), the write ends
2423
+ * with the drop said -- whether it landed is unknown -- and both
2424
+ * mark the model to be asked again when the session is back.
2425
+ * Bumping the round makes an answer of what was cut stale.
2426
+ ***************************************************************/
2427
+ function transport_dropped(gobj)
2428
+ {
2429
+ let priv = gobj.priv;
2430
+ let state = gobj_current_state(gobj);
2431
+
2432
+ priv.reload_on_open = true;
2433
+ if(state === "ST_SAVING") {
2434
+ priv.write_tag++;
2435
+ return end_writes(gobj, "the connection dropped during the write");
2436
+ }
2437
+ if(state === "ST_LOADING") {
2438
+ priv.load_round++;
2439
+ priv.pending = 0;
2440
+ priv.load_error = "";
2441
+ if(priv.model) {
2442
+ return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2443
+ }
2444
+ gobj_change_state(gobj, "ST_IDLE");
2445
+ render(gobj);
2446
+ }
2447
+ return 0;
2448
+ }
2449
+
2376
2450
  /***************************************************************
2377
2451
  * The queue is done, or gave up. Either way the model is what
2378
2452
  * the answers made it, so the screen is redrawn from it and the
@@ -2627,8 +2701,28 @@ function ac_mt_command_answer(gobj, event, kw, src)
2627
2701
  return 0; /* another panel's answer on a shared transport */
2628
2702
  }
2629
2703
 
2704
+ /* A failure while the transport is not in session is the DROP, not a
2705
+ * refusal: a routing adapter settles what it had in flight when its
2706
+ * session closes, and that can arrive before the host's
2707
+ * EV_TRANSPORT_STATE. Read as a refusal, the write it ended looked
2708
+ * refused, and the reconnect did not reload a model it may have
2709
+ * changed. */
2710
+ if(result < 0 && !transport_in_session(gobj) &&
2711
+ (gobj_current_state(gobj) === "ST_LOADING" ||
2712
+ gobj_current_state(gobj) === "ST_SAVING")) {
2713
+ if((command === "nodes" && is_current_load(gobj, md)) ||
2714
+ (command !== "nodes" && is_current_write(gobj, md))) {
2715
+ return transport_dropped(gobj);
2716
+ }
2717
+ }
2718
+
2630
2719
  if(command === "nodes") {
2631
2720
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2721
+ if(!is_current_load(gobj, md)) {
2722
+ log_warning(`${gobj_short_name(gobj)}: 'nodes' of '${topic_name}' answered ` +
2723
+ `for a load that is over: ignored`);
2724
+ return 0;
2725
+ }
2632
2726
  priv.pending--;
2633
2727
  if(result < 0) {
2634
2728
  priv.load_error = comment || t("cannot load the schemas");
@@ -2662,6 +2756,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
2662
2756
 
2663
2757
  if(command === "update-node" || command === "delete-node") {
2664
2758
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2759
+ if(!is_current_write(gobj, md)) {
2760
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' answered ` +
2761
+ `for a write that is over (result ${result}): ignored`);
2762
+ return 0;
2763
+ }
2665
2764
  if(result < 0) {
2666
2765
  return end_writes(gobj, comment || t("the treedb refused the write"));
2667
2766
  }
@@ -2743,16 +2842,31 @@ function ac_hide(gobj, event, kw, src)
2743
2842
  }
2744
2843
 
2745
2844
  /***************************************************************
2746
- * The session came up or went down. The model stays either way
2747
- * — a schema does not change under you — but a first load may
2748
- * have had no transport to run on.
2845
+ * The session came up or went down. The model stays -- a schema
2846
+ * does not change under you -- unless the drop cut a load or a
2847
+ * write (transport_dropped()): then it is asked again on the way
2848
+ * back, as it is when a first load had no transport to run on.
2849
+ *
2850
+ * Nothing else ends a load or a write the drop cut: their answers
2851
+ * will never come, and waiting in ST_LOADING skipped the reload
2852
+ * itself (M-1 of the independent review of 7.25.4).
2749
2853
  ***************************************************************/
2750
2854
  function ac_transport_state(gobj, event, kw, src)
2751
2855
  {
2752
2856
  let priv = gobj.priv;
2857
+ let state = gobj_current_state(gobj);
2753
2858
 
2754
2859
  priv.connected = !!(kw && kw.connected);
2755
- if(priv.connected && !priv.model && gobj_current_state(gobj) !== "ST_LOADING") {
2860
+ if(!priv.connected) {
2861
+ if(state === "ST_LOADING" || state === "ST_SAVING") {
2862
+ return transport_dropped(gobj);
2863
+ }
2864
+ render_toolbar(gobj);
2865
+ return 0;
2866
+ }
2867
+ if((!priv.model || priv.reload_on_open) &&
2868
+ state !== "ST_LOADING" && state !== "ST_SAVING") {
2869
+ priv.reload_on_open = false;
2756
2870
  return request_model(gobj);
2757
2871
  }
2758
2872
  render_toolbar(gobj);
@@ -0,0 +1,284 @@
1
+ /***********************************************************************
2
+ * schema_editor_transport.wiring.test.js
3
+ *
4
+ * C_YUI_SCHEMA_EDITOR when the transport drops (M-1 of the
5
+ * independent review of 7.25.4). A drop used to leave the editor
6
+ * where it was: in ST_SAVING with its body busy, or in ST_LOADING
7
+ * -- and the reconnect skipped the reload BECAUSE the state was
8
+ * ST_LOADING. Only a reload of the page got it out.
9
+ *
10
+ * Driven through the FSM on a document double, with a fake
11
+ * transport whose state is the one the library reads
12
+ * (`ST_SESSION` or not).
13
+ *
14
+ * Copyright (c) 2026, ArtGins.
15
+ * All Rights Reserved.
16
+ ***********************************************************************/
17
+ import {describe, test, expect, beforeAll, beforeEach, vi} from "vitest";
18
+ import {install_dom_double} from "../test/dom_double.js";
19
+
20
+ install_dom_double();
21
+
22
+ /* What the editor SAYS is asserted on the call. */
23
+ const shown = [];
24
+ vi.mock("./shell_modals.js", () => ({
25
+ yui_shell_show_error: (shell, message) => {
26
+ shown.push(message);
27
+ return {close() {}};
28
+ },
29
+ yui_shell_show_info: () => ({close() {}}),
30
+ yui_shell_show_modal: () => ({close() {}}),
31
+ yui_shell_confirm_danger: () => ({close() {}}),
32
+ }));
33
+
34
+ const {
35
+ SDATA_END,
36
+ gclass_create,
37
+ gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
38
+ gobj_start, gobj_send_event, gobj_read_attr,
39
+ gobj_change_state, gobj_current_state,
40
+ set_log_callback,
41
+ } = await import("@yuneta/gobj-js");
42
+ const {register_c_yui_schema_editor} = await import("./c_yui_schema_editor.js");
43
+
44
+ const logged = [];
45
+ const commands = [];
46
+
47
+ const RECORDS = {
48
+ treedbs: [{id: "db", schema_version: 4}],
49
+ topics: [
50
+ {id: "db.users", value: "users", order: 1, pkey: "id", topic_version: 2,
51
+ treedbs: ["treedbs^db^topics"]},
52
+ ],
53
+ cols: [
54
+ {id: "db.users.id", value: "id", order: 1, type: "string",
55
+ flag: ["persistent", "required"], topics: ["topics^db.users^cols"]},
56
+ ],
57
+ };
58
+
59
+ function remote_command_parser(gobj, command, kw, src)
60
+ {
61
+ commands.push({command, kw, src});
62
+ return null;
63
+ }
64
+
65
+ let yuno = null;
66
+
67
+ beforeAll(() => {
68
+ gobj_start_up(null, null, null, null, null, null, null);
69
+ set_log_callback((level, msg) => {
70
+ logged.push({level: String(level), msg: String(msg)});
71
+ });
72
+ gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
73
+ gclass_create(
74
+ "C_TEST_EDITOR_HOST",
75
+ [["EV_POSITION_CHANGED", 0], ["EV_RECORD_WRITTEN", 0], ["EV_SCHEMA_CHECKED", 0]],
76
+ [["ST_IDLE", [
77
+ ["EV_POSITION_CHANGED", () => 0, null],
78
+ ["EV_RECORD_WRITTEN", () => 0, null],
79
+ ["EV_SCHEMA_CHECKED", () => 0, null]
80
+ ]]],
81
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
82
+ );
83
+ /* The two states the library reads off a transport. */
84
+ gclass_create(
85
+ "C_TEST_REMOTE",
86
+ [],
87
+ [["ST_DISCONNECTED", []], ["ST_SESSION", []]],
88
+ {mt_command_parser: remote_command_parser},
89
+ 0, [SDATA_END()], {}, 0, 0, 0, 0
90
+ );
91
+ register_c_yui_schema_editor();
92
+ yuno = gobj_create_yuno("transport_yuno", "C_TEST_HOST", {});
93
+ gobj_start(yuno);
94
+ });
95
+
96
+ beforeEach(() => {
97
+ logged.length = 0;
98
+ commands.length = 0;
99
+ shown.length = 0;
100
+ });
101
+
102
+ function take(command)
103
+ {
104
+ const out = commands.filter((c) => c.command === command);
105
+ for(const c of out) {
106
+ commands.splice(commands.indexOf(c), 1);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /* The answer, the way a transport gives it: the request's frame on top. */
112
+ function answer(editor, remote, request, result, data, comment)
113
+ {
114
+ gobj_send_event(editor, "EV_MT_COMMAND_ANSWER", {
115
+ result: result,
116
+ comment: comment || "",
117
+ data: data,
118
+ __md_iev__: {command_stack: [{command: request.command, kw: request.kw}]}
119
+ }, remote);
120
+ }
121
+
122
+ function answer_the_load(editor, remote)
123
+ {
124
+ const asked = take("nodes");
125
+ for(const c of asked) {
126
+ answer(editor, remote, c, 0, RECORDS[c.kw.topic_name]);
127
+ }
128
+ return asked.length;
129
+ }
130
+
131
+ function drop(editor, remote, host)
132
+ {
133
+ gobj_change_state(remote, "ST_DISCONNECTED");
134
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
135
+ }
136
+
137
+ function reconnect(editor, remote, host)
138
+ {
139
+ gobj_change_state(remote, "ST_SESSION");
140
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
141
+ }
142
+
143
+ function build(name, loaded)
144
+ {
145
+ const remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
146
+ gobj_change_state(remote, "ST_SESSION");
147
+ const host = gobj_create(`${name}_host`, "C_TEST_EDITOR_HOST", {}, yuno);
148
+ const editor = gobj_create(`${name}_editor`, "C_YUI_SCHEMA_EDITOR", {
149
+ gobj_remote_yuno: remote,
150
+ treedb_name: "treedb_system_schema",
151
+ base_route: "/schemas"
152
+ }, host);
153
+ gobj_start(editor);
154
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
155
+ if(loaded) {
156
+ expect(answer_the_load(editor, remote)).toBe(3);
157
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
158
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
159
+ }
160
+ return {editor, remote, host};
161
+ }
162
+
163
+ /* A write in flight: a new column, saved from its form. */
164
+ function start_a_write(editor, host)
165
+ {
166
+ gobj_send_event(editor, "EV_SAVE_COLUMN", {
167
+ creating: true, topic: "users", values: {value: "name", type: "string"}
168
+ }, host);
169
+ expect(gobj_current_state(editor)).toBe("ST_SAVING");
170
+ const [write] = take("update-node");
171
+ expect(write).toBeTruthy();
172
+ return write;
173
+ }
174
+
175
+ function is_busy(editor)
176
+ {
177
+ const $c = gobj_read_attr(editor, "$container");
178
+ return $c.querySelector(".SCHEMA_BODY").classList.contains("SCHEMA_BUSY");
179
+ }
180
+
181
+ function errors()
182
+ {
183
+ return logged.filter((l) => l.level === "error").map((l) => l.msg);
184
+ }
185
+
186
+ describe("a drop while LOADING", () => {
187
+
188
+ test("the editor leaves ST_LOADING, and the reconnect loads again", () => {
189
+ const {editor, remote, host} = build("l1", false);
190
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
191
+ expect(take("nodes").length).toBe(3); /* never answered */
192
+
193
+ drop(editor, remote, host);
194
+ expect(gobj_current_state(editor)).toBe("ST_IDLE");
195
+
196
+ reconnect(editor, remote, host);
197
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
198
+ expect(answer_the_load(editor, remote)).toBe(3);
199
+ expect(gobj_current_state(editor)).toBe("ST_TREEDBS");
200
+ expect(errors()).toEqual([]);
201
+ });
202
+
203
+ test("an answer of the load the drop abandoned is not counted in the next one", () => {
204
+ const {editor, remote, host} = build("l2", false);
205
+ const old = take("nodes");
206
+ drop(editor, remote, host);
207
+ reconnect(editor, remote, host);
208
+ const fresh = take("nodes");
209
+ expect(fresh.length).toBe(3);
210
+
211
+ /* The three OLD answers arrive late: were they counted, the new
212
+ * round would end here with the old data and 3 answers owed. */
213
+ for(const c of old) {
214
+ answer(editor, remote, c, 0, []);
215
+ }
216
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
217
+ for(const c of fresh) {
218
+ answer(editor, remote, c, 0, RECORDS[c.kw.topic_name]);
219
+ }
220
+ expect(gobj_current_state(editor)).toBe("ST_TREEDBS");
221
+ expect(editor.priv.model.treedbs.length).toBe(1);
222
+ expect(errors()).toEqual([]);
223
+ });
224
+ });
225
+
226
+ describe("a drop while SAVING", () => {
227
+
228
+ test("the write ends, said, the body answers again, and the reconnect reloads", () => {
229
+ const {editor, remote, host} = build("s1", true);
230
+ start_a_write(editor, host);
231
+ expect(is_busy(editor)).toBe(true);
232
+
233
+ drop(editor, remote, host);
234
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
235
+ expect(is_busy(editor)).toBe(false);
236
+ expect(shown).toEqual(["the connection dropped during the write"]);
237
+
238
+ /* Whether the write landed is unknown: the model is asked again. */
239
+ reconnect(editor, remote, host);
240
+ expect(answer_the_load(editor, remote)).toBe(3);
241
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
242
+ expect(errors()).toEqual([]);
243
+ });
244
+
245
+ test("the transport's own failure arriving FIRST is read as the drop it is", () => {
246
+ /* A routing adapter settles what it had in flight when its session
247
+ * closes, and that answer can reach the editor before the host's
248
+ * EV_TRANSPORT_STATE. Read as a refusal, it ended the write and the
249
+ * reconnect did not reload a model the write may have changed. */
250
+ const {editor, remote, host} = build("s2", true);
251
+ const write = start_a_write(editor, host);
252
+
253
+ gobj_change_state(remote, "ST_DISCONNECTED");
254
+ answer(editor, remote, write, -1, null, "the connection dropped");
255
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
256
+ expect(shown).toEqual(["the connection dropped during the write"]);
257
+
258
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
259
+ expect(shown.length).toBe(1); /* said once */
260
+
261
+ reconnect(editor, remote, host);
262
+ expect(answer_the_load(editor, remote)).toBe(3);
263
+ expect(errors()).toEqual([]);
264
+ });
265
+
266
+ test("the answer of the write the drop ended is not taken for the next one", () => {
267
+ const {editor, remote, host} = build("s3", true);
268
+ const write = start_a_write(editor, host);
269
+ drop(editor, remote, host);
270
+ reconnect(editor, remote, host);
271
+ answer_the_load(editor, remote);
272
+
273
+ const second = start_a_write(editor, host);
274
+ /* The first write's answer, late: it must not end the second. */
275
+ answer(editor, remote, write, 0, {id: "db.users.name", value: "name",
276
+ topics: ["topics^db.users^cols"]});
277
+ expect(gobj_current_state(editor)).toBe("ST_SAVING");
278
+
279
+ answer(editor, remote, second, 0, {id: "db.users.name", value: "name", order: 2,
280
+ type: "string", topics: ["topics^db.users^cols"]});
281
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
282
+ expect(errors()).toEqual([]);
283
+ });
284
+ });