PageSourceSearch

https://smartcone.ai/src/shell/types/erdiagram/erdiagramDb.js

js smartcone.ai collected 2026-10-05 15:07:30 UTC 36,595 bytes, 864 lines download raw bytes

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.