@yuneta/gobj-ui 7.25.4 → 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.
@@ -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
  };
@@ -1,5 +1,5 @@
1
1
  import {describe, it, expect} from "vitest";
2
- import {mark_host_drafts, drafts_of_saved_answer} from "./host_drafts.js";
2
+ import {host_draft_ids, mark_host_drafts, drafts_of_saved_answer} from "./host_drafts.js";
3
3
 
4
4
  const model = {
5
5
  treedbs: [
@@ -60,3 +60,26 @@ describe("drafts_of_saved_answer", () => {
60
60
  expect(drafts_of_saved_answer(null)).toEqual({});
61
61
  });
62
62
  });
63
+
64
+ describe("host_draft_ids", () => {
65
+
66
+ it("resolves the host's names on the model, as topic ids", () => {
67
+ expect(host_draft_ids(model, {treedb_a: ["users"], treedb_b: ["users"]}))
68
+ .toEqual({"treedb_a^users": true, "treedb_b^users": true});
69
+ });
70
+
71
+ it("is a fresh map every time: what the host stopped naming is gone (M1)", () => {
72
+ /* mark_host_drafts() only ADDS, and the editor fed it the same map
73
+ * every time: a topic the host named before the Save stayed a
74
+ * draft after it. */
75
+ const before = host_draft_ids(model, {treedb_a: ["users", "roles"]});
76
+ const after = host_draft_ids(model, {treedb_a: []});
77
+ expect(before).toEqual({"treedb_a^users": true, "treedb_a^roles": true});
78
+ expect(after).toEqual({});
79
+ });
80
+
81
+ it("no model, no drafts: empty", () => {
82
+ expect(host_draft_ids(null, {treedb_a: ["users"]})).toEqual({});
83
+ expect(host_draft_ids(model, null)).toEqual({});
84
+ });
85
+ });
@@ -0,0 +1,176 @@
1
+ /***********************************************************************
2
+ * schema_editor_drafts.wiring.test.js
3
+ *
4
+ * EV_DRAFTS driven through a real C_YUI_SCHEMA_EDITOR on a document
5
+ * double. The helper was right and tested; the ACTION folded the
6
+ * host's marks into the session's, so a topic the host named before
7
+ * a Save stayed a draft after it (M1 of the 2026-09-23 review).
8
+ *
9
+ * Copyright (c) 2026, ArtGins.
10
+ * All Rights Reserved.
11
+ ***********************************************************************/
12
+ import {describe, test, expect, beforeAll, beforeEach} from "vitest";
13
+ import {install_dom_double} from "../test/dom_double.js";
14
+
15
+ install_dom_double();
16
+
17
+ const {
18
+ SDATA_END,
19
+ gclass_create,
20
+ gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
21
+ gobj_start, gobj_send_event, gobj_read_attr,
22
+ gobj_change_state,
23
+ set_log_callback,
24
+ } = await import("@yuneta/gobj-js");
25
+ const {register_c_yui_schema_editor, topic_is_draft} = await import("./c_yui_schema_editor.js");
26
+
27
+ const logged = [];
28
+ const commands = [];
29
+
30
+ /* The yuno's system schema: one treedb, two topics. */
31
+ const RECORDS = {
32
+ treedbs: [{id: "db", schema_version: 4}],
33
+ topics: [
34
+ {id: "db.users", value: "users", order: 1, pkey: "id", topic_version: 2,
35
+ treedbs: ["treedbs^db^topics"]},
36
+ {id: "db.roles", value: "roles", order: 2, pkey: "id", topic_version: 1,
37
+ treedbs: ["treedbs^db^topics"]},
38
+ ],
39
+ cols: [
40
+ {id: "db.users.id", value: "id", order: 1, type: "string",
41
+ flag: ["persistent", "required"], topics: ["topics^db.users^cols"]},
42
+ {id: "db.roles.id", value: "id", order: 1, type: "string",
43
+ flag: ["persistent", "required"], topics: ["topics^db.roles^cols"]},
44
+ ],
45
+ };
46
+
47
+ function remote_command_parser(gobj, command, kw, src)
48
+ {
49
+ commands.push({command, kw, src});
50
+ return null;
51
+ }
52
+
53
+ let yuno = null;
54
+
55
+ beforeAll(() => {
56
+ gobj_start_up(null, null, null, null, null, null, null);
57
+ set_log_callback((level, msg) => {
58
+ logged.push({level: String(level), msg: String(msg)});
59
+ });
60
+ gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
61
+ gclass_create(
62
+ "C_TEST_EDITOR_HOST",
63
+ [["EV_POSITION_CHANGED", 0], ["EV_RECORD_WRITTEN", 0], ["EV_SCHEMA_CHECKED", 0]],
64
+ [["ST_IDLE", [
65
+ ["EV_POSITION_CHANGED", () => 0, null],
66
+ ["EV_RECORD_WRITTEN", () => 0, null],
67
+ ["EV_SCHEMA_CHECKED", () => 0, null]
68
+ ]]],
69
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
70
+ );
71
+ gclass_create(
72
+ "C_TEST_REMOTE",
73
+ [],
74
+ [["ST_SESSION", []]],
75
+ {mt_command_parser: remote_command_parser},
76
+ 0, [SDATA_END()], {}, 0, 0, 0, 0
77
+ );
78
+ register_c_yui_schema_editor();
79
+ yuno = gobj_create_yuno("drafts_yuno", "C_TEST_HOST", {});
80
+ gobj_start(yuno);
81
+ });
82
+
83
+ beforeEach(() => {
84
+ logged.length = 0;
85
+ commands.length = 0;
86
+ });
87
+
88
+ /* Answer every `nodes` the editor asked, the way a transport does: the
89
+ * request's frame on top of the command_stack. */
90
+ function answer_the_load(editor, remote)
91
+ {
92
+ const asked = commands.filter((c) => c.command === "nodes");
93
+ commands.length = 0;
94
+ for(const c of asked) {
95
+ gobj_send_event(editor, "EV_MT_COMMAND_ANSWER", {
96
+ result: 0,
97
+ data: RECORDS[c.kw.topic_name],
98
+ __md_iev__: {command_stack: [{command: "nodes", kw: c.kw}]}
99
+ }, remote);
100
+ }
101
+ return asked.length;
102
+ }
103
+
104
+ function build(name)
105
+ {
106
+ const remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
107
+ gobj_change_state(remote, "ST_SESSION");
108
+ const host = gobj_create(`${name}_host`, "C_TEST_EDITOR_HOST", {}, yuno);
109
+ const editor = gobj_create(`${name}_editor`, "C_YUI_SCHEMA_EDITOR", {
110
+ gobj_remote_yuno: remote,
111
+ treedb_name: "treedb_system_schema",
112
+ base_route: "/schemas"
113
+ }, host);
114
+ gobj_start(editor);
115
+ expect(answer_the_load(editor, remote)).toBe(3);
116
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
117
+ return {editor, remote, host};
118
+ }
119
+
120
+ function drafts_on_screen(editor)
121
+ {
122
+ const $c = gobj_read_attr(editor, "$container");
123
+ return $c.querySelectorAll(".SCHEMA_TOPIC_VERSION .is-warning").length;
124
+ }
125
+
126
+ function is_draft(editor, topic_id)
127
+ {
128
+ return topic_is_draft(editor.priv, {id: topic_id});
129
+ }
130
+
131
+ describe("EV_DRAFTS: the host is the truth", () => {
132
+
133
+ test("the host names a topic: it is a draft, on screen too", () => {
134
+ const {editor, host} = build("d1");
135
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["users"]}}, host);
136
+ expect(is_draft(editor, "db.users")).toBe(true);
137
+ expect(is_draft(editor, "db.roles")).toBe(false);
138
+ expect(drafts_on_screen(editor)).toBe(1);
139
+ });
140
+
141
+ test("the host names none any more (a Save): the mark goes (M1)", () => {
142
+ /* It only ever ADDED: after the Save the editor still said
143
+ * "unsaved schema changes". */
144
+ const {editor, host} = build("d2");
145
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["users", "roles"]}}, host);
146
+ expect(drafts_on_screen(editor)).toBe(2);
147
+
148
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: []}}, host);
149
+ expect(is_draft(editor, "db.users")).toBe(false);
150
+ expect(is_draft(editor, "db.roles")).toBe(false);
151
+ expect(drafts_on_screen(editor)).toBe(0);
152
+ });
153
+
154
+ test("a reload keeps what the host said last, and only that", () => {
155
+ const {editor, remote, host} = build("d3");
156
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["roles"]}}, host);
157
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
158
+ expect(answer_the_load(editor, remote)).toBe(3);
159
+ expect(is_draft(editor, "db.roles")).toBe(true);
160
+ expect(is_draft(editor, "db.users")).toBe(false);
161
+ });
162
+
163
+ test("what this session wrote is not cleared by the host's answer", () => {
164
+ const {editor, host} = build("d4");
165
+ editor.priv.written["db.users"] = true; /* a write of this session */
166
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {}}, host);
167
+ expect(is_draft(editor, "db.users")).toBe(true);
168
+ });
169
+
170
+ test("no error logged on the way", () => {
171
+ const {editor, host} = build("d5");
172
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["users"]}}, host);
173
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {}}, host);
174
+ expect(logged.filter((l) => l.level === "error").map((l) => l.msg)).toEqual([]);
175
+ });
176
+ });