@istar-ts/actions 0.0.0-stage → 0.15.0-beta.0

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.
package/dist/index.js ADDED
@@ -0,0 +1,1103 @@
1
+ import { DEFAULT_PALETTE, DIAGNOSTIC_SEVERITIES, FILE_METAMODEL_KEY, LINK_SELECTOR, ModelOperationError, canLink, connectResultToDiagnostic, createEmptyModel, dependencyEnds, effectiveLinkKind, elementBehavesLike, inheritMetamodel, inheritSourceLayout, linkBehavesLike, metamodelOf, parsePistar, toPistarObject, validateModel, validateModelProperties } from "@istar-ts/core";
2
+ //#region src/actions.ts
3
+ const OPERATION_KIND_LIST = [
4
+ "createNode",
5
+ "createEdge",
6
+ "deleteElement",
7
+ "changeBounds",
8
+ "changeContainer",
9
+ "reconnectEdge",
10
+ "applyLabelEdit",
11
+ "compound",
12
+ "istar.addLeg",
13
+ "istar.removeLeg",
14
+ "istar.setCollapsed",
15
+ "istar.updateDiagram",
16
+ "istar.updateProperties",
17
+ "istar.updateDisplay",
18
+ "istar.setLinkValue",
19
+ "istar.updateLinkDisplay"
20
+ ];
21
+ const REQUEST_KIND_LIST = [
22
+ "requestModel",
23
+ "requestTypeHints",
24
+ "requestCheckEdge",
25
+ "requestContextActions",
26
+ "requestMarkers"
27
+ ];
28
+ /**
29
+ * The other actions the dispatcher accepts: `glspUndo`/`glspRedo`/`saveModel` (acted on), then
30
+ * the notifications it only re-emits to subscribers (`IstarNotificationAction`'s kinds).
31
+ */
32
+ const NOTIFICATION_KIND_LIST = [
33
+ "glspUndo",
34
+ "glspRedo",
35
+ "saveModel",
36
+ "sourceModelChanged",
37
+ "setDirtyState",
38
+ "triggerNodeCreation",
39
+ "triggerEdgeCreation",
40
+ "deleteMarkers"
41
+ ];
42
+ const OPERATION_KINDS = new Set(OPERATION_KIND_LIST);
43
+ const REQUEST_KINDS = new Set(REQUEST_KIND_LIST);
44
+ /**
45
+ * The action kinds `createActionDispatcher` accepts, grouped the way it treats them: `operations`
46
+ * edit the model as one undo step, `requests` get a response action, `notifications` (plus
47
+ * `glspUndo`/`glspRedo`/`saveModel`) are acted on or re-emitted as is. Derived from the same
48
+ * lists `isOperation`/`isRequestAction` use, so it can't drift from the dispatcher.
49
+ */
50
+ const HANDLED_ACTION_KINDS = {
51
+ operations: OPERATION_KIND_LIST,
52
+ requests: REQUEST_KIND_LIST,
53
+ notifications: NOTIFICATION_KIND_LIST
54
+ };
55
+ /** True for the operations (actions that edit the model). */
56
+ function isOperation(action) {
57
+ return OPERATION_KINDS.has(action.kind);
58
+ }
59
+ /** True for the requests (actions answered with a response action). */
60
+ function isRequestAction(action) {
61
+ return REQUEST_KINDS.has(action.kind);
62
+ }
63
+ //#endregion
64
+ //#region src/hints.ts
65
+ /**
66
+ * What a client can know before it asks: type hints from the metamodel, and the palette as
67
+ * GLSP context actions. Both are derived from data (`Metamodel`, `PaletteDescription`), so they
68
+ * work for any root or extension.
69
+ */
70
+ function isHub(definition) {
71
+ return definition.role === "hub";
72
+ }
73
+ /** Element kinds a rule selector matches, as `canLink` matches elements against it. */
74
+ function matching(metamodel, selector) {
75
+ const kinds = [];
76
+ for (const definition of metamodel.elements.values()) if (isHub(definition) ? definition.kind === selector : selector === "*" || selector === definition.category || elementBehavesLike(metamodel, definition.kind, selector)) kinds.push(definition.kind);
77
+ if (selector === LINK_SELECTOR) kinds.push(...metamodel.links.keys());
78
+ else if (metamodel.links.has(selector)) {
79
+ for (const kind of metamodel.links.keys()) if (linkBehavesLike(metamodel, kind, selector)) kinds.push(kind);
80
+ }
81
+ return kinds;
82
+ }
83
+ function union(metamodel, selectors) {
84
+ return [...new Set(selectors.flatMap((selector) => matching(metamodel, selector)))];
85
+ }
86
+ /** Kinds of a category, without hubs. */
87
+ function ofCategory(metamodel, category) {
88
+ return [...metamodel.elements.values()].filter((d) => !isHub(d) && (category === void 0 || d.category === category)).map((d) => d.kind);
89
+ }
90
+ /**
91
+ * GLSP type hints of a metamodel. Shapes: every element kind (hubs can't be resized or
92
+ * reparented; actors contain the node kinds that can be inner elements). Edges: every link kind,
93
+ * its sources and targets from its rules, or by category for kinds with built-in rules (iStar
94
+ * 2.0's), plus an n-ary kind's hub (`createEdge` onto a hub adds a leg to that link, #108). Edge
95
+ * hints are `repositionable` (their ends can be reconnected) and `dynamic`: `canLink` checks more
96
+ * than kinds.
97
+ */
98
+ function typeHintsFor(metamodel) {
99
+ const meta = metamodel;
100
+ const inner = [...meta.elements.values()].filter((d) => d.category === "node" && !isHub(d) && d.info?.canBeInnerElement !== false).map((d) => d.kind);
101
+ return {
102
+ shapeHints: [...meta.elements.values()].map((d) => {
103
+ const hub = isHub(d);
104
+ return {
105
+ elementTypeId: d.kind,
106
+ repositionable: true,
107
+ deletable: true,
108
+ resizable: !hub,
109
+ reparentable: !hub && d.category === "node" && d.info?.canBeInnerElement !== false,
110
+ ...d.category === "actor" ? { containableElementTypeIds: inner } : {}
111
+ };
112
+ }),
113
+ edgeHints: [...meta.links.values()].map((d) => {
114
+ const rules = meta.links.get(effectiveLinkKind(meta, d.kind))?.rules;
115
+ const builtIn = d.category === "dependency" ? ofCategory(meta) : ofCategory(meta, d.category);
116
+ const targets = rules ? union(meta, rules.targets) : builtIn;
117
+ const hub = d.parts?.hub;
118
+ return {
119
+ elementTypeId: d.kind,
120
+ repositionable: true,
121
+ deletable: true,
122
+ routable: true,
123
+ sourceElementTypeIds: rules ? union(meta, rules.sources) : builtIn,
124
+ targetElementTypeIds: hub === void 0 ? targets : [...targets, hub],
125
+ dynamic: true
126
+ };
127
+ })
128
+ };
129
+ }
130
+ function elementEntries(kind, value) {
131
+ return (Array.isArray(value) ? value : [value]).map((entry, i) => labeled(Array.isArray(value) ? `${kind}#${i}` : kind, entry, {
132
+ kind: "triggerNodeCreation",
133
+ elementTypeId: kind,
134
+ ...entry.properties ? { args: { customProperties: entry.properties } } : {}
135
+ }));
136
+ }
137
+ function linkEntries(kind, list) {
138
+ return list.map((entry, i) => {
139
+ const args = {
140
+ ...entry.value === void 0 ? {} : { value: entry.value },
141
+ ...entry.dependum === void 0 ? {} : { dependum: { kind: entry.dependum } }
142
+ };
143
+ return labeled(`${kind}#${i}`, entry, {
144
+ kind: "triggerEdgeCreation",
145
+ elementTypeId: kind,
146
+ ...Object.keys(args).length > 0 ? { args } : {}
147
+ });
148
+ });
149
+ }
150
+ function order(action) {
151
+ return action.order ?? Number.POSITIVE_INFINITY;
152
+ }
153
+ function labeled(id, entry, action) {
154
+ return {
155
+ id,
156
+ label: entry.label,
157
+ actions: [action],
158
+ ...entry.group === void 0 ? {} : { group: entry.group },
159
+ ...entry.section === void 0 ? {} : { section: entry.section },
160
+ ...entry.order === void 0 ? {} : { order: entry.order }
161
+ };
162
+ }
163
+ /**
164
+ * A palette as GLSP context actions: one `LabeledAction` per entry, triggering the client's
165
+ * creation tool with the entry's presets, sorted by `order`. Only the metamodel's kinds are
166
+ * offered, in the metamodel's order where orders tie; kinds the palette hides (`false`) or
167
+ * lacks are left out.
168
+ */
169
+ function contextActionsFor(palette, metamodel) {
170
+ const meta = metamodel;
171
+ const actions = [];
172
+ for (const kind of meta.elements.keys()) {
173
+ const value = palette.elements[kind];
174
+ if (value) actions.push(...elementEntries(kind, value));
175
+ }
176
+ for (const kind of meta.links.keys()) {
177
+ const list = palette.links[kind];
178
+ if (list) actions.push(...linkEntries(kind, list));
179
+ }
180
+ actions.sort((a, b) => order(a) === order(b) ? 0 : order(a) < order(b) ? -1 : 1);
181
+ return {
182
+ actions,
183
+ groups: palette.groups
184
+ };
185
+ }
186
+ //#endregion
187
+ //#region src/markers.ts
188
+ /** A `GoalDiagnostic` as a GLSP marker: `kind` is its severity, `label` its message. */
189
+ function diagnosticToMarker(diagnostic) {
190
+ const { elementId, key, severity, message, source, code } = diagnostic;
191
+ return {
192
+ label: message,
193
+ description: source === void 0 ? message : `${message} (${source})`,
194
+ elementId,
195
+ kind: severity,
196
+ ...key === void 0 ? {} : { key },
197
+ ...source === void 0 ? {} : { source },
198
+ ...code === void 0 ? {} : { code }
199
+ };
200
+ }
201
+ /**
202
+ * A GLSP marker as a `GoalDiagnostic`. A `kind` that is no `DiagnosticSeverity` reads as
203
+ * `info`; `range` and `data` are not carried by markers.
204
+ */
205
+ function markerToDiagnostic(marker) {
206
+ const { elementId, kind, label, key, source, code } = marker;
207
+ return {
208
+ elementId,
209
+ severity: DIAGNOSTIC_SEVERITIES.includes(kind) ? kind : "info",
210
+ message: label,
211
+ ...key === void 0 ? {} : { key },
212
+ ...source === void 0 ? {} : { source },
213
+ ...code === void 0 ? {} : { code }
214
+ };
215
+ }
216
+ //#endregion
217
+ //#region src/dispatcher.ts
218
+ /**
219
+ * The dispatcher: the server side of the protocol over a `ModelStore`. Operations run as one
220
+ * store transaction each and are rejected, model untouched, with `GoalDiagnostic`s; requests
221
+ * are answered from the model, its metamodel and the palette; store changes reach subscribers
222
+ * as `ModelSourceChangedAction`s, whoever made them.
223
+ */
224
+ /** Thrown inside a transaction to roll it back and reject the action. */
225
+ var Rejection = class extends Error {
226
+ diagnostics;
227
+ constructor(diagnostics) {
228
+ super(diagnostics.map((d) => d.message).join("; "));
229
+ this.diagnostics = diagnostics;
230
+ }
231
+ };
232
+ function reject(message, elementId = "", code) {
233
+ throw new Rejection([{
234
+ elementId,
235
+ severity: "error",
236
+ message,
237
+ ...code === void 0 ? {} : { code }
238
+ }]);
239
+ }
240
+ function rejectCheck(check, endpoints) {
241
+ throw new Rejection([connectResultToDiagnostic(check, endpoints)]);
242
+ }
243
+ function metaOf(model) {
244
+ return metamodelOf(model);
245
+ }
246
+ /** A model with `links` changed, as the core operations derive one. */
247
+ function withLinks(model, links) {
248
+ return inheritMetamodel(model, inheritSourceLayout(model, {
249
+ ...model,
250
+ links
251
+ }));
252
+ }
253
+ function createdIds(before, after) {
254
+ const ids = [];
255
+ for (const id of after.elements.keys()) if (!before.elements.has(id)) ids.push(id);
256
+ for (const id of after.links.keys()) if (!before.links.has(id)) ids.push(id);
257
+ return ids;
258
+ }
259
+ function issueKey(d) {
260
+ return `${d.id}\u0000${d.key}\u0000${d.message}`;
261
+ }
262
+ function linkIssueDiagnostics(model) {
263
+ return validateModel(model).map((issue) => ({
264
+ elementId: issue.linkId,
265
+ severity: "error",
266
+ message: issue.message,
267
+ code: issue.code
268
+ }));
269
+ }
270
+ /**
271
+ * A dispatcher over `store`, in-process. Hand it to a transport to serve remote clients.
272
+ */
273
+ function createActionDispatcher(store, options = {}) {
274
+ const s = store;
275
+ const guards = options.guards ?? [];
276
+ const palette = options.palette ?? DEFAULT_PALETTE;
277
+ const sourceModelName = options.sourceModelName ?? "";
278
+ const listeners = /* @__PURE__ */ new Set();
279
+ let saved = s.getModel();
280
+ let dirty = false;
281
+ const emit = (action) => {
282
+ for (const listener of listeners) listener(action);
283
+ };
284
+ const updateDirty = (reason) => {
285
+ const next = s.getModel() !== saved;
286
+ if (next === dirty) return;
287
+ dirty = next;
288
+ emit({
289
+ kind: "setDirtyState",
290
+ isDirty: next,
291
+ reason
292
+ });
293
+ };
294
+ const REASONS = {
295
+ edit: "operation",
296
+ undo: "undo",
297
+ redo: "redo",
298
+ load: "external"
299
+ };
300
+ const unsubscribe = s.subscribe((event) => {
301
+ if (event.source === "load") saved = event.model;
302
+ emit({
303
+ kind: "sourceModelChanged",
304
+ sourceModelName,
305
+ source: event.source,
306
+ changes: event.changes,
307
+ ...options.sendModel ? { newRoot: toPistarObject(event.model) } : {},
308
+ ...options.sendModel ? {
309
+ canUndo: s.canUndo(),
310
+ canRedo: s.canRedo()
311
+ } : {}
312
+ });
313
+ updateDirty(REASONS[event.source]);
314
+ });
315
+ const requireElement = (id) => s.getModel().elements.get(id) ?? reject(`unknown element "${id}"`, id, "unknown-element");
316
+ const runGuards = (operation, warnings) => {
317
+ const errors = [];
318
+ for (const guard of guards) for (const d of guard(operation, s.getModel()) ?? []) (d.severity === "error" ? errors : warnings).push(d);
319
+ if (errors.length > 0) throw new Rejection(errors);
320
+ };
321
+ const apply = (operation, warnings) => {
322
+ runGuards(operation, warnings);
323
+ const model = s.getModel();
324
+ const meta = metaOf(model);
325
+ switch (operation.kind) {
326
+ case "createNode": {
327
+ const { elementTypeId: kind, containerId, args = {} } = operation;
328
+ const definition = meta.elements.get(kind) ?? reject(`unknown element kind "${kind}" in ${meta.name}`, "", "unknown-kind");
329
+ if (definition.role === "hub") reject(`a ${definition.label} is created with its link (createEdge)`, "", "hub-kind");
330
+ const container = containerId === void 0 ? void 0 : requireElement(containerId);
331
+ if (definition.category === "node") {
332
+ if (container && definition.info?.canBeInnerElement === false) reject(`a ${definition.label} cannot be inside an actor`, containerId, "container");
333
+ if (!container && definition.info?.canBeOnPaper === false) reject(`a ${definition.label} must be created inside an actor`, "", "container");
334
+ }
335
+ const location = operation.location ?? container ?? {
336
+ x: 0,
337
+ y: 0
338
+ };
339
+ s.addElement({
340
+ kind,
341
+ x: location.x,
342
+ y: location.y,
343
+ ...containerId === void 0 ? {} : { parent: containerId },
344
+ ...args.id === void 0 ? {} : { id: args.id },
345
+ ...args.name === void 0 ? {} : { name: args.name },
346
+ ...args.customProperties ? { customProperties: args.customProperties } : {}
347
+ });
348
+ return;
349
+ }
350
+ case "createEdge": {
351
+ const { elementTypeId: kind, sourceElementId: source, targetElementId: target } = operation;
352
+ const args = operation.args ?? {};
353
+ const definition = meta.links.get(kind) ?? reject(`unknown link kind "${kind}" in ${meta.name}`, "", "unknown-kind");
354
+ if (definition.category === "dependency") {
355
+ const dependum = args.dependum ?? {};
356
+ const dependumKind = dependum.kind ?? [...meta.elements.values()].find((d) => d.category === "node" && d.role !== "hub" && d.info?.canBeDependum !== false)?.kind ?? reject(`${meta.name} has no kind that can be a dependum`, "", "unknown-kind");
357
+ const result = s.addDependency({
358
+ depender: source,
359
+ dependee: target,
360
+ linkKind: kind,
361
+ dependum: {
362
+ kind: dependumKind,
363
+ ...dependum.id === void 0 ? {} : { id: dependum.id },
364
+ ...dependum.name === void 0 ? {} : { name: dependum.name },
365
+ ...dependum.location ? {
366
+ x: dependum.location.x,
367
+ y: dependum.location.y
368
+ } : {},
369
+ ...dependum.customProperties ? { customProperties: dependum.customProperties } : {}
370
+ },
371
+ ...args.linkIds ? { linkIds: args.linkIds } : {}
372
+ });
373
+ if (!result.ok) rejectCheck(result, {
374
+ source,
375
+ target
376
+ });
377
+ return;
378
+ }
379
+ const values = definition.info.possibleLabels;
380
+ if (args.value !== void 0 && values && !values.includes(args.value)) reject(`"${args.value}" is not a value of a ${definition.label} link (${values.join(", ")})`, source, "invalid-value");
381
+ const result = s.connect({
382
+ kind,
383
+ source,
384
+ target,
385
+ ...args.id === void 0 ? {} : { id: args.id },
386
+ ...args.value === void 0 ? {} : { label: args.value },
387
+ ...args.name === void 0 ? {} : { name: args.name },
388
+ ...args.customProperties ? { customProperties: args.customProperties } : {},
389
+ ...args.legId === void 0 ? {} : { legId: args.legId },
390
+ ...args.endId === void 0 ? {} : { endId: args.endId }
391
+ });
392
+ if (!result.ok) rejectCheck(result, {
393
+ source,
394
+ target
395
+ });
396
+ return;
397
+ }
398
+ case "deleteElement": {
399
+ const elements = [];
400
+ const links = [];
401
+ for (const id of operation.elementIds) if (model.elements.has(id)) elements.push(id);
402
+ else if (model.links.has(id)) links.push(id);
403
+ else reject(`unknown element or link "${id}"`, id, "unknown-element");
404
+ if (elements.length > 0) s.removeElements(elements);
405
+ for (const id of links) if (s.getModel().links.has(id)) s.disconnect(id);
406
+ return;
407
+ }
408
+ case "changeBounds":
409
+ for (const { elementId, newPosition, newSize } of operation.newBounds) {
410
+ requireElement(elementId);
411
+ if (newPosition) s.moveElement(elementId, newPosition.x, newPosition.y);
412
+ if (newSize) s.updateElement(elementId, { display: {
413
+ width: newSize.width,
414
+ height: newSize.height
415
+ } });
416
+ }
417
+ return;
418
+ case "changeContainer": {
419
+ const { elementId, targetContainerId, location } = operation;
420
+ s.nestElement(elementId, targetContainerId);
421
+ if (location) s.moveElement(elementId, location.x, location.y);
422
+ return;
423
+ }
424
+ case "reconnectEdge":
425
+ reconnect(operation.edgeElementId, operation.sourceElementId, operation.targetElementId);
426
+ return;
427
+ case "applyLabelEdit": {
428
+ const hash = operation.labelId.indexOf("#");
429
+ const id = hash < 0 ? operation.labelId : operation.labelId.slice(0, hash);
430
+ const key = hash < 0 ? void 0 : operation.labelId.slice(hash + 1);
431
+ if (key === void 0) {
432
+ if (model.elements.has(id)) s.updateElement(id, { name: operation.text });
433
+ else if (model.links.has(id)) s.updateLink(id, { name: operation.text === "" ? null : operation.text });
434
+ else reject(`unknown element or link "${id}"`, id, "unknown-element");
435
+ } else setProperties(id, { [key]: operation.text });
436
+ return;
437
+ }
438
+ case "compound":
439
+ for (const child of operation.operationList) apply(child, warnings);
440
+ return;
441
+ case "istar.addLeg": {
442
+ const { hubId, sourceElementId: source } = operation;
443
+ const result = s.addLeg(hubId, source, operation.args?.id ? { id: operation.args.id } : {});
444
+ if (!result.ok) rejectCheck(result, {
445
+ source,
446
+ target: hubId
447
+ });
448
+ return;
449
+ }
450
+ case "istar.removeLeg":
451
+ s.removeLeg(operation.legId);
452
+ return;
453
+ case "istar.setCollapsed": {
454
+ const element = requireElement(operation.elementId);
455
+ if (meta.elements.get(element.kind)?.category !== "actor") reject(`"${element.id}" is not an actor`, element.id, "not-an-actor");
456
+ s.setCollapsed(element.id, operation.collapsed);
457
+ return;
458
+ }
459
+ case "istar.updateDiagram": {
460
+ const { name, width, height, customProperties } = operation.diagram;
461
+ s.updateDiagram({
462
+ ...name === void 0 ? {} : { name },
463
+ ...width === void 0 ? {} : { width },
464
+ ...height === void 0 ? {} : { height },
465
+ ...customProperties === void 0 ? {} : { customProperties }
466
+ });
467
+ return;
468
+ }
469
+ case "istar.updateProperties":
470
+ setProperties(operation.elementId, operation.properties);
471
+ return;
472
+ case "istar.updateDisplay":
473
+ requireElement(operation.elementId);
474
+ s.updateElement(operation.elementId, { display: operation.patch });
475
+ return;
476
+ case "istar.updateLinkDisplay":
477
+ if (!model.links.has(operation.linkId)) reject(`unknown link "${operation.linkId}"`, operation.linkId, "unknown-element");
478
+ s.updateLink(operation.linkId, { display: operation.patch });
479
+ return;
480
+ case "istar.setLinkValue": {
481
+ const { linkId, value } = operation;
482
+ const link = model.links.get(linkId) ?? reject(`unknown link "${linkId}"`, linkId, "unknown-element");
483
+ const definition = meta.links.get(link.kind);
484
+ const values = definition?.info.possibleLabels;
485
+ if (value !== null && values && !values.includes(value)) reject(`"${value}" is not a value of a ${definition.label} link (${values.join(", ")})`, linkId, "invalid-value");
486
+ s.updateLink(linkId, { label: value });
487
+ return;
488
+ }
489
+ default: reject(`unknown operation "${operation.kind}"`, "", "unknown-action");
490
+ }
491
+ };
492
+ /** Sets (or, with `null`, removes) custom properties of an element or link. */
493
+ const setProperties = (id, properties) => {
494
+ const model = s.getModel();
495
+ const owner = model.elements.get(id) ?? model.links.get(id);
496
+ if (!owner) reject(`unknown element or link "${id}"`, id, "unknown-element");
497
+ const next = { ...owner.customProperties };
498
+ for (const [key, value] of Object.entries(properties)) if (value === null) delete next[key];
499
+ else if (typeof value === "string") next[key] = value;
500
+ else reject(`the value of "${key}" must be a string or null`, id, "invalid-value");
501
+ const customProperties = Object.keys(next).length > 0 ? next : null;
502
+ if (model.elements.has(id)) s.updateElement(id, { customProperties });
503
+ else s.updateLink(id, { customProperties });
504
+ };
505
+ /**
506
+ * Gives a link new ends, checked with `canLink` against the model without it. The link keeps
507
+ * its place in the model (and the file), its id and everything else it carries.
508
+ */
509
+ const reconnect = (id, source, target) => {
510
+ const model = s.getModel();
511
+ const meta = metaOf(model);
512
+ const link = model.links.get(id) ?? reject(`unknown link "${id}"`, id, "unknown-element");
513
+ if (link.source === source && link.target === target) return;
514
+ const definition = meta.links.get(link.kind);
515
+ const others = new Map(model.links);
516
+ others.delete(id);
517
+ const without = withLinks(model, others);
518
+ let check;
519
+ if (definition?.category === "dependency") {
520
+ const dependumAtTarget = model.elements.get(link.target)?.isDependum === true;
521
+ const dependumId = dependumAtTarget ? link.target : link.source;
522
+ if ((dependumAtTarget ? target : source) !== dependumId) reject("a dependency link keeps its dependum", id, "dependum-endpoint");
523
+ const ends = dependencyEnds(model, dependumId);
524
+ const depender = dependumAtTarget ? source : ends.depender;
525
+ const dependee = dependumAtTarget ? ends.dependee : target;
526
+ if (depender === void 0 || dependee === void 0) reject("the dependency is incomplete", id, "dependum-endpoint");
527
+ check = canLink(without, depender, dependee, link.kind);
528
+ if (!check.ok) rejectCheck(check, {
529
+ source: depender,
530
+ target: dependee
531
+ });
532
+ } else {
533
+ if (definition?.role === "end" && source !== link.source) reject("the end of an n-ary link stays on its hub", id, "hub-target");
534
+ if (definition?.role === "leg" && target !== link.target) {
535
+ if ([...model.links.values()].filter((l) => l.target === link.target && l.kind === link.kind).length === 1) reject("the last leg of an n-ary link cannot leave its hub", id, "hub-target");
536
+ }
537
+ check = canLink(without, source, target, link.kind);
538
+ if (!check.ok) rejectCheck(check, {
539
+ source,
540
+ target
541
+ });
542
+ }
543
+ s.replace(withLinks(model, new Map(model.links).set(id, {
544
+ ...link,
545
+ source,
546
+ target
547
+ })));
548
+ };
549
+ const runOperation = (operation) => {
550
+ const before = s.getModel();
551
+ const warnings = [];
552
+ s.transaction(() => {
553
+ apply(operation, warnings);
554
+ const schemas = options.schemas;
555
+ if (!schemas || s.getModel() === before) return;
556
+ const known = new Set(validateModelProperties(before, schemas).map(issueKey));
557
+ const added = validateModelProperties(s.getModel(), schemas).filter((issue) => !known.has(issueKey(issue)));
558
+ if (added.length > 0) throw new Rejection(added.map((issue) => ({
559
+ elementId: issue.id,
560
+ key: issue.key,
561
+ severity: "error",
562
+ message: issue.message,
563
+ code: "invalid-property"
564
+ })));
565
+ });
566
+ return {
567
+ ok: true,
568
+ createdIds: createdIds(before, s.getModel()),
569
+ ...warnings.length > 0 ? { diagnostics: warnings } : {}
570
+ };
571
+ };
572
+ const checkEdge = (action) => {
573
+ const { edgeType, sourceElementId: source, targetElementId: target } = action;
574
+ const model = s.getModel();
575
+ const meta = metaOf(model);
576
+ const answer = (check) => ({
577
+ kind: "checkEdgeTargetResult",
578
+ responseId: action.requestId,
579
+ isValid: check.ok,
580
+ edgeType,
581
+ sourceElementId: source,
582
+ ...target === void 0 ? {} : { targetElementId: target },
583
+ ...check.ok ? {} : {
584
+ reason: check.reason,
585
+ code: check.code
586
+ }
587
+ });
588
+ if (target !== void 0) {
589
+ const check = canLink(model, source, target, edgeType);
590
+ if (!check.ok && meta.links.get(edgeType)?.info.tryReversedWhenAdding) {
591
+ const reversed = canLink(model, target, source, edgeType);
592
+ if (reversed.ok) return answer(reversed);
593
+ }
594
+ return answer(check);
595
+ }
596
+ const element = model.elements.get(source);
597
+ if (!element) return answer({
598
+ ok: false,
599
+ code: "unknown-element",
600
+ reason: `unknown source element "${source}"`
601
+ });
602
+ const hint = typeHintsFor(meta).edgeHints.find((h) => h.elementTypeId === edgeType);
603
+ if (!hint) return answer({
604
+ ok: false,
605
+ code: "unknown-link-kind",
606
+ reason: `unknown link kind "${edgeType}"`
607
+ });
608
+ if (hint.sourceElementTypeIds && !hint.sourceElementTypeIds.includes(element.kind)) return answer({
609
+ ok: false,
610
+ code: "invalid-source",
611
+ reason: `a ${meta.elements.get(element.kind)?.label ?? element.kind} cannot be the source of a ${meta.links.get(edgeType)?.label ?? edgeType} link`
612
+ });
613
+ return answer({ ok: true });
614
+ };
615
+ const markers = (action) => {
616
+ const model = s.getModel();
617
+ const diagnostics = linkIssueDiagnostics(model);
618
+ if (options.schemas) for (const issue of validateModelProperties(model, options.schemas)) diagnostics.push({
619
+ elementId: issue.id,
620
+ key: issue.key,
621
+ severity: "error",
622
+ message: issue.message,
623
+ code: "invalid-property"
624
+ });
625
+ diagnostics.push(...options.diagnostics?.() ?? []);
626
+ const ids = action.elementsIDs.filter((id) => model.elements.has(id) || model.links.has(id));
627
+ const wanted = new Set(ids);
628
+ return {
629
+ kind: "setMarkers",
630
+ responseId: action.requestId,
631
+ markers: diagnostics.filter((d) => wanted.size === 0 || wanted.has(d.elementId)).map(diagnosticToMarker),
632
+ ...action.reason === void 0 ? {} : { reason: action.reason }
633
+ };
634
+ };
635
+ const respond = (action) => {
636
+ const model = s.getModel();
637
+ switch (action.kind) {
638
+ case "requestModel": return {
639
+ kind: "setModel",
640
+ responseId: action.requestId,
641
+ newRoot: toPistarObject(model)
642
+ };
643
+ case "requestTypeHints": return {
644
+ kind: "setTypeHints",
645
+ responseId: action.requestId,
646
+ ...typeHintsFor(metaOf(model))
647
+ };
648
+ case "requestCheckEdge": return checkEdge(action);
649
+ case "requestContextActions": return {
650
+ kind: "setContextActions",
651
+ responseId: action.requestId,
652
+ ...contextActionsFor(palette, metaOf(model))
653
+ };
654
+ case "requestMarkers": return markers(action);
655
+ default: return;
656
+ }
657
+ };
658
+ const run = async (action) => {
659
+ if (isOperation(action)) return runOperation(action);
660
+ if (isRequestAction(action)) return {
661
+ ok: true,
662
+ createdIds: [],
663
+ response: respond(action)
664
+ };
665
+ switch (action.kind) {
666
+ case "glspUndo":
667
+ s.undo();
668
+ return {
669
+ ok: true,
670
+ createdIds: []
671
+ };
672
+ case "glspRedo":
673
+ s.redo();
674
+ return {
675
+ ok: true,
676
+ createdIds: []
677
+ };
678
+ case "saveModel": {
679
+ if (!options.save) reject("this host does not save models", "", "no-save-handler");
680
+ const model = s.getModel();
681
+ await options.save(toPistarObject(model, { saveDate: /* @__PURE__ */ new Date() }), action);
682
+ saved = model;
683
+ updateDirty("save");
684
+ return {
685
+ ok: true,
686
+ createdIds: []
687
+ };
688
+ }
689
+ case "sourceModelChanged":
690
+ case "setDirtyState":
691
+ case "triggerNodeCreation":
692
+ case "triggerEdgeCreation":
693
+ case "deleteMarkers":
694
+ emit(action);
695
+ return {
696
+ ok: true,
697
+ createdIds: []
698
+ };
699
+ default: return reject(`"${action.kind}" is not an action this dispatcher handles`, "", "unknown-action");
700
+ }
701
+ };
702
+ return {
703
+ async dispatch(action) {
704
+ try {
705
+ return await run(action);
706
+ } catch (error) {
707
+ if (error instanceof Rejection) return {
708
+ ok: false,
709
+ diagnostics: error.diagnostics
710
+ };
711
+ if (error instanceof ModelOperationError) return {
712
+ ok: false,
713
+ diagnostics: [error.toDiagnostic()]
714
+ };
715
+ throw error;
716
+ }
717
+ },
718
+ subscribe(listener) {
719
+ const l = listener;
720
+ listeners.add(l);
721
+ return () => listeners.delete(l);
722
+ },
723
+ isDirty: () => s.getModel() !== saved,
724
+ dispose: unsubscribe
725
+ };
726
+ }
727
+ //#endregion
728
+ //#region src/host.ts
729
+ /** `dispatcher`, refusing anything but requests: what a read-only host tool is handed. */
730
+ function readOnlyDispatcher(dispatcher, tool) {
731
+ return {
732
+ dispatch: (action) => isRequestAction(action) ? dispatcher.dispatch(action) : Promise.resolve({
733
+ ok: false,
734
+ diagnostics: [{
735
+ elementId: "",
736
+ severity: "error",
737
+ code: "read-only-tool",
738
+ message: `"${tool}" is read-only: it cannot dispatch ${action.kind}`
739
+ }]
740
+ }),
741
+ subscribe: (listener) => dispatcher.subscribe(listener)
742
+ };
743
+ }
744
+ /** The context a call of `tool` gets: `dispatcher` as is, or read-only for a read-only tool. */
745
+ function hostToolContext(tool, dispatcher, getModel, signal) {
746
+ return {
747
+ getModel,
748
+ dispatcher: tool.readOnly ? readOnlyDispatcher(dispatcher, tool.name) : dispatcher,
749
+ ...signal ? { signal } : {}
750
+ };
751
+ }
752
+ /** A provider's text for `model`. */
753
+ function contextText(provider, model) {
754
+ const { text } = provider;
755
+ return Promise.resolve(typeof text === "string" ? text : text(model));
756
+ }
757
+ //#endregion
758
+ //#region src/mirror.ts
759
+ /**
760
+ * A mirror store: a read-only `ModelStore` in this process whose model follows a dispatcher that
761
+ * lives elsewhere (a worker, a server, the other end of a `createRemoteDispatcher` port), so a
762
+ * canvas and an inspector written against `ModelStore` can render it. Edits never touch the
763
+ * mirror: they are dispatched to the remote, and come back as the remote's `sourceModelChanged`.
764
+ *
765
+ * How the model travels: an initial `requestModel`, then one update per `sourceModelChanged`.
766
+ * A notification that carries the new model (`newRoot`, a dispatcher created with `sendModel`)
767
+ * is applied as it arrives; one without it is answered with a `requestModel`, in order. Either
768
+ * way the update is a whole piStar file, re-parsed (2 ms for the largest fixture, 237 ids), and
769
+ * then shared with the previous model: an element, link or diagram equal to the one it replaces
770
+ * keeps the old object, so whatever is memoised on identity only redoes what changed.
771
+ */
772
+ /** Deep equality of JSON values (what parsed models are made of). */
773
+ function equal(a, b) {
774
+ if (a === b) return true;
775
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
776
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
777
+ const ka = Object.keys(a);
778
+ const kb = Object.keys(b);
779
+ if (ka.length !== kb.length) return false;
780
+ for (const key of ka) if (!equal(a[key], b[key])) return false;
781
+ return true;
782
+ }
783
+ /**
784
+ * `next`, with every element, link and the diagram that equals `previous`'s kept as
785
+ * `previous`'s object; `previous` itself when nothing differs (in content, order or metamodel).
786
+ */
787
+ function share(previous, next) {
788
+ let same = previous.elements.size === next.elements.size && previous.links.size === next.links.size && metamodelOf(previous) === metamodelOf(next);
789
+ const shareMap = (from, to) => {
790
+ const order = from.keys();
791
+ const out = /* @__PURE__ */ new Map();
792
+ for (const [id, value] of to) {
793
+ const old = from.get(id);
794
+ const keep = old !== void 0 && equal(old, value);
795
+ if (!keep || order.next().value !== id) same = false;
796
+ out.set(id, keep ? old : value);
797
+ }
798
+ return out;
799
+ };
800
+ const elements = shareMap(previous.elements, next.elements);
801
+ const links = shareMap(previous.links, next.links);
802
+ const diagram = equal(previous.diagram, next.diagram) ? previous.diagram : next.diagram;
803
+ const { elements: _e, links: _l, diagram: _d, ...rest } = next;
804
+ const { elements: _pe, links: _pl, diagram: _pd, ...previousRest } = previous;
805
+ if (same && equal(rest, previousRest)) return previous;
806
+ const shared = {
807
+ ...rest,
808
+ elements,
809
+ links,
810
+ ...diagram === void 0 ? {} : { diagram }
811
+ };
812
+ return inheritMetamodel(next, inheritSourceLayout(next, shared));
813
+ }
814
+ function readOnly() {
815
+ throw new ModelOperationError("a mirror store is read-only: dispatch the edit through its remote dispatcher");
816
+ }
817
+ const parseWith = parsePistar;
818
+ /**
819
+ * A mirror of `remote`'s model (see `MirrorStore`). Shows an empty model until `ready`.
820
+ *
821
+ * Order: changes are shown in the order the remote sends them, whoever made them, and a
822
+ * `dispatch` through `mirror.dispatcher` resolves after its change is shown, so code that
823
+ * selects a created id or rolls back a rejected one sees the model it expects. A rejected
824
+ * action sends no notification: the mirror stays as it was.
825
+ */
826
+ function createMirrorStore(remote, options = {}) {
827
+ const r = remote;
828
+ const metamodel = options.metamodel;
829
+ const limit = options.historyLimit ?? 100;
830
+ const onError = options.onError ?? (() => {});
831
+ const listeners = /* @__PURE__ */ new Set();
832
+ let model = metamodel ? createEmptyModel(void 0, { metamodel }) : createEmptyModel();
833
+ /** The last file's `"metamodel"` block, as JSON; `undefined` before the first file. */
834
+ let block;
835
+ let undoDepth = 0;
836
+ let redoDepth = 0;
837
+ /** How many pushed models (`newRoot`) were applied: a response asked for before one is stale. */
838
+ let pushed = 0;
839
+ let requests = 0;
840
+ let tail = Promise.resolve();
841
+ let disposed = false;
842
+ const parse = (file) => {
843
+ const nextBlock = JSON.stringify(file[FILE_METAMODEL_KEY] ?? null);
844
+ if (nextBlock === block) return inheritMetamodel(model, parseWith(file, { metamodel: metamodelOf(model) }));
845
+ const parsed = parseWith(file, {
846
+ ...metamodel ? { metamodel } : {},
847
+ ...options.fileMetamodel ? { fileMetamodel: true } : {}
848
+ });
849
+ block = nextBlock;
850
+ return parsed;
851
+ };
852
+ const apply = (file, source, changes) => {
853
+ const previous = model;
854
+ const next = share(previous, parse(file));
855
+ if (next === previous) return;
856
+ model = next;
857
+ const event = {
858
+ model,
859
+ previous,
860
+ changes,
861
+ source
862
+ };
863
+ for (const listener of listeners) listener(event);
864
+ };
865
+ /** Counts the remote's undo stack from what it reports (see `MirrorStore`). */
866
+ const track = (source, exact) => {
867
+ if (exact) {
868
+ undoDepth = exact.canUndo ? 1 : 0;
869
+ redoDepth = exact.canRedo ? 1 : 0;
870
+ } else if (source === "edit") {
871
+ undoDepth = Math.min(undoDepth + 1, limit);
872
+ redoDepth = 0;
873
+ } else if (source === "undo") {
874
+ undoDepth = Math.max(undoDepth - 1, 0);
875
+ redoDepth++;
876
+ } else if (source === "redo") {
877
+ undoDepth = Math.min(undoDepth + 1, limit);
878
+ redoDepth = Math.max(redoDepth - 1, 0);
879
+ } else {
880
+ undoDepth = 0;
881
+ redoDepth = 0;
882
+ }
883
+ };
884
+ /**
885
+ * Asks for the model now (so the remote answers in order) and shows it once every earlier
886
+ * update is shown. Dropped if a pushed model arrived meanwhile: that one is at least as new.
887
+ */
888
+ const refresh = (source, changes) => {
889
+ const seen = pushed;
890
+ const sent = r.dispatch({
891
+ kind: "requestModel",
892
+ requestId: `mirror-${++requests}`
893
+ }).then((result) => ({ result })).catch((error) => ({ error }));
894
+ const step = tail.then(async () => {
895
+ const answer = await sent;
896
+ if (disposed || pushed !== seen) return;
897
+ if ("error" in answer) throw answer.error;
898
+ const { result } = answer;
899
+ if (!result.ok) throw new Error(result.diagnostics[0]?.message ?? "requestModel was rejected");
900
+ if (!result.response) throw new Error("requestModel was answered without a model");
901
+ apply(result.response.newRoot, source, changes);
902
+ });
903
+ tail = step.catch(onError);
904
+ return step;
905
+ };
906
+ const unsubscribe = r.subscribe((action) => {
907
+ if (disposed || action.kind !== "sourceModelChanged") return;
908
+ track(action.source, action.canUndo !== void 0 && action.canRedo !== void 0 ? {
909
+ canUndo: action.canUndo,
910
+ canRedo: action.canRedo
911
+ } : void 0);
912
+ if (action.newRoot) {
913
+ pushed++;
914
+ try {
915
+ apply(action.newRoot, action.source, action.changes);
916
+ } catch (error) {
917
+ onError(error);
918
+ }
919
+ } else refresh(action.source, action.changes).catch(() => {});
920
+ });
921
+ const ready = refresh("load", []);
922
+ ready.catch(() => {});
923
+ const send = (kind) => {
924
+ r.dispatch({ kind }).catch(onError);
925
+ };
926
+ return {
927
+ ready,
928
+ dispatcher: {
929
+ async dispatch(action) {
930
+ const result = await r.dispatch(action);
931
+ await tail;
932
+ return result;
933
+ },
934
+ subscribe: (listener) => r.subscribe(listener)
935
+ },
936
+ synced: () => tail,
937
+ dispose() {
938
+ disposed = true;
939
+ unsubscribe();
940
+ },
941
+ getModel: () => model,
942
+ subscribe(listener) {
943
+ listeners.add(listener);
944
+ return () => listeners.delete(listener);
945
+ },
946
+ canLink: (source, target, kind) => canLink(model, source, target, kind),
947
+ addElement: readOnly,
948
+ updateElement: readOnly,
949
+ moveElement: readOnly,
950
+ nestElement: readOnly,
951
+ removeElement: readOnly,
952
+ removeElements: readOnly,
953
+ setCollapsed: readOnly,
954
+ connect: readOnly,
955
+ addDependency: readOnly,
956
+ addLeg: readOnly,
957
+ removeLeg: readOnly,
958
+ updateLink: readOnly,
959
+ disconnect: readOnly,
960
+ updateDiagram: readOnly,
961
+ replace: readOnly,
962
+ load: readOnly,
963
+ transaction: readOnly,
964
+ clearHistory: readOnly,
965
+ undo() {
966
+ if (undoDepth === 0) return false;
967
+ send("glspUndo");
968
+ return true;
969
+ },
970
+ redo() {
971
+ if (redoDepth === 0) return false;
972
+ send("glspRedo");
973
+ return true;
974
+ },
975
+ canUndo: () => undoDepth > 0,
976
+ canRedo: () => redoDepth > 0
977
+ };
978
+ }
979
+ //#endregion
980
+ //#region src/transport.ts
981
+ const JSONRPC_VERSION = "2.0";
982
+ function isRecord(value) {
983
+ return typeof value === "object" && value !== null;
984
+ }
985
+ function isJsonRpcId(value) {
986
+ return typeof value === "string" || typeof value === "number";
987
+ }
988
+ function errorMessage(id, code, message) {
989
+ return {
990
+ jsonrpc: JSONRPC_VERSION,
991
+ id,
992
+ error: {
993
+ code,
994
+ message
995
+ }
996
+ };
997
+ }
998
+ /**
999
+ * Serves `dispatcher` on `port`: every well-formed request is answered with the
1000
+ * `ActionResult` as `result`; `dispatcher.subscribe` events are pushed as notifications.
1001
+ * A malformed request answers `-32600` (invalid request) or `-32601` (no usable `method`); an
1002
+ * exception `dispatch` itself doesn't catch answers `-32603` (internal error). Returns a
1003
+ * disposer that stops forwarding subscriber events and detaches the port's `onmessage`.
1004
+ */
1005
+ function connectDispatcher(port, dispatcher) {
1006
+ const unsubscribe = dispatcher.subscribe((action) => {
1007
+ port.postMessage({
1008
+ jsonrpc: JSONRPC_VERSION,
1009
+ method: action.kind,
1010
+ params: action
1011
+ });
1012
+ });
1013
+ port.onmessage = (ev) => {
1014
+ const message = ev.data;
1015
+ if (!isRecord(message) || message.jsonrpc !== JSONRPC_VERSION) return;
1016
+ const { id, method, params } = message;
1017
+ const hasId = isJsonRpcId(id);
1018
+ if (typeof method !== "string" || method.length === 0) {
1019
+ if (hasId) port.postMessage(errorMessage(id, -32601, "method not found"));
1020
+ return;
1021
+ }
1022
+ if (!isRecord(params) || params.kind !== method) {
1023
+ if (hasId) port.postMessage(errorMessage(id, -32600, "invalid request"));
1024
+ return;
1025
+ }
1026
+ const action = params;
1027
+ if (!hasId) {
1028
+ dispatcher.dispatch(action);
1029
+ return;
1030
+ }
1031
+ dispatcher.dispatch(action).then((result) => {
1032
+ port.postMessage({
1033
+ jsonrpc: JSONRPC_VERSION,
1034
+ id,
1035
+ result
1036
+ });
1037
+ }, (error) => {
1038
+ const text = error instanceof Error ? error.message : "internal error";
1039
+ port.postMessage(errorMessage(id, -32603, text));
1040
+ });
1041
+ };
1042
+ return () => {
1043
+ unsubscribe();
1044
+ port.onmessage = null;
1045
+ };
1046
+ }
1047
+ /**
1048
+ * The `ActionDispatcher` for the other end of a `connectDispatcher` port: `dispatch` sends a
1049
+ * request and resolves with the `ActionResult` it is answered with (a JSON-RPC `error` rejects
1050
+ * the promise instead — a transport fault, not an `ActionResult`); `subscribe` hears the
1051
+ * notifications the server pushes.
1052
+ */
1053
+ function createRemoteDispatcher(port) {
1054
+ let nextId = 0;
1055
+ const pending = /* @__PURE__ */ new Map();
1056
+ const listeners = /* @__PURE__ */ new Set();
1057
+ port.onmessage = (ev) => {
1058
+ const message = ev.data;
1059
+ if (!isRecord(message) || message.jsonrpc !== JSONRPC_VERSION) return;
1060
+ const { id, method, params, result, error } = message;
1061
+ if (isJsonRpcId(id)) {
1062
+ const waiting = pending.get(id);
1063
+ if (!waiting) return;
1064
+ pending.delete(id);
1065
+ if (isRecord(error)) waiting.reject(/* @__PURE__ */ new Error(`JSON-RPC error ${String(error.code)}: ${String(error.message)}`));
1066
+ else waiting.resolve(result);
1067
+ return;
1068
+ }
1069
+ if (typeof method === "string" && isRecord(params)) {
1070
+ const action = params;
1071
+ for (const listener of listeners) listener(action);
1072
+ }
1073
+ };
1074
+ return {
1075
+ dispatch(action) {
1076
+ const id = nextId++;
1077
+ return new Promise((resolve, reject) => {
1078
+ pending.set(id, {
1079
+ resolve,
1080
+ reject
1081
+ });
1082
+ port.postMessage({
1083
+ jsonrpc: JSONRPC_VERSION,
1084
+ id,
1085
+ method: action.kind,
1086
+ params: action
1087
+ });
1088
+ });
1089
+ },
1090
+ subscribe(listener) {
1091
+ listeners.add(listener);
1092
+ return () => listeners.delete(listener);
1093
+ }
1094
+ };
1095
+ }
1096
+ //#endregion
1097
+ //#region src/index.ts
1098
+ /** Version of the action protocol this package speaks. */
1099
+ const ACTIONS_PROTOCOL_VERSION = "0.0.0";
1100
+ //#endregion
1101
+ export { ACTIONS_PROTOCOL_VERSION, HANDLED_ACTION_KINDS, connectDispatcher, contextActionsFor, contextText, createActionDispatcher, createMirrorStore, createRemoteDispatcher, diagnosticToMarker, hostToolContext, isOperation, isRequestAction, markerToDiagnostic, readOnlyDispatcher, typeHintsFor };
1102
+
1103
+ //# sourceMappingURL=index.js.map