@yuneta/gobj-ui 7.25.3 → 7.25.5

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.
@@ -26,8 +26,9 @@
26
26
  * the owner's design of M36 (2026-09-21 review) every write here
27
27
  * raised both versions, so an edit half made was already the
28
28
  * schema of the next start. The topic list marks what this session
29
- * wrote and has not saved; the host's EV_REFRESH after a save is
30
- * what forgets it.
29
+ * wrote and has not saved (the host's EV_REFRESH after a save is
30
+ * what forgets it) and what the host says is not saved (EV_DRAFTS,
31
+ * replaced whole each time it is sent).
31
32
  *
32
33
  * WHAT ELSE IS HERE, and why each is here and not somewhere else:
33
34
  *
@@ -113,7 +114,7 @@ import {
113
114
  empty_string,
114
115
  } from "@yuneta/gobj-js";
115
116
  import {yui_tint} from "./bulma_tint.js";
116
- import {mark_host_drafts} from "./host_drafts.js";
117
+ import {host_draft_ids} from "./host_drafts.js";
117
118
 
118
119
  import "./c_yui_schema_editor.css";
119
120
 
@@ -207,6 +208,8 @@ let PRIVATE_DATA = {
207
208
  * version still has to move. */
208
209
  baseline: null, /* {topic id: topic_version} at load time */
209
210
  written: null, /* {topic id: true} */
211
+ host_drafts: null, /* {treedb: [topic names]}, the host's last EV_DRAFTS */
212
+ host_ids: null, /* host_drafts resolved on the model, {topic id: true} */
210
213
  order_undo: null, /* {topic id: [{id, order}]} before the first drag */
211
214
 
212
215
  /* Children and overlays */
@@ -250,6 +253,7 @@ function mt_create(gobj)
250
253
  priv.baseline = {};
251
254
  priv.written = {};
252
255
  priv.host_drafts = {}; /* what the host says is a draft, {treedb: [topics]} (EV_DRAFTS) */
256
+ priv.host_ids = {}; /* the same, as {topic id: true} of the model */
253
257
  priv.save_queue = [];
254
258
 
255
259
  build_ui(gobj);
@@ -564,6 +568,7 @@ function start_measuring(gobj)
564
568
 
565
569
  priv.baseline = {};
566
570
  priv.written = {};
571
+ priv.host_ids = {};
567
572
 
568
573
  if(!priv.model) {
569
574
  return;
@@ -573,29 +578,39 @@ function start_measuring(gobj)
573
578
  priv.baseline[topic.id] = topic.topic_version;
574
579
  }
575
580
  }
576
- /* A draft is a draft after a reload too: what the host said stays
577
- * marked (host_drafts.js) until the host says otherwise. */
578
- mark_host_drafts(priv.written, priv.model, priv.host_drafts);
581
+ /* A draft is a draft after a reload too: what the host said last
582
+ * is resolved against the new model (host_drafts.js), apart from
583
+ * what this session writes, until the host says otherwise. */
584
+ priv.host_ids = host_draft_ids(priv.model, priv.host_drafts);
579
585
  }
580
586
 
581
587
  /***************************************************************
582
- * "This topic was written in this session and is not saved."
588
+ * "This topic holds an edit that is not saved."
583
589
  *
584
590
  * An edit is a DRAFT: it moves no version and reaches no treedb
585
591
  * until the host saves it (C_TREEDB's save-schema, which raises the
586
- * versions of what changed) -- and the save makes the host send
587
- * EV_REFRESH, whose reload is what forgets the drafts.
592
+ * versions of what changed). Two sources say a topic is one, and
593
+ * they are kept APART because they are forgotten differently:
594
+ * - `written`: what THIS session wrote since the model was loaded
595
+ * (a reload, the host's EV_REFRESH, forgets it -- the store says
596
+ * what is left);
597
+ * - `host_ids`: what the host said last (EV_DRAFTS), replaced WHOLE
598
+ * by the next EV_DRAFTS. Folded into `written`, a topic the host
599
+ * named once stayed a draft after the Save that published it.
588
600
  *
589
601
  * ONE predicate, because it is asked in three places -- the topic
590
602
  * list draws a chip with it, the column view a banner, the export
591
603
  * a warning -- and copies of a rule are how they drift.
592
604
  ***************************************************************/
593
- function topic_is_draft(written, topic)
605
+ function topic_is_draft(priv, topic)
594
606
  {
595
- if(!topic || !written) {
607
+ if(!topic || !priv) {
596
608
  return false;
597
609
  }
598
- return written[topic.id]? true: false;
610
+ if(priv.written && priv.written[topic.id]) {
611
+ return true;
612
+ }
613
+ return (priv.host_ids && priv.host_ids[topic.id])? true: false;
599
614
  }
600
615
 
601
616
  /***************************************************************
@@ -1050,7 +1065,7 @@ function render_topics(gobj, $body)
1050
1065
  let twice = repeated_names(treedb.topics);
1051
1066
  let $rows = [];
1052
1067
  for(let topic of treedb.topics) {
1053
- let pending = topic_is_draft(priv.written, topic);
1068
+ let pending = topic_is_draft(priv, topic);
1054
1069
  /* Two topics of one name: the id is what tells them apart, and
1055
1070
  * it is what the url has to carry — addressing by name would
1056
1071
  * open the first one whichever row was clicked. */
@@ -1256,7 +1271,7 @@ function draft_banner(gobj, topic)
1256
1271
  {
1257
1272
  let priv = gobj.priv;
1258
1273
 
1259
- if(!topic_is_draft(priv.written, topic)) {
1274
+ if(!topic_is_draft(priv, topic)) {
1260
1275
  return null;
1261
1276
  }
1262
1277
  if(is_readonly(gobj)) {
@@ -1997,7 +2012,7 @@ function open_report(gobj, findings, treedb_id)
1997
2012
  function open_export(gobj, treedb)
1998
2013
  {
1999
2014
  let priv = gobj.priv;
2000
- let drafts = treedb.topics.some((topic) => topic_is_draft(priv.written, topic));
2015
+ let drafts = treedb.topics.some((topic) => topic_is_draft(priv, topic));
2001
2016
  let c_text = schema_to_c(treedb);
2002
2017
  let json_text = JSON.stringify(schema_to_json(treedb), null, 4);
2003
2018
 
@@ -2762,20 +2777,24 @@ function ac_refresh(gobj, event, kw, src)
2762
2777
 
2763
2778
  /***************************************************************
2764
2779
  * {drafts: {treedb_name: [topic names]}} -- the host says which
2765
- * topics hold a draft in __system__ that the file in use does not
2766
- * have (C_TREEDB's saved-schema, `draft_changed`). The mark of a
2767
- * write lived in this session's memory only, so a reload of the
2768
- * page, a reconnect or a refresh showed no draft while __system__
2769
- * still differed from the file (N13 of the 2026-09-22 review).
2770
- * Replaces what the host said before; what this session wrote
2771
- * stays marked.
2780
+ * topics hold a draft in __system__ that is not saved (C_TREEDB's
2781
+ * saved-schema, `draft_changed`). The mark of a write lived in this
2782
+ * session's memory only, so a reload of the page, a reconnect or a
2783
+ * refresh showed no draft while __system__ still differed (N13 of
2784
+ * the 2026-09-22 review).
2785
+ *
2786
+ * REPLACES what the host said before -- the host is the truth after
2787
+ * a reload, and a Save is exactly the host saying "none any more".
2788
+ * It only ever added, and a topic stayed a draft after the Save that
2789
+ * published it (M1 of the 2026-09-23 review). What this session wrote
2790
+ * stays marked: it is kept apart (see topic_is_draft()).
2772
2791
  ***************************************************************/
2773
2792
  function ac_drafts(gobj, event, kw, src)
2774
2793
  {
2775
2794
  let priv = gobj.priv;
2776
2795
 
2777
2796
  priv.host_drafts = (kw && kw.drafts && typeof kw.drafts === "object")? kw.drafts: {};
2778
- mark_host_drafts(priv.written, priv.model, priv.host_drafts);
2797
+ priv.host_ids = host_draft_ids(priv.model, priv.host_drafts);
2779
2798
  if(priv.model) {
2780
2799
  render(gobj);
2781
2800
  }
@@ -43,6 +43,8 @@ import {
43
43
  createElement2, empty_string, is_object, is_array, is_string,
44
44
  refresh_language,
45
45
  is_gobj,
46
+ gobj_has_event,
47
+ gobj_is_destroying,
46
48
  } from "@yuneta/gobj-js";
47
49
 
48
50
  import {
@@ -174,6 +176,10 @@ let PRIVATE_DATA = {
174
176
  * backend-connected state. State is host/event-driven (unlike
175
177
  * the avatar provider it is a setter, not a pull callback). */
176
178
  conn_nodes: [],
179
+ /* The last state yui_shell_set_connection_state() was told, null
180
+ * while nobody told it anything: EV_CONNECTION_STATE goes out on an
181
+ * EDGE, not on every call. */
182
+ connected: null,
177
183
  /* Currently open toolbar dropdown panel, if any. Tracked here
178
184
  * so a second click on any trigger (or programmatic close) can
179
185
  * tear down the previous one through the same code path. */
@@ -3033,11 +3039,34 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
3033
3039
  /*---------------------------------------------*
3034
3040
  * Global methods table
3035
3041
  *---------------------------------------------*/
3042
+ /***************************************************************
3043
+ * EV_CONNECTION_STATE is an OPTIONAL fact: a view that waits for a
3044
+ * backend answer subscribes to it by name. An app that subscribed
3045
+ * to EVERY event of its shell with one attr (`subscriber`) did not
3046
+ * ask for it and does not declare it -- published to it anyway, the
3047
+ * event would be an FSM error in the app on every connection edge,
3048
+ * and adding it to the shell a breaking change for every such app.
3049
+ * So it reaches only the subscribers that declare it. Every other
3050
+ * event of the shell goes out as before.
3051
+ ***************************************************************/
3052
+ function mt_publication_pre_filter(gobj, subs, event, kw)
3053
+ {
3054
+ if(event !== "EV_CONNECTION_STATE") {
3055
+ return 1;
3056
+ }
3057
+ let subscriber = subs && subs.subscriber;
3058
+ if(!subscriber || !gobj_has_event(subscriber, event, 0)) {
3059
+ return 0;
3060
+ }
3061
+ return 1;
3062
+ }
3063
+
3036
3064
  const gmt = {
3037
- mt_create: mt_create,
3038
- mt_start: mt_start,
3039
- mt_stop: mt_stop,
3040
- mt_destroy: mt_destroy
3065
+ mt_create: mt_create,
3066
+ mt_start: mt_start,
3067
+ mt_stop: mt_stop,
3068
+ mt_destroy: mt_destroy,
3069
+ mt_publication_pre_filter: mt_publication_pre_filter
3041
3070
  };
3042
3071
 
3043
3072
  function create_gclass(gclass_name)
@@ -3088,6 +3117,13 @@ function create_gclass(gclass_name)
3088
3117
  * re-renders its own translated parts. */
3089
3118
  ["EV_LANGUAGE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
3090
3119
  |event_flag_t.EVF_PUBLIC_EVENT
3120
+ |event_flag_t.EVF_NO_WARN_SUBS],
3121
+
3122
+ /* The app's backend connection went up or down
3123
+ * (yui_shell_set_connection_state), {connected}. Delivered only
3124
+ * to the subscribers that declare it: see
3125
+ * mt_publication_pre_filter(). */
3126
+ ["EV_CONNECTION_STATE", event_flag_t.EVF_OUTPUT_EVENT
3091
3127
  |event_flag_t.EVF_NO_WARN_SUBS]
3092
3128
  ];
3093
3129
 
@@ -3552,14 +3588,31 @@ function yui_shell_language_changed(shell_gobj)
3552
3588
  ************************************************************/
3553
3589
  function yui_shell_set_connection_state(shell_gobj, connected)
3554
3590
  {
3591
+ if(!shell_gobj || !is_gobj(shell_gobj) || gobj_is_destroying(shell_gobj)) {
3592
+ return;
3593
+ }
3555
3594
  let priv = shell_gobj.priv;
3556
- if(!priv || !is_array(priv.conn_nodes)) {
3595
+ if(!priv) {
3557
3596
  return;
3558
3597
  }
3559
3598
  let on = !!connected;
3560
- for(let $n of priv.conn_nodes) {
3561
- $n.classList.toggle("is-connected", on);
3562
- $n.classList.toggle("is-disconnected", !on);
3599
+ let changed = (priv.connected !== on);
3600
+ priv.connected = on;
3601
+ if(is_array(priv.conn_nodes)) {
3602
+ for(let $n of priv.conn_nodes) {
3603
+ $n.classList.toggle("is-connected", on);
3604
+ $n.classList.toggle("is-disconnected", !on);
3605
+ }
3606
+ }
3607
+ /* The fact goes out as well, for the views: a write a view sent
3608
+ * before the backend dropped is never answered, and the view that
3609
+ * waits for it (a topic form's Save) learns the drop from HERE --
3610
+ * it holds no subscription of its own to the transport, because
3611
+ * subscribing to a C_IEVENT_CLI forwards the subscription to the
3612
+ * backend. Only an EDGE is published: the app calls this on every
3613
+ * reconnect attempt too. */
3614
+ if(changed) {
3615
+ gobj_publish_event(shell_gobj, "EV_CONNECTION_STATE", {connected: on});
3563
3616
  }
3564
3617
  }
3565
3618
 
@@ -99,7 +99,8 @@ import {t} from "i18next";
99
99
  import {yui_toolbar_icon} from "./yui_toolbar.js";
100
100
 
101
101
  import {
102
- plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb
102
+ plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb,
103
+ instance_keys_of
103
104
  } from "./treedb_write_plan.js";
104
105
  import {
105
106
  yui_file_read, yui_files_manifest, yui_file_id_label, yui_file_size_label
@@ -110,6 +111,7 @@ import {
110
111
  import {delete_impact} from "./delete_impact.js";
111
112
 
112
113
  import "./c_yui_treedb_topic_with_form.css";
114
+ import {yui_shell_show_error} from "./shell_modals.js";
113
115
  import {set_toolbar_busy} from "./form_busy.js";
114
116
  import "./tabulator.css";
115
117
  import {cell_text, hook_cell_spec} from "./table_cell_text.js";
@@ -2394,6 +2396,10 @@ function open_form_dialog(gobj, mode, record)
2394
2396
  style: 'display:flex; flex-direction:column; height:100%;'}, []]
2395
2397
  );
2396
2398
 
2399
+ /* The instance this form writes to, kept as the record HAD it: the
2400
+ * widget is not trusted with an address (instance_keys_of()). */
2401
+ priv.form_instance = (form_mode === "update")? instance_keys_of(record, desc) : null;
2402
+
2397
2403
  let form_plan = write_plan(gobj);
2398
2404
  let form = gobj_create_pure_child(
2399
2405
  "form_" + clean_name(gobj_name(gobj)),
@@ -2507,6 +2513,7 @@ function teardown_form_child(gobj)
2507
2513
  priv.reading_files = 0; // a read in flight lands on nobody
2508
2514
  priv.awaiting_write = 0; // and so does the answer of a write
2509
2515
  priv.form_busy = {busy: false}; // its toolbar goes with it
2516
+ priv.form_instance = null;
2510
2517
  if(priv.form) {
2511
2518
  if(gobj_is_running(priv.form)) {
2512
2519
  gobj_stop(priv.form);
@@ -3506,6 +3513,10 @@ function transform__form_value_2_treedb_value(gobj, col, value, operation)
3506
3513
  case "string":
3507
3514
  if(value && is_date(value)) {
3508
3515
  value = value.toISOString();
3516
+ } else if(typeof value === "number" && value > 0) {
3517
+ /* The form hands an epoch in seconds (form_time_value);
3518
+ * a string column stores the instant as ISO text. */
3519
+ value = new Date(value * 1000).toISOString();
3509
3520
  }
3510
3521
  break;
3511
3522
  case "integer":
@@ -4092,6 +4103,9 @@ function ac_confirmed(gobj, event, kw, src)
4092
4103
  * them): never replaced by the row that took their place. */
4093
4104
  log_error(`${gobj_short_name(gobj)}: ${event}: rows gone while ` +
4094
4105
  `confirming the delete, not deleted: ${found.missing.join(", ")}`);
4106
+ /* The person who answered "yes" is told, not only the log
4107
+ * (a low of the 2026-09-22 review). */
4108
+ yui_shell_show_error(yui_shell_of(gobj), "some records were gone before the delete", {t: t});
4095
4109
  }
4096
4110
  if(!found.rows.length) {
4097
4111
  return -1; /* Error already logged, or nothing was named */
@@ -4305,6 +4319,11 @@ function publish_treedb_write(gobj, mode, record, jn_files, form_write)
4305
4319
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
4306
4320
  record: strip_read_only_cols(gobj, record)
4307
4321
  };
4322
+ if(mode !== "create" && gobj.priv.form_instance) {
4323
+ /* The pkey2 names the instance: the value the record had, not
4324
+ * the one that came back through a datetime-local. */
4325
+ Object.assign(kw_write.record, gobj.priv.form_instance);
4326
+ }
4308
4327
  if(form_write) {
4309
4328
  /* The serial the host echoes back with the answer */
4310
4329
  kw_write.form_write = form_write;
@@ -156,10 +156,11 @@ let __gclass__ = null;
156
156
  ***************************************************************/
157
157
  function mt_create(gobj)
158
158
  {
159
- /* The writes of topic forms sent and not yet answered, by serial
160
- * (form_writes_in_flight): answered refused when the transport
161
- * closes under them. */
159
+ /* The writes of topic forms sent and not yet answered, by topic
160
+ * and serial (form_writes_in_flight): answered refused when the
161
+ * transport closes under them. */
162
162
  gobj.priv.form_writes = {};
163
+ gobj.priv.conn_shell = null; /* the shell whose EV_CONNECTION_STATE we hear */
163
164
 
164
165
  build_ui(gobj);
165
166
 
@@ -178,6 +179,17 @@ function mt_create(gobj)
178
179
  ***************************************************************/
179
180
  function mt_start(gobj)
180
181
  {
182
+ /* The app's backend connection, as the app tells its shell
183
+ * (yui_shell_set_connection_state). A write in flight when it drops
184
+ * is never answered, and its form waited for ever in every host
185
+ * that did not forward EV_TRANSPORT_STATE -- wattyzer and yunovatios
186
+ * among them (M8 of the 2026-09-23 review). The transport itself is
187
+ * not subscribed to: see ac_transport_state(). */
188
+ let shell = yui_shell_of(gobj);
189
+ if(shell) {
190
+ gobj_subscribe_event(shell, "EV_CONNECTION_STATE", {}, gobj);
191
+ gobj.priv.conn_shell = shell;
192
+ }
181
193
  request_treedb_descs(gobj);
182
194
  }
183
195
 
@@ -186,6 +198,14 @@ function mt_start(gobj)
186
198
  ***************************************************************/
187
199
  function mt_stop(gobj)
188
200
  {
201
+ let priv = gobj.priv;
202
+ if(priv.conn_shell) {
203
+ if(!gobj_is_destroying(priv.conn_shell)) {
204
+ gobj_unsubscribe_event(priv.conn_shell, "EV_CONNECTION_STATE", {}, gobj);
205
+ }
206
+ priv.conn_shell = null;
207
+ }
208
+
189
209
  /* Retire our site-map sub-routes so a torn-down view leaves no
190
210
  * stale children in the map (ROUTING.md contributor). */
191
211
  let shell = yui_shell_of(gobj);
@@ -2077,7 +2097,7 @@ function ac_mt_command_answer(gobj, event, kw, src)
2077
2097
  {topic_name: failed_topic}, gobj);
2078
2098
  }
2079
2099
  settle_form_write(gobj.priv.form_writes,
2080
- kw_get_int(gobj, kw_command, "form_write", 0, 0));
2100
+ kw_get_int(gobj, kw_command, "form_write", 0, 0), failed_topic);
2081
2101
  answer_form_write(
2082
2102
  gobj,
2083
2103
  get_gobj_formtable(gobj, failed_topic),
@@ -2177,7 +2197,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2177
2197
  command: command
2178
2198
  });
2179
2199
  settle_form_write(gobj.priv.form_writes,
2180
- kw_get_int(gobj, kw_command, "form_write", 0, 0));
2200
+ kw_get_int(gobj, kw_command, "form_write", 0, 0),
2201
+ kw_get_str(gobj, kw_command, "topic_name", "", 0));
2181
2202
  answer_form_write(
2182
2203
  gobj,
2183
2204
  get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)),
@@ -2413,11 +2434,19 @@ function ac_back_to_topics(gobj, event, kw, src)
2413
2434
  }
2414
2435
 
2415
2436
  /************************************************************
2416
- * The host (C_TREEDB_VIEW) forwards the backend transport edges here so the
2417
- * toolbar can disable the JSON viewers the moment the session drops (and
2418
- * re-enable them on reconnect) — the library view must not subscribe to the
2419
- * C_IEVENT_CLI itself (that forwards the subscription upstream and breaks
2420
- * the session).
2437
+ * A backend transport edge, from either of two places:
2438
+ * - EV_TRANSPORT_STATE, forwarded by a host that keeps one
2439
+ * transport per view (gui_treedb's C_TREEDB_VIEW, gui_agent's
2440
+ * C_AGENT_TREEDB_VIEW);
2441
+ * - EV_CONNECTION_STATE, published by the SHELL when the app tells
2442
+ * it its backend went up or down (yui_shell_set_connection_state),
2443
+ * which is what every single-backend app already does for the
2444
+ * toolbar dot. No host has to remember anything for this one.
2445
+ * Both can arrive for one edge; what is done here is idempotent.
2446
+ * The toolbar disables the JSON viewers the moment the session drops
2447
+ * (and re-enables them on reconnect). The library view must not
2448
+ * subscribe to the C_IEVENT_CLI itself: that forwards the subscription
2449
+ * upstream and breaks the session.
2421
2450
  ************************************************************/
2422
2451
  function ac_transport_state(gobj, event, kw, src)
2423
2452
  {
@@ -3042,6 +3071,7 @@ function create_gclass(gclass_name)
3042
3071
  ["EV_HIDE", ac_hide, null],
3043
3072
  ["EV_BACK_TO_TOPICS", ac_back_to_topics, null],
3044
3073
  ["EV_TRANSPORT_STATE", ac_transport_state, null],
3074
+ ["EV_CONNECTION_STATE", ac_transport_state, null],
3045
3075
  ]]
3046
3076
  ];
3047
3077
 
@@ -3077,6 +3107,7 @@ function create_gclass(gclass_name)
3077
3107
  ["EV_HIDE", 0],
3078
3108
  ["EV_BACK_TO_TOPICS", 0],
3079
3109
  ["EV_TRANSPORT_STATE", 0],
3110
+ ["EV_CONNECTION_STATE", 0],
3080
3111
  ["EV_TOPIC_SELECTED",
3081
3112
  event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS]
3082
3113
  ];
@@ -6,6 +6,29 @@
6
6
  * them, and they must be each other's inverse: a writable time
7
7
  * column travels back on EVERY save of the record.
8
8
  *
9
+ * KNOWN LIMIT -- the change of hour. A `datetime-local` holds a
10
+ * LOCAL WALL time and no offset, and a wall time is not always
11
+ * one instant:
12
+ *
13
+ * - the night the clocks go back (autumn), one hour happens
14
+ * twice. "02:30" names two instants an hour apart, and
15
+ * `new Date("YYYY-MM-DDT02:30:00")` answers the EARLIER one
16
+ * (ECMAScript's "compatible" disambiguation). An epoch in the
17
+ * SECOND pass of that hour is shown as 02:30 and comes back
18
+ * one hour earlier: saving the record -- any field of it --
19
+ * moves a writable time column that holds it by -3600 s.
20
+ * - the night the clocks go forward (spring), one hour does not
21
+ * exist. No epoch is shown in it, so nothing read comes back
22
+ * wrong; a wall time TYPED in it is moved forward by the
23
+ * browser.
24
+ *
25
+ * Not fixable here without the offset, which the input cannot
26
+ * carry. What is safe: a read-only time column never goes back
27
+ * (treedb_write_plan.js, col_goes_back_to_treedb), and a time
28
+ * pkey2 goes back as the record had it, never through this
29
+ * widget (instance_keys_of). A writable time column edited or
30
+ * merely saved inside the repeated hour is the exposed case.
31
+ *
9
32
  * Copyright (c) 2026, ArtGins.
10
33
  * All Rights Reserved.
11
34
  ****************************************************************************/
@@ -2,18 +2,33 @@
2
2
  * form_writes_in_flight.js
3
3
  *
4
4
  * The writes of topic forms a C_YUI_TREEDB_TOPICS view has sent
5
- * and not yet answered, by their `form_write` serial. A form that
6
- * waits for its answer (form_waits_for_answer) stays open and
7
- * busy until the host answers, so the host has to answer EVERY
8
- * way a write ends -- and the transport closing under a write in
9
- * flight, or a command that never left because there was no
10
- * session, are two of them that nobody answered: the form stayed
11
- * busy for ever (N8 of the 2026-09-22 review).
5
+ * and not yet answered. A form that waits for its answer
6
+ * (form_waits_for_answer) stays open and busy until the host
7
+ * answers, so the host has to answer EVERY way a write ends --
8
+ * and the transport closing under a write in flight, or a
9
+ * command that never left because there was no session, are two
10
+ * of them that nobody answered: the form stayed busy for ever
11
+ * (N8 of the 2026-09-22 review).
12
+ *
13
+ * A write is known by its TOPIC and its `form_write` serial,
14
+ * never by the serial alone: each topic has its own form, and
15
+ * each form counts its serials from 1. Keyed by the serial, two
16
+ * forms with a write in flight at once shared the key "1", the
17
+ * first answer settled both, and the transport closing then
18
+ * answered only one of them.
12
19
  *
13
20
  * Copyright (c) 2026, ArtGins.
14
21
  * All Rights Reserved.
15
22
  ***********************************************************************/
16
23
 
24
+ /************************************************************
25
+ * The key of one write: `<topic>^<serial>`.
26
+ ************************************************************/
27
+ function form_write_key(topic_name, form_write)
28
+ {
29
+ return `${topic_name || ""}^${form_write}`;
30
+ }
31
+
17
32
  /************************************************************
18
33
  * A write left: remember whose it is
19
34
  ************************************************************/
@@ -22,18 +37,21 @@ function track_form_write(in_flight, form_write, topic_name)
22
37
  if(!in_flight || !form_write) {
23
38
  return;
24
39
  }
25
- in_flight[form_write] = topic_name || "";
40
+ in_flight[form_write_key(topic_name, form_write)] = {
41
+ form_write: form_write,
42
+ topic_name: topic_name || ""
43
+ };
26
44
  }
27
45
 
28
46
  /************************************************************
29
47
  * A write was answered, one way or the other
30
48
  ************************************************************/
31
- function settle_form_write(in_flight, form_write)
49
+ function settle_form_write(in_flight, form_write, topic_name)
32
50
  {
33
51
  if(!in_flight || !form_write) {
34
52
  return;
35
53
  }
36
- delete in_flight[form_write];
54
+ delete in_flight[form_write_key(topic_name, form_write)];
37
55
  }
38
56
 
39
57
  /************************************************************
@@ -46,9 +64,10 @@ function abandon_form_writes(in_flight)
46
64
  if(!in_flight) {
47
65
  return abandoned;
48
66
  }
49
- Object.keys(in_flight).forEach((serial) => {
50
- abandoned.push({form_write: Number(serial), topic_name: in_flight[serial]});
51
- delete in_flight[serial];
67
+ Object.keys(in_flight).forEach((key) => {
68
+ const w = in_flight[key];
69
+ abandoned.push({form_write: Number(w.form_write), topic_name: w.topic_name});
70
+ delete in_flight[key];
52
71
  });
53
72
  return abandoned;
54
73
  }
@@ -11,8 +11,23 @@ describe("form writes in flight", () => {
11
11
  const in_flight = {};
12
12
  track_form_write(in_flight, 1, "users");
13
13
  track_form_write(in_flight, 2, "roles");
14
- settle_form_write(in_flight, 1);
15
- expect(Object.keys(in_flight)).toEqual(["2"]);
14
+ settle_form_write(in_flight, 1, "users");
15
+ expect(abandon_form_writes(in_flight)).toEqual([
16
+ {form_write: 2, topic_name: "roles"},
17
+ ]);
18
+ });
19
+
20
+ it("two forms, each at its serial 1: two writes, not one", () => {
21
+ /* Each topic's form counts its serials from 1. Keyed by the serial
22
+ * alone, the answer of one form settled the other's write, and a
23
+ * transport closing then left that one busy for ever. */
24
+ const in_flight = {};
25
+ track_form_write(in_flight, 1, "users");
26
+ track_form_write(in_flight, 1, "roles");
27
+ settle_form_write(in_flight, 1, "users");
28
+ expect(abandon_form_writes(in_flight)).toEqual([
29
+ {form_write: 1, topic_name: "roles"},
30
+ ]);
16
31
  });
17
32
 
18
33
  it("the transport closes: every write in flight is abandoned, once (N8)", () => {
@@ -39,7 +54,7 @@ describe("form writes in flight", () => {
39
54
 
40
55
  it("no map: nothing to do", () => {
41
56
  expect(() => track_form_write(null, 1, "x")).not.toThrow();
42
- expect(() => settle_form_write(null, 1)).not.toThrow();
57
+ expect(() => settle_form_write(null, 1, "x")).not.toThrow();
43
58
  expect(abandon_form_writes(null)).toEqual([]);
44
59
  });
45
60
  });
@@ -10,17 +10,42 @@
10
10
  * __system__ still differed from the file in use (N13 of the
11
11
  * 2026-09-22 review). The host reads that difference from
12
12
  * C_TREEDB's `saved-schema` (`draft_changed`, by treedb) and
13
- * hands it here; the editor applies it to its model.
13
+ * hands it here; the editor applies it to its model, in place
14
+ * of what the host said before.
15
+ *
16
+ * What `draft_changed` measures depends on the node: the draft
17
+ * against the SAVED schema on a node newer than yunetas 7.25.3,
18
+ * and against the file IN USE on a 7.25.3 node -- where a topic saved and
19
+ * not yet applied is still named, and the editor, which cannot
20
+ * tell the two apart, shows it as a draft until the Apply.
14
21
  *
15
22
  * Copyright (c) 2026, ArtGins.
16
23
  * All Rights Reserved.
17
24
  ***********************************************************************/
18
25
 
26
+ /************************************************************
27
+ * The topics of `model` the host names as drafts, as
28
+ * {topic id: true}: `drafts` is {treedb_name: [topic names]}. A
29
+ * treedb or a topic the model does not hold is ignored.
30
+ *
31
+ * What the editor keeps is THIS, apart from what its own session
32
+ * wrote, and replaced whole on every EV_DRAFTS: the host is the
33
+ * truth after a reload. Folded into the session's marks, a topic the
34
+ * host once named stayed a draft after the save that published it.
35
+ ************************************************************/
36
+ function host_draft_ids(model, drafts)
37
+ {
38
+ const ids = {};
39
+ mark_host_drafts(ids, model, drafts);
40
+ return ids;
41
+ }
42
+
19
43
  /************************************************************
20
44
  * Mark in `written` the topics of `model` the host names as
21
45
  * drafts: `drafts` is {treedb_name: [topic names]}. A treedb or a
22
46
  * topic the model does not hold is ignored. Returns how many were
23
- * marked.
47
+ * marked. It only ADDS: a caller that must forget what the host
48
+ * no longer names starts from an empty map (host_draft_ids()).
24
49
  ************************************************************/
25
50
  function mark_host_drafts(written, model, drafts)
26
51
  {
@@ -63,6 +88,7 @@ function drafts_of_saved_answer(rows, into)
63
88
  }
64
89
 
65
90
  export {
91
+ host_draft_ids,
66
92
  mark_host_drafts,
67
93
  drafts_of_saved_answer,
68
94
  };