@yuneta/gobj-ui 5.17.0 → 6.1.0

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,265 @@
1
+ /***********************************************************************
2
+ * schema_validate.test.js
3
+ *
4
+ * What must be caught BEFORE the restart, pinned.
5
+ *
6
+ * Each of these is a way to spend an outage finding out something
7
+ * the records already said: a link that saves nothing, a topic
8
+ * that will not open, or — the expensive one — a restart that
9
+ * succeeds and changes nothing because the version did not move.
10
+ ***********************************************************************/
11
+ import { describe, test, expect } from "vitest";
12
+ import { build_schema_model } from "./schema_model.js";
13
+ import { validate_schema, validate_model, has_errors } from "./schema_validate.js";
14
+
15
+
16
+ /* A treedb of two topics linked by departments.users -> users.departments,
17
+ which is the shape every test below breaks in one place. */
18
+ function schema(overrides)
19
+ {
20
+ let o = overrides || {};
21
+ const records = {
22
+ treedbs: [{id: "db", schema_version: 1}],
23
+ topics: [
24
+ {id: "db.departments", value: "departments", order: 1, pkey: "id",
25
+ topic_version: o.dep_version === undefined ? 1 : o.dep_version,
26
+ treedbs: ["treedbs^db^topics"]},
27
+ {id: "db.users", value: "users", order: 2, pkey: o.users_pkey || "id",
28
+ topic_version: 1, pkey2s: o.users_pkey2s,
29
+ treedbs: ["treedbs^db^topics"]},
30
+ ],
31
+ cols: [
32
+ {id: "db.departments.id", value: "id", order: 1, type: "string",
33
+ flag: ["persistent"], topics: ["topics^db.departments^cols"]},
34
+ {id: "db.departments.users", value: "users", order: 2,
35
+ type: o.hook_type === undefined ? "dict" : o.hook_type,
36
+ flag: ["hook"], hook: o.hook === undefined ? {users: "departments"} : o.hook,
37
+ topics: ["topics^db.departments^cols"]},
38
+ {id: "db.users.id", value: "id", order: 1, type: "string",
39
+ flag: ["persistent"], topics: ["topics^db.users^cols"]},
40
+ {id: "db.users.departments", value: "departments", order: 2,
41
+ type: o.fkey_type === undefined ? "array" : o.fkey_type,
42
+ flag: o.fkey_flag === undefined ? ["fkey"] : o.fkey_flag,
43
+ topics: ["topics^db.users^cols"]},
44
+ ],
45
+ };
46
+ if(o.extra_col) {
47
+ records.cols.push(o.extra_col);
48
+ }
49
+ return build_schema_model(records).treedbs[0];
50
+ }
51
+
52
+ function codes(findings)
53
+ {
54
+ return findings.map(f => f.code);
55
+ }
56
+
57
+
58
+ describe("a schema with nothing wrong", () => {
59
+ test("says nothing", () => {
60
+ expect(validate_schema(schema())).toEqual([]);
61
+ });
62
+
63
+ test("no treedb at all is not a crash", () => {
64
+ expect(validate_schema(null)).toEqual([]);
65
+ expect(validate_schema(undefined, {})).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe("the topic will not open", () => {
70
+ test("a pkey naming no column", () => {
71
+ const f = validate_schema(schema({users_pkey: "user_id"}));
72
+ expect(codes(f)).toContain("pkey names no column");
73
+ expect(f[0].detail).toBe("user_id");
74
+ expect(has_errors(f)).toBe(true);
75
+ });
76
+
77
+ test("a pkey2 naming no column", () => {
78
+ expect(codes(validate_schema(schema({users_pkey2s: ["name"]}))))
79
+ .toContain("pkey2 names no column");
80
+ });
81
+
82
+ test("a pkey2 declared as a bare string is read the same way", () => {
83
+ expect(codes(validate_schema(schema({users_pkey2s: "name"}))))
84
+ .toContain("pkey2 names no column");
85
+ });
86
+
87
+ test("a topic with no column", () => {
88
+ const model = build_schema_model({
89
+ treedbs: [{id: "db"}],
90
+ topics: [{id: "db.empty", value: "empty", treedbs: ["treedbs^db^topics"]}],
91
+ cols: [],
92
+ });
93
+ expect(codes(validate_schema(model.treedbs[0]))).toContain("topic has no column");
94
+ });
95
+ });
96
+
97
+ describe("the link does nothing, and the write succeeded", () => {
98
+ test("a hook with no mapping", () => {
99
+ expect(codes(validate_schema(schema({hook: null})))).toContain("hook has no mapping");
100
+ expect(codes(validate_schema(schema({hook: {}})))).toContain("hook has no mapping");
101
+ });
102
+
103
+ test("a hook naming a topic that is not in this treedb", () => {
104
+ expect(codes(validate_schema(schema({hook: {clients: "departments"}}))))
105
+ .toContain("hook names no topic");
106
+ });
107
+
108
+ test("a hook naming a column the child does not have", () => {
109
+ const f = validate_schema(schema({hook: {users: "department_id"}}));
110
+ expect(codes(f)).toContain("hook names no fkey column");
111
+ expect(f[0].detail).toBe("users.department_id");
112
+ });
113
+
114
+ test("a hook whose target column is NOT flagged fkey", () => {
115
+ expect(codes(validate_schema(schema({fkey_flag: ["persistent"]}))))
116
+ .toContain("hook target is not a fkey");
117
+ });
118
+
119
+ test("a hook typed as a scalar cannot hold children", () => {
120
+ expect(codes(validate_schema(schema({hook_type: "string"}))))
121
+ .toContain("hook must be a collection");
122
+ });
123
+
124
+ test("a fkey typed as an integer", () => {
125
+ expect(codes(validate_schema(schema({fkey_type: "integer"}))))
126
+ .toContain("fkey has a bad type");
127
+ });
128
+
129
+ test("a fkey no hook names is a WARNING — the parent side went away", () => {
130
+ const f = validate_schema(schema({hook: {users: "departments"},
131
+ extra_col: {id: "db.users.boss", value: "boss", order: 3, type: "string",
132
+ flag: ["fkey"], topics: ["topics^db.users^cols"]}}));
133
+ const orphan = f.filter(x => x.code === "fkey with no hook");
134
+ expect(orphan.length).toBe(1);
135
+ expect(orphan[0].col).toBe("boss");
136
+ expect(orphan[0].severity).toBe("warning");
137
+ });
138
+ });
139
+
140
+ describe("the column itself", () => {
141
+ test("no type", () => {
142
+ expect(codes(validate_schema(schema({
143
+ extra_col: {id: "db.users.x", value: "x", order: 9,
144
+ topics: ["topics^db.users^cols"]}
145
+ })))).toContain("column has no type");
146
+ });
147
+
148
+ test("a type the treedb's own validator does not know", () => {
149
+ expect(codes(validate_schema(schema({
150
+ extra_col: {id: "db.users.x", value: "x", order: 9, type: "text",
151
+ topics: ["topics^db.users^cols"]}
152
+ })))).toContain("unknown column type");
153
+ });
154
+
155
+ test("flagged enum with no enum list", () => {
156
+ expect(codes(validate_schema(schema({
157
+ extra_col: {id: "db.users.x", value: "x", order: 9, type: "string",
158
+ flag: ["enum"], topics: ["topics^db.users^cols"]}
159
+ })))).toContain("enum column has no enum");
160
+ });
161
+
162
+ test("an enum stored as JSON TEXT counts as an enum", () => {
163
+ expect(codes(validate_schema(schema({
164
+ extra_col: {id: "db.users.x", value: "x", order: 9, type: "string",
165
+ flag: ["enum"], enum: '["a","b"]',
166
+ topics: ["topics^db.users^cols"]}
167
+ })))).not.toContain("enum column has no enum");
168
+ });
169
+
170
+ test("a hook stored as JSON TEXT is still a hook mapping", () => {
171
+ expect(validate_schema(schema({hook: '{"users":"departments"}'}))).toEqual([]);
172
+ });
173
+
174
+ test("a flag stored as a bare string is still a flag", () => {
175
+ expect(codes(validate_schema(schema({fkey_flag: "fkey"}))))
176
+ .not.toContain("hook target is not a fkey");
177
+ });
178
+ });
179
+
180
+ describe("the version that did not move", () => {
181
+ test("a written topic whose topic_version is unchanged is reported", () => {
182
+ const f = validate_schema(schema(), {
183
+ written_topics: ["db.departments"],
184
+ baseline: {"db.departments": 1}
185
+ });
186
+ const v = f.filter(x => x.code === "topic version not bumped");
187
+ expect(v.length).toBe(1);
188
+ expect(v[0].topic).toBe("departments");
189
+ expect(v[0].severity).toBe("warning");
190
+ });
191
+
192
+ test("a bumped version says nothing", () => {
193
+ expect(validate_schema(schema({dep_version: 2}), {
194
+ written_topics: ["db.departments"],
195
+ baseline: {"db.departments": 1}
196
+ })).toEqual([]);
197
+ });
198
+
199
+ test("a topic nobody wrote is not asked about its version", () => {
200
+ expect(validate_schema(schema(), {baseline: {"db.departments": 1}})).toEqual([]);
201
+ });
202
+
203
+ test("a version compared across types still matches (2 vs '2')", () => {
204
+ expect(validate_schema(schema({dep_version: "1"}), {
205
+ written_topics: ["db.departments"],
206
+ baseline: {"db.departments": 1}
207
+ }).length).toBe(1);
208
+ });
209
+ });
210
+
211
+ describe("errors come first, and orphans are the model's business", () => {
212
+ test("an error outranks a warning in the list", () => {
213
+ const f = validate_schema(schema({users_pkey: "nope",
214
+ extra_col: {id: "db.users.boss", value: "boss", order: 3, type: "string",
215
+ flag: ["fkey"], topics: ["topics^db.users^cols"]}}));
216
+ expect(f[0].severity).toBe("error");
217
+ expect(f[f.length - 1].severity).toBe("warning");
218
+ });
219
+
220
+ test("validate_model reports what belongs to nothing", () => {
221
+ const model = build_schema_model({
222
+ treedbs: [{id: "db"}],
223
+ topics: [{id: "gone.users", value: "users", treedbs: ["treedbs^gone^topics"]}],
224
+ cols: [{id: "db.gone.x", value: "x", topics: ["topics^db.gone^cols"]}],
225
+ });
226
+ const c = codes(validate_model(model));
227
+ expect(c).toContain("topic belongs to no treedb");
228
+ expect(c).toContain("column belongs to no topic");
229
+ });
230
+
231
+ test("has_errors ignores warnings", () => {
232
+ expect(has_errors([{severity: "warning"}])).toBe(false);
233
+ expect(has_errors([{severity: "warning"}, {severity: "error"}])).toBe(true);
234
+ expect(has_errors(null)).toBe(false);
235
+ });
236
+ });
237
+
238
+ describe("only one hook may name a fkey column", () => {
239
+ test("two hooks on the same fkey is what the treedb refuses to open", () => {
240
+ const model = build_schema_model({
241
+ treedbs: [{id: "db"}],
242
+ topics: [
243
+ {id: "db.a", value: "a", order: 1, treedbs: ["treedbs^db^topics"]},
244
+ {id: "db.b", value: "b", order: 2, treedbs: ["treedbs^db^topics"]},
245
+ {id: "db.c", value: "c", order: 3, treedbs: ["treedbs^db^topics"]},
246
+ ],
247
+ cols: [
248
+ {id: "db.a.id", value: "id", order: 1, type: "string", topics: ["topics^db.a^cols"]},
249
+ {id: "db.a.cs", value: "cs", order: 2, type: "dict", flag: ["hook"],
250
+ hook: {c: "parent"}, topics: ["topics^db.a^cols"]},
251
+ {id: "db.b.id", value: "id", order: 1, type: "string", topics: ["topics^db.b^cols"]},
252
+ {id: "db.b.cs", value: "cs", order: 2, type: "dict", flag: ["hook"],
253
+ hook: {c: "parent"}, topics: ["topics^db.b^cols"]},
254
+ {id: "db.c.id", value: "id", order: 1, type: "string", topics: ["topics^db.c^cols"]},
255
+ {id: "db.c.parent", value: "parent", order: 2, type: "array", flag: ["fkey"],
256
+ topics: ["topics^db.c^cols"]},
257
+ ],
258
+ });
259
+ const f = validate_schema(model.treedbs[0]);
260
+ const dup = f.filter(x => x.code === "fkey named by two hooks");
261
+ expect(dup.length).toBe(1);
262
+ expect(dup[0].col).toBe("parent");
263
+ expect(dup[0].severity).toBe("error");
264
+ });
265
+ });
@@ -14,14 +14,17 @@
14
14
  * What a node is CALLED, which is not always what it is
15
15
  * keyed by.
16
16
  *
17
- * A topic whose id column is flagged `rowid` or `uuid` keys
18
- * its records by a value nobody reads — that is the point of
19
- * those flags and the name a human knows the record by
20
- * lives in the secondary key the topic declares (`pkey2s`,
21
- * carried in the desc since SDK > 7.13.0). The `topics` and
22
- * `cols` topics of treedb_system_schema are the case that
23
- * forced this: keyed by rowid, named in `value`, so every
24
- * card in the graph read "181", "225", "193".
17
+ * A topic whose id column is flagged `rowid`, `uuid` or
18
+ * `qualified` keys its records by something that is not the
19
+ * namea counter, a random string, or the name with every
20
+ * ancestor in front of it — and the name a human knows the
21
+ * record by lives in the secondary key the topic declares
22
+ * (`pkey2s`, carried in the desc since SDK > 7.13.0). The
23
+ * `topics` and `cols` topics of treedb_system_schema are the
24
+ * case that forced this: named in `value`, so every card in
25
+ * the graph read "181", "225", "193" while they were keyed by
26
+ * rowid, and reads the whole path now that they are
27
+ * qualified.
25
28
  *
26
29
  * The pkey stays reachable: it is the card's tooltip.
27
30
  *
@@ -48,7 +51,10 @@ export function node_label(desc, record)
48
51
  if(!id_col || !Array.isArray(id_col.flag)) {
49
52
  return id;
50
53
  }
51
- if(id_col.flag.indexOf("rowid") < 0 && id_col.flag.indexOf("uuid") < 0) {
54
+ if(id_col.flag.indexOf("rowid") < 0 &&
55
+ id_col.flag.indexOf("uuid") < 0 &&
56
+ id_col.flag.indexOf("qualified") < 0
57
+ ) {
52
58
  return id;
53
59
  }
54
60
 
@@ -50,6 +50,20 @@ describe("node_label", () => {
50
50
  expect(node_label(desc, {id: "181", value: "yuno_role"})).toBe("yuno_role");
51
51
  });
52
52
 
53
+ it("treats a qualified pkey like a rowid one", () => {
54
+ let desc = {
55
+ ...COLS_DESC,
56
+ cols: [
57
+ {id: "id", type: "string", flag: ["persistent", "qualified"]},
58
+ {id: "value", type: "string", flag: ["persistent", "required"]},
59
+ ],
60
+ };
61
+ expect(node_label(desc, {
62
+ id: "treedb_yunovatioscodb.yunos.yuno_role",
63
+ value: "yuno_role",
64
+ })).toBe("yuno_role");
65
+ });
66
+
53
67
  it("treats a uuid pkey like a rowid one", () => {
54
68
  let desc = {
55
69
  ...COLS_DESC,