@yuneta/gobj-ui 7.23.170 → 7.23.171

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.
@@ -23046,13 +23046,455 @@ function nodes_answer(data) {
23046
23046
  };
23047
23047
  }
23048
23048
  //#endregion
23049
+ //#region src/schema_model.js
23050
+ /***********************************************************************
23051
+ * schema_model.js
23052
+ *
23053
+ * A SCHEMA out of the three meta-topics that store it.
23054
+ *
23055
+ * `treedb_system_schema` keeps every schema of a yuno as DATA, in
23056
+ * three flat topics — `treedbs`, `topics`, `cols` — linked by
23057
+ * fkeys. That is the right STORAGE and the wrong thing to put in
23058
+ * front of an operator: adding one column to one topic means
23059
+ * finding it in a table holding every column of every topic of
23060
+ * every treedb the yuno has.
23061
+ *
23062
+ * This module turns those three lists back into what they
23063
+ * describe: treedb -> topics -> columns, each in its declared
23064
+ * `order`. Pure, so the editor's model can be tested with no DOM
23065
+ * and no backend — the gclass fetches and draws, this decides
23066
+ * what the records MEAN.
23067
+ *
23068
+ * TWO THINGS IT EXISTS TO STATE ONCE:
23069
+ *
23070
+ * 1. A record is grouped by its FKEY, not by its id. The id is
23071
+ * qualified (`<treedb>.<topic>.<column>`) and reading it back
23072
+ * with a split on '.' works right up to the first treedb or
23073
+ * topic whose name carries one. The fkey is the link the store
23074
+ * itself uses; the id is only the fallback for a record whose
23075
+ * fkey never arrived.
23076
+ *
23077
+ * 2. A fkey reaches this code in FOUR shapes, because `nodes`
23078
+ * answers differently depending on the options it was called
23079
+ * with: the bare ref string, the expanded object, a list of
23080
+ * either, or a dict keyed by the ref. All four are the same
23081
+ * fact and parse_fkey_ref() flattens them to it.
23082
+ *
23083
+ * Copyright (c) 2026, ArtGins.
23084
+ * All Rights Reserved.
23085
+ ***********************************************************************/
23086
+ var DEFAULT_ORDER = 9999;
23087
+ /***************************************************************
23088
+ * parse_fkey_ref(value) -> [{topic_name, id, hook_name}, ...]
23089
+ *
23090
+ * Every shape a fkey column can arrive in, flattened to the
23091
+ * refs it names. Anything unrecognizable contributes nothing —
23092
+ * a schema drawn without one link beats a screen that throws.
23093
+ ***************************************************************/
23094
+ function parse_fkey_ref(value) {
23095
+ let refs = [];
23096
+ let push_one = (v) => {
23097
+ if (typeof v === "string") {
23098
+ let parts = v.split("^");
23099
+ if (parts.length !== 3) return;
23100
+ refs.push({
23101
+ topic_name: parts[0],
23102
+ id: parts[1],
23103
+ hook_name: parts[2]
23104
+ });
23105
+ return;
23106
+ }
23107
+ if (v && typeof v === "object") {
23108
+ let id = v.id;
23109
+ if (typeof id !== "string" || id.length === 0) return;
23110
+ refs.push({
23111
+ topic_name: v.topic_name || "",
23112
+ id,
23113
+ hook_name: v.hook_name || ""
23114
+ });
23115
+ }
23116
+ };
23117
+ if (value === null || value === void 0) return refs;
23118
+ if (typeof value === "string") {
23119
+ push_one(value);
23120
+ return refs;
23121
+ }
23122
+ if (Array.isArray(value)) {
23123
+ for (let v of value) push_one(v);
23124
+ return refs;
23125
+ }
23126
+ if (typeof value === "object") for (let [key, v] of Object.entries(value)) if (v && typeof v === "object" && typeof v.id === "string") push_one(v);
23127
+ else push_one(key);
23128
+ return refs;
23129
+ }
23130
+ /***************************************************************
23131
+ * The id of the FIRST parent a record names, or "" when it
23132
+ * names none. A record has one parent here — a column belongs to
23133
+ * one topic, a topic to one treedb — and a second ref would be a
23134
+ * store this editor cannot draw anyway.
23135
+ ***************************************************************/
23136
+ function parent_id(record, fkey_col) {
23137
+ let refs = parse_fkey_ref(record ? record[fkey_col] : null);
23138
+ return refs.length > 0 ? refs[0].id : "";
23139
+ }
23140
+ /***************************************************************
23141
+ * The name a record is KNOWN by, which is not what it is keyed
23142
+ * by: `topics` and `cols` key by the qualified id and carry the
23143
+ * bare name in `value` (its pkey2). An older store that still
23144
+ * keyed by rowid has no `value` worth showing, so the id stands
23145
+ * in — the same fallback as node_label().
23146
+ ***************************************************************/
23147
+ function record_name(record) {
23148
+ if (!record) return "";
23149
+ if (typeof record.value === "string" && record.value.length > 0) return record.value;
23150
+ return typeof record.id === "string" ? record.id : "";
23151
+ }
23152
+ /***************************************************************
23153
+ * The `order` of a record as a NUMBER, whatever the store put
23154
+ * there: an integer column read back from JSON may arrive as a
23155
+ * string, and comparing "10" with 9 sorts the schema wrong.
23156
+ ***************************************************************/
23157
+ function record_order(record) {
23158
+ let order = record ? record.order : void 0;
23159
+ if (typeof order === "number" && isFinite(order)) return order;
23160
+ if (typeof order === "string" && order.trim().length > 0) {
23161
+ let n = Number(order);
23162
+ if (isFinite(n)) return n;
23163
+ }
23164
+ return DEFAULT_ORDER;
23165
+ }
23166
+ /***************************************************************
23167
+ * Sort in place by `order`, ties broken by name so a schema
23168
+ * whose records all default to 9999 still draws the same way
23169
+ * twice.
23170
+ ***************************************************************/
23171
+ function sort_by_order(list) {
23172
+ list.sort((a, b) => {
23173
+ if (a.order !== b.order) return a.order - b.order;
23174
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
23175
+ });
23176
+ return list;
23177
+ }
23178
+ /***************************************************************
23179
+ * build_schema_model({treedbs, topics, cols}) -> model
23180
+ *
23181
+ * model = {
23182
+ * treedbs: [{
23183
+ * id, name, record,
23184
+ * schema_version, c_schema_version, system_schema_version,
23185
+ * topics: [{
23186
+ * id, name, order, record,
23187
+ * pkey, topic_version, system_topic, main_topic,
23188
+ * cols: [{id, name, order, record}]
23189
+ * }]
23190
+ * }],
23191
+ * orphan_topics: [...], topics whose treedb is not in the store
23192
+ * orphan_cols: [...] columns whose topic is not in the store
23193
+ * }
23194
+ *
23195
+ * An orphan is NOT dropped. A column whose topic was deleted is
23196
+ * exactly the kind of leftover an operator opens this editor to
23197
+ * find, and a model that hides it makes the editor lie.
23198
+ ***************************************************************/
23199
+ function build_schema_model(records) {
23200
+ let r = records || {};
23201
+ let treedb_rows = Array.isArray(r.treedbs) ? r.treedbs : [];
23202
+ let topic_rows = Array.isArray(r.topics) ? r.topics : [];
23203
+ let col_rows = Array.isArray(r.cols) ? r.cols : [];
23204
+ let treedbs = [];
23205
+ let by_treedb_id = {};
23206
+ for (let record of treedb_rows) {
23207
+ if (!record || typeof record.id !== "string") continue;
23208
+ let entry = {
23209
+ id: record.id,
23210
+ name: record.id,
23211
+ record,
23212
+ schema_version: record.schema_version,
23213
+ c_schema_version: record.c_schema_version,
23214
+ system_schema_version: record.system_schema_version,
23215
+ topics: []
23216
+ };
23217
+ treedbs.push(entry);
23218
+ by_treedb_id[entry.id] = entry;
23219
+ }
23220
+ let orphan_topics = [];
23221
+ let by_topic_id = {};
23222
+ for (let record of topic_rows) {
23223
+ if (!record || typeof record.id !== "string") continue;
23224
+ let entry = {
23225
+ id: record.id,
23226
+ name: record_name(record),
23227
+ order: record_order(record),
23228
+ record,
23229
+ treedb_id: parent_id(record, "treedbs"),
23230
+ pkey: record.pkey || "",
23231
+ pkey2s: record.pkey2s,
23232
+ system_flag: record.system_flag || "",
23233
+ tkey: record.tkey || "",
23234
+ topic_version: record.topic_version,
23235
+ system_topic: !!record.system_topic,
23236
+ main_topic: !!record.main_topic,
23237
+ cols: []
23238
+ };
23239
+ by_topic_id[entry.id] = entry;
23240
+ let parent = by_treedb_id[entry.treedb_id];
23241
+ if (parent) parent.topics.push(entry);
23242
+ else orphan_topics.push(entry);
23243
+ }
23244
+ let orphan_cols = [];
23245
+ for (let record of col_rows) {
23246
+ if (!record || typeof record.id !== "string") continue;
23247
+ let entry = {
23248
+ id: record.id,
23249
+ name: record_name(record),
23250
+ order: record_order(record),
23251
+ record,
23252
+ topic_id: parent_id(record, "topics")
23253
+ };
23254
+ let parent = by_topic_id[entry.topic_id];
23255
+ if (parent) parent.cols.push(entry);
23256
+ else orphan_cols.push(entry);
23257
+ }
23258
+ for (let treedb of treedbs) {
23259
+ sort_by_order(treedb.topics);
23260
+ for (let topic of treedb.topics) sort_by_order(topic.cols);
23261
+ }
23262
+ for (let topic of orphan_topics) sort_by_order(topic.cols);
23263
+ treedbs.sort((a, b) => {
23264
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
23265
+ });
23266
+ sort_by_order(orphan_topics);
23267
+ sort_by_order(orphan_cols);
23268
+ return {
23269
+ treedbs,
23270
+ orphan_topics,
23271
+ orphan_cols
23272
+ };
23273
+ }
23274
+ /***************************************************************
23275
+ * Is there a value here at all?
23276
+ *
23277
+ * The store answers a `blob` column that was never set with an
23278
+ * EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
23279
+ * `default: {}`. Read as "a value", those turn every export into
23280
+ * a schema full of empty objects — and `'hook': {}` in a literal
23281
+ * is a hook with no mapping, which is a link the treedb builds
23282
+ * and nothing writes.
23283
+ ***************************************************************/
23284
+ function is_empty_value(value) {
23285
+ if (value === null || value === void 0 || value === "") return true;
23286
+ if (Array.isArray(value)) return value.length === 0;
23287
+ if (typeof value === "object") return Object.keys(value).length === 0;
23288
+ return false;
23289
+ }
23290
+ /***************************************************************
23291
+ * The fields of a column that are DECLARED as one thing and
23292
+ * STORED as another.
23293
+ *
23294
+ * `hook`, `enum`, `default`, `pkey2s` and `properties` are `blob`
23295
+ * columns of the `cols` topic (treedb_system_schema.c), so what
23296
+ * comes back may be the value or the JSON text of the value,
23297
+ * depending on how it was written — a projection writes the
23298
+ * value, a hand edit through a plain text field writes the text.
23299
+ * `flag` is an array that a single-flag edit can leave as a bare
23300
+ * string. Reading these without saying so is how a hook drawn
23301
+ * from `{"users":"department"}` becomes no hook at all.
23302
+ ***************************************************************/
23303
+ function as_json(value) {
23304
+ if (typeof value !== "string") return value;
23305
+ let text = value.trim();
23306
+ if (text.length === 0) return null;
23307
+ try {
23308
+ return JSON.parse(text);
23309
+ } catch (e) {
23310
+ return value;
23311
+ }
23312
+ }
23313
+ function col_flags(record) {
23314
+ let flag = record ? record.flag : null;
23315
+ if (Array.isArray(flag)) return flag.filter((f) => typeof f === "string" && f.length > 0);
23316
+ if (typeof flag === "string" && flag.length > 0) {
23317
+ let parsed = as_json(flag);
23318
+ if (Array.isArray(parsed)) return parsed.filter((f) => typeof f === "string" && f.length > 0);
23319
+ return [flag];
23320
+ }
23321
+ return [];
23322
+ }
23323
+ function col_hook(record) {
23324
+ let hook = as_json(record ? record.hook : null);
23325
+ if (hook && typeof hook === "object" && !Array.isArray(hook)) return hook;
23326
+ return null;
23327
+ }
23328
+ function col_enum(record) {
23329
+ let e = as_json(record ? record.enum : null);
23330
+ if (Array.isArray(e)) return e;
23331
+ return [];
23332
+ }
23333
+ function topic_pkey2s(record) {
23334
+ let pkey2s = as_json(record ? record.pkey2s : null);
23335
+ if (Array.isArray(pkey2s)) return pkey2s.filter((k) => typeof k === "string" && k.length > 0);
23336
+ if (typeof pkey2s === "string" && pkey2s.length > 0) return [pkey2s];
23337
+ return [];
23338
+ }
23339
+ /***************************************************************
23340
+ * Lookups by id. The editor addresses a position by NAME (the
23341
+ * url carries `<treedb>/<topic>`), so both are offered.
23342
+ ***************************************************************/
23343
+ function find_treedb(model, treedb_id) {
23344
+ if (!model || !Array.isArray(model.treedbs)) return null;
23345
+ for (let treedb of model.treedbs) if (treedb.id === treedb_id) return treedb;
23346
+ return null;
23347
+ }
23348
+ function find_topic(model, treedb_id, topic_name) {
23349
+ let treedb = find_treedb(model, treedb_id);
23350
+ if (!treedb) return null;
23351
+ for (let topic of treedb.topics) if (topic.name === topic_name || topic.id === topic_name) return topic;
23352
+ return null;
23353
+ }
23354
+ function find_col(model, treedb_id, topic_name, col_name) {
23355
+ let topic = find_topic(model, treedb_id, topic_name);
23356
+ if (!topic) return null;
23357
+ for (let col of topic.cols) if (col.name === col_name || col.id === col_name) return col;
23358
+ return null;
23359
+ }
23360
+ /***************************************************************
23361
+ * The fkey a NEW record must carry to belong to `parent_id`.
23362
+ * Written here because the editor is the only writer that has to
23363
+ * compose one, and getting the hook name wrong links the record
23364
+ * to nothing while the write still succeeds.
23365
+ ***************************************************************/
23366
+ function fkey_ref(parent_topic, parent_id_, hook_name) {
23367
+ return `${parent_topic}^${parent_id_}^${hook_name}`;
23368
+ }
23369
+ /***************************************************************
23370
+ * Where a new sibling goes: after the last one. The store's own
23371
+ * default (9999) would put every hand-made column in the same
23372
+ * place and let their names decide the schema's order.
23373
+ ***************************************************************/
23374
+ function next_order(siblings) {
23375
+ let max = 0;
23376
+ if (Array.isArray(siblings)) for (let s of siblings) {
23377
+ let order = s && typeof s.order === "number" ? s.order : DEFAULT_ORDER;
23378
+ if (order < 9999 && order > max) max = order;
23379
+ }
23380
+ return max + 1;
23381
+ }
23382
+ /***************************************************************
23383
+ * Renumber a list AFTER a move, as the writes it takes: only the
23384
+ * records whose order actually changed, so a drag of one row in a
23385
+ * 40-column topic is not 40 writes.
23386
+ *
23387
+ * moved_orders(list) -> [{id, order}, ...]
23388
+ ***************************************************************/
23389
+ function moved_orders(list) {
23390
+ let writes = [];
23391
+ if (!Array.isArray(list)) return writes;
23392
+ for (let i = 0; i < list.length; i++) {
23393
+ let entry = list[i];
23394
+ let order = i + 1;
23395
+ if (!entry || typeof entry.id !== "string") continue;
23396
+ if (entry.order !== order) writes.push({
23397
+ id: entry.id,
23398
+ order
23399
+ });
23400
+ }
23401
+ return writes;
23402
+ }
23403
+ //#endregion
23404
+ //#region src/treedb_topic_keys.js
23405
+ /***********************************************************************
23406
+ * treedb_topic_keys.js
23407
+ *
23408
+ * The KEYS of a treedb topic, read from its desc: the pkey, the
23409
+ * secondary keys (`pkey2s`) and the time key (`tkey`), plus the
23410
+ * names behind its `system_flag` number.
23411
+ *
23412
+ * Every view that describes a topic -- the schema diagram, the
23413
+ * topic-info panel, the topic cards -- must say them, and say them
23414
+ * the same way. A topic with `pkey2s` is not a topic with one more
23415
+ * column: one id holds several INSTANCES, an update has to carry
23416
+ * the pkey2 or it lands on the primary, and `delete-instance`
23417
+ * exists. `tkey` names where the time of every record comes from.
23418
+ * The views said neither, so this is where they ask.
23419
+ *
23420
+ * Pure, so it is tested with no DOM.
23421
+ *
23422
+ * Copyright (c) 2026, ArtGins.
23423
+ * All Rights Reserved.
23424
+ ***********************************************************************/
23425
+ var SF_NAMES = [
23426
+ "sf_string_key",
23427
+ "sf_rowid_key",
23428
+ "sf_int_key",
23429
+ "",
23430
+ "sf_zip_record",
23431
+ "sf_cipher_record",
23432
+ "",
23433
+ "",
23434
+ "sf_t_ms",
23435
+ "sf_tm_ms",
23436
+ "sf_deleted_instance",
23437
+ "sf_immutable_record",
23438
+ "sf_loading_from_disk",
23439
+ "",
23440
+ "",
23441
+ ""
23442
+ ];
23443
+ /************************************************************
23444
+ * The secondary keys of a topic, always a list. The desc
23445
+ * carries a list; a schema literal may declare a bare
23446
+ * string.
23447
+ ************************************************************/
23448
+ function desc_pkey2s(desc) {
23449
+ return topic_pkey2s(desc);
23450
+ }
23451
+ /************************************************************
23452
+ * The time key of a topic, "" when it has none -- and then
23453
+ * the time of a record is the time it was appended.
23454
+ ************************************************************/
23455
+ function desc_tkey(desc) {
23456
+ let tkey = desc ? desc.tkey : "";
23457
+ return typeof tkey === "string" ? tkey : "";
23458
+ }
23459
+ /************************************************************
23460
+ * Which keys of its topic a column IS, in the order the
23461
+ * views print them: "pkey", "pkey2", "tkey". Empty for a
23462
+ * column that is none.
23463
+ ************************************************************/
23464
+ function col_key_roles(desc, col_id) {
23465
+ let roles = [];
23466
+ if (!desc || typeof col_id !== "string" || col_id.length === 0) return roles;
23467
+ if (col_id === (desc.pkey || "id")) roles.push("pkey");
23468
+ if (desc_pkey2s(desc).includes(col_id)) roles.push("pkey2");
23469
+ if (desc_tkey(desc) === col_id) roles.push("tkey");
23470
+ return roles;
23471
+ }
23472
+ /************************************************************
23473
+ * The names of a system_flag. The desc carries the NUMBER
23474
+ * (`1`), a schema literal carries the names
23475
+ * ("sf_string_key|sf_t_ms"); both come back as a list. A
23476
+ * set bit with no name is shown by its value, never
23477
+ * dropped.
23478
+ ************************************************************/
23479
+ function system_flag_names(system_flag) {
23480
+ if (Array.isArray(system_flag)) return system_flag.filter((n) => typeof n === "string" && n.length > 0);
23481
+ if (typeof system_flag === "string") return system_flag.split(/[|,]/).map((n) => n.trim()).filter((n) => n.length > 0);
23482
+ if (typeof system_flag !== "number" || !Number.isInteger(system_flag) || system_flag <= 0) return [];
23483
+ let names = [];
23484
+ for (let bit = 0; bit < SF_NAMES.length; bit++) {
23485
+ let mask = 1 << bit;
23486
+ if (system_flag & mask) names.push(SF_NAMES[bit] || `0x${mask.toString(16).padStart(4, "0")}`);
23487
+ }
23488
+ return names;
23489
+ }
23490
+ //#endregion
23049
23491
  //#region src/c_yui_treedb_topics.js
23050
23492
  /***********************************************************************
23051
23493
  * c_yui_treedb_topics.js
23052
23494
  *
23053
23495
  * Management of TreeDB's topics with Bulma tabs
23054
23496
  *
23055
- * Copyright (c) 2025, ArtGins.
23497
+ * Copyright (c) 2025-2026, ArtGins.
23056
23498
  * All Rights Reserved.
23057
23499
  ***********************************************************************/
23058
23500
  /***************************************************************
@@ -23566,6 +24008,29 @@ function topic_card_meta(desc) {
23566
24008
  { class: "TREEDB_TOPIC_CARD_META" },
23567
24009
  head
23568
24010
  ]);
24011
+ let pkey2s = desc_pkey2s(desc);
24012
+ if (pkey2s.length) {
24013
+ let list = pkey2s.join(", ");
24014
+ meta.push([
24015
+ "div",
24016
+ {
24017
+ class: "TREEDB_TOPIC_CARD_REL TREEDB_TOPIC_CARD_PKEY2S",
24018
+ title: list
24019
+ },
24020
+ [[
24021
+ "span",
24022
+ {
24023
+ class: "TREEDB_TOPIC_CARD_REL_LABEL",
24024
+ i18n: "pkey2s"
24025
+ },
24026
+ "pkey2s"
24027
+ ], [
24028
+ "span",
24029
+ { class: "TREEDB_TOPIC_CARD_REL_LIST" },
24030
+ list
24031
+ ]]
24032
+ ]);
24033
+ }
23569
24034
  let relation = (cls, key, arrow, names) => {
23570
24035
  let list = names.join(", ");
23571
24036
  return [
@@ -23829,33 +24294,26 @@ function show_topic_info(gobj, topic) {
23829
24294
  }
23830
24295
  /************************************************************
23831
24296
  * Build the read-only topic-info panel from a topic desc: the
23832
- * topic name, its pkey, and a table of columns (name / type /
24297
+ * topic name, its KEYS, and a table of columns (name / type /
23833
24298
  * key relationship). Everything is guarded — a malformed desc
23834
24299
  * renders a shorter panel, never throws.
24300
+ *
24301
+ * The keys are what an operator has to know before touching a
24302
+ * record, so they are all said, and the empty ones too: a
24303
+ * topic with `pkey2s` keeps several instances under one id,
24304
+ * and an empty `tkey` means the time of a record is the time
24305
+ * it was appended -- a row missing is not the same as "none".
23835
24306
  ************************************************************/
23836
24307
  function build_topic_info_panel(gobj, $info, topic, desc) {
23837
24308
  while ($info.firstChild) $info.removeChild($info.firstChild);
23838
24309
  let pkey = desc.pkey || "";
23839
- let fmt_flag = (v) => {
23840
- if (is_array(v)) return v.join(", ");
23841
- if (is_object(v)) return Object.keys(v).join(", ");
23842
- return v === void 0 || v === null ? "" : String(v);
23843
- };
24310
+ let pkey2s = desc_pkey2s(desc);
24311
+ let tkey = desc_tkey(desc);
23844
24312
  let $meta = [];
23845
- let push_meta = (key, value, highlight) => {
23846
- if (value === void 0 || value === null || value === "") return;
23847
- let $val = highlight ? [
23848
- "span",
23849
- { class: "tag is-info" },
23850
- `${value}`
23851
- ] : [
23852
- "code",
23853
- {},
23854
- `${value}`
23855
- ];
24313
+ let push_meta = (key, $val) => {
23856
24314
  $meta.push([
23857
24315
  "tr",
23858
- {},
24316
+ { class: `TREEDB_TOPIC_INFO_${key.toUpperCase()}` },
23859
24317
  [[
23860
24318
  "th",
23861
24319
  {
@@ -23870,29 +24328,64 @@ function build_topic_info_panel(gobj, $info, topic, desc) {
23870
24328
  ]]
23871
24329
  ]);
23872
24330
  };
23873
- push_meta("version", desc.topic_version, true);
23874
- push_meta("system", fmt_flag(desc.system_flag));
23875
- push_meta("pkey", pkey);
23876
- push_meta("tkey", desc.tkey);
24331
+ if (desc.topic_version !== void 0 && desc.topic_version !== null && desc.topic_version !== "") push_meta("version", [
24332
+ "span",
24333
+ { class: "tag is-info" },
24334
+ `${desc.topic_version}`
24335
+ ]);
24336
+ let sf_names = system_flag_names(desc.system_flag);
24337
+ if (sf_names.length > 0) push_meta("system", [
24338
+ "code",
24339
+ { title: `${desc.system_flag}` },
24340
+ sf_names.join(" | ")
24341
+ ]);
24342
+ if (pkey) push_meta("pkey", [
24343
+ "code",
24344
+ {},
24345
+ pkey
24346
+ ]);
24347
+ if (pkey2s.length > 0) push_meta("pkey2s", [
24348
+ "span",
24349
+ { class: "tags" },
24350
+ pkey2s.map((name) => [
24351
+ "span",
24352
+ { class: "tag is-warning" },
24353
+ name
24354
+ ])
24355
+ ]);
24356
+ push_meta("tkey", tkey ? [
24357
+ "code",
24358
+ {},
24359
+ tkey
24360
+ ] : [
24361
+ "span",
24362
+ {
24363
+ class: "has-text-grey",
24364
+ i18n: "append time"
24365
+ },
24366
+ "append time"
24367
+ ]);
23877
24368
  let $rows = [];
23878
24369
  let cols = is_array(desc.cols) ? desc.cols : [];
23879
24370
  for (let col of cols) {
23880
24371
  if (!col || !col.id || col.id.charAt(0) === "_") continue;
23881
24372
  let type = col.type || (is_array(col.flag) ? col.flag.join(", ") : col.flag || "");
23882
- let rel = "";
23883
- if (col.id === pkey) rel = "pkey";
23884
- else if (is_object(col.fkey)) rel = "→ " + Object.keys(col.fkey).join(", ");
23885
- else if (is_object(col.hook)) rel = "hook → " + Object.keys(col.hook).join(", ");
24373
+ let roles = col_key_roles(desc, col.id);
24374
+ let parts = roles.slice();
24375
+ if (is_object(col.fkey)) parts.push("→ " + Object.keys(col.fkey).join(", "));
24376
+ else if (is_object(col.hook)) parts.push("hook → " + Object.keys(col.hook).join(", "));
24377
+ let rel = parts.join(" · ");
24378
+ let is_key = roles.length > 0;
23886
24379
  $rows.push([
23887
24380
  "tr",
23888
- {},
24381
+ is_key ? { class: "TREEDB_TOPIC_INFO_KEY_COL" } : {},
23889
24382
  [
23890
24383
  [
23891
24384
  "td",
23892
24385
  {},
23893
24386
  [[
23894
24387
  "code",
23895
- {},
24388
+ is_key ? { class: "has-text-weight-bold" } : {},
23896
24389
  `${col.id}`
23897
24390
  ]]
23898
24391
  ],
@@ -23903,7 +24396,7 @@ function build_topic_info_panel(gobj, $info, topic, desc) {
23903
24396
  ],
23904
24397
  [
23905
24398
  "td",
23906
- {},
24399
+ is_key ? { class: "has-text-weight-semibold" } : {},
23907
24400
  `${rel}`
23908
24401
  ]
23909
24402
  ]
@@ -25638,361 +26131,6 @@ function row_matches(row, term) {
25638
26131
  });
25639
26132
  }
25640
26133
  //#endregion
25641
- //#region src/schema_model.js
25642
- /***********************************************************************
25643
- * schema_model.js
25644
- *
25645
- * A SCHEMA out of the three meta-topics that store it.
25646
- *
25647
- * `treedb_system_schema` keeps every schema of a yuno as DATA, in
25648
- * three flat topics — `treedbs`, `topics`, `cols` — linked by
25649
- * fkeys. That is the right STORAGE and the wrong thing to put in
25650
- * front of an operator: adding one column to one topic means
25651
- * finding it in a table holding every column of every topic of
25652
- * every treedb the yuno has.
25653
- *
25654
- * This module turns those three lists back into what they
25655
- * describe: treedb -> topics -> columns, each in its declared
25656
- * `order`. Pure, so the editor's model can be tested with no DOM
25657
- * and no backend — the gclass fetches and draws, this decides
25658
- * what the records MEAN.
25659
- *
25660
- * TWO THINGS IT EXISTS TO STATE ONCE:
25661
- *
25662
- * 1. A record is grouped by its FKEY, not by its id. The id is
25663
- * qualified (`<treedb>.<topic>.<column>`) and reading it back
25664
- * with a split on '.' works right up to the first treedb or
25665
- * topic whose name carries one. The fkey is the link the store
25666
- * itself uses; the id is only the fallback for a record whose
25667
- * fkey never arrived.
25668
- *
25669
- * 2. A fkey reaches this code in FOUR shapes, because `nodes`
25670
- * answers differently depending on the options it was called
25671
- * with: the bare ref string, the expanded object, a list of
25672
- * either, or a dict keyed by the ref. All four are the same
25673
- * fact and parse_fkey_ref() flattens them to it.
25674
- *
25675
- * Copyright (c) 2026, ArtGins.
25676
- * All Rights Reserved.
25677
- ***********************************************************************/
25678
- var DEFAULT_ORDER = 9999;
25679
- /***************************************************************
25680
- * parse_fkey_ref(value) -> [{topic_name, id, hook_name}, ...]
25681
- *
25682
- * Every shape a fkey column can arrive in, flattened to the
25683
- * refs it names. Anything unrecognizable contributes nothing —
25684
- * a schema drawn without one link beats a screen that throws.
25685
- ***************************************************************/
25686
- function parse_fkey_ref(value) {
25687
- let refs = [];
25688
- let push_one = (v) => {
25689
- if (typeof v === "string") {
25690
- let parts = v.split("^");
25691
- if (parts.length !== 3) return;
25692
- refs.push({
25693
- topic_name: parts[0],
25694
- id: parts[1],
25695
- hook_name: parts[2]
25696
- });
25697
- return;
25698
- }
25699
- if (v && typeof v === "object") {
25700
- let id = v.id;
25701
- if (typeof id !== "string" || id.length === 0) return;
25702
- refs.push({
25703
- topic_name: v.topic_name || "",
25704
- id,
25705
- hook_name: v.hook_name || ""
25706
- });
25707
- }
25708
- };
25709
- if (value === null || value === void 0) return refs;
25710
- if (typeof value === "string") {
25711
- push_one(value);
25712
- return refs;
25713
- }
25714
- if (Array.isArray(value)) {
25715
- for (let v of value) push_one(v);
25716
- return refs;
25717
- }
25718
- if (typeof value === "object") for (let [key, v] of Object.entries(value)) if (v && typeof v === "object" && typeof v.id === "string") push_one(v);
25719
- else push_one(key);
25720
- return refs;
25721
- }
25722
- /***************************************************************
25723
- * The id of the FIRST parent a record names, or "" when it
25724
- * names none. A record has one parent here — a column belongs to
25725
- * one topic, a topic to one treedb — and a second ref would be a
25726
- * store this editor cannot draw anyway.
25727
- ***************************************************************/
25728
- function parent_id(record, fkey_col) {
25729
- let refs = parse_fkey_ref(record ? record[fkey_col] : null);
25730
- return refs.length > 0 ? refs[0].id : "";
25731
- }
25732
- /***************************************************************
25733
- * The name a record is KNOWN by, which is not what it is keyed
25734
- * by: `topics` and `cols` key by the qualified id and carry the
25735
- * bare name in `value` (its pkey2). An older store that still
25736
- * keyed by rowid has no `value` worth showing, so the id stands
25737
- * in — the same fallback as node_label().
25738
- ***************************************************************/
25739
- function record_name(record) {
25740
- if (!record) return "";
25741
- if (typeof record.value === "string" && record.value.length > 0) return record.value;
25742
- return typeof record.id === "string" ? record.id : "";
25743
- }
25744
- /***************************************************************
25745
- * The `order` of a record as a NUMBER, whatever the store put
25746
- * there: an integer column read back from JSON may arrive as a
25747
- * string, and comparing "10" with 9 sorts the schema wrong.
25748
- ***************************************************************/
25749
- function record_order(record) {
25750
- let order = record ? record.order : void 0;
25751
- if (typeof order === "number" && isFinite(order)) return order;
25752
- if (typeof order === "string" && order.trim().length > 0) {
25753
- let n = Number(order);
25754
- if (isFinite(n)) return n;
25755
- }
25756
- return DEFAULT_ORDER;
25757
- }
25758
- /***************************************************************
25759
- * Sort in place by `order`, ties broken by name so a schema
25760
- * whose records all default to 9999 still draws the same way
25761
- * twice.
25762
- ***************************************************************/
25763
- function sort_by_order(list) {
25764
- list.sort((a, b) => {
25765
- if (a.order !== b.order) return a.order - b.order;
25766
- return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
25767
- });
25768
- return list;
25769
- }
25770
- /***************************************************************
25771
- * build_schema_model({treedbs, topics, cols}) -> model
25772
- *
25773
- * model = {
25774
- * treedbs: [{
25775
- * id, name, record,
25776
- * schema_version, c_schema_version, system_schema_version,
25777
- * topics: [{
25778
- * id, name, order, record,
25779
- * pkey, topic_version, system_topic, main_topic,
25780
- * cols: [{id, name, order, record}]
25781
- * }]
25782
- * }],
25783
- * orphan_topics: [...], topics whose treedb is not in the store
25784
- * orphan_cols: [...] columns whose topic is not in the store
25785
- * }
25786
- *
25787
- * An orphan is NOT dropped. A column whose topic was deleted is
25788
- * exactly the kind of leftover an operator opens this editor to
25789
- * find, and a model that hides it makes the editor lie.
25790
- ***************************************************************/
25791
- function build_schema_model(records) {
25792
- let r = records || {};
25793
- let treedb_rows = Array.isArray(r.treedbs) ? r.treedbs : [];
25794
- let topic_rows = Array.isArray(r.topics) ? r.topics : [];
25795
- let col_rows = Array.isArray(r.cols) ? r.cols : [];
25796
- let treedbs = [];
25797
- let by_treedb_id = {};
25798
- for (let record of treedb_rows) {
25799
- if (!record || typeof record.id !== "string") continue;
25800
- let entry = {
25801
- id: record.id,
25802
- name: record.id,
25803
- record,
25804
- schema_version: record.schema_version,
25805
- c_schema_version: record.c_schema_version,
25806
- system_schema_version: record.system_schema_version,
25807
- topics: []
25808
- };
25809
- treedbs.push(entry);
25810
- by_treedb_id[entry.id] = entry;
25811
- }
25812
- let orphan_topics = [];
25813
- let by_topic_id = {};
25814
- for (let record of topic_rows) {
25815
- if (!record || typeof record.id !== "string") continue;
25816
- let entry = {
25817
- id: record.id,
25818
- name: record_name(record),
25819
- order: record_order(record),
25820
- record,
25821
- treedb_id: parent_id(record, "treedbs"),
25822
- pkey: record.pkey || "",
25823
- pkey2s: record.pkey2s,
25824
- system_flag: record.system_flag || "",
25825
- tkey: record.tkey || "",
25826
- topic_version: record.topic_version,
25827
- system_topic: !!record.system_topic,
25828
- main_topic: !!record.main_topic,
25829
- cols: []
25830
- };
25831
- by_topic_id[entry.id] = entry;
25832
- let parent = by_treedb_id[entry.treedb_id];
25833
- if (parent) parent.topics.push(entry);
25834
- else orphan_topics.push(entry);
25835
- }
25836
- let orphan_cols = [];
25837
- for (let record of col_rows) {
25838
- if (!record || typeof record.id !== "string") continue;
25839
- let entry = {
25840
- id: record.id,
25841
- name: record_name(record),
25842
- order: record_order(record),
25843
- record,
25844
- topic_id: parent_id(record, "topics")
25845
- };
25846
- let parent = by_topic_id[entry.topic_id];
25847
- if (parent) parent.cols.push(entry);
25848
- else orphan_cols.push(entry);
25849
- }
25850
- for (let treedb of treedbs) {
25851
- sort_by_order(treedb.topics);
25852
- for (let topic of treedb.topics) sort_by_order(topic.cols);
25853
- }
25854
- for (let topic of orphan_topics) sort_by_order(topic.cols);
25855
- treedbs.sort((a, b) => {
25856
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
25857
- });
25858
- sort_by_order(orphan_topics);
25859
- sort_by_order(orphan_cols);
25860
- return {
25861
- treedbs,
25862
- orphan_topics,
25863
- orphan_cols
25864
- };
25865
- }
25866
- /***************************************************************
25867
- * Is there a value here at all?
25868
- *
25869
- * The store answers a `blob` column that was never set with an
25870
- * EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
25871
- * `default: {}`. Read as "a value", those turn every export into
25872
- * a schema full of empty objects — and `'hook': {}` in a literal
25873
- * is a hook with no mapping, which is a link the treedb builds
25874
- * and nothing writes.
25875
- ***************************************************************/
25876
- function is_empty_value(value) {
25877
- if (value === null || value === void 0 || value === "") return true;
25878
- if (Array.isArray(value)) return value.length === 0;
25879
- if (typeof value === "object") return Object.keys(value).length === 0;
25880
- return false;
25881
- }
25882
- /***************************************************************
25883
- * The fields of a column that are DECLARED as one thing and
25884
- * STORED as another.
25885
- *
25886
- * `hook`, `enum`, `default`, `pkey2s` and `properties` are `blob`
25887
- * columns of the `cols` topic (treedb_system_schema.c), so what
25888
- * comes back may be the value or the JSON text of the value,
25889
- * depending on how it was written — a projection writes the
25890
- * value, a hand edit through a plain text field writes the text.
25891
- * `flag` is an array that a single-flag edit can leave as a bare
25892
- * string. Reading these without saying so is how a hook drawn
25893
- * from `{"users":"department"}` becomes no hook at all.
25894
- ***************************************************************/
25895
- function as_json(value) {
25896
- if (typeof value !== "string") return value;
25897
- let text = value.trim();
25898
- if (text.length === 0) return null;
25899
- try {
25900
- return JSON.parse(text);
25901
- } catch (e) {
25902
- return value;
25903
- }
25904
- }
25905
- function col_flags(record) {
25906
- let flag = record ? record.flag : null;
25907
- if (Array.isArray(flag)) return flag.filter((f) => typeof f === "string" && f.length > 0);
25908
- if (typeof flag === "string" && flag.length > 0) {
25909
- let parsed = as_json(flag);
25910
- if (Array.isArray(parsed)) return parsed.filter((f) => typeof f === "string" && f.length > 0);
25911
- return [flag];
25912
- }
25913
- return [];
25914
- }
25915
- function col_hook(record) {
25916
- let hook = as_json(record ? record.hook : null);
25917
- if (hook && typeof hook === "object" && !Array.isArray(hook)) return hook;
25918
- return null;
25919
- }
25920
- function col_enum(record) {
25921
- let e = as_json(record ? record.enum : null);
25922
- if (Array.isArray(e)) return e;
25923
- return [];
25924
- }
25925
- function topic_pkey2s(record) {
25926
- let pkey2s = as_json(record ? record.pkey2s : null);
25927
- if (Array.isArray(pkey2s)) return pkey2s.filter((k) => typeof k === "string" && k.length > 0);
25928
- if (typeof pkey2s === "string" && pkey2s.length > 0) return [pkey2s];
25929
- return [];
25930
- }
25931
- /***************************************************************
25932
- * Lookups by id. The editor addresses a position by NAME (the
25933
- * url carries `<treedb>/<topic>`), so both are offered.
25934
- ***************************************************************/
25935
- function find_treedb(model, treedb_id) {
25936
- if (!model || !Array.isArray(model.treedbs)) return null;
25937
- for (let treedb of model.treedbs) if (treedb.id === treedb_id) return treedb;
25938
- return null;
25939
- }
25940
- function find_topic(model, treedb_id, topic_name) {
25941
- let treedb = find_treedb(model, treedb_id);
25942
- if (!treedb) return null;
25943
- for (let topic of treedb.topics) if (topic.name === topic_name || topic.id === topic_name) return topic;
25944
- return null;
25945
- }
25946
- function find_col(model, treedb_id, topic_name, col_name) {
25947
- let topic = find_topic(model, treedb_id, topic_name);
25948
- if (!topic) return null;
25949
- for (let col of topic.cols) if (col.name === col_name || col.id === col_name) return col;
25950
- return null;
25951
- }
25952
- /***************************************************************
25953
- * The fkey a NEW record must carry to belong to `parent_id`.
25954
- * Written here because the editor is the only writer that has to
25955
- * compose one, and getting the hook name wrong links the record
25956
- * to nothing while the write still succeeds.
25957
- ***************************************************************/
25958
- function fkey_ref(parent_topic, parent_id_, hook_name) {
25959
- return `${parent_topic}^${parent_id_}^${hook_name}`;
25960
- }
25961
- /***************************************************************
25962
- * Where a new sibling goes: after the last one. The store's own
25963
- * default (9999) would put every hand-made column in the same
25964
- * place and let their names decide the schema's order.
25965
- ***************************************************************/
25966
- function next_order(siblings) {
25967
- let max = 0;
25968
- if (Array.isArray(siblings)) for (let s of siblings) {
25969
- let order = s && typeof s.order === "number" ? s.order : DEFAULT_ORDER;
25970
- if (order < 9999 && order > max) max = order;
25971
- }
25972
- return max + 1;
25973
- }
25974
- /***************************************************************
25975
- * Renumber a list AFTER a move, as the writes it takes: only the
25976
- * records whose order actually changed, so a drag of one row in a
25977
- * 40-column topic is not 40 writes.
25978
- *
25979
- * moved_orders(list) -> [{id, order}, ...]
25980
- ***************************************************************/
25981
- function moved_orders(list) {
25982
- let writes = [];
25983
- if (!Array.isArray(list)) return writes;
25984
- for (let i = 0; i < list.length; i++) {
25985
- let entry = list[i];
25986
- let order = i + 1;
25987
- if (!entry || typeof entry.id !== "string") continue;
25988
- if (entry.order !== order) writes.push({
25989
- id: entry.id,
25990
- order
25991
- });
25992
- }
25993
- return writes;
25994
- }
25995
- //#endregion
25996
26134
  //#region src/treedb_write_plan.js
25997
26135
  /***********************************************************************
25998
26136
  * treedb_write_plan.js
@@ -68838,12 +68976,20 @@ function hook_mark(type) {
68838
68976
  * (↖) 1 fkey (1 parent)
68839
68977
  * [↖] n fkeys (n parents)
68840
68978
  * {↖} N fkeys (N parents)
68979
+ * (2) a secondary key (pkey2)
68980
+ * (t) the time key (tkey)
68841
68981
  * * required
68842
68982
  * # the primary key
68843
68983
  *
68844
- * A column can be BOTH hook and fkey, so the flags are read
68845
- * here rather than through treedb_get_field_desc's single
68846
- * `type`, which keeps only the last flag it saw.
68984
+ * The key marks lead the mark column, and a pkey2 is drawn in
68985
+ * bold like the pkey: a topic with secondary keys keeps several
68986
+ * instances under one id, which is the first thing to know
68987
+ * about it. `(t)` is new to the notation: the literals' legend
68988
+ * carries it too.
68989
+ *
68990
+ * The flags are read here rather than through
68991
+ * treedb_get_field_desc's single `type`, which keeps only the
68992
+ * last flag it saw.
68847
68993
  ************************************************************/
68848
68994
  function schema_rows(desc) {
68849
68995
  let rows = [];
@@ -68853,19 +68999,25 @@ function schema_rows(desc) {
68853
68999
  let flags = Array.isArray(col.flag) ? col.flag : [];
68854
69000
  let is_hook = flags.indexOf("hook") >= 0;
68855
69001
  let is_fkey = flags.indexOf("fkey") >= 0;
69002
+ let roles = col_key_roles(desc, col.id);
69003
+ let key_marks = [];
69004
+ if (roles.includes("pkey2")) key_marks.push("(2)");
69005
+ if (roles.includes("tkey")) key_marks.push("(t)");
68856
69006
  let mark = "";
68857
69007
  if (is_hook) mark = hook_mark(col.type);
68858
69008
  if (is_fkey) {
68859
69009
  let fmark = hook_mark(col.type).replace("}", "↖}").replace("]", "↖]").replace(")", "↖)");
68860
69010
  mark = mark ? mark + " " + fmark : fmark;
68861
69011
  }
69012
+ if (key_marks.length) mark = mark ? key_marks.join(" ") + " " + mark : key_marks.join(" ");
68862
69013
  rows.push({
68863
69014
  name: col.id,
68864
69015
  type: col.type || "",
68865
69016
  mark,
68866
69017
  is_hook,
68867
69018
  is_fkey,
68868
- is_pkey: col.id === desc.pkey,
69019
+ is_pkey: roles.includes("pkey"),
69020
+ is_pkey2: roles.includes("pkey2"),
68869
69021
  required: flags.indexOf("required") >= 0 || flags.indexOf("notnull") >= 0
68870
69022
  });
68871
69023
  }
@@ -68912,7 +69064,7 @@ function build_card_innerHTML(topic, rows, color, dark) {
68912
69064
  let rows_html = "";
68913
69065
  for (let row of rows) {
68914
69066
  let lead = row.is_pkey ? "#" : row.required ? "*" : "";
68915
- let name_weight = row.is_pkey || row.required ? "600" : "400";
69067
+ let name_weight = row.is_pkey || row.is_pkey2 ? "700" : row.required ? "600" : "400";
68916
69068
  let name_color = row.is_hook || row.is_fkey ? title_color : text_color;
68917
69069
  rows_html += ` <div class="TREEDB_SCHEMA_FIELD" style="
68918
69070
  display: flex; align-items: center; gap: 6px;