@yuneta/gobj-ui 6.1.0 → 6.1.2

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.
@@ -120,6 +120,7 @@ import {
120
120
  col_flags,
121
121
  col_hook,
122
122
  col_enum,
123
+ is_empty_value,
123
124
  fkey_ref,
124
125
  next_order,
125
126
  moved_orders,
@@ -129,6 +130,7 @@ import {topic_descs} from "./schema_descs.js";
129
130
  import {schema_to_json, schema_to_c} from "./schema_to_c.js";
130
131
  import {plan_import, plan_deletes} from "./schema_import.js";
131
132
  import {grouped_flags, toggle_flag} from "./schema_flags.js";
133
+ import {write_command, write_options} from "./schema_write_options.js";
132
134
  import {COL_TYPES} from "./schema_validate.js";
133
135
 
134
136
  import {yui_shell_of} from "./c_yui_shell.js";
@@ -488,10 +490,23 @@ function remote_command(gobj, command, kw)
488
490
  service: treedb_name,
489
491
  treedb_name: treedb_name
490
492
  }, kw || {});
491
- full_kw.__md_command__ = Object.assign({
493
+
494
+ /* What has to come BACK with the answer.
495
+ *
496
+ * Its keys repeat the ones the command already carries, on purpose:
497
+ * the transport may be a direct C_IEVENT_CLI, which echoes the whole
498
+ * request as the command_stack frame, or a routing adapter, which
499
+ * echoes `__md_command__` AS that frame (c_agent_treedb_link.js).
500
+ * Same names in both places is what makes the answer readable
501
+ * whichever one is under this view — and `record_id` is here and
502
+ * NOT at the top level because a delete answers with nothing, and a
503
+ * parameter the treedb does not know has no business travelling. */
504
+ full_kw.__md_command__ = {
492
505
  purpose: PURPOSE,
493
- command: command
494
- }, kw && kw.topic_name ? {topic_name: kw.topic_name} : {});
506
+ topic_name: (kw && kw.topic_name) || "",
507
+ record_id: (kw && kw.record_id) || ""
508
+ };
509
+ delete full_kw.record_id;
495
510
 
496
511
  let ret = gobj_command(remote, command, full_kw, gobj);
497
512
  if(ret) {
@@ -743,12 +758,12 @@ function render_toolbar(gobj)
743
758
  if(priv.treedb_id && has_model) {
744
759
  $right.push(toolbar_button("SCHEMA_DIAGRAM_BTN", "yi-hexagon-nodes",
745
760
  priv.diagram ? "topics" : "diagram", "EV_TOGGLE_DIAGRAM", false));
746
- $right.push(toolbar_button("SCHEMA_VALIDATE_BTN", "yi-circle-check",
761
+ $right.push(toolbar_button("SCHEMA_VALIDATE_BTN", "yi-square-check",
747
762
  "check", "EV_VALIDATE", false));
748
- $right.push(toolbar_button("SCHEMA_EXPORT_BTN", "yi-file-export",
763
+ $right.push(toolbar_button("SCHEMA_EXPORT_BTN", "yi-download",
749
764
  "export", "EV_EXPORT", false));
750
765
  if(!readonly) {
751
- $right.push(toolbar_button("SCHEMA_IMPORT_BTN", "yi-file-import",
766
+ $right.push(toolbar_button("SCHEMA_IMPORT_BTN", "yi-upload",
752
767
  "import", "EV_IMPORT", false));
753
768
  }
754
769
  }
@@ -944,17 +959,23 @@ function render_topics(gobj, $body)
944
959
  }
945
960
 
946
961
  let readonly = is_readonly(gobj);
962
+ let twice = repeated_names(treedb.topics);
947
963
  let $rows = [];
948
964
  for(let topic of treedb.topics) {
949
965
  let pending = priv.written[topic.id] &&
950
966
  String(priv.baseline[topic.id]) === String(topic.topic_version);
967
+ /* Two topics of one name: the id is what tells them apart, and
968
+ * it is what the url has to carry — addressing by name would
969
+ * open the first one whichever row was clicked. */
970
+ let ambiguous = !!twice[topic.name];
971
+ let address = ambiguous ? topic.id : topic.name;
951
972
 
952
973
  let $actions = [];
953
974
  if(!readonly) {
954
975
  $actions.push(row_icon(gobj, "SCHEMA_TOPIC_EDIT", "yi-pen", "edit topic",
955
- "EV_EDIT_TOPIC", {topic: topic.name}));
976
+ "EV_EDIT_TOPIC", {topic: address}));
956
977
  $actions.push(row_icon(gobj, "SCHEMA_TOPIC_DELETE", "yi-trash", "delete topic",
957
- "EV_DELETE_TOPIC", {topic: topic.name}));
978
+ "EV_DELETE_TOPIC", {topic: address}));
958
979
  }
959
980
 
960
981
  $rows.push(["tr", {class: "SCHEMA_TOPIC_ROW", role: "button", tabindex: "0"}, [
@@ -963,6 +984,10 @@ function render_topics(gobj, $body)
963
984
  topic.system_topic
964
985
  ? ["span", {class: "SCHEMA_TOPIC_SYSTEM tag is-light ml-2",
965
986
  i18n: "system"}, t("system")]
987
+ : ["span", {}, ""],
988
+ ambiguous
989
+ ? ["div", {class: "SCHEMA_TOPIC_ID is-size-7 has-text-grey"},
990
+ [["code", {}, `${topic.id}`]]]
966
991
  : ["span", {}, ""]
967
992
  ]],
968
993
  ["td", {class: "SCHEMA_TOPIC_PKEY"}, [["code", {}, `${topic.pkey || "id"}`]]],
@@ -977,14 +1002,14 @@ function render_topics(gobj, $body)
977
1002
  if(evt.target.closest(".SCHEMA_TOPIC_ACTIONS")) {
978
1003
  return; /* a row action, not the row */
979
1004
  }
980
- gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: topic.name}, gobj);
1005
+ gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: address}, gobj);
981
1006
  },
982
1007
  keydown: (evt) => {
983
1008
  if(evt.key !== "Enter" && evt.key !== " ") {
984
1009
  return;
985
1010
  }
986
1011
  evt.preventDefault();
987
- gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: topic.name}, gobj);
1012
+ gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: address}, gobj);
988
1013
  }
989
1014
  }]);
990
1015
  }
@@ -1029,6 +1054,31 @@ function row_icon(gobj, logical, icon, key, event, kw)
1029
1054
  }];
1030
1055
  }
1031
1056
 
1057
+ /***************************************************************
1058
+ * Which names appear more than once.
1059
+ *
1060
+ * A store can hold TWO GENERATIONS of the same schema: the
1061
+ * projector never deletes, so a treedb re-keyed by a newer SDK
1062
+ * keeps its old rowid-keyed rows next to the qualified ones, and
1063
+ * both are children of the same treedb with the same name. Drawn
1064
+ * as two identical rows that behave differently, that reads as a
1065
+ * bug in the editor; drawn with what tells them apart, it reads
1066
+ * as what it is.
1067
+ ***************************************************************/
1068
+ function repeated_names(list)
1069
+ {
1070
+ let seen = {};
1071
+ let twice = {};
1072
+
1073
+ for(let entry of list) {
1074
+ if(seen[entry.name]) {
1075
+ twice[entry.name] = true;
1076
+ }
1077
+ seen[entry.name] = true;
1078
+ }
1079
+ return twice;
1080
+ }
1081
+
1032
1082
  /***************************************************************
1033
1083
  * ST_COLUMNS — the columns of one topic, in schema order.
1034
1084
  *
@@ -1062,9 +1112,10 @@ function render_columns(gobj, $body)
1062
1112
  $wrap.appendChild($banner);
1063
1113
  }
1064
1114
 
1115
+ let twice = repeated_names(topic.cols);
1065
1116
  let $rows = [];
1066
1117
  for(let i = 0; i < topic.cols.length; i++) {
1067
- $rows.push(column_row(gobj, topic, topic.cols[i], i, readonly));
1118
+ $rows.push(column_row(gobj, topic, topic.cols[i], i, readonly, twice));
1068
1119
  }
1069
1120
 
1070
1121
  let $table = createElement2(
@@ -1133,7 +1184,7 @@ function version_banner(gobj, topic)
1133
1184
  title: t("raise the version"), "aria-label": t("raise the version"),
1134
1185
  "data-i18n-title": "raise the version",
1135
1186
  "data-i18n-aria-label": "raise the version"}, [
1136
- ["span", {class: "icon"}, [["i", {class: "yi-arrow-up"}]]],
1187
+ ["span", {class: "icon"}, [["i", {class: "yi-chevron-up"}]]],
1137
1188
  ["span", {i18n: "raise"}, t("raise")]
1138
1189
  ]]
1139
1190
  ]]
@@ -1152,9 +1203,13 @@ function version_banner(gobj, topic)
1152
1203
  * Yuneta event is plain JSON, so a DOM node never travels in
1153
1204
  * one.
1154
1205
  ***************************************************************/
1155
- function column_row(gobj, topic, col, index, readonly)
1206
+ function column_row(gobj, topic, col, index, readonly, twice)
1156
1207
  {
1157
1208
  let record = col.record || {};
1209
+ /* Same two generations as the topics above: when a name is not
1210
+ * unique the id is what addresses the row. */
1211
+ let ambiguous = !!(twice && twice[col.name]);
1212
+ let address = ambiguous ? col.id : col.name;
1158
1213
  let flags = col_flags(record);
1159
1214
  let hook = col_hook(record);
1160
1215
  let is_pkey = (col.name === (topic.pkey || "id"));
@@ -1163,8 +1218,11 @@ function column_row(gobj, topic, col, index, readonly)
1163
1218
  return ["span", {class: `SCHEMA_COL_FLAG tag is-light ${flag_style(flag)}`}, `${flag}`];
1164
1219
  });
1165
1220
 
1221
+ /* An unset `blob` column comes back as `{}`, which is an object and
1222
+ * is not a hook: read as one, every column in the topic draws an
1223
+ * arrow pointing at nothing. */
1166
1224
  let link = "";
1167
- if(hook) {
1225
+ if(hook && !is_empty_value(hook)) {
1168
1226
  link = "→ " + Object.keys(hook).join(", ");
1169
1227
  } else if(flags.indexOf("fkey") >= 0) {
1170
1228
  link = "↖";
@@ -1173,11 +1231,11 @@ function column_row(gobj, topic, col, index, readonly)
1173
1231
  let $actions = [];
1174
1232
  if(!readonly) {
1175
1233
  $actions.push(row_icon(gobj, "SCHEMA_COL_EDIT", "yi-pen", "edit column",
1176
- "EV_EDIT_COLUMN", {col: col.name}));
1234
+ "EV_EDIT_COLUMN", {col: address}));
1177
1235
  $actions.push(row_icon(gobj, "SCHEMA_COL_DUPLICATE", "yi-copy", "duplicate column",
1178
- "EV_DUPLICATE_COLUMN", {col: col.name}));
1236
+ "EV_DUPLICATE_COLUMN", {col: address}));
1179
1237
  $actions.push(row_icon(gobj, "SCHEMA_COL_DELETE", "yi-trash", "delete column",
1180
- "EV_DELETE_COLUMN", {col: col.name}));
1238
+ "EV_DELETE_COLUMN", {col: address}));
1181
1239
  }
1182
1240
 
1183
1241
  let $row = createElement2(
@@ -1188,6 +1246,10 @@ function column_row(gobj, topic, col, index, readonly)
1188
1246
  [["i", {class: "yi-grip-vertical"}]]]]],
1189
1247
  ["td", {class: "SCHEMA_COL_NAME"}, [
1190
1248
  ["code", {}, `${col.name}`],
1249
+ ambiguous
1250
+ ? ["span", {class: "SCHEMA_COL_ID is-size-7 has-text-grey ml-2"},
1251
+ [["code", {}, `${col.id}`]]]
1252
+ : ["span", {}, ""],
1191
1253
  is_pkey
1192
1254
  ? ["span", {class: "SCHEMA_COL_PKEY tag is-info is-light ml-2",
1193
1255
  i18n: "pkey"}, t("pkey")]
@@ -1210,7 +1272,7 @@ function column_row(gobj, topic, col, index, readonly)
1210
1272
  if(readonly || evt.target.closest(".SCHEMA_COL_ACTIONS")) {
1211
1273
  return;
1212
1274
  }
1213
- gobj_send_event(gobj, "EV_EDIT_COLUMN", {col: col.name}, gobj);
1275
+ gobj_send_event(gobj, "EV_EDIT_COLUMN", {col: address}, gobj);
1214
1276
  });
1215
1277
  return $row;
1216
1278
  }
@@ -1327,9 +1389,11 @@ function render_diagram(gobj)
1327
1389
  subscriber: gobj,
1328
1390
  descs: descs,
1329
1391
  /* No `node_route`: this diagram lives inside the editor and
1330
- * a node click is answered here, not by a hash the shell
1331
- * would have to own. */
1332
- node_route: "",
1392
+ * a node click is answered HERE, not by a hash the shell
1393
+ * would have to own — which is what `with_node_click` asks
1394
+ * for, and why EV_NODE_CLICK is declared in this FSM. */
1395
+ node_route: "",
1396
+ with_node_click: true,
1333
1397
  system: true
1334
1398
  },
1335
1399
  gobj
@@ -1552,7 +1616,10 @@ function open_column_form(gobj, topic, col, prefill)
1552
1616
  let record = prefill || (col && col.record) || {};
1553
1617
  let creating = !col;
1554
1618
  let type = record.type || "string";
1555
- let hook = col_hook(record) || {};
1619
+ let hook = col_hook(record);
1620
+ if(is_empty_value(hook)) {
1621
+ hook = {};
1622
+ }
1556
1623
  let hook_topic = Object.keys(hook)[0] || "";
1557
1624
  let hook_col = hook_topic ? hook[hook_topic] : "";
1558
1625
  let treedb = current_treedb(gobj);
@@ -1686,7 +1753,7 @@ function open_topic_form(gobj, treedb, topic)
1686
1753
  col_names.length > 0 ? col_names : [record.pkey || "id"])),
1687
1754
  field("SCHEMA_TOPIC_FORM_PKEY2S", "pkey2s", "secondary keys",
1688
1755
  text_input("pkey2s", stringify_field(record.pkey2s), ""),
1689
- "the column a record is KNOWN by when its key is composed"),
1756
+ "the column a record is known by when its key is composed"),
1690
1757
  field("SCHEMA_TOPIC_FORM_SYSTEM_FLAG", "system_flag", "system flag",
1691
1758
  text_input("system_flag", record.system_flag || "sf_string_key", "")),
1692
1759
  field("SCHEMA_TOPIC_FORM_TKEY", "tkey", "time key",
@@ -1888,7 +1955,7 @@ function open_import(gobj, treedb)
1888
1955
  ["span", {i18n: "preview"}, t("preview")]]],
1889
1956
  ["button", {class: "SCHEMA_IMPORT_RUN button is-small is-warning",
1890
1957
  type: "button", disabled: "disabled"},
1891
- [["span", {class: "icon"}, [["i", {class: "yi-file-import"}]]],
1958
+ [["span", {class: "icon"}, [["i", {class: "yi-upload"}]]],
1892
1959
  ["span", {i18n: "import"}, t("import")]]]
1893
1960
  ]]
1894
1961
  ]]
@@ -2112,13 +2179,18 @@ function run_next_write(gobj)
2112
2179
  return end_writes(gobj, "");
2113
2180
  }
2114
2181
  let write = priv.save_queue[0];
2115
- let command = (write.op === "create") ? "create-node"
2116
- : (write.op === "delete") ? "delete-node" : "update-node";
2182
+
2183
+ /* Which command and which options: three words that decide whether
2184
+ * a write does what it says, and each one wrong in a way that
2185
+ * succeeds. They live in schema_write_options.js, with the reasons
2186
+ * and the tests. */
2187
+ let command = write_command(write.op);
2188
+ let options = write_options(write.op);
2117
2189
 
2118
2190
  if(remote_command(gobj, command, {
2119
2191
  topic_name: write.topic_name,
2120
2192
  record: write.record,
2121
- options: {},
2193
+ options: options,
2122
2194
  record_id: write.record.id || ""
2123
2195
  }) < 0) {
2124
2196
  return end_writes(gobj, t("cannot reach the treedb"));
@@ -2198,7 +2270,14 @@ function column_record(gobj, topic, col, values)
2198
2270
  record.header = values.header || "";
2199
2271
  record.type = values.type || "string";
2200
2272
  record.flag = flags;
2201
- record.fillspace = values.fillspace === "" ? "" : Number(values.fillspace);
2273
+ /* An empty number field is ABSENT, not zero: sending "" stored a
2274
+ * fillspace of 0 and the column's schema default (10) never
2275
+ * applied. */
2276
+ if(String(values.fillspace).trim() !== "") {
2277
+ record.fillspace = Number(values.fillspace);
2278
+ } else if(!creating) {
2279
+ record.fillspace = ""; /* cleared on purpose */
2280
+ }
2202
2281
  record.placeholder = values.placeholder || "";
2203
2282
  record.description = values.description || "";
2204
2283
  record.default = values.default || "";
@@ -2359,7 +2438,14 @@ function ac_mt_command_answer(gobj, event, kw, src)
2359
2438
  let __command__ = msg_iev_get_stack(gobj, kw, "command_stack", true);
2360
2439
  let command = kw_get_str(gobj, __command__, "command", "", 0);
2361
2440
  let kw_command = kw_get_dict(gobj, __command__, "kw", {}, 0);
2362
- let md = kw_get_dict(gobj, kw_command, "__md_command__", {}, 0);
2441
+
2442
+ /* The frame carries either the whole request (a direct transport)
2443
+ * or the `__md_command__` this view sent (a routing adapter). Both
2444
+ * answer the same questions under the same names, so unwrap one
2445
+ * level when it is there and read from whichever it is. */
2446
+ /* Not kw_get_dict with a null default: it answers Object(null), which
2447
+ * is `{}` and truthy, so the fallback would never run. */
2448
+ let md = is_object(kw_command.__md_command__) ? kw_command.__md_command__ : kw_command;
2363
2449
 
2364
2450
  if(kw_get_str(gobj, md, "purpose", "", 0) !== PURPOSE) {
2365
2451
  return 0; /* another panel's answer on a shared transport */
@@ -2388,15 +2474,14 @@ function ac_mt_command_answer(gobj, event, kw, src)
2388
2474
  return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2389
2475
  }
2390
2476
 
2391
- if(command === "create-node" || command === "update-node" || command === "delete-node") {
2392
- let topic_name = kw_get_str(gobj, kw_command, "topic_name", "", 0);
2477
+ if(command === "update-node" || command === "delete-node") {
2478
+ let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2393
2479
  if(result < 0) {
2394
2480
  return end_writes(gobj, comment || t("the treedb refused the write"));
2395
2481
  }
2396
2482
  priv.save_wrote = true;
2397
2483
  if(command === "delete-node") {
2398
- forget_record(gobj, topic_name,
2399
- kw_get_str(gobj, kw_command, "record_id", "", 0));
2484
+ forget_record(gobj, topic_name, kw_get_str(gobj, md, "record_id", "", 0));
2400
2485
  } else {
2401
2486
  let record = is_object(data) ? data
2402
2487
  : (Array.isArray(data) && is_object(data[0]) ? data[0] : null);
@@ -2412,8 +2497,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
2412
2497
  }
2413
2498
  }
2414
2499
  if(topic_name === T_TOPICS || topic_name === T_COLS) {
2415
- let touched = kw_get_dict(gobj, kw_command, "record", {}, 0);
2416
- mark_written(gobj, topic_name, touched);
2500
+ /* Which record it was is in the QUEUE, not in the answer: an
2501
+ * adapter echoes only what it was asked to echo, and the
2502
+ * record is not small enough to be worth echoing. */
2503
+ let write = priv.save_queue[0];
2504
+ mark_written(gobj, topic_name, write ? write.record : null);
2417
2505
  }
2418
2506
  priv.save_queue.shift();
2419
2507
  return run_next_write(gobj);
@@ -2927,7 +3015,7 @@ function ac_preview_import(gobj, event, kw, src)
2927
3015
  } catch(e) {
2928
3016
  priv.import_plan = null;
2929
3017
  render_import_plan(gobj, {writes: [], summary: {},
2930
- conflicts: [{code: "that is not JSON", topic: "", col: ""}]});
3018
+ conflicts: [{code: "that is not json", topic: "", col: ""}]});
2931
3019
  return -1;
2932
3020
  }
2933
3021
 
@@ -94,6 +94,7 @@ const attrs_table = [
94
94
  SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
95
95
  SDATA(data_type_t.DTP_JSON, "descs", 0, null, "Treedb schema: {topic_name: desc}"),
96
96
  SDATA(data_type_t.DTP_STRING, "node_route", 0, "", "Hash-route template with a {topic} placeholder: a node click opens that topic (e.g. '#/topics/db/<sel>/{topic}')"),
97
+ SDATA(data_type_t.DTP_BOOLEAN, "with_node_click", 0, false, "With no `node_route`, PUBLISH a node click as EV_NODE_CLICK instead of dropping it. Opt-in, because a subscriber has to DECLARE the event: the hosts that route by hash, and the ones that want nothing, must not have it appear underneath them"),
97
98
  SDATA(data_type_t.DTP_BOOLEAN, "system", 0, false, "Include system topics (__*__) too"),
98
99
  SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTML element"),
99
100
  SDATA_END()
@@ -622,12 +623,19 @@ function destroy_graph(gobj)
622
623
  * With a `node_route` the click IS a navigation and this view
623
624
  * makes it: that is what the route was handed down for.
624
625
  *
625
- * Without one it is still an action, and it belongs to whoever
626
- * mounted this view — the schema editor draws the same picture
627
- * inside its own screens, where a topic opens in place and no
628
- * hash is involved. So the click is published rather than
629
- * dropped. A host that subscribes must DECLARE EV_NODE_CLICK in
630
- * its own FSM, as with every event a child publishes.
626
+ * Without one it is still an action, and it can belong to
627
+ * whoever mounted this view — the schema editor draws the same
628
+ * picture inside its own screens, where a topic opens in place
629
+ * and no hash is involved. That host asks for it with
630
+ * `with_node_click` and DECLARES EV_NODE_CLICK in its own FSM,
631
+ * as with every event a child publishes.
632
+ *
633
+ * It is opt-in and not the default for exactly that reason: the
634
+ * CHILD subscription model subscribes the host to ALL of this
635
+ * view's events, so publishing one unasked turns a click into
636
+ * "Event NOT DEFINED in state" in every host that never wanted
637
+ * it — the topics view that mounts this as its schema landing,
638
+ * and the offline demo that mounts it against a json file.
631
639
  ************************************************************/
632
640
  function ac_node_click(gobj, event, kw, src)
633
641
  {
@@ -638,7 +646,9 @@ function ac_node_click(gobj, event, kw, src)
638
646
  return 0;
639
647
  }
640
648
  if(!route) {
641
- gobj_publish_event(gobj, "EV_NODE_CLICK", {topic: topic});
649
+ if(gobj_read_bool_attr(gobj, "with_node_click")) {
650
+ gobj_publish_event(gobj, "EV_NODE_CLICK", {topic: topic});
651
+ }
642
652
  return 0;
643
653
  }
644
654
  let href = route.replace("{topic}", topic);
@@ -34,16 +34,18 @@ import {
34
34
  col_enum,
35
35
  topic_pkey2s,
36
36
  as_json,
37
+ is_empty_value,
37
38
  } from "./schema_model.js";
38
39
 
39
40
 
40
41
  /* Storage-only fields of a `cols` record: they describe where the
41
42
  * record LIVES in treedb_system_schema, not the column it declares.
42
43
  * The C validator drops the same ones (_treedb_create_topic_cols_desc). */
43
- const COL_STORAGE_FIELDS = ["id", "value", "topics", "order", "__md_treedb__"];
44
+ const COL_STORAGE_FIELDS = ["id", "value", "topics", "order", "_geometry", "__md_treedb__"];
44
45
 
45
46
  /* Same, one level up, for a `topics` record. */
46
- const TOPIC_STORAGE_FIELDS = ["id", "value", "treedbs", "cols", "order", "__md_treedb__"];
47
+ const TOPIC_STORAGE_FIELDS = ["id", "value", "treedbs", "cols", "order", "_geometry",
48
+ "__md_treedb__"];
47
49
 
48
50
  /* Fields of a column whose stored form is TEXT and whose desc form is
49
51
  * the value: they are `blob` columns of the `cols` topic. */
@@ -67,16 +69,22 @@ function col_desc(col)
67
69
  if(COL_STORAGE_FIELDS.indexOf(key) >= 0) {
68
70
  continue;
69
71
  }
70
- if(value === null || value === undefined || value === "") {
72
+ if(is_empty_value(value)) {
71
73
  continue;
72
74
  }
73
- desc[key] = COL_JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
75
+ let read = COL_JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
76
+ if(is_empty_value(read)) {
77
+ continue;
78
+ }
79
+ desc[key] = read;
74
80
  }
75
81
  desc.id = col.name;
76
82
  desc.flag = col_flags(record);
77
83
  let hook = col_hook(record);
78
- if(hook) {
84
+ if(hook && !is_empty_value(hook)) {
79
85
  desc.hook = hook;
86
+ } else {
87
+ delete desc.hook;
80
88
  }
81
89
  let e = col_enum(record);
82
90
  if(e.length > 0) {
@@ -105,7 +113,7 @@ function topic_descs(treedb)
105
113
  if(TOPIC_STORAGE_FIELDS.indexOf(key) >= 0) {
106
114
  continue;
107
115
  }
108
- if(value === null || value === undefined || value === "") {
116
+ if(is_empty_value(value)) {
109
117
  continue;
110
118
  }
111
119
  desc[key] = value;
@@ -143,3 +143,28 @@ describe("edges", () => {
143
143
  expect(topic_descs({})).toEqual({});
144
144
  });
145
145
  });
146
+
147
+ describe("the empty collections the store answers with", () => {
148
+ const model = build_schema_model({
149
+ treedbs: [{id: "db"}],
150
+ topics: [{id: "db.t", value: "t", _geometry: {}, treedbs: ["treedbs^db^topics"]}],
151
+ cols: [{id: "db.t.id", value: "id", type: "string",
152
+ enum: {}, hook: {}, default: {}, _geometry: {},
153
+ topics: ["topics^db.t^cols"]}],
154
+ });
155
+ const descs = topic_descs(model.treedbs[0]);
156
+
157
+ test("an unset blob does not become a value in the desc", () => {
158
+ for(const key of ["enum", "hook", "default", "_geometry"]) {
159
+ expect(key in descs.t.cols[0]).toBe(false);
160
+ }
161
+ });
162
+
163
+ test("an empty hook is NOT a hook — the info panel would draw an arrow to nothing", () => {
164
+ expect(descs.t.cols[0].hook).toBeUndefined();
165
+ });
166
+
167
+ test("the topic record's own _geometry does not travel", () => {
168
+ expect("_geometry" in descs.t).toBe(false);
169
+ });
170
+ });
@@ -29,6 +29,7 @@ import {
29
29
  col_hook,
30
30
  col_enum,
31
31
  as_json,
32
+ is_empty_value,
32
33
  fkey_ref,
33
34
  } from "./schema_model.js";
34
35
 
@@ -66,7 +67,7 @@ function same_value(field, a, b)
66
67
  list = list.filter(f => typeof f === "string" && f.length > 0).sort();
67
68
  return JSON.stringify(list);
68
69
  }
69
- if(v === null || v === undefined || v === "") {
70
+ if(is_empty_value(v)) {
70
71
  return "";
71
72
  }
72
73
  if(typeof v === "object") {
@@ -86,10 +87,14 @@ function incoming_col_fields(col)
86
87
 
87
88
  for(let field of COL_FIELDS) {
88
89
  let value = col ? col[field] : undefined;
89
- if(value === undefined || value === null || value === "") {
90
+ if(is_empty_value(value)) {
90
91
  continue;
91
92
  }
92
- out[field] = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
93
+ let read = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
94
+ if(is_empty_value(read)) {
95
+ continue;
96
+ }
97
+ out[field] = read;
93
98
  }
94
99
  if(Array.isArray(out.flag)) {
95
100
  out.flag = out.flag.filter(f => typeof f === "string" && f.length > 0);
@@ -108,10 +113,14 @@ function stored_col_fields(record)
108
113
 
109
114
  for(let field of COL_FIELDS) {
110
115
  let value = record ? record[field] : undefined;
111
- if(value === undefined || value === null || value === "") {
116
+ if(is_empty_value(value)) {
112
117
  continue;
113
118
  }
114
- out[field] = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
119
+ let read = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
120
+ if(is_empty_value(read)) {
121
+ continue;
122
+ }
123
+ out[field] = read;
115
124
  }
116
125
  let flags = col_flags(record);
117
126
  if(flags.length > 0) {
@@ -120,8 +129,10 @@ function stored_col_fields(record)
120
129
  delete out.flag;
121
130
  }
122
131
  let hook = col_hook(record);
123
- if(hook) {
132
+ if(hook && !is_empty_value(hook)) {
124
133
  out.hook = hook;
134
+ } else {
135
+ delete out.hook;
125
136
  }
126
137
  let e = col_enum(record);
127
138
  if(e.length > 0) {
@@ -232,3 +232,24 @@ describe("what is not a schema", () => {
232
232
  expect(plan_import(null, {topics: []}).conflicts[0].code).toBe("schema has no id");
233
233
  });
234
234
  });
235
+
236
+ describe("the empty collections the store answers with", () => {
237
+ test("a stored `{}` and an absent field are the same, so nothing is planned", () => {
238
+ /* Otherwise every import of an unchanged schema rewrites every
239
+ column to clear fields that were never set. */
240
+ const stored = build_schema_model({
241
+ treedbs: [{id: "db", schema_version: 1}],
242
+ topics: [{id: "db.t", value: "t", order: 1, pkey: "id", topic_version: 1,
243
+ treedbs: ["treedbs^db^topics"]}],
244
+ cols: [{id: "db.t.id", value: "id", order: 1, header: "Id", type: "string",
245
+ enum: {}, hook: {}, default: {}, template: {}, properties: {},
246
+ topics: ["topics^db.t^cols"]}],
247
+ }).treedbs[0];
248
+ const incoming = {
249
+ id: "db", schema_version: "1",
250
+ topics: [{id: "t", pkey: "id", topic_version: "1",
251
+ cols: {id: {header: "Id", type: "string"}}}]
252
+ };
253
+ expect(plan_import(stored, incoming).writes).toEqual([]);
254
+ });
255
+ });
@@ -294,6 +294,31 @@ function build_schema_model(records)
294
294
  };
295
295
  }
296
296
 
297
+ /***************************************************************
298
+ * Is there a value here at all?
299
+ *
300
+ * The store answers a `blob` column that was never set with an
301
+ * EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
302
+ * `default: {}`. Read as "a value", those turn every export into
303
+ * a schema full of empty objects — and `'hook': {}` in a literal
304
+ * is a hook with no mapping, which is a link the treedb builds
305
+ * and nothing writes.
306
+ ***************************************************************/
307
+ function is_empty_value(value)
308
+ {
309
+ if(value === null || value === undefined || value === "") {
310
+ return true;
311
+ }
312
+ if(Array.isArray(value)) {
313
+ return value.length === 0;
314
+ }
315
+ if(typeof value === "object") {
316
+ return Object.keys(value).length === 0;
317
+ }
318
+ return false;
319
+ }
320
+
321
+
297
322
  /***************************************************************
298
323
  * The fields of a column that are DECLARED as one thing and
299
324
  * STORED as another.
@@ -495,6 +520,7 @@ export {
495
520
  REF_SEP,
496
521
  parse_fkey_ref,
497
522
  as_json,
523
+ is_empty_value,
498
524
  col_flags,
499
525
  col_has_flag,
500
526
  col_hook,
@@ -22,6 +22,7 @@ import {
22
22
  fkey_ref,
23
23
  next_order,
24
24
  moved_orders,
25
+ is_empty_value,
25
26
  } from "./schema_model.js";
26
27
 
27
28
 
@@ -231,3 +232,27 @@ describe("writing helpers", () => {
231
232
  expect(moved_orders(list)).toEqual([{id: "a", order: 1}, {id: "b", order: 2}]);
232
233
  });
233
234
  });
235
+
236
+ describe("is_empty_value — what the store answers when a blob was never set", () => {
237
+ test("nothing is nothing", () => {
238
+ expect(is_empty_value(null)).toBe(true);
239
+ expect(is_empty_value(undefined)).toBe(true);
240
+ expect(is_empty_value("")).toBe(true);
241
+ });
242
+
243
+ test("an EMPTY COLLECTION is nothing too — this is the whole point", () => {
244
+ /* A `blob` column that was never set comes back as `{}`, not as
245
+ nothing. Read as a value it puts `'hook': {}` into an exported
246
+ literal, which is a hook with no mapping. */
247
+ expect(is_empty_value({})).toBe(true);
248
+ expect(is_empty_value([])).toBe(true);
249
+ });
250
+
251
+ test("anything with something in it is something", () => {
252
+ expect(is_empty_value({a: 1})).toBe(false);
253
+ expect(is_empty_value(["a"])).toBe(false);
254
+ expect(is_empty_value("x")).toBe(false);
255
+ expect(is_empty_value(0)).toBe(false);
256
+ expect(is_empty_value(false)).toBe(false);
257
+ });
258
+ });