@yuneta/gobj-ui 7.23.169 → 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
  ]
@@ -25699,6 +26192,43 @@ function plan_treedb_writes(flags) {
25699
26192
  form_toolbar: null
25700
26193
  };
25701
26194
  }
26195
+ /************************************************************
26196
+ * Does column `col` travel back to treedb on a write?
26197
+ *
26198
+ * The form SHOWS every field and sends back only what the topic
26199
+ * accepts: the writable cols, the fkeys (a link is edited by
26200
+ * linking) and the pkey, which is not written but is what
26201
+ * ADDRESSES the record.
26202
+ *
26203
+ * It matters because `treedb_update_node()` does not check
26204
+ * `writable` -- it writes any col it is handed -- so a read-only
26205
+ * field travelling back is written with whatever the form made of
26206
+ * it, and the fields that describe a record are exactly the ones
26207
+ * that do not survive a round trip through a widget: a `time` is an
26208
+ * integer rendered as a `datetime-local`, with no seconds, so every
26209
+ * save moved it back up to 59 s. Nothing looked wrong until the
26210
+ * stored timestamp had moved.
26211
+ *
26212
+ * `is_file` and not the type: a `file` column IS an fkey (it is
26213
+ * flagged ['fkey','file']) but answers `type: "file"`, and the write
26214
+ * goes out with `autolink`, which rebuilds the links from what the
26215
+ * record carries. Dropped, a read-only `file` column -- the one only
26216
+ * a load fills -- was UNLINKED by every save of any other field.
26217
+ *
26218
+ * The pkey2s go back for the same reason as the pkey: a secondary key
26219
+ * names the INSTANCE the update is for (`yunos.yuno_release` in the
26220
+ * agent), and it is `persistent, required`, not `writable`. Dropped,
26221
+ * the update reached the backend with no pkey2 and C_NODE resolved it
26222
+ * to the PRIMARY instance -- right by chance while the table lists
26223
+ * primaries, wrong from a form opened on a row of `instances`.
26224
+ ************************************************************/
26225
+ function col_goes_back_to_treedb(desc, col) {
26226
+ let d = desc || {};
26227
+ let pkey = d.pkey || "id";
26228
+ const field_desc = treedb_get_field_desc(col);
26229
+ if (field_desc.is_writable || field_desc.type === "fkey" || field_desc.is_file || col.id === pkey) return true;
26230
+ return topic_pkey2s(d).includes(col.id);
26231
+ }
25702
26232
  //#endregion
25703
26233
  //#region src/delete_impact.js
25704
26234
  /***********************************************************************
@@ -28046,35 +28576,6 @@ function get_schema_col(gobj, id) {
28046
28576
  return null;
28047
28577
  }
28048
28578
  /************************************************************
28049
- * Does column `col` travel back to treedb on a write?
28050
- *
28051
- * The form SHOWS every field and sends back only what the topic
28052
- * accepts: the writable cols, the fkeys (a link is edited by
28053
- * linking) and the pkey, which is not written but is what
28054
- * ADDRESSES the record.
28055
- *
28056
- * It matters because `treedb_update_node()` does not check
28057
- * `writable` -- it writes any col it is handed -- so a read-only
28058
- * field travelling back is written with whatever the form made of
28059
- * it, and the fields that describe a record are exactly the ones
28060
- * that do not survive a round trip through a widget: a `time` is an
28061
- * integer rendered as a `datetime-local`, with no seconds, so every
28062
- * save moved it back up to 59 s. Nothing looked wrong until the
28063
- * stored timestamp had moved.
28064
- *
28065
- * `is_file` and not the type: a `file` column IS an fkey (it is
28066
- * flagged ['fkey','file']) but answers `type: "file"`, and the write
28067
- * goes out with `autolink`, which rebuilds the links from what the
28068
- * record carries. Dropped, a read-only `file` column -- the one only
28069
- * a load fills -- was UNLINKED by every save of any other field.
28070
- ************************************************************/
28071
- function col_goes_back_to_treedb(gobj, col) {
28072
- let pkey = gobj_read_attr(gobj, "desc").pkey || "id";
28073
- const field_desc = treedb_get_field_desc(col);
28074
- if (field_desc.is_writable || field_desc.type === "fkey" || field_desc.is_file || col.id === pkey) return true;
28075
- return false;
28076
- }
28077
- /************************************************************
28078
28579
  * The record the FORM hands over, without the columns that do
28079
28580
  * not go back to treedb (see col_goes_back_to_treedb()). The
28080
28581
  * form has already encoded the values, so only the columns are
@@ -28084,7 +28585,7 @@ function strip_read_only_cols(gobj, record) {
28084
28585
  let row = {};
28085
28586
  for (let field_name of Object.keys(record || {})) {
28086
28587
  let col = get_schema_col(gobj, field_name);
28087
- if (col && !col_goes_back_to_treedb(gobj, col)) continue;
28588
+ if (col && !col_goes_back_to_treedb(gobj_read_attr(gobj, "desc"), col)) continue;
28088
28589
  row[field_name] = record[field_name];
28089
28590
  }
28090
28591
  return row;
@@ -28100,7 +28601,7 @@ function transform__form_record_2_treedb_record(gobj, kw, operation) {
28100
28601
  if (empty_string(field_name)) continue;
28101
28602
  let col = get_schema_col(gobj, field_name);
28102
28603
  if (!col) continue;
28103
- if (!col_goes_back_to_treedb(gobj, col)) continue;
28604
+ if (!col_goes_back_to_treedb(gobj_read_attr(gobj, "desc"), col)) continue;
28104
28605
  let value = kw[field_name];
28105
28606
  row[field_name] = transform__form_value_2_treedb_value(gobj, col, value, operation);
28106
28607
  }
@@ -68475,12 +68976,20 @@ function hook_mark(type) {
68475
68976
  * (↖) 1 fkey (1 parent)
68476
68977
  * [↖] n fkeys (n parents)
68477
68978
  * {↖} N fkeys (N parents)
68979
+ * (2) a secondary key (pkey2)
68980
+ * (t) the time key (tkey)
68478
68981
  * * required
68479
68982
  * # the primary key
68480
68983
  *
68481
- * A column can be BOTH hook and fkey, so the flags are read
68482
- * here rather than through treedb_get_field_desc's single
68483
- * `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.
68484
68993
  ************************************************************/
68485
68994
  function schema_rows(desc) {
68486
68995
  let rows = [];
@@ -68490,19 +68999,25 @@ function schema_rows(desc) {
68490
68999
  let flags = Array.isArray(col.flag) ? col.flag : [];
68491
69000
  let is_hook = flags.indexOf("hook") >= 0;
68492
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)");
68493
69006
  let mark = "";
68494
69007
  if (is_hook) mark = hook_mark(col.type);
68495
69008
  if (is_fkey) {
68496
69009
  let fmark = hook_mark(col.type).replace("}", "↖}").replace("]", "↖]").replace(")", "↖)");
68497
69010
  mark = mark ? mark + " " + fmark : fmark;
68498
69011
  }
69012
+ if (key_marks.length) mark = mark ? key_marks.join(" ") + " " + mark : key_marks.join(" ");
68499
69013
  rows.push({
68500
69014
  name: col.id,
68501
69015
  type: col.type || "",
68502
69016
  mark,
68503
69017
  is_hook,
68504
69018
  is_fkey,
68505
- is_pkey: col.id === desc.pkey,
69019
+ is_pkey: roles.includes("pkey"),
69020
+ is_pkey2: roles.includes("pkey2"),
68506
69021
  required: flags.indexOf("required") >= 0 || flags.indexOf("notnull") >= 0
68507
69022
  });
68508
69023
  }
@@ -68549,7 +69064,7 @@ function build_card_innerHTML(topic, rows, color, dark) {
68549
69064
  let rows_html = "";
68550
69065
  for (let row of rows) {
68551
69066
  let lead = row.is_pkey ? "#" : row.required ? "*" : "";
68552
- 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";
68553
69068
  let name_color = row.is_hook || row.is_fkey ? title_color : text_color;
68554
69069
  rows_html += ` <div class="TREEDB_SCHEMA_FIELD" style="
68555
69070
  display: flex; align-items: center; gap: 6px;
@@ -69018,361 +69533,6 @@ function register_c_yui_treedb_schema() {
69018
69533
  return create_gclass$2(GCLASS_NAME$2);
69019
69534
  }
69020
69535
  //#endregion
69021
- //#region src/schema_model.js
69022
- /***********************************************************************
69023
- * schema_model.js
69024
- *
69025
- * A SCHEMA out of the three meta-topics that store it.
69026
- *
69027
- * `treedb_system_schema` keeps every schema of a yuno as DATA, in
69028
- * three flat topics — `treedbs`, `topics`, `cols` — linked by
69029
- * fkeys. That is the right STORAGE and the wrong thing to put in
69030
- * front of an operator: adding one column to one topic means
69031
- * finding it in a table holding every column of every topic of
69032
- * every treedb the yuno has.
69033
- *
69034
- * This module turns those three lists back into what they
69035
- * describe: treedb -> topics -> columns, each in its declared
69036
- * `order`. Pure, so the editor's model can be tested with no DOM
69037
- * and no backend — the gclass fetches and draws, this decides
69038
- * what the records MEAN.
69039
- *
69040
- * TWO THINGS IT EXISTS TO STATE ONCE:
69041
- *
69042
- * 1. A record is grouped by its FKEY, not by its id. The id is
69043
- * qualified (`<treedb>.<topic>.<column>`) and reading it back
69044
- * with a split on '.' works right up to the first treedb or
69045
- * topic whose name carries one. The fkey is the link the store
69046
- * itself uses; the id is only the fallback for a record whose
69047
- * fkey never arrived.
69048
- *
69049
- * 2. A fkey reaches this code in FOUR shapes, because `nodes`
69050
- * answers differently depending on the options it was called
69051
- * with: the bare ref string, the expanded object, a list of
69052
- * either, or a dict keyed by the ref. All four are the same
69053
- * fact and parse_fkey_ref() flattens them to it.
69054
- *
69055
- * Copyright (c) 2026, ArtGins.
69056
- * All Rights Reserved.
69057
- ***********************************************************************/
69058
- var DEFAULT_ORDER = 9999;
69059
- /***************************************************************
69060
- * parse_fkey_ref(value) -> [{topic_name, id, hook_name}, ...]
69061
- *
69062
- * Every shape a fkey column can arrive in, flattened to the
69063
- * refs it names. Anything unrecognizable contributes nothing —
69064
- * a schema drawn without one link beats a screen that throws.
69065
- ***************************************************************/
69066
- function parse_fkey_ref(value) {
69067
- let refs = [];
69068
- let push_one = (v) => {
69069
- if (typeof v === "string") {
69070
- let parts = v.split("^");
69071
- if (parts.length !== 3) return;
69072
- refs.push({
69073
- topic_name: parts[0],
69074
- id: parts[1],
69075
- hook_name: parts[2]
69076
- });
69077
- return;
69078
- }
69079
- if (v && typeof v === "object") {
69080
- let id = v.id;
69081
- if (typeof id !== "string" || id.length === 0) return;
69082
- refs.push({
69083
- topic_name: v.topic_name || "",
69084
- id,
69085
- hook_name: v.hook_name || ""
69086
- });
69087
- }
69088
- };
69089
- if (value === null || value === void 0) return refs;
69090
- if (typeof value === "string") {
69091
- push_one(value);
69092
- return refs;
69093
- }
69094
- if (Array.isArray(value)) {
69095
- for (let v of value) push_one(v);
69096
- return refs;
69097
- }
69098
- if (typeof value === "object") for (let [key, v] of Object.entries(value)) if (v && typeof v === "object" && typeof v.id === "string") push_one(v);
69099
- else push_one(key);
69100
- return refs;
69101
- }
69102
- /***************************************************************
69103
- * The id of the FIRST parent a record names, or "" when it
69104
- * names none. A record has one parent here — a column belongs to
69105
- * one topic, a topic to one treedb — and a second ref would be a
69106
- * store this editor cannot draw anyway.
69107
- ***************************************************************/
69108
- function parent_id(record, fkey_col) {
69109
- let refs = parse_fkey_ref(record ? record[fkey_col] : null);
69110
- return refs.length > 0 ? refs[0].id : "";
69111
- }
69112
- /***************************************************************
69113
- * The name a record is KNOWN by, which is not what it is keyed
69114
- * by: `topics` and `cols` key by the qualified id and carry the
69115
- * bare name in `value` (its pkey2). An older store that still
69116
- * keyed by rowid has no `value` worth showing, so the id stands
69117
- * in — the same fallback as node_label().
69118
- ***************************************************************/
69119
- function record_name(record) {
69120
- if (!record) return "";
69121
- if (typeof record.value === "string" && record.value.length > 0) return record.value;
69122
- return typeof record.id === "string" ? record.id : "";
69123
- }
69124
- /***************************************************************
69125
- * The `order` of a record as a NUMBER, whatever the store put
69126
- * there: an integer column read back from JSON may arrive as a
69127
- * string, and comparing "10" with 9 sorts the schema wrong.
69128
- ***************************************************************/
69129
- function record_order(record) {
69130
- let order = record ? record.order : void 0;
69131
- if (typeof order === "number" && isFinite(order)) return order;
69132
- if (typeof order === "string" && order.trim().length > 0) {
69133
- let n = Number(order);
69134
- if (isFinite(n)) return n;
69135
- }
69136
- return DEFAULT_ORDER;
69137
- }
69138
- /***************************************************************
69139
- * Sort in place by `order`, ties broken by name so a schema
69140
- * whose records all default to 9999 still draws the same way
69141
- * twice.
69142
- ***************************************************************/
69143
- function sort_by_order(list) {
69144
- list.sort((a, b) => {
69145
- if (a.order !== b.order) return a.order - b.order;
69146
- return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
69147
- });
69148
- return list;
69149
- }
69150
- /***************************************************************
69151
- * build_schema_model({treedbs, topics, cols}) -> model
69152
- *
69153
- * model = {
69154
- * treedbs: [{
69155
- * id, name, record,
69156
- * schema_version, c_schema_version, system_schema_version,
69157
- * topics: [{
69158
- * id, name, order, record,
69159
- * pkey, topic_version, system_topic, main_topic,
69160
- * cols: [{id, name, order, record}]
69161
- * }]
69162
- * }],
69163
- * orphan_topics: [...], topics whose treedb is not in the store
69164
- * orphan_cols: [...] columns whose topic is not in the store
69165
- * }
69166
- *
69167
- * An orphan is NOT dropped. A column whose topic was deleted is
69168
- * exactly the kind of leftover an operator opens this editor to
69169
- * find, and a model that hides it makes the editor lie.
69170
- ***************************************************************/
69171
- function build_schema_model(records) {
69172
- let r = records || {};
69173
- let treedb_rows = Array.isArray(r.treedbs) ? r.treedbs : [];
69174
- let topic_rows = Array.isArray(r.topics) ? r.topics : [];
69175
- let col_rows = Array.isArray(r.cols) ? r.cols : [];
69176
- let treedbs = [];
69177
- let by_treedb_id = {};
69178
- for (let record of treedb_rows) {
69179
- if (!record || typeof record.id !== "string") continue;
69180
- let entry = {
69181
- id: record.id,
69182
- name: record.id,
69183
- record,
69184
- schema_version: record.schema_version,
69185
- c_schema_version: record.c_schema_version,
69186
- system_schema_version: record.system_schema_version,
69187
- topics: []
69188
- };
69189
- treedbs.push(entry);
69190
- by_treedb_id[entry.id] = entry;
69191
- }
69192
- let orphan_topics = [];
69193
- let by_topic_id = {};
69194
- for (let record of topic_rows) {
69195
- if (!record || typeof record.id !== "string") continue;
69196
- let entry = {
69197
- id: record.id,
69198
- name: record_name(record),
69199
- order: record_order(record),
69200
- record,
69201
- treedb_id: parent_id(record, "treedbs"),
69202
- pkey: record.pkey || "",
69203
- pkey2s: record.pkey2s,
69204
- system_flag: record.system_flag || "",
69205
- tkey: record.tkey || "",
69206
- topic_version: record.topic_version,
69207
- system_topic: !!record.system_topic,
69208
- main_topic: !!record.main_topic,
69209
- cols: []
69210
- };
69211
- by_topic_id[entry.id] = entry;
69212
- let parent = by_treedb_id[entry.treedb_id];
69213
- if (parent) parent.topics.push(entry);
69214
- else orphan_topics.push(entry);
69215
- }
69216
- let orphan_cols = [];
69217
- for (let record of col_rows) {
69218
- if (!record || typeof record.id !== "string") continue;
69219
- let entry = {
69220
- id: record.id,
69221
- name: record_name(record),
69222
- order: record_order(record),
69223
- record,
69224
- topic_id: parent_id(record, "topics")
69225
- };
69226
- let parent = by_topic_id[entry.topic_id];
69227
- if (parent) parent.cols.push(entry);
69228
- else orphan_cols.push(entry);
69229
- }
69230
- for (let treedb of treedbs) {
69231
- sort_by_order(treedb.topics);
69232
- for (let topic of treedb.topics) sort_by_order(topic.cols);
69233
- }
69234
- for (let topic of orphan_topics) sort_by_order(topic.cols);
69235
- treedbs.sort((a, b) => {
69236
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
69237
- });
69238
- sort_by_order(orphan_topics);
69239
- sort_by_order(orphan_cols);
69240
- return {
69241
- treedbs,
69242
- orphan_topics,
69243
- orphan_cols
69244
- };
69245
- }
69246
- /***************************************************************
69247
- * Is there a value here at all?
69248
- *
69249
- * The store answers a `blob` column that was never set with an
69250
- * EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
69251
- * `default: {}`. Read as "a value", those turn every export into
69252
- * a schema full of empty objects — and `'hook': {}` in a literal
69253
- * is a hook with no mapping, which is a link the treedb builds
69254
- * and nothing writes.
69255
- ***************************************************************/
69256
- function is_empty_value(value) {
69257
- if (value === null || value === void 0 || value === "") return true;
69258
- if (Array.isArray(value)) return value.length === 0;
69259
- if (typeof value === "object") return Object.keys(value).length === 0;
69260
- return false;
69261
- }
69262
- /***************************************************************
69263
- * The fields of a column that are DECLARED as one thing and
69264
- * STORED as another.
69265
- *
69266
- * `hook`, `enum`, `default`, `pkey2s` and `properties` are `blob`
69267
- * columns of the `cols` topic (treedb_system_schema.c), so what
69268
- * comes back may be the value or the JSON text of the value,
69269
- * depending on how it was written — a projection writes the
69270
- * value, a hand edit through a plain text field writes the text.
69271
- * `flag` is an array that a single-flag edit can leave as a bare
69272
- * string. Reading these without saying so is how a hook drawn
69273
- * from `{"users":"department"}` becomes no hook at all.
69274
- ***************************************************************/
69275
- function as_json(value) {
69276
- if (typeof value !== "string") return value;
69277
- let text = value.trim();
69278
- if (text.length === 0) return null;
69279
- try {
69280
- return JSON.parse(text);
69281
- } catch (e) {
69282
- return value;
69283
- }
69284
- }
69285
- function col_flags(record) {
69286
- let flag = record ? record.flag : null;
69287
- if (Array.isArray(flag)) return flag.filter((f) => typeof f === "string" && f.length > 0);
69288
- if (typeof flag === "string" && flag.length > 0) {
69289
- let parsed = as_json(flag);
69290
- if (Array.isArray(parsed)) return parsed.filter((f) => typeof f === "string" && f.length > 0);
69291
- return [flag];
69292
- }
69293
- return [];
69294
- }
69295
- function col_hook(record) {
69296
- let hook = as_json(record ? record.hook : null);
69297
- if (hook && typeof hook === "object" && !Array.isArray(hook)) return hook;
69298
- return null;
69299
- }
69300
- function col_enum(record) {
69301
- let e = as_json(record ? record.enum : null);
69302
- if (Array.isArray(e)) return e;
69303
- return [];
69304
- }
69305
- function topic_pkey2s(record) {
69306
- let pkey2s = as_json(record ? record.pkey2s : null);
69307
- if (Array.isArray(pkey2s)) return pkey2s.filter((k) => typeof k === "string" && k.length > 0);
69308
- if (typeof pkey2s === "string" && pkey2s.length > 0) return [pkey2s];
69309
- return [];
69310
- }
69311
- /***************************************************************
69312
- * Lookups by id. The editor addresses a position by NAME (the
69313
- * url carries `<treedb>/<topic>`), so both are offered.
69314
- ***************************************************************/
69315
- function find_treedb(model, treedb_id) {
69316
- if (!model || !Array.isArray(model.treedbs)) return null;
69317
- for (let treedb of model.treedbs) if (treedb.id === treedb_id) return treedb;
69318
- return null;
69319
- }
69320
- function find_topic(model, treedb_id, topic_name) {
69321
- let treedb = find_treedb(model, treedb_id);
69322
- if (!treedb) return null;
69323
- for (let topic of treedb.topics) if (topic.name === topic_name || topic.id === topic_name) return topic;
69324
- return null;
69325
- }
69326
- function find_col(model, treedb_id, topic_name, col_name) {
69327
- let topic = find_topic(model, treedb_id, topic_name);
69328
- if (!topic) return null;
69329
- for (let col of topic.cols) if (col.name === col_name || col.id === col_name) return col;
69330
- return null;
69331
- }
69332
- /***************************************************************
69333
- * The fkey a NEW record must carry to belong to `parent_id`.
69334
- * Written here because the editor is the only writer that has to
69335
- * compose one, and getting the hook name wrong links the record
69336
- * to nothing while the write still succeeds.
69337
- ***************************************************************/
69338
- function fkey_ref(parent_topic, parent_id_, hook_name) {
69339
- return `${parent_topic}^${parent_id_}^${hook_name}`;
69340
- }
69341
- /***************************************************************
69342
- * Where a new sibling goes: after the last one. The store's own
69343
- * default (9999) would put every hand-made column in the same
69344
- * place and let their names decide the schema's order.
69345
- ***************************************************************/
69346
- function next_order(siblings) {
69347
- let max = 0;
69348
- if (Array.isArray(siblings)) for (let s of siblings) {
69349
- let order = s && typeof s.order === "number" ? s.order : DEFAULT_ORDER;
69350
- if (order < 9999 && order > max) max = order;
69351
- }
69352
- return max + 1;
69353
- }
69354
- /***************************************************************
69355
- * Renumber a list AFTER a move, as the writes it takes: only the
69356
- * records whose order actually changed, so a drag of one row in a
69357
- * 40-column topic is not 40 writes.
69358
- *
69359
- * moved_orders(list) -> [{id, order}, ...]
69360
- ***************************************************************/
69361
- function moved_orders(list) {
69362
- let writes = [];
69363
- if (!Array.isArray(list)) return writes;
69364
- for (let i = 0; i < list.length; i++) {
69365
- let entry = list[i];
69366
- let order = i + 1;
69367
- if (!entry || typeof entry.id !== "string") continue;
69368
- if (entry.order !== order) writes.push({
69369
- id: entry.id,
69370
- order
69371
- });
69372
- }
69373
- return writes;
69374
- }
69375
- //#endregion
69376
69536
  //#region src/schema_validate.js
69377
69537
  /***********************************************************************
69378
69538
  * schema_validate.js