@yuneta/gobj-ui 6.0.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,133 @@
1
+ /***********************************************************************
2
+ * schema_flags.test.js
3
+ *
4
+ * The flag catalogue, pinned.
5
+ *
6
+ * The two properties that matter: a flag the catalogue does not
7
+ * know must survive an edit (a newer node declares one, and
8
+ * dropping it silently rewrites that node's schema), and turning
9
+ * on a flag that excludes another must turn the other off (a
10
+ * column flagged both `hook` and `fkey` is a link the treedb
11
+ * writes from both ends).
12
+ ***********************************************************************/
13
+ import { describe, test, expect } from "vitest";
14
+ import {
15
+ FLAG_CATALOG, FLAG_GROUPS,
16
+ flags_for_type, grouped_flags, toggle_flag, flag_description,
17
+ } from "./schema_flags.js";
18
+
19
+
20
+ describe("the catalogue", () => {
21
+ test("every entry has a group the editor draws", () => {
22
+ for(const flag of FLAG_CATALOG) {
23
+ expect(FLAG_GROUPS).toContain(flag.group);
24
+ }
25
+ });
26
+
27
+ test("every entry says what it does", () => {
28
+ for(const flag of FLAG_CATALOG) {
29
+ expect(typeof flag.desc).toBe("string");
30
+ expect(flag.desc.length).toBeGreaterThan(0);
31
+ }
32
+ });
33
+
34
+ test("no flag is listed twice", () => {
35
+ const names = FLAG_CATALOG.map(f => f.name);
36
+ expect(new Set(names).size).toBe(names.length);
37
+ });
38
+
39
+ test("it covers the flags the meta schema declares", () => {
40
+ /* The `flag` column of `cols` (treedb_system_schema.c) is the
41
+ list this table has to keep up with. */
42
+ const names = FLAG_CATALOG.map(f => f.name);
43
+ for(const flag of ["persistent", "required", "notnull", "wild", "inherit",
44
+ "readable", "writable", "hidden", "stats", "rstats", "pstats",
45
+ "hook", "fkey", "enum", "template", "uuid", "rowid",
46
+ "qualified", "password", "email", "url", "time", "now",
47
+ "date", "color", "image", "tel", "table", "id", "currency",
48
+ "hex", "binary", "percent", "base64", "coordinates", "gbuffer"]) {
49
+ expect(names).toContain(flag);
50
+ }
51
+ });
52
+ });
53
+
54
+ describe("what is meaningful on a type", () => {
55
+ test("a hook is meaningful on a collection and not on a string", () => {
56
+ const on_dict = flags_for_type("dict").find(f => f.name === "hook");
57
+ const on_string = flags_for_type("string").find(f => f.name === "hook");
58
+ expect(on_dict.meaningful).toBe(true);
59
+ expect(on_string.meaningful).toBe(false);
60
+ });
61
+
62
+ test("persistent is meaningful on everything", () => {
63
+ for(const type of ["string", "integer", "dict", "blob"]) {
64
+ expect(flags_for_type(type).find(f => f.name === "persistent").meaningful).toBe(true);
65
+ }
66
+ });
67
+
68
+ test("nothing is filtered out — a set flag must stay visible", () => {
69
+ expect(flags_for_type("string").length).toBe(FLAG_CATALOG.length);
70
+ });
71
+ });
72
+
73
+ describe("grouped_flags", () => {
74
+ test("groups come out in the drawing order", () => {
75
+ const groups = grouped_flags("string", []).map(g => g.group);
76
+ expect(groups).toEqual(FLAG_GROUPS);
77
+ });
78
+
79
+ test("what the column carries is marked on", () => {
80
+ const relation = grouped_flags("dict", ["hook"]).find(g => g.group === "relation");
81
+ expect(relation.flags.find(f => f.name === "hook").on).toBe(true);
82
+ expect(relation.flags.find(f => f.name === "fkey").on).toBe(false);
83
+ });
84
+
85
+ test("a flag the catalogue does not know is kept, in its own group", () => {
86
+ const groups = grouped_flags("string", ["persistent", "from_a_newer_node"]);
87
+ const other = groups.find(g => g.group === "other");
88
+ expect(other.flags.map(f => f.name)).toEqual(["from_a_newer_node"]);
89
+ expect(other.flags[0].on).toBe(true);
90
+ });
91
+
92
+ test("no unknown flag means no extra group", () => {
93
+ expect(grouped_flags("string", ["persistent"]).find(g => g.group === "other"))
94
+ .toBeUndefined();
95
+ });
96
+ });
97
+
98
+ describe("toggle_flag", () => {
99
+ test("turning one on adds it once", () => {
100
+ expect(toggle_flag(["persistent"], "required", true))
101
+ .toEqual(["persistent", "required"]);
102
+ expect(toggle_flag(["persistent"], "persistent", true)).toEqual(["persistent"]);
103
+ });
104
+
105
+ test("turning one off removes it", () => {
106
+ expect(toggle_flag(["persistent", "required"], "required", false))
107
+ .toEqual(["persistent"]);
108
+ });
109
+
110
+ test("hook and fkey exclude each other — a link is written from ONE end", () => {
111
+ expect(toggle_flag(["fkey", "persistent"], "hook", true))
112
+ .toEqual(["persistent", "hook"]);
113
+ expect(toggle_flag(["hook"], "fkey", true)).toEqual(["fkey"]);
114
+ });
115
+
116
+ test("only one way of generating a key at a time", () => {
117
+ expect(toggle_flag(["rowid"], "qualified", true)).toEqual(["qualified"]);
118
+ expect(toggle_flag(["uuid", "persistent"], "rowid", true))
119
+ .toEqual(["persistent", "rowid"]);
120
+ });
121
+
122
+ test("an absent or rubbish list is a list", () => {
123
+ expect(toggle_flag(null, "hook", true)).toEqual(["hook"]);
124
+ expect(toggle_flag(["", null], "hook", true)).toEqual(["hook"]);
125
+ });
126
+ });
127
+
128
+ describe("flag_description", () => {
129
+ test("answers for a known flag and says nothing for an unknown one", () => {
130
+ expect(flag_description("persistent").length).toBeGreaterThan(0);
131
+ expect(flag_description("from_a_newer_node")).toBe("");
132
+ });
133
+ });
@@ -0,0 +1,413 @@
1
+ /***********************************************************************
2
+ * schema_import.js
3
+ *
4
+ * The writes that make a STORED schema equal a given one.
5
+ *
6
+ * The export says what the node is running, in the form the source
7
+ * can hold. This is the way back in: a schema written in C — or
8
+ * exported from another node, or edited in a text editor — turned
9
+ * into the create/update/delete calls that put it in
10
+ * `treedb_system_schema`.
11
+ *
12
+ * It is a PLAN and not a write. Import is the one operation here
13
+ * that can delete a column, so what it is about to do is shown
14
+ * first and executed second; and the plan is a value, so what is
15
+ * shown is what runs.
16
+ *
17
+ * THE VERSION IS PART OF THE PLAN, not an afterthought. A topic
18
+ * whose columns changed and whose `topic_version` did not is a
19
+ * topic whose persisted `topic_cols.json` masks the whole import:
20
+ * the restart succeeds and nothing moved. So a changed topic
21
+ * always leaves this plan with a version above the stored one,
22
+ * whatever the incoming schema said.
23
+ *
24
+ * Copyright (c) 2026, ArtGins.
25
+ * All Rights Reserved.
26
+ ***********************************************************************/
27
+ import {
28
+ col_flags,
29
+ col_hook,
30
+ col_enum,
31
+ as_json,
32
+ fkey_ref,
33
+ } from "./schema_model.js";
34
+
35
+
36
+ /* Fields of a `topics` record an import owns. The rest (`id`, `value`,
37
+ * the fkey, `order`) is composed, and `_geometry` belongs to whoever
38
+ * laid the graph out — an import must not move their boxes. */
39
+ const TOPIC_FIELDS = [
40
+ "pkey", "pkey2s", "system_flag", "tkey", "topic_version", "system_topic"
41
+ ];
42
+
43
+ /* Same for a `cols` record. */
44
+ const COL_FIELDS = [
45
+ "header", "fillspace", "type", "flag", "enum", "hook", "default",
46
+ "placeholder", "description", "template", "properties", "pkey2s"
47
+ ];
48
+
49
+ /* Read as values whatever they were stored as. */
50
+ const JSON_FIELDS = ["enum", "hook", "default", "template", "properties", "pkey2s"];
51
+
52
+
53
+ /***************************************************************
54
+ * Two values, compared the way the store would see them: a flag
55
+ * list written in another order is the same flags, and a hook
56
+ * written as text is the same hook.
57
+ ***************************************************************/
58
+ function same_value(field, a, b)
59
+ {
60
+ let norm = (v) => {
61
+ if(JSON_FIELDS.indexOf(field) >= 0) {
62
+ v = as_json(v);
63
+ }
64
+ if(field === "flag") {
65
+ let list = Array.isArray(v) ? v.slice() : (typeof v === "string" && v ? [v] : []);
66
+ list = list.filter(f => typeof f === "string" && f.length > 0).sort();
67
+ return JSON.stringify(list);
68
+ }
69
+ if(v === null || v === undefined || v === "") {
70
+ return "";
71
+ }
72
+ if(typeof v === "object") {
73
+ return JSON.stringify(v);
74
+ }
75
+ return String(v);
76
+ };
77
+ return norm(a) === norm(b);
78
+ }
79
+
80
+ /***************************************************************
81
+ * The declared fields of an incoming column, normalized.
82
+ ***************************************************************/
83
+ function incoming_col_fields(col)
84
+ {
85
+ let out = {};
86
+
87
+ for(let field of COL_FIELDS) {
88
+ let value = col ? col[field] : undefined;
89
+ if(value === undefined || value === null || value === "") {
90
+ continue;
91
+ }
92
+ out[field] = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
93
+ }
94
+ if(Array.isArray(out.flag)) {
95
+ out.flag = out.flag.filter(f => typeof f === "string" && f.length > 0);
96
+ } else if(typeof out.flag === "string" && out.flag) {
97
+ out.flag = [out.flag];
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /***************************************************************
103
+ * The same for a stored column, read off its record.
104
+ ***************************************************************/
105
+ function stored_col_fields(record)
106
+ {
107
+ let out = {};
108
+
109
+ for(let field of COL_FIELDS) {
110
+ let value = record ? record[field] : undefined;
111
+ if(value === undefined || value === null || value === "") {
112
+ continue;
113
+ }
114
+ out[field] = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
115
+ }
116
+ let flags = col_flags(record);
117
+ if(flags.length > 0) {
118
+ out.flag = flags;
119
+ } else {
120
+ delete out.flag;
121
+ }
122
+ let hook = col_hook(record);
123
+ if(hook) {
124
+ out.hook = hook;
125
+ }
126
+ let e = col_enum(record);
127
+ if(e.length > 0) {
128
+ out.enum = e;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /***************************************************************
134
+ * The columns of an incoming topic, as a list in schema order.
135
+ * A literal writes them as an ordered dict; an exported plan may
136
+ * hand a list instead, and both mean the same thing.
137
+ ***************************************************************/
138
+ function incoming_cols(topic)
139
+ {
140
+ let cols = topic ? topic.cols : null;
141
+
142
+ if(Array.isArray(cols)) {
143
+ return cols.filter(c => c && c.id).map(c => ({name: c.id, col: c}));
144
+ }
145
+ if(cols && typeof cols === "object") {
146
+ return Object.entries(cols).map(([name, col]) => ({name: name, col: col || {}}));
147
+ }
148
+ return [];
149
+ }
150
+
151
+ /***************************************************************
152
+ * plan_import(treedb, incoming, options) -> plan
153
+ *
154
+ * treedb one entry of build_schema_model().treedbs, or null
155
+ * for a treedb the store does not have yet
156
+ * incoming the schema as the .c literal holds it:
157
+ * {id, schema_version, topics: [{id, ..., cols: {...}}]}
158
+ * options {prune: true} delete what the incoming schema does
159
+ * not declare. Off leaves it alone: two schemas that
160
+ * only ADD are merged.
161
+ *
162
+ * plan = {
163
+ * writes: [{op, topic_name, record, what}], in order
164
+ * summary: {topics_created, topics_updated, topics_deleted,
165
+ * cols_created, cols_updated, cols_deleted},
166
+ * conflicts: [{code, topic, col}]
167
+ * }
168
+ *
169
+ * `writes` is ordered so it can be run straight through: a topic
170
+ * exists before its columns reference it, and a delete runs after
171
+ * the writes that may have moved things out of its way.
172
+ ***************************************************************/
173
+ function plan_import(treedb, incoming, options)
174
+ {
175
+ let opts = options || {};
176
+ let prune = opts.prune === undefined ? true : !!opts.prune;
177
+ let writes = [];
178
+ let conflicts = [];
179
+ let summary = {
180
+ topics_created: 0, topics_updated: 0, topics_deleted: 0,
181
+ cols_created: 0, cols_updated: 0, cols_deleted: 0
182
+ };
183
+
184
+ if(!incoming || !Array.isArray(incoming.topics)) {
185
+ conflicts.push({code: "not a schema", topic: "", col: ""});
186
+ return {writes: writes, summary: summary, conflicts: conflicts};
187
+ }
188
+
189
+ let treedb_id = (treedb && treedb.id) || incoming.id;
190
+ if(!treedb_id) {
191
+ conflicts.push({code: "schema has no id", topic: "", col: ""});
192
+ return {writes: writes, summary: summary, conflicts: conflicts};
193
+ }
194
+
195
+ let stored_topics = {};
196
+ for(let topic of ((treedb && treedb.topics) || [])) {
197
+ stored_topics[topic.name] = topic;
198
+ }
199
+
200
+ let seen_topics = {};
201
+
202
+ for(let i = 0; i < incoming.topics.length; i++) {
203
+ let in_topic = incoming.topics[i] || {};
204
+ let name = in_topic.id;
205
+ if(!name) {
206
+ conflicts.push({code: "topic has no name", topic: "", col: ""});
207
+ continue;
208
+ }
209
+ seen_topics[name] = true;
210
+
211
+ let stored = stored_topics[name] || null;
212
+ let order = i + 1;
213
+
214
+ /* What the incoming topic declares about itself. */
215
+ let fields = {};
216
+ for(let field of TOPIC_FIELDS) {
217
+ let value = in_topic[field];
218
+ if(value === undefined || value === null || value === "") {
219
+ continue;
220
+ }
221
+ fields[field] = value;
222
+ }
223
+
224
+ let in_cols = incoming_cols(in_topic);
225
+ let stored_cols = {};
226
+ for(let col of ((stored && stored.cols) || [])) {
227
+ stored_cols[col.name] = col;
228
+ }
229
+
230
+ /* Does anything under this topic move? Decided BEFORE the
231
+ * version, because it is what the version has to answer for. */
232
+ let col_writes = [];
233
+ let seen_cols = {};
234
+ for(let j = 0; j < in_cols.length; j++) {
235
+ let col_name = in_cols[j].name;
236
+ let in_fields = incoming_col_fields(in_cols[j].col);
237
+ seen_cols[col_name] = true;
238
+
239
+ let stored_col = stored_cols[col_name] || null;
240
+ if(!stored_col) {
241
+ let record = Object.assign({
242
+ value: col_name,
243
+ order: j + 1,
244
+ topics: [fkey_ref("topics", `${treedb_id}.${name}`, "cols")]
245
+ }, in_fields);
246
+ col_writes.push({
247
+ op: "create", topic_name: "cols", record: record,
248
+ what: {topic: name, col: col_name}
249
+ });
250
+ continue;
251
+ }
252
+
253
+ let changed = {};
254
+ for(let [field, value] of Object.entries(in_fields)) {
255
+ if(!same_value(field, stored_col.record[field], value)) {
256
+ changed[field] = value;
257
+ }
258
+ }
259
+ /* A field the stored column has and the incoming one does
260
+ * not is a REMOVAL, and clearing it is a write too. */
261
+ for(let field of Object.keys(stored_col_fields(stored_col.record))) {
262
+ if(!(field in in_fields)) {
263
+ changed[field] = (field === "flag") ? [] : "";
264
+ }
265
+ }
266
+ if(stored_col.order !== j + 1) {
267
+ changed.order = j + 1;
268
+ }
269
+ if(Object.keys(changed).length > 0) {
270
+ col_writes.push({
271
+ op: "update", topic_name: "cols",
272
+ record: Object.assign({id: stored_col.id}, changed),
273
+ what: {topic: name, col: col_name}
274
+ });
275
+ }
276
+ }
277
+
278
+ let col_deletes = [];
279
+ if(prune && stored) {
280
+ for(let col of stored.cols) {
281
+ if(seen_cols[col.name]) {
282
+ continue;
283
+ }
284
+ col_deletes.push({
285
+ op: "delete", topic_name: "cols", record: {id: col.id},
286
+ what: {topic: name, col: col.name}
287
+ });
288
+ }
289
+ }
290
+
291
+ let topic_changed = col_writes.length > 0 || col_deletes.length > 0;
292
+
293
+ if(!stored) {
294
+ let record = Object.assign({
295
+ value: name,
296
+ order: order,
297
+ treedbs: [fkey_ref("treedbs", treedb_id, "topics")]
298
+ }, fields);
299
+ writes.push({
300
+ op: "create", topic_name: "topics", record: record,
301
+ what: {topic: name, col: ""}
302
+ });
303
+ summary.topics_created++;
304
+ } else {
305
+ let changed = {};
306
+ for(let [field, value] of Object.entries(fields)) {
307
+ if(!same_value(field, stored.record[field], value)) {
308
+ changed[field] = value;
309
+ }
310
+ }
311
+ if(stored.order !== order) {
312
+ changed.order = order;
313
+ }
314
+
315
+ /* The version that has to answer for the columns above. A
316
+ * topic that changed and kept its version publishes
317
+ * nothing, so it is raised here even when the incoming
318
+ * schema asked for the same number. */
319
+ if(topic_changed) {
320
+ let stored_version = Number(stored.topic_version);
321
+ let wanted = Number(changed.topic_version !== undefined ?
322
+ changed.topic_version : stored.topic_version);
323
+ if(!isFinite(stored_version)) {
324
+ stored_version = 0;
325
+ }
326
+ if(!isFinite(wanted) || wanted <= stored_version) {
327
+ changed.topic_version = stored_version + 1;
328
+ }
329
+ }
330
+
331
+ if(Object.keys(changed).length > 0) {
332
+ writes.push({
333
+ op: "update", topic_name: "topics",
334
+ record: Object.assign({id: stored.id}, changed),
335
+ what: {topic: name, col: ""}
336
+ });
337
+ summary.topics_updated++;
338
+ }
339
+ }
340
+
341
+ for(let w of col_writes) {
342
+ writes.push(w);
343
+ if(w.op === "create") {
344
+ summary.cols_created++;
345
+ } else {
346
+ summary.cols_updated++;
347
+ }
348
+ }
349
+ for(let w of col_deletes) {
350
+ writes.push(w);
351
+ summary.cols_deleted++;
352
+ }
353
+ }
354
+
355
+ if(prune && treedb) {
356
+ for(let topic of treedb.topics) {
357
+ if(seen_topics[topic.name]) {
358
+ continue;
359
+ }
360
+ for(let col of topic.cols) {
361
+ writes.push({
362
+ op: "delete", topic_name: "cols", record: {id: col.id},
363
+ what: {topic: topic.name, col: col.name}
364
+ });
365
+ summary.cols_deleted++;
366
+ }
367
+ writes.push({
368
+ op: "delete", topic_name: "topics", record: {id: topic.id},
369
+ what: {topic: topic.name, col: ""}
370
+ });
371
+ summary.topics_deleted++;
372
+ }
373
+ }
374
+
375
+ /* The treedb's own version, for the same reason one level up: it
376
+ * is what makes a re-projection publish. */
377
+ if(treedb && writes.length > 0) {
378
+ let stored_version = Number(treedb.schema_version);
379
+ if(!isFinite(stored_version)) {
380
+ stored_version = 0;
381
+ }
382
+ let wanted = Number(incoming.schema_version);
383
+ let next = (isFinite(wanted) && wanted > stored_version) ? wanted : stored_version + 1;
384
+ writes.push({
385
+ op: "update", topic_name: "treedbs",
386
+ record: {id: treedb.id, schema_version: next},
387
+ what: {topic: "", col: ""}
388
+ });
389
+ }
390
+
391
+ return {writes: writes, summary: summary, conflicts: conflicts};
392
+ }
393
+
394
+ /***************************************************************
395
+ * Is this plan going to remove anything? What an import dialog
396
+ * has to say out loud before its confirm button.
397
+ ***************************************************************/
398
+ function plan_deletes(plan)
399
+ {
400
+ if(!plan || !Array.isArray(plan.writes)) {
401
+ return [];
402
+ }
403
+ return plan.writes.filter(w => w.op === "delete");
404
+ }
405
+
406
+
407
+ export {
408
+ TOPIC_FIELDS,
409
+ COL_FIELDS,
410
+ same_value,
411
+ plan_import,
412
+ plan_deletes,
413
+ };