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