@yuneta/gobj-ui 7.23.195 → 7.23.197

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.
@@ -0,0 +1,301 @@
1
+ /***********************************************************************
2
+ * schema_to_diagram.js
3
+ *
4
+ * The graph of a treedb schema, drawn as text.
5
+ *
6
+ * It is the comment that heads every `treedb_schema_<db>.c`: one
7
+ * box per topic, in the order the schema declares them, a row per
8
+ * column, and every hook -> fkey link drawn as a line from the
9
+ * hook's row (`◀`) to the fkey's row. It is DERIVED from the
10
+ * schema, never drawn by hand, so the file that holds the literal
11
+ * can be replaced whole with an export and its picture is never
12
+ * behind it.
13
+ *
14
+ * Each link runs down its own LANE to the right of the boxes, the
15
+ * shorter links nearer to them, so no search is needed to lay it
16
+ * out: the only ambiguity left is where a line crosses a lane that
17
+ * is in use, and that is drawn as a junction.
18
+ *
19
+ * The input is the schema as its literal holds it (what
20
+ * schema_to_json() answers, or the parsed literal itself):
21
+ * {id, schema_version, topics: [{id, pkey2s, tkey, main_topic,
22
+ * cols: {name: {type, flag, hook}}}]}
23
+ *
24
+ * Copyright (c) 2026, ArtGins.
25
+ * All Rights Reserved.
26
+ ***********************************************************************/
27
+
28
+
29
+ /* Where the boxes start, and their narrowest inside: the widths of
30
+ * the diagrams drawn by hand before this, so a generated one reads
31
+ * like them. */
32
+ const INDENT = 8;
33
+ const MIN_INNER = 27;
34
+
35
+ /* From a box's right edge to its first lane, and between lanes. */
36
+ const LANE_GAP = 4;
37
+
38
+ const LEGEND = [
39
+ "{} dict hook (N unique children)",
40
+ "[] list hook (n not-unique children)",
41
+ "() string hook (1 unique child)",
42
+ "(↖) 1 fkey (1 parent)",
43
+ "[↖] n fkeys (n parents)",
44
+ "{↖} N fkeys (N parents)",
45
+ "",
46
+ "(2) pkey2 - secondary key",
47
+ "(t) tkey - time key",
48
+ "* field required",
49
+ "= field inherited",
50
+ ];
51
+
52
+ /* A cell of a line is a set of the directions it joins; the glyph is
53
+ * what those directions draw. */
54
+ const L = 1, R = 2, U = 4, D = 8;
55
+ const GLYPH = {
56
+ [L|R]: "─", [L]: "─", [R]: "─",
57
+ [U|D]: "│", [U]: "│", [D]: "│",
58
+ [R|D]: "┌", [L|D]: "┐", [R|U]: "└", [L|U]: "┘",
59
+ [L|R|D]: "┬", [L|R|U]: "┴", [U|D|R]: "├", [U|D|L]: "┤",
60
+ [L|R|U|D]: "┼",
61
+ };
62
+
63
+
64
+ /***************************************************************
65
+ * A value that may be one string or a list of them, as a list.
66
+ ***************************************************************/
67
+ function as_list(value)
68
+ {
69
+ if(typeof value === "string") {
70
+ return value ? [value] : [];
71
+ }
72
+ if(Array.isArray(value)) {
73
+ let list = [];
74
+ for(let v of value) {
75
+ list.push(...as_list(v));
76
+ }
77
+ return list;
78
+ }
79
+ if(value && typeof value === "object") {
80
+ let list = [];
81
+ for(let v of Object.values(value)) {
82
+ list.push(...as_list(v));
83
+ }
84
+ return list;
85
+ }
86
+ return [];
87
+ }
88
+
89
+ /***************************************************************
90
+ * The columns of a topic as [name, desc] in declared order: a
91
+ * dict in the literals, a list of {id} in some stores.
92
+ ***************************************************************/
93
+ function topic_cols(topic)
94
+ {
95
+ let cols = topic.cols || {};
96
+ if(Array.isArray(cols)) {
97
+ return cols.map((c) => [c.id, c]);
98
+ }
99
+ return Object.entries(cols);
100
+ }
101
+
102
+ /***************************************************************
103
+ * What a link column looks like at its end of the line.
104
+ ***************************************************************/
105
+ function hook_marker(type)
106
+ {
107
+ if(type === "dict" || type === "object") {
108
+ return "{}";
109
+ }
110
+ if(type === "array" || type === "list") {
111
+ return "[]";
112
+ }
113
+ return "()";
114
+ }
115
+
116
+ function fkey_marker(type)
117
+ {
118
+ if(type === "dict" || type === "object") {
119
+ return "{↖}";
120
+ }
121
+ if(type === "array" || type === "list") {
122
+ return "[↖]";
123
+ }
124
+ return "(↖)";
125
+ }
126
+
127
+ /***************************************************************
128
+ * One row of a box: the text inside it, and whether it is a
129
+ * link column (written against the right edge, where its line
130
+ * leaves the box).
131
+ ***************************************************************/
132
+ function col_row(topic, name, desc, pkey2s)
133
+ {
134
+ let flags = as_list(desc.flag);
135
+ if(flags.indexOf("hook") >= 0) {
136
+ return {text: `${name} ${hook_marker(desc.type)}`, link: true};
137
+ }
138
+ if(flags.indexOf("fkey") >= 0) {
139
+ return {text: `${name} ${fkey_marker(desc.type)}`, link: true};
140
+ }
141
+ let mark = flags.indexOf("required") >= 0 ? "*" :
142
+ flags.indexOf("inherit") >= 0 ? "=" : " ";
143
+ let text = `${mark} ${name}`;
144
+ if(pkey2s.indexOf(name) >= 0) {
145
+ text += " (2)";
146
+ }
147
+ if(topic.tkey && topic.tkey === name) {
148
+ text += " (t)";
149
+ }
150
+ return {text: text, link: false};
151
+ }
152
+
153
+ /***************************************************************
154
+ * schema_to_diagram(schema) -> the text, one string with a
155
+ * newline per line and no trailing spaces.
156
+ ***************************************************************/
157
+ function schema_to_diagram(schema)
158
+ {
159
+ if(!schema || !Array.isArray(schema.topics)) {
160
+ return "";
161
+ }
162
+
163
+ /*
164
+ * The boxes, and where each column landed
165
+ */
166
+ let boxes = [];
167
+ let inner = MIN_INNER;
168
+ for(let topic of schema.topics) {
169
+ let pkey2s = as_list(topic.pkey2s);
170
+ let rows = topic_cols(topic).map(([name, desc]) =>
171
+ Object.assign({name: name}, col_row(topic, name, desc || {}, pkey2s))
172
+ );
173
+ for(let row of rows) {
174
+ inner = Math.max(inner, row.text.length + 2);
175
+ }
176
+ let title = topic.id + (topic.main_topic ? " (main_topic)" : "");
177
+ inner = Math.max(inner, title.length);
178
+ boxes.push({topic: topic, title: title, rows: rows});
179
+ }
180
+
181
+ let lines = [];
182
+ let where = {}; /* "topic`col" -> line index */
183
+ let edge = INDENT + inner + 1;
184
+
185
+ lines.push(`${schema.id || ""} (schema_version ${schema.schema_version || "?"})`);
186
+ lines.push("");
187
+ for(let l of LEGEND) {
188
+ lines.push(l);
189
+ }
190
+ lines.push("");
191
+ lines.push("");
192
+
193
+ for(let box of boxes) {
194
+ let pad = Math.max(0, Math.floor((inner + 2 - box.title.length) / 2));
195
+ lines.push(" ".repeat(INDENT + pad) + box.title);
196
+ lines.push(" ".repeat(INDENT) + "┌" + "─".repeat(inner) + "┐");
197
+ for(let row of box.rows) {
198
+ let text = row.link ?
199
+ row.text.padStart(inner - 1) + " " :
200
+ row.text.padEnd(inner);
201
+ where[`${box.topic.id}\`${row.name}`] = lines.length;
202
+ lines.push(" ".repeat(INDENT) + "│" + text + "│");
203
+ }
204
+ lines.push(" ".repeat(INDENT) + "└" + "─".repeat(inner) + "┘");
205
+ lines.push("");
206
+ }
207
+
208
+ /*
209
+ * The links: from a hook's row to the row of the fkey it fills
210
+ */
211
+ let links = [];
212
+ for(let topic of schema.topics) {
213
+ for(let [name, desc] of topic_cols(topic)) {
214
+ desc = desc || {};
215
+ if(as_list(desc.flag).indexOf("hook") < 0) {
216
+ continue;
217
+ }
218
+ let hook = desc.hook;
219
+ if(!hook || typeof hook !== "object" || Array.isArray(hook)) {
220
+ continue;
221
+ }
222
+ for(let [child_topic, fkey_col] of Object.entries(hook)) {
223
+ let from = where[`${topic.id}\`${name}`];
224
+ let to = where[`${child_topic}\`${fkey_col}`];
225
+ if(from === undefined || to === undefined) {
226
+ continue;
227
+ }
228
+ links.push({from: from, to: to,
229
+ top: Math.min(from, to), bottom: Math.max(from, to)});
230
+ }
231
+ }
232
+ }
233
+
234
+ /* Shorter links nearer the boxes: a lane holds links whose rows
235
+ * do not overlap. */
236
+ links.sort((a, b) => (a.bottom - a.top) - (b.bottom - b.top) || a.top - b.top);
237
+ let lanes = [];
238
+ for(let link of links) {
239
+ let k = 0;
240
+ for(;; k++) {
241
+ let used = lanes[k] || [];
242
+ let free = used.every((o) => link.bottom < o.top || o.bottom < link.top);
243
+ if(free) {
244
+ break;
245
+ }
246
+ }
247
+ lanes[k] = (lanes[k] || []).concat([link]);
248
+ link.x = edge + LANE_GAP + LANE_GAP * k;
249
+ }
250
+
251
+ /*
252
+ * Draw them on a grid of direction bits, then as glyphs
253
+ */
254
+ let bits = {};
255
+ let fixed = {};
256
+ let join = (y, x, b) => {
257
+ let key = `${y},${x}`;
258
+ bits[key] = (bits[key] || 0) | b;
259
+ };
260
+ let hline = (y, x1, x2) => {
261
+ for(let x = x1; x <= x2; x++) {
262
+ join(y, x, (x > x1 ? L : 0) | (x < x2 ? R : 0));
263
+ }
264
+ };
265
+ let vline = (x, y1, y2) => {
266
+ for(let y = y1; y <= y2; y++) {
267
+ join(y, x, (y > y1 ? U : 0) | (y < y2 ? D : 0));
268
+ }
269
+ };
270
+ for(let link of links) {
271
+ hline(link.from, edge + 2, link.x);
272
+ hline(link.to, edge + 2, link.x);
273
+ vline(link.x, link.top, link.bottom);
274
+ fixed[`${link.from},${edge + 2}`] = "◀";
275
+ }
276
+
277
+ let out = lines.map((line, y) => {
278
+ let chars = Array.from(line);
279
+ for(let key of Object.keys(bits)) {
280
+ let [ky, kx] = key.split(",").map(Number);
281
+ if(ky !== y) {
282
+ continue;
283
+ }
284
+ while(chars.length <= kx) {
285
+ chars.push(" ");
286
+ }
287
+ chars[kx] = fixed[key] || GLYPH[bits[key]] || " ";
288
+ }
289
+ return chars.join("").replace(/\s+$/, "");
290
+ });
291
+
292
+ while(out.length && out[out.length - 1] === "") {
293
+ out.pop();
294
+ }
295
+ return out.join("\n");
296
+ }
297
+
298
+
299
+ export {
300
+ schema_to_diagram,
301
+ };
@@ -0,0 +1,107 @@
1
+ /***********************************************************************
2
+ * schema_to_diagram.test.js
3
+ *
4
+ * The graph of a schema as text, pinned on a small schema that has
5
+ * every case: a self-referent hook, a hook to another topic, one
6
+ * hook filling two topics, a pkey2, a tkey, required and inherited
7
+ * fields, and the main topic.
8
+ ***********************************************************************/
9
+ import { describe, test, expect } from "vitest";
10
+ import { schema_to_diagram } from "./schema_to_diagram.js";
11
+
12
+
13
+ const SCHEMA = {
14
+ id: "treedb_sample",
15
+ schema_version: "4",
16
+ topics: [
17
+ {
18
+ id: "departments",
19
+ pkey: "id",
20
+ main_topic: true,
21
+ cols: {
22
+ id: {type: "string", flag: ["persistent", "required"]},
23
+ departments: {type: "dict", flag: ["hook"], hook: {departments: "department_id"}},
24
+ department_id: {type: "string", flag: ["fkey"]},
25
+ users: {type: "dict", flag: ["hook"], hook: {users: "departments"}},
26
+ managers: {type: "list", flag: ["hook"],
27
+ hook: {users: "manager", departments: "manager"}},
28
+ manager: {type: "array", flag: ["fkey"]},
29
+ }
30
+ },
31
+ {
32
+ id: "users",
33
+ pkey: "id",
34
+ pkey2s: "name",
35
+ tkey: "tm",
36
+ cols: {
37
+ id: {type: "string", flag: "required"},
38
+ name: {type: "string", flag: ["persistent"]},
39
+ enabled: {type: "boolean", flag: ["persistent", "inherit"]},
40
+ tm: {type: "integer", flag: ["time"]},
41
+ departments: {type: "array", flag: ["fkey"]},
42
+ manager: {type: "array", flag: ["fkey"]},
43
+ }
44
+ },
45
+ ]
46
+ };
47
+
48
+ const EXPECTED = `treedb_sample (schema_version 4)
49
+
50
+ {} dict hook (N unique children)
51
+ [] list hook (n not-unique children)
52
+ () string hook (1 unique child)
53
+ (↖) 1 fkey (1 parent)
54
+ [↖] n fkeys (n parents)
55
+ {↖} N fkeys (N parents)
56
+
57
+ (2) pkey2 - secondary key
58
+ (t) tkey - time key
59
+ * field required
60
+ = field inherited
61
+
62
+
63
+ departments (main_topic)
64
+ ┌───────────────────────────┐
65
+ │* id │
66
+ │ departments {} │ ◀─┐
67
+ │ department_id (↖) │ ──┘
68
+ │ users {} │ ◀─────┐
69
+ │ managers [] │ ◀─┬───┼───┐
70
+ │ manager [↖] │ ──┘ │ │
71
+ └───────────────────────────┘ │ │
72
+ │ │
73
+ users │ │
74
+ ┌───────────────────────────┐ │ │
75
+ │* id │ │ │
76
+ │ name (2) │ │ │
77
+ │= enabled │ │ │
78
+ │ tm (t) │ │ │
79
+ │ departments [↖] │ ──────┘ │
80
+ │ manager [↖] │ ──────────┘
81
+ └───────────────────────────┘`;
82
+
83
+
84
+ describe("schema_to_diagram", () => {
85
+ const diagram = schema_to_diagram(SCHEMA);
86
+
87
+ test("the whole picture", () => {
88
+ expect(diagram).toBe(EXPECTED);
89
+ });
90
+
91
+ test("no line ends in a space", () => {
92
+ for(const line of diagram.split("\n")) {
93
+ expect(line).toBe(line.trimEnd());
94
+ }
95
+ });
96
+
97
+ test("a link to a column that does not exist is not drawn", () => {
98
+ const broken = JSON.parse(JSON.stringify(SCHEMA));
99
+ broken.topics[0].cols.users.hook = {users: "nowhere"};
100
+ expect(schema_to_diagram(broken)).not.toContain("◀─────┐");
101
+ });
102
+
103
+ test("nothing to draw is an empty string, not a throw", () => {
104
+ expect(schema_to_diagram(null)).toBe("");
105
+ expect(schema_to_diagram({id: "x"})).toBe("");
106
+ });
107
+ });
@@ -16,10 +16,11 @@
16
16
  * operator drew does nothing at all. Restarting on
17
17
  * this is an outage with no gain.
18
18
  * `warning` the schema opens and something is not what it looks
19
- * like — an unbumped `topic_version` masking the whole
20
- * edit is the one that costs the most time, because
21
- * the restart SUCCEEDS and the change is simply not
22
- * there.
19
+ * like.
20
+ *
21
+ * A version is not checked here: an edit is a DRAFT and moves no
22
+ * version, and C_TREEDB's save-schema raises the versions of what
23
+ * changed when it publishes it.
23
24
  *
24
25
  * Every `code` is an i18n key: the caller renders, this decides.
25
26
  *
@@ -69,8 +70,6 @@ function validate_schema(treedb, options)
69
70
  {
70
71
  let findings = [];
71
72
  let opts = options || {};
72
- let written = Array.isArray(opts.written_topics) ? opts.written_topics : [];
73
- let baseline = opts.baseline || {};
74
73
 
75
74
  if(!treedb || !Array.isArray(treedb.topics)) {
76
75
  return findings;
@@ -117,17 +116,6 @@ function validate_schema(treedb, options)
117
116
  }
118
117
  }
119
118
 
120
- /* A version that did not move republishes nothing: the stored
121
- * topic_cols.json masks the whole edit and the restart looks
122
- * like it worked. */
123
- if(written.indexOf(topic.id) >= 0) {
124
- let before = baseline[topic.id];
125
- if(before !== undefined && String(before) === String(topic.topic_version)) {
126
- add("warning", "topic version not bumped", topic.name, "",
127
- String(topic.topic_version));
128
- }
129
- }
130
-
131
119
  for(let col of topic.cols) {
132
120
  let flags = col_flags(col.record);
133
121
  let type = col.record ? col.record.type : "";
@@ -221,34 +221,14 @@ describe("the column itself", () => {
221
221
  });
222
222
  });
223
223
 
224
- describe("the version that did not move", () => {
225
- test("a written topic whose topic_version is unchanged is reported", () => {
224
+ describe("a version is not the validator's", () => {
225
+ test("a written topic whose topic_version did not move is NOT reported", () => {
226
+ /* An edit is a draft; save-schema raises the version. */
226
227
  const f = validate_schema(schema(), {
227
228
  written_topics: ["db.departments"],
228
229
  baseline: {"db.departments": 1}
229
230
  });
230
- const v = f.filter(x => x.code === "topic version not bumped");
231
- expect(v.length).toBe(1);
232
- expect(v[0].topic).toBe("departments");
233
- expect(v[0].severity).toBe("warning");
234
- });
235
-
236
- test("a bumped version says nothing", () => {
237
- expect(validate_schema(schema({dep_version: 2}), {
238
- written_topics: ["db.departments"],
239
- baseline: {"db.departments": 1}
240
- })).toEqual([]);
241
- });
242
-
243
- test("a topic nobody wrote is not asked about its version", () => {
244
- expect(validate_schema(schema(), {baseline: {"db.departments": 1}})).toEqual([]);
245
- });
246
-
247
- test("a version compared across types still matches (2 vs '2')", () => {
248
- expect(validate_schema(schema({dep_version: "1"}), {
249
- written_topics: ["db.departments"],
250
- baseline: {"db.departments": 1}
251
- }).length).toBe(1);
231
+ expect(f.filter(x => x.code === "topic version not bumped")).toEqual([]);
252
232
  });
253
233
  });
254
234
 
@@ -1,92 +1,35 @@
1
1
  /***********************************************************************
2
2
  * schema_version_bump.test.js
3
3
  *
4
- * "This topic was written and its version did not move."
4
+ * "This topic was written in this session and is not saved."
5
5
  *
6
- * THE RULE MEASURES AGAINST A BASELINE, AND THE BASELINE MUST NOT
7
- * MOVE WITH IT. It used to be re-taken inside build_model(), which
8
- * runs again on the patch after EVERY write -- so it came back as
9
- * the version the write had just raised, the rule read TRUE for a
10
- * topic whose version HAD moved, and the banner that says so
11
- * offered a Raise button that wrote another version and was
12
- * measured against itself again.
13
- *
14
- * The banner could not be dismissed. One operator pressed it
15
- * thirty-three times on a single column delete, and that is why
16
- * schema versions climbed by tens with the schema unchanged.
6
+ * An edit is a DRAFT (M36 of yunetas' 2026-09-21 review): it moves
7
+ * no version, and the host's save-schema raises the versions of what
8
+ * changed. So "written" alone is the whole question -- the version is
9
+ * no longer measured against a baseline, which is also why the old
10
+ * "Raise" button and the loop it fed are gone.
17
11
  *
18
12
  * Copyright (c) 2026, ArtGins.
19
13
  * All Rights Reserved.
20
14
  ***********************************************************************/
21
15
  import {describe, it, expect} from "vitest";
22
- import {needs_version_bump} from "./c_yui_schema_editor.js";
16
+ import {topic_is_draft} from "./c_yui_schema_editor.js";
23
17
 
24
18
  const TOPIC = {id: "device_groups", topic_version: 5};
25
19
 
26
- describe("needs_version_bump", () => {
20
+ describe("topic_is_draft", () => {
27
21
  it("says nothing about a topic this session never wrote", () => {
28
- expect(needs_version_bump({}, {device_groups: 5}, TOPIC)).toBe(false);
22
+ expect(topic_is_draft({}, TOPIC)).toBe(false);
29
23
  });
30
24
 
31
- it("asks for the bump when the version sits where it was found", () => {
32
- expect(needs_version_bump({device_groups: true}, {device_groups: 5}, TOPIC))
25
+ it("a written topic is a draft, whatever its version says", () => {
26
+ expect(topic_is_draft({device_groups: true}, TOPIC)).toBe(true);
27
+ expect(topic_is_draft({device_groups: true}, {id: "device_groups", topic_version: 6}))
33
28
  .toBe(true);
34
29
  });
35
30
 
36
- it("STOPS asking once the version has moved past the baseline", () => {
37
- /* The case the loop got wrong: the write raised it to 6 and the
38
- * baseline still says 5, so there is nothing left to raise. */
39
- expect(needs_version_bump(
40
- {device_groups: true}, {device_groups: 5}, {id: "device_groups", topic_version: 6}
41
- )).toBe(false);
42
- });
43
-
44
- it("compares as text, so 5 and '5' are the same version", () => {
45
- expect(needs_version_bump(
46
- {device_groups: true}, {device_groups: "5"}, TOPIC
47
- )).toBe(true);
48
- expect(needs_version_bump(
49
- {device_groups: true}, {device_groups: 5}, {id: "device_groups", topic_version: "5"}
50
- )).toBe(true);
51
- });
52
-
53
31
  it("answers false rather than throwing on a missing half", () => {
54
- expect(needs_version_bump(null, {device_groups: 5}, TOPIC)).toBe(false);
55
- expect(needs_version_bump({device_groups: true}, null, TOPIC)).toBe(false);
56
- expect(needs_version_bump({device_groups: true}, {device_groups: 5}, null)).toBe(false);
57
- });
58
-
59
- it("a topic with no baseline entry is not owed a bump", () => {
60
- expect(needs_version_bump({device_groups: true}, {}, TOPIC)).toBe(false);
61
- });
62
- });
63
-
64
- describe("the loop it came from", () => {
65
- /*
66
- * What the editor does per bump: raise the version by one. With the
67
- * baseline left alone, ONE press settles it; with the baseline
68
- * re-taken each time -- the old bug -- it never settles.
69
- */
70
- function press_raise(state) {
71
- return {written: state.written, baseline: state.baseline,
72
- topic: {id: "t", topic_version: Number(state.topic.topic_version) + 1}};
73
- }
74
-
75
- it("settles after a single press", () => {
76
- let st = {written: {t: true}, baseline: {t: 5}, topic: {id: "t", topic_version: 5}};
77
- expect(needs_version_bump(st.written, st.baseline, st.topic)).toBe(true);
78
- st = press_raise(st);
79
- expect(needs_version_bump(st.written, st.baseline, st.topic)).toBe(false);
80
- });
81
-
82
- it("never settles if the baseline is re-taken with the version", () => {
83
- let st = {written: {t: true}, baseline: {t: 5}, topic: {id: "t", topic_version: 5}};
84
- let presses = 0;
85
- while(needs_version_bump(st.written, st.baseline, st.topic) && presses < 40) {
86
- st = press_raise(st);
87
- st.baseline = {t: st.topic.topic_version}; /* the bug */
88
- presses++;
89
- }
90
- expect(presses).toBe(40); /* it ran to the guard: no way out */
32
+ expect(topic_is_draft(null, TOPIC)).toBe(false);
33
+ expect(topic_is_draft({device_groups: true}, null)).toBe(false);
91
34
  });
92
35
  });