@yuneta/gobj-ui 6.1.0 → 6.1.1

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
  }
@@ -1552,7 +1614,10 @@ function open_column_form(gobj, topic, col, prefill)
1552
1614
  let record = prefill || (col && col.record) || {};
1553
1615
  let creating = !col;
1554
1616
  let type = record.type || "string";
1555
- let hook = col_hook(record) || {};
1617
+ let hook = col_hook(record);
1618
+ if(is_empty_value(hook)) {
1619
+ hook = {};
1620
+ }
1556
1621
  let hook_topic = Object.keys(hook)[0] || "";
1557
1622
  let hook_col = hook_topic ? hook[hook_topic] : "";
1558
1623
  let treedb = current_treedb(gobj);
@@ -1686,7 +1751,7 @@ function open_topic_form(gobj, treedb, topic)
1686
1751
  col_names.length > 0 ? col_names : [record.pkey || "id"])),
1687
1752
  field("SCHEMA_TOPIC_FORM_PKEY2S", "pkey2s", "secondary keys",
1688
1753
  text_input("pkey2s", stringify_field(record.pkey2s), ""),
1689
- "the column a record is KNOWN by when its key is composed"),
1754
+ "the column a record is known by when its key is composed"),
1690
1755
  field("SCHEMA_TOPIC_FORM_SYSTEM_FLAG", "system_flag", "system flag",
1691
1756
  text_input("system_flag", record.system_flag || "sf_string_key", "")),
1692
1757
  field("SCHEMA_TOPIC_FORM_TKEY", "tkey", "time key",
@@ -1888,7 +1953,7 @@ function open_import(gobj, treedb)
1888
1953
  ["span", {i18n: "preview"}, t("preview")]]],
1889
1954
  ["button", {class: "SCHEMA_IMPORT_RUN button is-small is-warning",
1890
1955
  type: "button", disabled: "disabled"},
1891
- [["span", {class: "icon"}, [["i", {class: "yi-file-import"}]]],
1956
+ [["span", {class: "icon"}, [["i", {class: "yi-upload"}]]],
1892
1957
  ["span", {i18n: "import"}, t("import")]]]
1893
1958
  ]]
1894
1959
  ]]
@@ -2112,13 +2177,18 @@ function run_next_write(gobj)
2112
2177
  return end_writes(gobj, "");
2113
2178
  }
2114
2179
  let write = priv.save_queue[0];
2115
- let command = (write.op === "create") ? "create-node"
2116
- : (write.op === "delete") ? "delete-node" : "update-node";
2180
+
2181
+ /* Which command and which options: three words that decide whether
2182
+ * a write does what it says, and each one wrong in a way that
2183
+ * succeeds. They live in schema_write_options.js, with the reasons
2184
+ * and the tests. */
2185
+ let command = write_command(write.op);
2186
+ let options = write_options(write.op);
2117
2187
 
2118
2188
  if(remote_command(gobj, command, {
2119
2189
  topic_name: write.topic_name,
2120
2190
  record: write.record,
2121
- options: {},
2191
+ options: options,
2122
2192
  record_id: write.record.id || ""
2123
2193
  }) < 0) {
2124
2194
  return end_writes(gobj, t("cannot reach the treedb"));
@@ -2198,7 +2268,14 @@ function column_record(gobj, topic, col, values)
2198
2268
  record.header = values.header || "";
2199
2269
  record.type = values.type || "string";
2200
2270
  record.flag = flags;
2201
- record.fillspace = values.fillspace === "" ? "" : Number(values.fillspace);
2271
+ /* An empty number field is ABSENT, not zero: sending "" stored a
2272
+ * fillspace of 0 and the column's schema default (10) never
2273
+ * applied. */
2274
+ if(String(values.fillspace).trim() !== "") {
2275
+ record.fillspace = Number(values.fillspace);
2276
+ } else if(!creating) {
2277
+ record.fillspace = ""; /* cleared on purpose */
2278
+ }
2202
2279
  record.placeholder = values.placeholder || "";
2203
2280
  record.description = values.description || "";
2204
2281
  record.default = values.default || "";
@@ -2359,7 +2436,14 @@ function ac_mt_command_answer(gobj, event, kw, src)
2359
2436
  let __command__ = msg_iev_get_stack(gobj, kw, "command_stack", true);
2360
2437
  let command = kw_get_str(gobj, __command__, "command", "", 0);
2361
2438
  let kw_command = kw_get_dict(gobj, __command__, "kw", {}, 0);
2362
- let md = kw_get_dict(gobj, kw_command, "__md_command__", {}, 0);
2439
+
2440
+ /* The frame carries either the whole request (a direct transport)
2441
+ * or the `__md_command__` this view sent (a routing adapter). Both
2442
+ * answer the same questions under the same names, so unwrap one
2443
+ * level when it is there and read from whichever it is. */
2444
+ /* Not kw_get_dict with a null default: it answers Object(null), which
2445
+ * is `{}` and truthy, so the fallback would never run. */
2446
+ let md = is_object(kw_command.__md_command__) ? kw_command.__md_command__ : kw_command;
2363
2447
 
2364
2448
  if(kw_get_str(gobj, md, "purpose", "", 0) !== PURPOSE) {
2365
2449
  return 0; /* another panel's answer on a shared transport */
@@ -2388,15 +2472,14 @@ function ac_mt_command_answer(gobj, event, kw, src)
2388
2472
  return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2389
2473
  }
2390
2474
 
2391
- if(command === "create-node" || command === "update-node" || command === "delete-node") {
2392
- let topic_name = kw_get_str(gobj, kw_command, "topic_name", "", 0);
2475
+ if(command === "update-node" || command === "delete-node") {
2476
+ let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2393
2477
  if(result < 0) {
2394
2478
  return end_writes(gobj, comment || t("the treedb refused the write"));
2395
2479
  }
2396
2480
  priv.save_wrote = true;
2397
2481
  if(command === "delete-node") {
2398
- forget_record(gobj, topic_name,
2399
- kw_get_str(gobj, kw_command, "record_id", "", 0));
2482
+ forget_record(gobj, topic_name, kw_get_str(gobj, md, "record_id", "", 0));
2400
2483
  } else {
2401
2484
  let record = is_object(data) ? data
2402
2485
  : (Array.isArray(data) && is_object(data[0]) ? data[0] : null);
@@ -2412,8 +2495,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
2412
2495
  }
2413
2496
  }
2414
2497
  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);
2498
+ /* Which record it was is in the QUEUE, not in the answer: an
2499
+ * adapter echoes only what it was asked to echo, and the
2500
+ * record is not small enough to be worth echoing. */
2501
+ let write = priv.save_queue[0];
2502
+ mark_written(gobj, topic_name, write ? write.record : null);
2417
2503
  }
2418
2504
  priv.save_queue.shift();
2419
2505
  return run_next_write(gobj);
@@ -2927,7 +3013,7 @@ function ac_preview_import(gobj, event, kw, src)
2927
3013
  } catch(e) {
2928
3014
  priv.import_plan = null;
2929
3015
  render_import_plan(gobj, {writes: [], summary: {},
2930
- conflicts: [{code: "that is not JSON", topic: "", col: ""}]});
3016
+ conflicts: [{code: "that is not json", topic: "", col: ""}]});
2931
3017
  return -1;
2932
3018
  }
2933
3019
 
@@ -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
+ });
@@ -32,6 +32,7 @@ import {
32
32
  col_enum,
33
33
  topic_pkey2s,
34
34
  as_json,
35
+ is_empty_value,
35
36
  } from "./schema_model.js";
36
37
 
37
38
 
@@ -60,9 +61,12 @@ const COL_KEY_ORDER = [
60
61
  * hand-written schema without looking foreign. */
61
62
  const VERSION_KEYS = ["schema_version", "topic_version"];
62
63
 
63
- /* Storage-only: they say where the record lives, not what it declares. */
64
- const COL_SKIP = ["id", "value", "topics", "order", "__md_treedb__"];
65
- const TOPIC_SKIP = ["id", "value", "treedbs", "cols", "order", "__md_treedb__"];
64
+ /* Storage-only: they say where the record lives, not what it declares.
65
+ * `_geometry` is here as a FIELD — where the graph editor put this
66
+ * record's box and it is not the same thing as a column NAMED
67
+ * `_geometry`, which the .c literals do declare and which stays. */
68
+ const COL_SKIP = ["id", "value", "topics", "order", "_geometry", "__md_treedb__"];
69
+ const TOPIC_SKIP = ["id", "value", "treedbs", "cols", "order", "_geometry", "__md_treedb__"];
66
70
 
67
71
  /* Fields whose stored form may be the JSON text of the value. */
68
72
  const JSON_FIELDS = ["enum", "hook", "default", "template", "properties", "pkey2s"];
@@ -149,10 +153,14 @@ function schema_to_json(treedb)
149
153
  if(TOPIC_SKIP.indexOf(key) >= 0) {
150
154
  continue;
151
155
  }
152
- if(value === null || value === undefined || value === "") {
156
+ if(is_empty_value(value)) {
153
157
  continue;
154
158
  }
155
- jn_topic[key] = JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
159
+ let read = JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
160
+ if(is_empty_value(read)) {
161
+ continue;
162
+ }
163
+ jn_topic[key] = read;
156
164
  }
157
165
  if(jn_topic.topic_version !== undefined) {
158
166
  jn_topic.topic_version = version(jn_topic.topic_version);
@@ -175,10 +183,14 @@ function schema_to_json(treedb)
175
183
  if(COL_SKIP.indexOf(key) >= 0) {
176
184
  continue;
177
185
  }
178
- if(value === null || value === undefined || value === "") {
186
+ if(is_empty_value(value)) {
179
187
  continue;
180
188
  }
181
- jn_col[key] = JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
189
+ let read = JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
190
+ if(is_empty_value(read)) {
191
+ continue;
192
+ }
193
+ jn_col[key] = read;
182
194
  }
183
195
  let flags = col_flags(col_record);
184
196
  if(flags.length > 0) {
@@ -187,8 +199,10 @@ function schema_to_json(treedb)
187
199
  delete jn_col.flag;
188
200
  }
189
201
  let hook = col_hook(col_record);
190
- if(hook) {
202
+ if(hook && !is_empty_value(hook)) {
191
203
  jn_col.hook = hook;
204
+ } else {
205
+ delete jn_col.hook;
192
206
  }
193
207
  let e = col_enum(col_record);
194
208
  if(e.length > 0) {