1// ER Diagram Database Object 2// Provides local and remote storage for ER Diagram data. 3// 4// Local: localStorage (key: erdiagram-db-${id}) 5// Remote: ws-zide API (pZOMCall LISTENTS / LISTRELS live Object Dictionary) 6// 7// Usage: 8// const db = createErDiagramDb("my-diagram-id"); 9// db.saveLocal(ndjson); 10// const ndjson = db.loadLocal(); 11// await db.saveApi("my-project", ndjson); 12// const ndjson = await db.loadApi("my-db"); 13// 14// // Module-level helpers (no db instance needed): 15// const files = await listSchemaFiles("MyDB"); 16// const text = await loadSchemaFromServer("MyDB"); 17 18import { buildWsUrl } from "../../shared/protocol.js"; 19import { WS_ZIDE, WS_DBA } from "../../shared/protocolConstants.js"; 20import { sendNdjsonOverWebSocket } from "../../shared/wssClient.js"; 21import { getActiveServerContext, getApiBase } from "../../shared/serverContext.js"; 22import { callAdminProc } from "../../shared/zdbAdminClient.js"; 23// Diagram-DOCUMENT persistence (canvas layout) goes to the generic zim doc store. 24// This is distinct from saveApi/loadApi below, which provision/read the live 25// SCHEMA in the Object Dictionary. 26import { saveDoc, loadDoc } from "../../common/shellDocStore.js"; 27 28const SHELL_TYPE = "erdiagram"; 29const LOCAL_KEY = (id) => `erdiagram-db-${id || "default"}`; 30const SHADOW_KEY = (id) => `erdiagram-shadow-${id || "default"}`; 31const UPDATE_EVENT = "erdiagram-db:updated"; 32 33/** 34 * Accepted file extensions for ER Diagram schema files. 35 * Both formats are newline-delimited JSON (NDJSON); treat identically. 36 */ 37export const SCHEMA_EXTENSIONS = [".jsonl", ".ndjson"]; 38 39/** 40 * ZimIDE management integration: 41 * - primary path: /api/ws-zide (JSON-RPC-like procedure calls) 42 * - fallback path: legacy repo HTTP API (for environments not yet exposing 43 * ER-specific save/load operations on pZIDE). 44 */ 45function getZideToken() { 46 return ( 47 sessionStorage.getItem("zimxToken") || 48 sessionStorage.getItem("zimx.access_token") || 49 "" 50 ).trim(); 51} 52 53function getZideDatabase() { 54 return (localStorage.getItem("repo.wsDb") || "").trim() || "ZimAdmin"; 55} 56 57function getZideUrl() { 58 const base = getApiBase(getActiveServerContext()) || window.location.origin; 59 return buildWsUrl(base, WS_ZIDE); 60} 61 62// ââ ws-dba provisioning (replaces the retired ws-zide pZOMEnts/pZOMRels path) ââ 63// ws-zide/ws-zadmin are gone; provisioning now rides ws-dba â the working, tenant 64// router â into `pBuildSchema` (the write twin of pErSchema). We emit one small 65// frame per entity / field / relationship (the pImportSchema shape the proc reads 66// from webprompt) instead of one packet carrying a nested fields[] array, so the 67// ZIM side never has to parse a JSON array. 68function getDbaUrl() { 69 const base = getApiBase(getActiveServerContext()) || window.location.origin; 70 return buildWsUrl(base, WS_DBA); 71} 72 73// Flatten a diagram (NDJSON) into pBuildSchema op frames: each entity IMMEDIATELY 74// followed by its own fields, then all relationships, then a terminating "end". 75// `database` is stamped on every frame so the proc writes into the right DirName. 76// 77// Diagram authoring types â canonical ZIM catalog types. Without this the 78// provisioning path stored alpha/varalpha/numeric verbatim â valid, but the odd 79// ones out: the live codebase uses vastint/char/varchar/int/datetime (1300+ 80// fields) and effectively zero alpha/varalpha/numeric. numericâvastint is the 81// proven money/decimal type (avoids int's 32767 overflow). Mirrors the 82// SQL-export map in index.js. 83const ZIM_TYPE = { 84 alpha: "char", varalpha: "varchar", numeric: "vastint", 85 char: "char", varchar: "varchar", vastint: "vastint", 86 int: "int", longint: "longint", datetime: "datetime", date: "date", blob: "blob", 87}; 88 89export function buildProvisionFrames(ndjson, database) { 90 const recs = parseDiagramRecords(ndjson); 91 const db = String(database || "").trim(); 92 const frames = []; 93 let seq = 0; 94 const mk = (p) => `erp-${p}-${seq++}`; 95 96 // One field-frame builder for BOTH entity-owned and relationship-owned fields. 97 // `owner` is an entity name OR a relationship name: a relationship that OWNS 98 // fields is a "datarel" â the data-manufacturing site (docs/architecture/ 99 // datarel-fusion-and-manufactured-data.md). The virtual/default keys are emitted 100 // ONLY when set, so an ordinary stored entity field's frame stays byte-identical 101 // to before this change (zero regression to the working entity path); a virtual/ 102 // computed field (a datarel's fused rank) then provisions AS virtual carrying its 103 // compute expression, instead of silently landing as a plain stored column. 104 // The LANGUAGE mechanism is vendor-confirmed: `create field ownername fieldname`
105 // where ownername is "the EntitySet, RELATIONSHIP WITH FIELDS, or application 106 // document to which fieldname belongs" (ZIM 4GL CREATE reference), and a virtual 107 // field's value is computed from its Default attribute and cannot be varchar/ 108 // varalpha (ZIM Field Virtual reference). What remains on-box (tracked: 109 // zxplat-datarel-tooling) is confirming pBuildSchema TRANSLATES a rel-owner field 110 // frame (+ virtual/default) into that add Fields / create field sequence â verify 111 // the proc against this known-correct target, it is no longer a guess. 112 const fieldFrame = (owner, f) => { 113 const fname = String(f?.name || "").trim(); 114 if (!fname) return null; 115 const frame = { 116 id: mk("fld"), type: "field", entity: owner, name: fname, 117 fieldType: ZIM_TYPE[String(f?.type || "char").toLowerCase()] || "char", 118 length: String(f?.length ?? 0), 119 decimals: String(f?.decimals ?? 0), 120 index: (f?.pk || f?.index) ? "Yes" : "No", 121 database: db, 122 }; 123 if (f?.virtual) frame.virtual = "Yes"; 124 const dflt = String(f?.default ?? f?.defaultValue ?? f?.Default ?? "").trim(); 125 if (dflt) frame.default = dflt; 126 return frame; 127 }; 128 129 const entVals = Object.values(recs.entities || {}); 130 // Each entity frame is IMMEDIATELY followed by its own field frames. pBuildSchema 131 // adds an entity's fields to the dictionary, then MATERIALISES it (create EntitySet) 132 // when the next entity / relationship / end frame arrives â so the fields MUST come 133 // before that boundary. Emitting all entities first then all fields made pBuildSchema 134 // create each entity with zero columns and abort. Rels still follow every entity, so 135 // they resolve against live owners. 136 for (const rec of entVals) { 137 const name = String(rec?.name || rec?.id || "").trim(); 138 if (!name) continue; 139 frames.push(JSON.stringify({ id: mk("ent"), type: "entity", name, database: db })); 140 for (const f of (Array.isArray(rec?.fields) ? rec.fields : [])) { 141 const frame = fieldFrame(name, f); 142 if (frame) frames.push(JSON.stringify(frame)); 143 } 144 } 145 for (const rec of Object.values(recs.relationships || {})) { 146 const name = String(rec?.name || rec?.id || "").trim(); 147 if (!name) continue; 148 frames.push(JSON.stringify({ id: mk("rel"), type: "relationship", name, condition: String(rec?.condition || ""), database: db })); 149 // Datarel-owned fields follow their OWN relationship frame â mirroring the 150 // entity â fields â boundary ordering above, so the field frames materialise 151 // against a live owner. A plain rel has no fields[] and emits nothing extra. 152 for (const f of (Array.isArray(rec?.fields) ? rec.fields : [])) { 153 const frame = fieldFrame(name, f); 154 if (frame) frames.push(JSON.stringify(frame)); 155 } 156 } 157 frames.push(JSON.stringify({ id: mk("end"), type: "end", database: db })); 158 return frames; 159} 160 161// Open ws-dba, bootstrap pBuildSchema in the target DB, stream the frames, collect 162// each result envelope. One connection serves every op (the proc loops on webprompt). 163async function sendProvisionFramesOverWsDba(database, frames, procName = "pBuildSchema()") { 164 const token = getZideToken(); 165 if (!token) throw new Error("Missing access token for ws-dba provisioning"); 166 const db = String(database || "").trim(); 167 if (!db) throw Object.assign(new Error("A target database name is required to provision."), { code: "MISSING_DATABASE" }); 168 const bootstrap = JSON.stringify({ db, host: "localhost", proc: procName, token }); 169 170 // A freshly-created DB enables its FeBe handlers (including pBuildSchema) 171 // ASYNCHRONOUSLY: pZdbExec "create" fires febe-enable-db.sh detached and returns 172 // febeEnabled:"queued" â the enable finishes ~1-2s later. If we bootstrap 173 // pBuildSchema before that window closes, the engine replies "...is not a known 174 // name" and drops every frame. Retry the whole bootstrap+frames until the proc is 175 // present; the frames are idempotent ($exist$ guards), so replay is safe. 176 const MAX_TRIES = 8; 177 const RETRY_MS = 1000; 178 let parsed = []; 179 for (let attempt = 1; attempt <= MAX_TRIES; attempt += 1) { 180 const replies = []; 181 await sendNdjsonOverWebSocket(getDbaUrl(), [bootstrap, ...frames], (text) => replies.push(String(text || ""))); 182 parsed = replies.map((line) => { try { return JSON.parse(line); } catch { return null; } }
182).filter(Boolean); 183 const procMissing = replies.some((r) => /is not a known name/i.test(r)); 184 if (!procMissing) return parsed; 185 if (attempt < MAX_TRIES) await new Promise((resolve) => setTimeout(resolve, RETRY_MS)); 186 } 187 return parsed; 188} 189 190async function sendEntityRelationLines(lines) { 191 const token = getZideToken(); 192 if (!token) throw new Error("Missing access token for ws-zide"); 193 const replies = []; 194 await sendNdjsonOverWebSocket( 195 getZideUrl(), 196 lines.map((line) => { 197 const obj = JSON.parse(line); 198 if (!obj.token) obj.token = token; 199 return JSON.stringify(obj); 200 }), 201 (text) => replies.push(String(text || "")) 202 ); 203 return replies.map((line) => { 204 try { return JSON.parse(line); } catch { return null; } 205 }).filter(Boolean); 206} 207 208function getMgmtOrigin() { 209 return getApiBase(getActiveServerContext()) || window.location.origin; 210} 211 212function normalizeZimRelType(value) { 213 return String(value || "").trim() || "Zim"; 214} 215 216// Matches a single unquoted ZIM identifier token such as eUsers or userId. 217const IDENTIFIER_PATTERN = String.raw`[A-Za-z_][A-Za-z0-9_]*`; 218const REL_CONDITION_SIDE_RE = new RegExp(`^(${IDENTIFIER_PATTERN})\\s*\\.\\s*(${IDENTIFIER_PATTERN})$`); 219 220function adaptField(field) { 221 const name = String(field?.name || field?.FieldName || "").trim(); 222 const type = String(field?.type || field?.Type || "unknown").trim() || "unknown"; 223 const lengthRaw = field?.length ?? field?.Length; 224 const decimalsRaw = field?.decimals ?? field?.Decimals; 225 const lengthNum = Number(lengthRaw); 226 const decimalsNum = Number(decimalsRaw); 227 const length = Number.isFinite(lengthNum) ? lengthNum : undefined; 228 const decimals = Number.isFinite(decimalsNum) ? decimalsNum : undefined; 229 const index = String(field?.index ?? field?.Index ?? "").toLowerCase(); 230 const indexed = field?.indexed ?? field?.Indexed; 231 // PK only from a real key marker; an ordinary index is NOT a PK. 232 const pk = Boolean(field?.pk) || index === "pkey" || index === "pk"; 233 const isIndexed = Boolean(indexed) || index === "yes"; 234 const virtual = Boolean(field?.virtual) || String(field?.Virtual ?? "").toLowerCase() === "yes"; 235 const defaultValue = String(field?.defaultValue ?? field?.default ?? field?.Default ?? "").trim(); 236 return { name, type, length, decimals, pk, fk: false, index: isIndexed, virtual, defaultValue }; 237} 238 239function getFieldRows(owner) { 240 if (Array.isArray(owner?.fields)) return owner.fields; 241 if (Array.isArray(owner?.Fields)) return owner.Fields; 242 return []; 243} 244 245function parseRelationshipEndpoints(condition) { 246 const expr = String(condition || "").trim(); 247 if (!expr) return null; 248 const parts = expr.split("="); 249 if (parts.length !== 2) return null; 250 const parseSide = (value) => { 251 const match = String(value || "").trim().match(REL_CONDITION_SIDE_RE); 252 if (!match) return null; 253 return { entity: match[1], field: match[2] }; 254 }; 255 const left = parseSide(parts[0]); 256 const right = parseSide(parts[1]); 257 if (!left || !right) return null; 258 return { 259 entities: [left.entity, right.entity], 260 fields: [left.field, right.field], 261 }; 262} 263 264export function adaptZomSchemaToErdiagramNdjson(schema, database, version = "live") { 265 const dbName = String(database || "ZimAdmin").trim() || "ZimAdmin"; 266 const entities = Array.isArray(schema?.entities) ? schema.entities.filter(Boolean).map(String) : []; 267 const fieldsByEntity = schema?.fieldsByEntity && typeof schema.fieldsByEntity === "object" 268 ? schema.fieldsByEntity 269 : {}; 270 const rels = Array.isArray(schema?.relationships) ? schema.relationships : []; 271 272 const lines = [ 273 JSON.stringify({ 274 type: "erdiagram-header", 275 version: 1, 276 schema: dbName, 277 schemaVersion: String(version || "live"), 278 generator: "ZimX Gen DB", 279 entities, 280 }), 281 ]; 282 283 const COLS = 4; 284 const START_X = 40; 285 const START_Y = 40; 286 const GAP_X = 300; 287 const GAP_Y = 220; 288 const WIDTH = 240; 289 290 entities.forEach((entityName, idx) => { 291 const x = START_X + (idx % COLS) * GAP_X; 292 const y = START_Y + Math.floor(idx / COLS) * GAP_Y; 293 const fields = (Array.isArray(fieldsByEntity[entityName]) ? fieldsByEntity[entityName] : []) 294 .map(adaptField) 295 .filter((field) => field.name); 296 297 lines.push(JSON.stringify({ 298 type: "entity", 299 id: entityName, 300 name: entityName, 301 x, 302 y, 303 w: WIDTH, 304 fields, 305 })); 306 }); 307
308 rels.forEach((rel, idx) => { 309 const name = String(rel?.name || rel?.id || `rel-${idx + 1}`).trim(); 310 const condition = String(rel?.condition || rel?.RelCondition || rel?.relCondition || "").trim(); 311 const relationship = { 312 type: "relationship", 313 id: String(rel?.id || `rel-${idx + 1}`), 314 name, 315 relType: normalizeZimRelType(rel?.relType || rel?.RelType), 316 condition, 317 fields: getFieldRows(rel) 318 .map(adaptField) 319 .filter((field) => field.name), 320 }; 321 const endpoints = parseRelationshipEndpoints(condition); 322 if (endpoints) { 323 relationship.endpoints = endpoints; 324 // draw.js draws each edge from data-from/data-to (set from these fields); 325 // derive them from the ZIM condition (eA.field = eB.field) so loaded rels 326 // actually render as connecting lines. 327 relationship.from = endpoints.entities[0]; 328 relationship.to = endpoints.entities[1]; 329 } 330 lines.push(JSON.stringify(relationship)); 331 }); 332 333 return lines.join("\n"); 334} 335 336// Option B: load the live schema (ents + rels + vars) over the ws-dba universal 337// router via pErSchema, which runs INSIDE the target DB and reads its Fields/Rels/ 338// Vars catalogs NATIVELY â avoiding the ws-zide cross-DB introspection trap that 339// left "Load from database" empty. Same transport FeBe uses for pFbSchema. 340async function loadSchemaViaWsDba(database, deps = {}) { 341 const db = database || getZideDatabase(); 342 const { rows } = await callAdminProc(getMgmtOrigin(), { proc: "pErSchema", op: "schema", db }); 343 const fieldsByEntity = {}; 344 const entities = new Set(); 345 const relationships = []; 346 const vars = []; 347 (Array.isArray(rows) ? rows : []).forEach((r) => { 348 const kind = String(r?.kind || ""); 349 if (kind === "field") { 350 const owner = String(r.owner || "").trim(); 351 const field = String(r.field || "").trim(); 352 if (!owner || !field) return; 353 entities.add(owner); 354 if (!fieldsByEntity[owner]) fieldsByEntity[owner] = []; 355 fieldsByEntity[owner].push({ 356 name: field, 357 type: r.ftype || "char", 358 length: r.len, 359 decimals: r.dec, 360 index: r.index || "no", 361 indexed: String(r.index || "no").toLowerCase() === "yes", 362 virtual: String(r.virtual || "no").toLowerCase() === "yes", 363 defaultValue: r.default || "", 364 }); 365 } else if (kind === "rel") { 366 // ZIM rels are CONDITION-based, RelType="ZIM" â not crow's-foot cardinality. 367 relationships.push({ name: r.name || "", condition: r.cond || "", relType: r.reltype || "ZIM" }); 368 } else if (kind === "var") { 369 vars.push({ name: r.name || "", type: r.vtype || "char", length: r.len }); 370 } 371 }); 372 // Surface globals (Vars) as a synthetic node so they appear in the diagram too. 373 if (vars.length) { 374 entities.add("zGlobals"); 375 fieldsByEntity["zGlobals"] = vars.map((v) => ({ name: v.name, type: v.type, length: v.length })); 376 } 377 const schema = { entities: Array.from(entities), fieldsByEntity, relationships }; 378 return adaptZomSchemaToErdiagramNdjson(schema, db, deps.version || "live"); 379} 380 381async function loadSchemaViaZom(database, deps = {}) { 382 const result = await loadSchemaViaZomDetailed(database, deps); 383 return result.schemaText; 384} 385 386function normalizeLoadError(error, fallbackMessage, details = {}) { 387 if (error && typeof error === "object") return error; 388 const wrapped = new Error(fallbackMessage); 389 wrapped.code = "ERDIAGRAM_SCHEMA_LOAD_FAILED"; 390 wrapped.details = { ...details, cause: String(error || "") }; 391 return wrapped; 392} 393 394async function loadSchemaViaZomDetailed(database, deps = {}) { 395 const db = database || getZideDatabase(); 396 let fetchSchemaDetailed = deps.fetchSchemaDetailed; 397 const fetchSchema = deps.fetchSchema; 398 if (!fetchSchemaDetailed && fetchSchema) { 399 fetchSchemaDetailed = async (origin, databaseName) => { 400 const schema = await fetchSchema(origin, databaseName); 401 return { schema, error: null }; 402 }; 403 } 404 if (!fetchSchemaDetailed) { 405 const dbSchemaSource = await import("../../../streams/dbSchemaSource.js"); 406 fetchSchemaDetailed = dbSchemaSource.fetchSchemaViaZideDetailed; 407 } 408 const result = await fetchSchemaDetailed(getMgmtOrigin(), db); 409 const schema = result?.schema || { entities: [], fieldsByEntity: {}, relationships: [] }; 410 if (result?.error) return { schemaText: null, isEmpty: false, error: result.error }; 411 if (!Array.isArray(schema?.entities) || schema.entities.length === 0) { 412 return { schemaText: null, isEmpty: true, error: null }; 413 } 414 return { 415 schemaText: adaptZomSchemaToErdiagramNdjson(schema, db, deps.version || "live"), 416 isEmpty: false, 417 error: null, 418 }; 419} 420 421function parseDiagramRecords(text) { 422 const out = { entities: {}, relationships: {} }; 423 const lines = String(text || "").split("\n").map((line) =>
423 line.trim()).filter(Boolean); 424 for (const line of lines) { 425 try { 426 const rec = JSON.parse(line); 427 if (rec?.type === "entity" && rec?.id) out.entities[String(rec.id)] = rec; 428 if (rec?.type === "relationship" && rec?.id) out.relationships[String(rec.id)] = rec; 429 } catch {} 430 } 431 return out; 432} 433 434function buildOperationLines(currentNdjson, previousNdjson, database) { 435 const current = parseDiagramRecords(currentNdjson); 436 const previous = parseDiagramRecords(previousNdjson); 437 const ops = []; 438 let seq = 0; 439 const makeId = (prefix) => `erd-op-${Date.now()}-${prefix}-${seq++}`; 440 441 const emitEntity = (op, rec) => { 442 const payload = { 443 id: makeId("entity"), 444 type: "entity", 445 op, 446 name: String(rec?.name || rec?.id || "Entity"), 447 entityId: String(rec?.id || ""), 448 database, 449 }; 450 if (op !== "delete") payload.fields = Array.isArray(rec?.fields) ? rec.fields : []; 451 ops.push(JSON.stringify(payload)); 452 }; 453 454 const emitRelationship = (op, rec) => { 455 const payload = { 456 id: makeId("relationship"), 457 type: "relationship", 458 op, 459 name: String(rec?.name || rec?.id || "Relationship"), 460 relationshipId: String(rec?.id || ""), 461 database, 462 }; 463 if (op !== "delete") { 464 payload.relType = String(rec?.relType || "Zim"); 465 payload.condition = String(rec?.condition || ""); 466 payload.fields = Array.isArray(rec?.fields) ? rec.fields : []; 467 } 468 ops.push(JSON.stringify(payload)); 469 }; 470 471 for (const [id, rec] of Object.entries(current.entities)) { 472 if (!previous.entities[id]) emitEntity("create", rec); 473 else if (JSON.stringify(previous.entities[id]) !== JSON.stringify(rec)) emitEntity("update", rec); 474 } 475 for (const [id, rec] of Object.entries(previous.entities)) { 476 if (!current.entities[id]) emitEntity("delete", rec); 477 } 478 for (const [id, rec] of Object.entries(current.relationships)) { 479 if (!previous.relationships[id]) emitRelationship("create", rec); 480 else if (JSON.stringify(previous.relationships[id]) !== JSON.stringify(rec)) emitRelationship("update", rec); 481 } 482 for (const [id, rec] of Object.entries(previous.relationships)) { 483 if (!current.relationships[id]) emitRelationship("delete", rec); 484 } 485 return ops; 486} 487 488/** 489 * findOperationError(responses) 490 * Returns the first ws-zide response that signalled a failure, or null when 491 * every operation was accepted. 492 */ 493function findOperationError(responses) { 494 if (!Array.isArray(responses)) return null; 495 return responses.find((resp) => resp?.type === "error" || resp?.success === false) || null; 496} 497 498/** 499 * provisionDiagramOverWss(database, ndjson, deps?) 500 * 501 * Provision a database exclusively by emitting the documented NDJSON 502 * entity/relationship packets (`type:"entity"|"relationship"`, `op`, `name`, 503 * `database`, `fields`, `relType`, `condition`) over WSS to `/api/ws-zide`. 504 * 505 * No raw Zim-4GL is generated client-side: the packets are normalised to 506 * `pZOMEnts` / `pZOMRels` server-side, where Zim's mature object-dictionary 507 * tooling performs all field/temp-owner/create logic. There is no REST 508 * fallback â the packet is only ever sent over WSS. 509 * 510 * @param {string} database Target database (e.g. application/database name). 511 * @param {string} ndjson Serialised ER-diagram NDJSON to provision. 512 * @param {object} [deps] Optional dependency injection for testing. 513 * @returns {Promise<{type:string,success:boolean,data:object,error:null}>} 514 */ 515export async function provisionDiagramOverWss(database, ndjson, deps = {}) { 516 const dbName = String(database || "").trim(); 517 if (!dbName) { 518 throw Object.assign(new Error("A target database name is required to provision."), { 519 code: "MISSING_DATABASE", 520 }); 521 } 522 // Provisioning rides ws-dba -> pBuildSchema now (ws-zide/pZOMEnts is retired). 523 // Every entity/field/relationship is its own frame; deps.sendFrames is injectable 524 // for tests. previous is "" so every record is a fresh create. 525 const sendFrames = deps.sendFrames || sendProvisionFramesOverWsDba; 526 const frames = buildProvisionFrames(ndjson, dbName); 527 // frames always ends with a single "end"; length 1 == empty diagram, nothing to do. 528 if (frames.length <= 1) { 529 return { type: "result", success: true, data: { operations: 0, responses: [] }, error: null }; 530 } 531 const responses = await sendFrames(dbName, frames); 532 const firstError = findOperationError(responses); 533 if (firstError) { 534 throw new Error(firstError?.error?.message || "ws-dba rejected one or more schema ops"); 535 } 536 // Completeness gate: pBuildSchema emits a terminal {done:true} on the "end" frame. 537 // If an op aborts the proc mid-stream it returns before "end", and the error frame 538 // may not reach us before the socket closes â so a MISSING terminal frame is also a 539 // failure, never a silent success. 540 if (!responses.some((r) => r?.data?.done === true)) { 541 throw new Error("Schema build did not complete â an operation failed mid-stream (no terminal frame)"); 542 } 543 return { 544 type: "result", 545 success: true, 546 data: { operations: frames.length - 1, responses, database: dbName }, 547 error: null, 548 }; 549} 550 551// ââ Per-entity ops (ws-dba -> pEntUpdate) âââââââââââââââââââââââââââââââââââââ 552// The unit of work here is ONE entity, not the whole diagram. Gen DB streams every 553// entity, field and relationship on every save, which is both slow and create-only: 554// pBuildSchema skips an entity that already exists, so a field added to an existing 555// entity reports success and changes nothing. pEntUpdate takes a single entity and 556// reconciles it against the live dictionary via pAlterEntity. 557// 558// The field frames are the COMPLETE intended field list, not a delta â the diagram 559// is the source of truth for shape, and the server derives add/drop by comparing it 560// to what the dictionary currently holds. Fields that already match cost nothing. 561 562/** The database per-entity ops target: the same one "Save Schema" writes to. */ 563export function getSchemaDatabase() { 564 return getZideDatabase(); 565} 566 567/** 568 * buildEntityFrames(entity, database, op) 569 * Frames for one entity: the entity frame, its own field frames, then "end". 570 * `entity` is {name, fields[]} as carried on the diagram node's data-fields attr.
571 * A "delete" op sends no field frames â the entity goes as a whole. 572 */ 573export function buildEntityFrames(entity, database, op = "save") { 574 const db = String(database || "").trim(); 575 const name = String(entity?.name || "").trim(); 576 if (!name) throw Object.assign(new Error("An entity name is required."), { code: "MISSING_ENTITY" }); 577 let seq = 0; 578 const mk = (p) => `ere-${p}-${seq++}`; 579 const frames = [JSON.stringify({ id: mk("ent"), type: "entity", op, name, database: db })]; 580 if (op !== "delete") { 581 for (const f of (Array.isArray(entity?.fields) ? entity.fields : [])) { 582 const fname = String(f?.name || "").trim(); 583 if (!fname) continue; 584 // Same coercions as buildProvisionFrames: field editors produce `length` as a 585 // string and server-loaded fields produce it as a number, and only the 586 // server-loaded path carries `decimals` at all. 587 frames.push(JSON.stringify({ 588 id: mk("fld"), type: "field", entity: name, name: fname, 589 fieldType: ZIM_TYPE[String(f?.type || "char").toLowerCase()] || "char", 590 length: String(f?.length ?? 0), 591 decimals: String(f?.decimals ?? 0), 592 index: (f?.pk || f?.index) ? "Yes" : "No", 593 database: db, 594 })); 595 } 596 } 597 frames.push(JSON.stringify({ id: mk("end"), type: "end", database: db })); 598 return frames; 599} 600 601async function runEntityOp(database, entity, op, deps = {}) { 602 const dbName = String(database || "").trim(); 603 if (!dbName) { 604 throw Object.assign(new Error("A target database name is required."), { code: "MISSING_DATABASE" }); 605 } 606 const send = deps.sendFrames 607 || ((db, frames) => sendProvisionFramesOverWsDba(db, frames, "pEntUpdate()")); 608 const responses = await send(dbName, buildEntityFrames(entity, dbName, op)); 609 const firstError = findOperationError(responses); 610 if (firstError) { 611 throw new Error(firstError?.error?.message || `ws-dba rejected the ${op} op`); 612 } 613 // Same completeness gate as provisioning: pEntUpdate emits {done:true} only on the 614 // "end" frame. An op that aborts the proc returns before that, and its error frame 615 // may not reach us before the socket closes â so a missing terminal frame is a 616 // failure, never a silent success. 617 if (!responses.some((r) => r?.data?.done === true)) { 618 throw new Error(`Entity ${op} did not complete â no terminal frame (the operation aborted mid-stream)`); 619 } 620 return { 621 type: "result", 622 success: true, 623 data: { entity: String(entity?.name || ""), op, responses, database: dbName }, 624 error: null, 625 }; 626} 627 628/** Reconcile one entity's fields into `database`. Creates it if it does not exist. */ 629export function saveEntityOverWss(database, entity, deps = {}) { 630 return runEntityOp(database, entity, "save", deps); 631} 632 633/** Destroy one entity, its rows and its dictionary rows. Not reversible. */ 634export function deleteEntityOverWss(database, entityName, deps = {}) { 635 return runEntityOp(database, { name: entityName }, "delete", deps); 636} 637 638/** 639 * listSchemaFiles(database, deps?) 640 * Returns one synthetic "live" entry because schema is now loaded directly 641 * from the database Object Dictionary. 642 * 643 * @param {string} database Database name (e.g. "ZimAdmin"). 644 * @param {object} [deps] Optional dependency injection for testing. 645 * @returns {Promise<Array<{version:string, filename:string, ext:string}>>} 646 */ 647export async function listSchemaFiles(database, deps = {}) { 648 void database; 649 void deps; 650 return [{ version: "live", filename: "Live Object Dictionary", ext: ".live" }]; 651} 652 653/** 654 * loadSchemaFromServer(database, version?, deps?) 655 * Loads schema from the live Object Dictionary via pZOMCall LISTENTS/LISTRELS. 656 * Never falls back to localStorage. 657 * 658 * @param {string} database Database name. 659 * @param {string} [version] Schema version label (defaults to "live"). 660 * @param {object} [deps] Optional dependency injection for testing. 661 * @returns {Promise<string|null>} 662 */ 663export async function loadSchemaFromServer(database, version = "live", deps = {}) { 664 const result = await loadSchemaFromServerDetailed(database, version, deps); 665 return result.schemaText; 666} 667 668export async function loadSchemaFromServerDetailed(database, version = "live", deps = {}) { 669 try { 670 return await loadSchemaViaZomDetailed(database, { ...deps, version }); 671 } catch (err) { 672 const normalized = normalizeLoadError(err, "Unable to load schema from live Object Dictionary", { 673 database, 674 version, 675 }); 676 console.error("[erdiagramDb] loadSchemaFromServer (LISTENTS/LISTRELS) failed:", normalized.message, normalized.details || {}); 677 return { schemaText: null, isEmpty: false, error: normalized }; 678 } 679} 680 681/** 682 * createErDiagramDb(id) 683 * Returns a database object scoped to this diagram ID. 684 * Synchronous methods for local storage; async methods for backend API. 685 * 686 * @param {string} id Diagram identifier (used as storage key). 687 */ 688export function createErDiagramDb(id, deps = {}) { 689 const key = LOCAL_KEY(id); 690 const shadowKey = SHADOW_KEY(id);
691 const sendOps = deps.sendOps || sendEntityRelationLines; 692 693 return { 694 /** The diagram ID this db is scoped to. */ 695 id, 696 697 // ââ Local Storage ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 698 699 /** 700 * Save NDJSON string to localStorage. 701 * @param {string} ndjson Serialised diagram. 702 * @returns {boolean} true on success. 703 */ 704 saveLocal(ndjson) { 705 try { 706 localStorage.setItem(key, ndjson); 707 window.dispatchEvent( 708 new CustomEvent(UPDATE_EVENT, { detail: { id, action: "save" } }) 709 ); 710 return true; 711 } catch (err) { 712 console.error("ER Diagram local save failed:", err); 713 return false; 714 } 715 }, 716 717 /** 718 * Load NDJSON string from localStorage. 719 * @returns {string|null} NDJSON data or null if not found. 720 */ 721 loadLocal() { 722 try { 723 return localStorage.getItem(key) || null; 724 } catch { 725 return null; 726 } 727 }, 728 729 /** 730 * Delete saved data from localStorage. 731 */ 732 clearLocal() { 733 try { 734 localStorage.removeItem(key); 735 window.dispatchEvent( 736 new CustomEvent(UPDATE_EVENT, { detail: { id, action: "clear" } }) 737 ); 738 } catch {} 739 }, 740 741 // ââ Backend API ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 742 743 /** 744 * Save the diagram to the server by emitting the documented NDJSON 745 * entity/relationship packets over WSS to `/api/ws-zide`. 746 * 747 * The packet is the only thing ever sent, and it is only ever sent over 748 * WSS â there is no REST (`saveRepo`) or raw `pZOMCall` fallback. The 749 * server normalises each packet to `pZOMEnts` / `pZOMRels`, where Zim's 750 * mature object-dictionary tooling performs all field/temp-owner/create 751 * logic. Any rejection is surfaced to the caller. 752 * 753 * @param {string} project Project/namespace identifier (unused for the 754 * WSS packet; retained for signature stability). 755 * @param {string} ndjson Serialised diagram. 756 * @returns {Promise<{type:string,success:boolean,data:object,error:null}>} 757 */ 758 async saveApi(project, ndjson) { 759 void project; 760 const database = getZideDatabase(); 761 const previous = localStorage.getItem(shadowKey) || ""; 762 const operationLines = buildOperationLines(ndjson, previous, database); 763 764 if (operationLines.length === 0) { 765 localStorage.setItem(shadowKey, ndjson); 766 return { type: "result", success: true, data: { operations: 0, responses: [] }, error: null }; 767 } 768 769 const responses = await sendOps(operationLines); 770 const firstError = findOperationError(responses); 771 if (firstError) { 772 throw new Error(firstError?.error?.message || "ws-zide rejected one or more operations"); 773 } 774 localStorage.setItem(shadowKey, ndjson); 775 return { 776 type: "result", 777 success: true, 778 data: { operations: operationLines.length, responses }, 779 error: null, 780 }; 781 }, 782 783 /** 784 * Load NDJSON from live Object Dictionary (pZOMCall LISTENTS/LISTRELS). 785 * Returns null â never falls back to localStorage or the repo API â 786 * so the caller can surface a clear "no schema on server" message. 787 * 788 * @param {string} [database] Database name (defaults to current db). 789 * @param {string} [version] Schema version label (empty = bare file). 790 * @returns {Promise<string|null>} NDJSON text or null if not found. 791 */ 792 async loadApi(database, version) { 793 try { 794 const text = await loadSchemaViaWsDba(database, { ...(deps || {}), version: version || "live" }); 795 if (typeof text === "string" && text.trim()) { 796 // Keep shadow baseline for diff tracking (not as a data source). 797 localStorage.setItem(shadowKey, text); 798 return text; 799 } 800 } catch (err) { 801 console.warn("[erdiagramDb] loadApi (LISTENTS/LISTRELS) failed:", err.message); 802 } 803 return null; 804 }, 805 806 // ââ Diagram document (canvas layout) â zim store ââââââââââââââââââââââââââââ 807 808 /** 809 * Persist the diagram DOCUMENT (canvas: entities, relationships, positions, 810 * notes) to the zim store â the system of record for the saved diagram. 811 * Distinct from saveApi(), which provisions the live SCHEMA into the Object 812 * Dictionary. Mirrors to the local cache so an offline reload still works. 813 * @param {string} ndjson Serialised diagram. 814 * @returns {Promise<{docId, savedCount, status}>} 815 */ 816 async saveDocRemote(ndjson, docName) { 817 // The diagram's NAME is its identity: docId = erdiagram:<name>, so re-saving 818 // the same titled diagram upserts it, pDocSave archives the prior body as 819 // "<base>::v<n>", and the Open picker lists one row per named diagram. This 820 // is the mind map's rule (createMindMapDb().saveApi). With no name supplied 821 // it falls back to the instance id, which is the pre-title behaviour, so 822 // diagrams already saved under a project id keep their docId. 823 const name = String(docName || id || "erdiagram").trim() || "erdiagram"; 824 const result = await saveDoc({ 825 shellType: SHELL_TYPE, 826 instanceId: name, 827 docName: name, 828 body: ndjson, 829 });
830 try { localStorage.setItem(key, ndjson); } catch {} 831 return result; 832 }, 833 834 /** 835 * Load the saved diagram DOCUMENT from the zim store. Returns null when none 836 * is saved server-side, so the caller can fall back to loadLocal(). 837 * @returns {Promise<string|null>} 838 */ 839 async loadDocRemote(docName) { 840 const name = String(docName || id || "default").trim() || "default"; 841 const text = await loadDoc({ shellType: SHELL_TYPE, instanceId: name }); 842 if (typeof text === "string" && text.trim()) { 843 try { localStorage.setItem(key, text); } catch {} 844 return text; 845 } 846 return null; 847 }, 848 849 // ââ Change Subscriptions âââââââââââââââââââââââââââââââââââââââââââââââââââ 850 851 /** 852 * Subscribe to local DB changes (same-tab via CustomEvent). 853 * @param {function} handler Called with { id, action } on any change. 854 * @returns {function} Cleanup function to unsubscribe. 855 */ 856 onChange(handler) { 857 const listener = (e) => { 858 if (e.detail?.id === id) handler(e.detail); 859 }; 860 window.addEventListener(UPDATE_EVENT, listener); 861 return () => window.removeEventListener(UPDATE_EVENT, listener); 862 }, 863 }; 864}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.