Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ef5472e5 | ||
|
|
7fd34fcce0 | ||
|
|
f4663c089d | ||
|
|
2ab4287f02 | ||
|
|
0745de822a | ||
|
|
f4df6a0834 | ||
|
|
4b36136432 | ||
|
|
7dde9ffffe | ||
|
|
f6f59fae67 | ||
|
|
0752128789 | ||
|
|
517697197e | ||
|
|
c71296b01a | ||
|
|
86a8adf03b | ||
|
|
6153c7f145 | ||
|
|
885977c4aa | ||
|
|
95c6e60199 | ||
|
|
b54304fef8 | ||
|
|
8407c9a956 | ||
|
|
c2d33780f9 | ||
|
|
90c7be06de | ||
|
|
7d2d55543e | ||
|
|
c262bb06a7 | ||
|
|
63f0633daa | ||
|
|
ef272e4d96 | ||
|
|
f6097b1d00 | ||
|
|
9ccd67b2dd | ||
|
|
d02c331155 | ||
|
|
c99f3e5fe8 | ||
|
|
a9021312b0 |
No files matched your search
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.2 — 2026-11-08
|
||||
|
||||
- The ArchiMate examples' layered views put the labels of the arrows between layers in the gap between the bands, no longer on the band titles.
|
||||
|
||||
## 0.11.1 — 2026-11-08
|
||||
|
||||
- **The arc42 examples cover all twelve sections.** Each overview carries cards for 1.1 Requirements, 1.2 Quality goals, 1.3 Stakeholders, 2 Constraints, 4 Solution strategy, 11 Risks and technical debt and 12 Glossary; the technical context lists the domain interfaces (3); every building block has a black-box description, responsibility and interfaces (5); the deployment view places the building blocks on their nodes with a mapping (7); level 1 carries the crosscutting concepts (8); a new `1000-quality` folder draws the quality tree with its scenarios (10). Each model's description says where each section lives.
|
||||
|
||||
## 0.11.0 — 2026-11-08
|
||||
|
||||
- **arc42 and ArchiMate examples.** *Archify | New Model…* now offers nine examples, three per framework. **arc42** (Cinema Tickets, IoT Telemetry, Team Wiki): the overview is the business context with quality goals, constraints, quality scenarios, risks and a glossary as cards; then `0300-context`, `0500-building-blocks` (levels 1 and 2), `0600-runtime` (sequence diagrams), `0700-deployment`, and the decisions as section 9. **ArchiMate** (Insurance Claims, HR Onboarding, Logistics Tracking): the overview is a layered view of the business, application and technology layers; then `0100-business` (with the business process as a workflow), `0200-application` and `0300-technology`, one registry file per layer, each element's ArchiMate type under its name.
|
||||
- **`ref:` in every diagram kind.** Workflow and dataflow nodes, sequence participants and lifecycle states can use registry elements, as architecture components do; the Source view's schema check accepts them too. Sequence participants take no `tag`.
|
||||
|
||||
## 0.10.10 — 2026-11-08
|
||||
|
||||
- README: the Buy me a coffee button at the top.
|
||||
|
||||
## 0.10.9 — 2026-11-08
|
||||
|
||||
- The Sync button is square again on the model, registry, decision and Graph pages (0.10.8 squeezed it narrow there).
|
||||
|
||||
## 0.10.8 — 2026-11-08
|
||||
|
||||
- **A model opens as its outline.** On `main.archify` the top-level panels (Shared, Diagrams, Registries, Decisions) start collapsed; open the one you need. What you open or close is kept while the page is open.
|
||||
- **Sync button in line on every page.** On the model, registry and decision pages it sits next to Model | Graph | Source instead of floating above it, and on the Graph page it comes after Export PNG instead of covering it. It takes the height of the controls beside it.
|
||||
|
||||
## 0.10.7 — 2026-11-08
|
||||
|
||||
- **Model | Graph | Source looks like 2D | 3D.** The selected view is the same inset pill, the same height as the other toolbar controls.
|
||||
- The Sync button of a shared model is green in high contrast themes too.
|
||||
|
||||
## 0.10.6 — 2026-11-08
|
||||
|
||||
- **Sync sign-in comes back by itself.** The browser now returns the sign-in to a one-off port on this machine (`127.0.0.1`) instead of a `vscode://` link. Browsers open such links only after a click, so when you were already signed in to the identity provider the editor kept waiting. Remote and web editors still use the link.
|
||||
|
||||
## 0.10.5 — 2026-11-08
|
||||
|
||||
- **Sync button fixed.** It is now an icon only (a cloud; green with a broadcast icon once the model is shared), the same size on every page and theme. On a diagram it sits in the toolbar after **Export** instead of over it; in narrow windows and high contrast themes it no longer shrinks or loses its colour.
|
||||
|
||||
## 0.10.4 — 2026-11-07
|
||||
|
||||
- **A real Sync button.** Every Archify page (diagram, model, graph, registry, decision, and in Source) shows a **Sync** button top right. It opens the Sync menu: sign in, share the model, join one, set the server address. On a model that is shared it turns green and reads **Shared**.
|
||||
- Removed the small title-bar icon and the status bar entry shown before anything was shared, with their settings `archify.sync.titleButton` and `archify.sync.statusBar`. A shared model still shows its live state in the status bar.
|
||||
|
||||
## 0.10.3 — 2026-11-07
|
||||
|
||||
- **Sync button in the title bar.** Every Archify page (and `.archify` text editors) has a **Sync** button top right, next to *Open in Text Editor*: sign in, share a model, join one, set the server address. The status bar entry stays for those who show the status bar. `archify.sync.titleButton: false` hides the button.
|
||||
|
||||
## 0.10.2 — 2026-11-07
|
||||
|
||||
- Fixed: going back from **Source** to **Model**, **Diagram** or **View** left the YAML editor on the page under the picture when nothing had been edited. It now hides at once.
|
||||
|
||||
## 0.10.1 — 2026-11-06
|
||||
|
||||
- **Archify Sync is easier to find.** In workspaces with Archify files, **Archify Sync** sits in the status bar. Click it for a menu: sign in, share a model, join one, set the server address, open the settings. It shows the server you are signed in to, and the live state once a model is shared. `archify.sync.statusBar: false` hides it; nothing else changes without a server.
|
||||
- **Server address as a setting.** `archify.sync.server` (empty by default, for example `https://archify.example.com`) is used by sign-in, share and the menu without asking. In the workspace settings it gives a whole project the same server. *Archify | Set Sync Server Address…* sets it.
|
||||
|
||||
## 0.10.0 — 2026-11-06
|
||||
|
||||
- **Archify Sync (optional).** Several people edit one model at once through a server you run yourself ([archify-vscode-ext-sync](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync)); the files stay ordinary files on each disk. Commands: *Sign in to Sync Server…*, *Share Model…*, *Copy Invite Link*, *Join Shared Model…*, *Publish Version…*, *Versions…*, *Stop Syncing Model…*. Sign-in through the browser (PKCE), sessions in VS Code's secret storage, an *Archify Sync* account provider. Edits from editors, pages, Git and agents go out at once; others' edits arrive in open editors with undo intact. Everything is kept on the machine first, so work goes on offline and merges later. Teammates' cursors show as named carets in the Source view and in VS Code's text editors, and stay on their word while others type. The status bar shows who is here; the model page gets a **Shared** section with people, the file each has open, and the latest versions with **Compare** and **Restore**.
|
||||
- Without a server nothing changes: the sync code is a separate bundle that loads only when a model in the workspace is shared, after a sign-in, or when a sync command or invite link is used.
|
||||
|
||||
## 0.9.3 — 2026-11-04
|
||||
|
||||
- Fixed: on a `main.archify` page in Source, **Graph** did nothing, and **Model** then showed the Graph page instead of the model. Graph now leaves Source and opens at once, and **Model** on the Graph page comes back to the model, not to Source.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Archify Diagram Viewer
|
||||
|
||||
[](https://buymeacoffee.com/root.at.skic)
|
||||
|
||||
Create and view [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. You write a diagram as a **`.archify` file in YAML**; clicking it opens the interactive diagram, and the diagram follows your edits as you type.
|
||||
|
||||

|
||||
|
||||
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
|
||||
|
||||
Free and open source. If it saves you time, you can [buy me a coffee](https://buymeacoffee.com/root.at.skic) ☕
|
||||
|
||||
## Supported files
|
||||
|
||||
| File | Format | What the extension does |
|
||||
@@ -22,7 +22,7 @@ New diagrams should be `.archify` files; everything below describes them unless
|
||||
|
||||
The fastest start: right-click a folder in the Explorer and choose **Archify | New Diagram…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
|
||||
|
||||
For a whole set of diagrams, choose **Archify | New Model…** instead and pick one of the examples (C4 - Standard Web App, C4 - E-Shop, C4 - Event-Based Integration). A model folder with context, container and component diagrams and a shared registry is created and its model page opens. See [Models](#models).
|
||||
For a whole set of diagrams, choose **Archify | New Model…** instead and pick one of the examples: three in C4, three in arc42 and three in ArchiMate. A model folder with its diagrams, a shared registry and decisions is created and its model page opens. See [Models](#models).
|
||||
|
||||
To write one by hand:
|
||||
|
||||
@@ -64,7 +64,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
|
||||
## Features
|
||||
|
||||
- **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
|
||||
- **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration).
|
||||
- **New model from an example**: *Archify | New Model…* creates a complete model folder from an example: C4, arc42 or ArchiMate, three of each.
|
||||
- **Model graph**: the **Graph** switch on any page of a model shows the whole model as one graph, with a query box (`type:database`, `uses:api`, `from:web to:db`, …), a side panel per element and PNG export. See [Models](#models).
|
||||
- **Diagram editor**: `.archify` files open as the rendered diagram. **Source** shows the YAML in its place, in an editor inside the page: problems underlined as you type and counted on the switch, completion and hover from the schema, undo, and ⌘S/Ctrl+S to save. **Open in Text Editor** opens the YAML beside the diagram instead, and edits re-render live.
|
||||
- **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`).
|
||||
@@ -126,7 +126,7 @@ online-shop/
|
||||
- **Decisions.** One `*.archify-decision` file per architecture decision: id, title, status, date, the registry elements and diagrams it is about, and prose for context, decision and consequences. Diagrams point at decisions with `decisions: [<id>]` on the diagram, a component or a connection. They show as a **Decisions** panel on the `main.archify` page, a **Decisions** rail panel under each diagram (hover lights up what the decision is attached to), on element cards and in the element dialog, and a decision opens as its own page. *Archify | New Decision…* creates one; links are checked in Problems.
|
||||
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **View | Source** at the top right switches to the YAML, checked against the registry schema as you type.
|
||||
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
|
||||
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
|
||||
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers), each with context, container and component diagrams. **arc42 - Cinema Tickets**, **arc42 - IoT Telemetry** and **arc42 - Team Wiki** cover all twelve arc42 sections: the overview is the business context (3) with requirements, quality goals, stakeholders, constraints, solution strategy, risks and technical debt, and a glossary as numbered cards; then `0300-context` (domain interfaces), `0500-building-blocks` (black boxes, crosscutting concepts), `0600-runtime` (sequence diagrams), `0700-deployment` (blocks mapped to nodes) and `1000-quality` (quality tree), with section 9 as decisions. **ArchiMate - Insurance Claims**, **ArchiMate - HR Onboarding** and **ArchiMate - Logistics Tracking** are layered: the overview is a layered view, then `0100-business`, `0200-application` and `0300-technology`, one registry file per layer, each element's ArchiMate type as its tag. Every example has a registry and decisions. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
|
||||
- **The graph.** The **Model | Graph | Source** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. The layout is **Layered** by default, like Archify's stack: clients on the left, data on the right, registries in a row above, decisions in a row below, connections drawn orthogonally; click the button for a **Force** layout instead. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Labels** toggles connection labels, the dotted **Links** from decisions and registries to their elements are on by default and can be switched off, in which case only the selected element's show (hovering an element lights up its connections), the sun/moon button switches the page between its dark and light theme (remembered, shared with the diagram pages' theme choice), **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph | Source** and Graph opens the graph already narrowed to that diagram; **Source** on the graph page goes back to `main.archify` in its Source view. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
|
||||
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. The **?** button lists them; clicking an example runs it.
|
||||
- **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**.
|
||||
@@ -134,6 +134,24 @@ online-shop/
|
||||
|
||||
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
|
||||
|
||||
## Working together (Archify Sync)
|
||||
|
||||
Optional. Without a sync server the extension works exactly as described above and loads none of this.
|
||||
|
||||
[Archify Sync](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync) is a small server you run yourself (one container and PostgreSQL; a Helm chart is included). With it, a model is edited by several people at once, like a shared document, and its files stay ordinary files on each person's disk, so Git, agents and every page of this extension keep working.
|
||||
|
||||
- **Where.** Every Archify page (diagram, model, graph, registry, decision, also in Source) has a Sync button (a cloud) top right; on a diagram it sits after **Export**. It opens the Sync menu: sign in, share the model, join one, the server address, settings. On a shared model it turns green with a broadcast icon. The same menu is *Archify | Sync…* in the Command Palette.
|
||||
- **Server address.** Set `archify.sync.server` once (Settings › Archify › Sync, or *Archify | Set Sync Server Address…*), for example `https://archify.example.com`. Put it in the workspace settings and everyone in the project uses the same server. Left empty, the address is asked for once and remembered.
|
||||
- **Sign in.** *Archify | Sign in to Sync Server…* uses that address (or asks for it) and signs in through the browser; the session is kept in VS Code's secret storage and shows under **Accounts** as *Archify Sync*. A new server is set up first on its `/setup` page with the code from its log.
|
||||
- **Share.** *Archify | Share Model…* (also on `main.archify` in the Explorer) puts the model on the server and writes a small `.archify-sync` file next to `main.archify`: the server and the model's id, no secrets. Commit it, and everyone who clones the repository and is a member opens the shared model.
|
||||
- **Invite and join.** *Archify | Copy Invite Link* copies a link for the model; send it any way you like. Opening it (or *Archify | Join Shared Model…*) signs the person in, or up, and puts the files in an empty folder of their choice.
|
||||
- **Live.** Edits in any editor, page, Source view, by Git or by an agent reach the others within a moment. Their edits arrive in your open editors with undo intact and are saved to disk; your own unsaved work stays unsaved. The status bar shows **Archify live · 3 here**; the model page gets a **Shared** section with who is here and in which file, and each diagram card says who has it open. In the Source view and in text editors, teammates' cursors show as coloured carets with their names.
|
||||
- **Offline.** Every edit is kept on your machine first. Without a connection you keep working; edits on both sides merge when it is back, also after a restart, and also edits made while VS Code was closed.
|
||||
- **Versions.** The server takes a snapshot after a pause in editing (0.1, 0.2, …). *Archify | Publish Version…* names the current state as a major version (1.0 "Q4 baseline"). The model page lists the latest versions with **Compare** (a diff per changed file) and **Restore** (brings the files back for everyone, as a new version; nothing is lost); *Archify | Versions…* lists them all.
|
||||
- **Stop.** *Archify | Stop Syncing Model…* removes `.archify-sync` here and leaves the files as they are.
|
||||
|
||||
Only model files (`.archify`, `.archify-registry`, `.archify-decision`) are shared, and only below `main.archify`.
|
||||
|
||||
## Source evidence
|
||||
|
||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||
@@ -156,7 +174,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
|
||||
|
||||
## Security
|
||||
|
||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code. The extension itself talks to a network only for Archify Sync, and only to a server you sign in to or a model's `.archify-sync` file names, over HTTPS (plain HTTP only to this machine).
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ if (tests) {
|
||||
outfile: 'dist/extension.js',
|
||||
external: ['vscode'],
|
||||
}),
|
||||
// Archify Sync, loaded only when a model is shared (src/sync/loader.ts).
|
||||
// ws's native speed-ups are optional and not shipped.
|
||||
esbuild.context({
|
||||
...common,
|
||||
entryPoints: ['src/sync/index.ts'],
|
||||
outfile: 'dist/sync.js',
|
||||
external: ['vscode', 'bufferutil', 'utf-8-validate'],
|
||||
}),
|
||||
// Runs inside the Archify page in the webview; three.js is bundled in.
|
||||
esbuild.context({
|
||||
...common,
|
||||
|
||||
Generated
+107
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "archify-vscode-ext",
|
||||
"version": "0.9.3",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "archify-vscode-ext",
|
||||
"version": "0.9.3",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.4",
|
||||
@@ -22,8 +22,12 @@
|
||||
"codemirror-json-schema": "^0.8.1",
|
||||
"cytoscape": "^3.34.4",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"lib0": "^0.2.119",
|
||||
"three": "^0.186.1",
|
||||
"yaml": "^2.9.1"
|
||||
"ws": "^8.22.1",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yaml": "^2.9.1",
|
||||
"yjs": "^13.6.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
@@ -31,6 +35,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/three": "^0.186.0",
|
||||
"@types/vscode": "~1.100.0",
|
||||
"@types/ws": "^8.18.2",
|
||||
"@vscode/test-electron": "^3.1.0",
|
||||
"@vscode/vsce": "^3.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -1392,6 +1397,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
||||
@@ -3308,6 +3323,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/istextorbinary": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz",
|
||||
@@ -3547,6 +3572,27 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.119",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz",
|
||||
"integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -5707,6 +5753,27 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
@@ -5747,6 +5814,26 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y-protocols": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.85"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@@ -5792,6 +5879,23 @@
|
||||
"buffer-crc32": "~0.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.33",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz",
|
||||
"integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
+94
-3
@@ -2,7 +2,7 @@
|
||||
"name": "archify-vscode-ext",
|
||||
"displayName": "Archify Diagram Viewer",
|
||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
||||
"version": "0.9.3",
|
||||
"version": "0.11.2",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
@@ -45,7 +45,8 @@
|
||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||
"onWebviewPanel:archify.preview",
|
||||
"workspaceContains:**/*.archify-registry",
|
||||
"workspaceContains:**/*.archify-decision"
|
||||
"workspaceContains:**/*.archify-decision",
|
||||
"onUri"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
@@ -118,6 +119,56 @@
|
||||
"command": "archify.openModelGraph",
|
||||
"title": "Archify | Open Model Graph",
|
||||
"icon": "$(type-hierarchy)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.signIn",
|
||||
"title": "Archify | Sign in to Sync Server…",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.signOut",
|
||||
"title": "Archify | Sign out of Sync Server",
|
||||
"icon": "$(sign-out)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.share",
|
||||
"title": "Archify | Share Model…",
|
||||
"icon": "$(live-share)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.invite",
|
||||
"title": "Archify | Copy Invite Link",
|
||||
"icon": "$(person-add)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.join",
|
||||
"title": "Archify | Join Shared Model…",
|
||||
"icon": "$(link)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.publish",
|
||||
"title": "Archify | Publish Version…",
|
||||
"icon": "$(tag)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.versions",
|
||||
"title": "Archify | Versions…",
|
||||
"icon": "$(history)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.stop",
|
||||
"title": "Archify | Stop Syncing Model…",
|
||||
"icon": "$(debug-disconnect)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.menu",
|
||||
"title": "Archify | Sync…",
|
||||
"icon": "$(live-share)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.setServer",
|
||||
"title": "Archify | Set Sync Server Address…",
|
||||
"icon": "$(server)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -161,6 +212,22 @@
|
||||
{
|
||||
"command": "archify.openModelGraph",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.invite",
|
||||
"when": "archify.sync.active"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.publish",
|
||||
"when": "archify.sync.active"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.versions",
|
||||
"when": "archify.sync.active"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.stop",
|
||||
"when": "archify.sync.active"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -250,6 +317,11 @@
|
||||
"command": "archify.openModelGraph",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@9"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.share",
|
||||
"when": "resourceFilename == main.archify",
|
||||
"group": "archify@9"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
@@ -442,6 +514,14 @@
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Node.js executable (18 or newer) used to run the renderer. Empty means the runtime bundled with VS Code."
|
||||
},
|
||||
"archify.sync.server": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"scope": "window",
|
||||
"pattern": "^$|^https?://",
|
||||
"patternErrorMessage": "An address such as https://archify.example.com",
|
||||
"markdownDescription": "Address of your Archify Sync server, for example `https://archify.example.com`. The **Sync** button on Archify pages, *Sign in* and *Share Model* use it without asking. Set it in the workspace settings so everyone in the project uses the same server. Empty: asked once and remembered. Archify Sync is optional; see the README."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -464,6 +544,12 @@
|
||||
"fileMatch": "*.archify-registry",
|
||||
"url": "./schemas/archify-registry.schema.json"
|
||||
}
|
||||
],
|
||||
"authentication": [
|
||||
{
|
||||
"id": "archify-sync",
|
||||
"label": "Archify Sync"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
@@ -482,6 +568,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/three": "^0.186.0",
|
||||
"@types/vscode": "~1.100.0",
|
||||
"@types/ws": "^8.18.2",
|
||||
"@vscode/test-electron": "^3.1.0",
|
||||
"@vscode/vsce": "^3.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -506,7 +593,11 @@
|
||||
"codemirror-json-schema": "^0.8.1",
|
||||
"cytoscape": "^3.34.4",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"lib0": "^0.2.119",
|
||||
"three": "^0.186.1",
|
||||
"yaml": "^2.9.1"
|
||||
"ws": "^8.22.1",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yaml": "^2.9.1",
|
||||
"yjs": "^13.6.33"
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag."
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds its own placement and may override label, sublabel and tag."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
|
||||
@@ -129,13 +129,6 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"stage",
|
||||
"row"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
@@ -188,8 +181,35 @@
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds its own placement and may override label, sublabel and tag."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"ref",
|
||||
"stage",
|
||||
"row"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"stage",
|
||||
"row"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"flows": {
|
||||
|
||||
@@ -145,13 +145,6 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"lane",
|
||||
"col"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
@@ -216,8 +209,35 @@
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds its own placement and may override label, sublabel and tag."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"ref",
|
||||
"lane",
|
||||
"col"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"lane",
|
||||
"col"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"transitions": {
|
||||
|
||||
@@ -117,11 +117,6 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
@@ -152,8 +147,31 @@
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds its own placement and may override label, sublabel and tag."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"ref"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"segments": {
|
||||
|
||||
@@ -280,13 +280,6 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"lane",
|
||||
"col",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
@@ -339,8 +332,35 @@
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds its own placement and may override label, sublabel and tag."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"ref",
|
||||
"lane",
|
||||
"col"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"id",
|
||||
"lane",
|
||||
"col",
|
||||
"type",
|
||||
"label"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Adds the model keys to the editor schemas in schemas/ (the CLI keeps the
|
||||
// strict originals): `model` on main.archify, `ref` and `diagram` on
|
||||
// architecture components, and a schema for *.archify-registry files.
|
||||
// strict originals): `model` on main.archify, `ref` and `diagram` on each
|
||||
// kind's nodes, and a schema for *.archify-registry files.
|
||||
// Run after sync-archify.mjs, or on its own after editing this file.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -28,6 +28,7 @@ const DIAGRAM_LINK = { type: 'string', description: 'Diagram that explains this
|
||||
const DECISIONS = { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram.' };
|
||||
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'];
|
||||
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'];
|
||||
const NODE_COLLECTION_OF = { architecture: 'components', workflow: 'nodes', dataflow: 'nodes', sequence: 'participants', lifecycle: 'states' };
|
||||
|
||||
for (const file of fs.readdirSync(schemas)) {
|
||||
if (!/^(architecture|workflow|sequence|dataflow|lifecycle)\.schema\.json$/.test(file)) continue;
|
||||
@@ -38,14 +39,21 @@ for (const file of fs.readdirSync(schemas)) {
|
||||
const items = schema.properties[key]?.items;
|
||||
if (items?.properties) items.properties.decisions = DECISIONS;
|
||||
}
|
||||
if (file === 'architecture.schema.json') {
|
||||
const item = schema.properties.components.items;
|
||||
item.properties.ref = { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag.' };
|
||||
// The list where each kind keeps its nodes takes `ref` (as the extension's resolver does).
|
||||
const nodes = NODE_COLLECTION_OF[file.replace('.schema.json', '')];
|
||||
const item = schema.properties[nodes]?.items;
|
||||
if (item?.properties) {
|
||||
item.properties.ref = { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds its own placement and may override label, sublabel and tag.' };
|
||||
item.properties.diagram = DIAGRAM_LINK;
|
||||
// A ref brings type and label with it; an inline component must state them.
|
||||
item.anyOf = [{ required: ['ref'] }, { required: ['id', 'type', 'label'] }];
|
||||
// A ref brings type and label with it; an inline node must state them. Re-runs keep the original list.
|
||||
const required = item.required ?? item.anyOf?.[1]?.required;
|
||||
if (required) {
|
||||
// Placement (lane/col, stage/row) stays the diagram's own; only id, type and label may come from the ref.
|
||||
const placement = required.filter((key) => !['id', 'type', 'label'].includes(key));
|
||||
item.anyOf = [{ required: ['ref', ...placement] }, { required }];
|
||||
delete item.required;
|
||||
}
|
||||
}
|
||||
write(file, schema);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DiagramViewManager } from './views/diagramView';
|
||||
import { RegistryViewProvider } from './views/registryView';
|
||||
import { DecisionViewProvider } from './views/decisionView';
|
||||
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
|
||||
import { registerSync } from './sync/loader';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const models = new ModelService();
|
||||
@@ -47,6 +48,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
...newDiagramCommands(ctx),
|
||||
...renderCommands(ctx),
|
||||
...modelCommands(ctx),
|
||||
...registerSync(context, models),
|
||||
);
|
||||
registerContextKeys(context, models);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { modelIdFromName, titleFromName } from './templates';
|
||||
/**
|
||||
* Example models shipped with the extension (templates/models/<folder>).
|
||||
* "Archify | New Model…" offers them; each is a complete model folder:
|
||||
* main.archify, registry/*.archify-registry and diagrams by level.
|
||||
* main.archify, registry/*.archify-registry, decisions and diagrams in the
|
||||
* framework's folders: C4 levels, arc42 sections or ArchiMate layers.
|
||||
*/
|
||||
export interface ModelExample {
|
||||
/** Folder under templates/models and the default name of the new model folder. */
|
||||
@@ -17,8 +18,17 @@ export const MODEL_EXAMPLES: readonly ModelExample[] = [
|
||||
{ folder: 'c4-standard-web-app', label: 'C4 - Standard Web App', description: 'Single-page app, API, database, cache, file storage, identity provider' },
|
||||
{ folder: 'c4-e-shop', label: 'C4 - E-Shop', description: 'Web shop, API, worker, queue, payment and email providers' },
|
||||
{ folder: 'c4-event-based-integration', label: 'C4 - Event-Based Integration', description: 'Event bus, router, dead-letter queue, event store, consumers' },
|
||||
{ folder: 'arc42-cinema-tickets', label: 'arc42 - Cinema Tickets', description: 'Context, building blocks, runtime and deployment views; seat booking with payment' },
|
||||
{ folder: 'arc42-iot-telemetry', label: 'arc42 - IoT Telemetry', description: 'Devices over MQTT, ingest, time-series store, alerting, dashboards' },
|
||||
{ folder: 'arc42-team-wiki', label: 'arc42 - Team Wiki', description: 'A small self-hosted wiki: the arc42 sections at their smallest' },
|
||||
{ folder: 'archimate-insurance-claims', label: 'ArchiMate - Insurance Claims', description: 'Business, application and technology layers of claim handling' },
|
||||
{ folder: 'archimate-hr-onboarding', label: 'ArchiMate - HR Onboarding', description: 'Onboarding a new employee across HR, identity and IT services' },
|
||||
{ folder: 'archimate-logistics-tracking', label: 'ArchiMate - Logistics Tracking', description: 'Shipment tracking from pick-up to proof of delivery, layer by layer' },
|
||||
];
|
||||
|
||||
/** The framework prefix of an example folder; the model id is the folder without it. */
|
||||
export const EXAMPLE_PREFIX = /^(c4|arc42|archimate)-/;
|
||||
|
||||
/**
|
||||
* The example's main.archify for a new model folder. When the folder keeps
|
||||
* the example's name the file is used as written; otherwise id, name and the
|
||||
|
||||
+23
-11
@@ -163,13 +163,21 @@ export interface DecisionRef {
|
||||
|
||||
/** Collections whose items may carry `decisions` (and `diagram` links), per diagram kind. */
|
||||
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'] as const;
|
||||
/** The list that holds a diagram kind's nodes, where `ref` is resolved. */
|
||||
const NODE_COLLECTION_OF: Record<string, (typeof NODE_COLLECTIONS)[number]> = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
dataflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'] as const;
|
||||
|
||||
export interface ResolvedDiagram {
|
||||
/** The diagram as Archify expects it: refs expanded, model block and links removed. */
|
||||
diagram: Obj;
|
||||
problems: ModelProblem[];
|
||||
/** Component index → the registry element it came from. */
|
||||
/** Node index (in the kind's node list) → the registry element it came from. */
|
||||
refs: Map<number, RegistryElement>;
|
||||
/** Node id → diagram path (relative to the model root) it links to. */
|
||||
links: Map<string, string>;
|
||||
@@ -253,10 +261,14 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
delete diagram.model;
|
||||
const decisionRefs = takeDecisionRefs(diagram, options, problems);
|
||||
const { registry } = options;
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : undefined;
|
||||
if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||
// Each kind keeps its nodes in its own list; sequence participants have no tag.
|
||||
const kind = str(diagram.diagram_type);
|
||||
const collection = (kind && NODE_COLLECTION_OF[kind]) ?? NODE_COLLECTIONS.find((key) => Array.isArray(diagram[key]));
|
||||
const nodes = collection && Array.isArray(diagram[collection]) ? (diagram[collection] as unknown[]) : undefined;
|
||||
if (!collection || !nodes) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||
const copied: readonly string[] = kind === 'sequence' ? COPIED_FIELDS.filter((key) => key !== 'tag') : COPIED_FIELDS;
|
||||
|
||||
diagram.components = components.map((raw, index) => {
|
||||
diagram[collection] = nodes.map((raw, index) => {
|
||||
if (!isObj(raw)) return raw;
|
||||
const ref = str(raw.ref);
|
||||
const linkFromComponent = str(raw.diagram);
|
||||
@@ -275,7 +287,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
? `Unknown element "${ref}": no registry of this model defines it.`
|
||||
: `"${ref}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`,
|
||||
severity: 'error',
|
||||
pointer: `/components/${index}/ref`,
|
||||
pointer: `/${collection}/${index}/ref`,
|
||||
});
|
||||
return { type: 'external', label: ref, ...own, id };
|
||||
}
|
||||
@@ -283,19 +295,19 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
refs.set(index, element);
|
||||
if (element.description) descriptions.set(id, element.description.trim());
|
||||
for (const key of Object.keys(own)) {
|
||||
if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
|
||||
if (copied.includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
|
||||
problems.push({
|
||||
message: `"${key}" comes from the registry element "${ref}" and cannot be changed in a diagram.`,
|
||||
severity: 'error',
|
||||
pointer: `/components/${index}/${key}`,
|
||||
pointer: `/${collection}/${index}/${key}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const merged: Obj = { id };
|
||||
for (const key of COPIED_FIELDS) if (element[key] !== undefined) merged[key] = element[key];
|
||||
for (const key of copied) if (element[key as (typeof COPIED_FIELDS)[number]] !== undefined) merged[key] = element[key as (typeof COPIED_FIELDS)[number]];
|
||||
for (const [key, value] of Object.entries(own)) {
|
||||
if (key === 'id') continue;
|
||||
if (!(COPIED_FIELDS as readonly string[]).includes(key) || (OVERRIDABLE_FIELDS as readonly string[]).includes(key)) merged[key] = value;
|
||||
if (!copied.includes(key) || (OVERRIDABLE_FIELDS as readonly string[]).includes(key)) merged[key] = value;
|
||||
}
|
||||
const link = linkFromComponent ?? element.diagram;
|
||||
if (link) links.set(id, link);
|
||||
@@ -353,8 +365,8 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
|
||||
}
|
||||
const obj = isObj(doc) ? doc : {};
|
||||
const meta = isObj(obj.meta) ? obj.meta : {};
|
||||
const components = Array.isArray(obj.components) ? obj.components : [];
|
||||
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
|
||||
const items = NODE_COLLECTIONS.flatMap((key) => (Array.isArray(obj[key]) ? (obj[key] as unknown[]) : []));
|
||||
const used = [...new Set(items.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
|
||||
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
|
||||
const nodes: DiagramNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ApiError,
|
||||
Invite,
|
||||
JoinResponse,
|
||||
Member,
|
||||
PROTOCOL_VERSION,
|
||||
ServerInfo,
|
||||
Space,
|
||||
TokenResponse,
|
||||
Version,
|
||||
VersionFiles,
|
||||
} from './protocol';
|
||||
|
||||
export class SyncApiError extends Error {
|
||||
constructor(readonly status: number, readonly code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
/**
|
||||
* The server address as the extension keeps it: http(s), no trailing slash.
|
||||
* Plain http is refused except on this machine; tokens travel on it.
|
||||
*/
|
||||
export function checkServerUrl(raw: string): { url?: string; problem?: string } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return { problem: 'Enter the server address, for example https://archify.example.com' };
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { problem: 'The address starts with https://' };
|
||||
if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) return { problem: 'Use https://. Plain http:// is only for a server on this machine.' };
|
||||
return { url: `${url.origin}${url.pathname.replace(/\/+$/, '')}` };
|
||||
}
|
||||
|
||||
export function syncSocketUrl(server: string, space: string): string {
|
||||
return `${server.replace(/^http/, 'ws')}/v1/spaces/${space}/sync?v=${PROTOCOL_VERSION}`;
|
||||
}
|
||||
|
||||
/** Reads `server` and `invite` from an invite link: `https://…/join/<token>` or the extension's `…/join?server=&invite=`. */
|
||||
export function parseInviteLink(raw: string): { server: string; invite: string } | undefined {
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
const server = url.searchParams.get('server');
|
||||
const invite = url.searchParams.get('invite');
|
||||
if (server && invite) return { server: server.replace(/\/+$/, ''), invite };
|
||||
const match = /^(.*)\/join\/([A-Za-z0-9_-]{20,})\/?$/.exec(url.pathname);
|
||||
if (match && (url.protocol === 'https:' || url.protocol === 'http:')) return { server: `${url.origin}${match[1]}`, invite: match[2] };
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The server's HTTP API. `token` gives a fresh access token for calls that need one. */
|
||||
export class SyncApi {
|
||||
constructor(readonly server: string, private readonly token?: (force?: boolean) => Promise<string | undefined>) {}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown, signedIn = true): Promise<T> {
|
||||
const send = async (force: boolean) => {
|
||||
const headers: Record<string, string> = { accept: 'application/json' };
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
if (signedIn) {
|
||||
const token = await this.token?.(force);
|
||||
if (!token) throw new SyncApiError(401, 'unauthorized', 'Sign in to the sync server first.');
|
||||
headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
return fetch(`${this.server}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
};
|
||||
let response = await send(false);
|
||||
// The access token may have expired between the check and the call.
|
||||
if (response.status === 401 && signedIn) response = await send(true);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let error: Partial<ApiError> = {};
|
||||
try {
|
||||
error = JSON.parse(text) as ApiError;
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
throw new SyncApiError(response.status, error.error ?? 'http', error.message ?? `The server answered ${response.status}.`);
|
||||
}
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
info = () => this.request<ServerInfo>('GET', '/v1/info', undefined, false);
|
||||
exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
|
||||
this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: redirectUri }, false);
|
||||
refresh = (refreshToken: string) => this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken }, false);
|
||||
logout = (refreshToken: string) => this.request<void>('POST', '/v1/auth/logout', { refresh_token: refreshToken }, false);
|
||||
spaces = () => this.request<Space[]>('GET', '/v1/spaces');
|
||||
createSpace = (name: string) => this.request<Space>('POST', '/v1/spaces', { name });
|
||||
members = (space: string) => this.request<Member[]>('GET', `/v1/spaces/${space}/members`);
|
||||
invite = (space: string) => this.request<Invite>('POST', `/v1/spaces/${space}/invites`, {});
|
||||
acceptInvite = (token: string) => this.request<JoinResponse>('POST', `/v1/invites/${encodeURIComponent(token)}/accept`);
|
||||
versions = (space: string) => this.request<Version[]>('GET', `/v1/spaces/${space}/versions`);
|
||||
publish = (space: string, name: string) => this.request<Version>('POST', `/v1/spaces/${space}/versions`, { name });
|
||||
versionFiles = (space: string, version: number) => this.request<VersionFiles>('GET', `/v1/spaces/${space}/versions/${version}/files`);
|
||||
restore = (space: string, version: number) => this.request<Version>('POST', `/v1/spaces/${space}/versions/${version}/restore`);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as http from 'node:http';
|
||||
import * as vscode from 'vscode';
|
||||
import { SyncApi, SyncApiError } from './api';
|
||||
import { Account, TokenResponse } from './protocol';
|
||||
|
||||
export const AUTH_PROVIDER_ID = 'archify-sync';
|
||||
const SECRET_KEY = 'archifySync.sessions';
|
||||
const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
interface StoredSession {
|
||||
id: string;
|
||||
server: string;
|
||||
accessToken: string;
|
||||
/** Epoch ms. */
|
||||
expiresAt: number;
|
||||
refreshToken: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
interface PendingSignIn {
|
||||
resolve: (code: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Archify Sync" in the Accounts menu: one session per server. Sign-in runs
|
||||
* in the browser and comes back through the extension's URI handler with a
|
||||
* one-time code, exchanged with a PKCE verifier. Tokens live in SecretStorage.
|
||||
* Refresh tokens are single use, so refreshes are serialised per server and
|
||||
* read the latest stored session first (another window may have refreshed).
|
||||
*/
|
||||
export class SyncAuth implements vscode.AuthenticationProvider, vscode.Disposable {
|
||||
private readonly changed = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
|
||||
readonly onDidChangeSessions = this.changed.event;
|
||||
private readonly pending = new Map<string, PendingSignIn>();
|
||||
private readonly refreshing = new Map<string, Promise<string | undefined>>();
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext, private readonly log: vscode.LogOutputChannel) {
|
||||
this.disposables = [
|
||||
vscode.authentication.registerAuthenticationProvider(AUTH_PROVIDER_ID, 'Archify Sync', this, { supportsMultipleAccounts: true }),
|
||||
// Another window signed in or out.
|
||||
context.secrets.onDidChange((e) => {
|
||||
if (e.key === SECRET_KEY) this.changed.fire({ added: [], removed: [], changed: [] });
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredSession[]> {
|
||||
try {
|
||||
return JSON.parse((await this.context.secrets.get(SECRET_KEY)) ?? '[]') as StoredSession[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async save(sessions: StoredSession[]): Promise<void> {
|
||||
await this.context.secrets.store(SECRET_KEY, JSON.stringify(sessions));
|
||||
}
|
||||
|
||||
private toSession(s: StoredSession): vscode.AuthenticationSession {
|
||||
return {
|
||||
id: s.id,
|
||||
accessToken: s.accessToken,
|
||||
account: { id: s.account.id, label: `${s.account.display_name} (${new URL(s.server).host})` },
|
||||
scopes: [s.server],
|
||||
};
|
||||
}
|
||||
|
||||
async getSessions(scopes?: readonly string[]): Promise<vscode.AuthenticationSession[]> {
|
||||
const server = scopes?.[0];
|
||||
return (await this.load()).filter((s) => !server || s.server === server).map((s) => this.toSession(s));
|
||||
}
|
||||
|
||||
async createSession(scopes: readonly string[]): Promise<vscode.AuthenticationSession> {
|
||||
const server = scopes[0];
|
||||
if (!server) throw new Error('Archify Sync: say which server to sign in to.');
|
||||
return this.toSession(await this.signIn(server));
|
||||
}
|
||||
|
||||
async removeSession(id: string): Promise<void> {
|
||||
const sessions = await this.load();
|
||||
const gone = sessions.find((s) => s.id === id);
|
||||
if (!gone) return;
|
||||
await this.save(sessions.filter((s) => s.id !== id));
|
||||
new SyncApi(gone.server).logout(gone.refreshToken).catch(() => undefined);
|
||||
this.changed.fire({ added: [], removed: [this.toSession(gone)], changed: [] });
|
||||
}
|
||||
|
||||
async session(server: string): Promise<StoredSession | undefined> {
|
||||
return (await this.load()).find((s) => s.server === server);
|
||||
}
|
||||
|
||||
async servers(): Promise<string[]> {
|
||||
return [...new Set((await this.load()).map((s) => s.server))];
|
||||
}
|
||||
|
||||
/** Signs in through the browser; with an invite, the page also offers to make an account. */
|
||||
async signIn(server: string, invite?: string): Promise<StoredSession> {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
const state = crypto.randomBytes(16).toString('base64url');
|
||||
// On the desktop the browser comes back to a port on this machine: no
|
||||
// click needed, and it reaches this window. Remote and web editors use
|
||||
// the extension's URI handler (vscode://…/auth) instead.
|
||||
const loopback = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Desktop ? await this.listen().catch(() => undefined) : undefined;
|
||||
const redirectUri = loopback
|
||||
? `http://127.0.0.1:${loopback.port}/auth`
|
||||
: (await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://${this.context.extension.id}/auth`))).toString(true);
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
if (invite) query.set('invite', invite);
|
||||
|
||||
const code = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: `Archify: signing in to ${new URL(server).host} in the browser…`, cancellable: true },
|
||||
(_progress, cancel) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('The sign-in took too long. Try again.')), SIGN_IN_TIMEOUT_MS);
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(state);
|
||||
loopback?.close();
|
||||
};
|
||||
this.pending.set(state, {
|
||||
resolve: (c) => (done(), resolve(c)),
|
||||
reject: (e) => (done(), reject(e)),
|
||||
});
|
||||
cancel.onCancellationRequested(() => this.pending.get(state)?.reject(new Error('Sign-in cancelled.')));
|
||||
void vscode.env.openExternal(vscode.Uri.parse(`${server}/authorize?${query}`, true)).then((opened) => {
|
||||
if (!opened) this.pending.get(state)?.reject(new Error('The browser did not open.'));
|
||||
});
|
||||
}),
|
||||
);
|
||||
const tokens = await new SyncApi(server).exchangeCode(code, verifier, redirectUri);
|
||||
const session = await this.store(server, tokens);
|
||||
await this.context.globalState.update('archifySync.used', true);
|
||||
this.log.info(`sync: signed in to ${server} as ${tokens.account.name}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** A one-off HTTP listener on 127.0.0.1 for the browser's way back; closed when the sign-in ends. */
|
||||
private listen(): Promise<{ port: number; close: () => void }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const known = url.pathname === '/auth' && this.pending.has(url.searchParams.get('state') ?? '');
|
||||
res.writeHead(known ? 200 : 404, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(
|
||||
known
|
||||
? '<!doctype html><meta charset="utf-8"><title>Archify Sync</title><body style="font:15px system-ui;margin:48px auto;max-width:420px"><h2>Signed in</h2><p>You can close this tab and go back to the editor.</p></body>'
|
||||
: 'Not found',
|
||||
);
|
||||
if (known) this.completeSignIn(url.searchParams);
|
||||
});
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') return reject(new Error('no port'));
|
||||
resolve({ port: address.port, close: () => server.close() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The browser came back: `…/auth?code=&state=`. */
|
||||
handleAuthUri(uri: vscode.Uri): void {
|
||||
this.completeSignIn(new URLSearchParams(uri.query));
|
||||
}
|
||||
|
||||
private completeSignIn(query: URLSearchParams): void {
|
||||
const state = query.get('state') ?? '';
|
||||
const code = query.get('code');
|
||||
const pending = this.pending.get(state);
|
||||
if (!pending) {
|
||||
this.log.warn('sync: a sign-in came back that this window did not start');
|
||||
return;
|
||||
}
|
||||
if (code) pending.resolve(code);
|
||||
else pending.reject(new Error(query.get('error_description') ?? 'The sign-in was not completed.'));
|
||||
}
|
||||
|
||||
private async store(server: string, tokens: TokenResponse, id?: string): Promise<StoredSession> {
|
||||
const session: StoredSession = {
|
||||
id: id ?? crypto.randomUUID(),
|
||||
server,
|
||||
accessToken: tokens.access_token,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
refreshToken: tokens.refresh_token,
|
||||
account: tokens.account,
|
||||
};
|
||||
const others = (await this.load()).filter((s) => s.server !== server);
|
||||
await this.save([...others, session]);
|
||||
const event = id ? { added: [], removed: [], changed: [this.toSession(session)] } : { added: [this.toSession(session)], removed: [], changed: [] };
|
||||
this.changed.fire(event);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** A valid access token for the server, refreshed when close to expiry; undefined without a session. */
|
||||
accessToken(server: string, force = false): Promise<string | undefined> {
|
||||
const running = this.refreshing.get(server);
|
||||
if (running) return running;
|
||||
const work = this.freshToken(server, force).finally(() => this.refreshing.delete(server));
|
||||
this.refreshing.set(server, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
private async freshToken(server: string, force: boolean): Promise<string | undefined> {
|
||||
const session = await this.session(server);
|
||||
if (!session) return undefined;
|
||||
if (!force && session.expiresAt - 60_000 > Date.now()) return session.accessToken;
|
||||
try {
|
||||
const tokens = await new SyncApi(server).refresh(session.refreshToken);
|
||||
return (await this.store(server, tokens, session.id)).accessToken;
|
||||
} catch (error) {
|
||||
if (error instanceof SyncApiError && error.status === 401) {
|
||||
// Expired, or signed out elsewhere: the person signs in again.
|
||||
const latest = await this.session(server);
|
||||
if (latest && latest.refreshToken !== session.refreshToken) return latest.accessToken;
|
||||
await this.removeSession(session.id);
|
||||
this.log.warn(`sync: the session for ${server} ended; sign in again`);
|
||||
return undefined;
|
||||
}
|
||||
// Offline: keep the old token; the connection waits and tries again.
|
||||
this.log.info(`sync: could not refresh the token for ${server} (${String(error)})`);
|
||||
return session.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const pending of this.pending.values()) pending.reject(new Error('Closed.'));
|
||||
for (const d of this.disposables) d.dispose();
|
||||
this.changed.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import * as awarenessProtocol from 'y-protocols/awareness';
|
||||
import * as syncProtocol from 'y-protocols/sync';
|
||||
import * as decoding from 'lib0/decoding';
|
||||
import * as encoding from 'lib0/encoding';
|
||||
|
||||
const MESSAGE_SYNC = 0;
|
||||
const MESSAGE_AWARENESS = 1;
|
||||
const MESSAGE_QUERY_AWARENESS = 3;
|
||||
|
||||
export type ConnectionState =
|
||||
| 'connecting'
|
||||
| 'online'
|
||||
| 'offline'
|
||||
/** No session for the server, or the server refused it. */
|
||||
| 'signed-out'
|
||||
/** The server no longer lets this account in (removed from the space). */
|
||||
| 'forbidden'
|
||||
/** The server speaks another protocol version. */
|
||||
| 'outdated';
|
||||
|
||||
export interface ConnectionOptions {
|
||||
url: string;
|
||||
/** A fresh access token; `force` after the server refused the last one. */
|
||||
token: (force: boolean) => Promise<string | undefined>;
|
||||
doc: Y.Doc;
|
||||
awareness: awarenessProtocol.Awareness;
|
||||
onState: (state: ConnectionState) => void;
|
||||
/** After the first full exchange of each connection. */
|
||||
onSynced: () => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One space's WebSocket to the server: the y-websocket protocol (sync step 1
|
||||
* and 2, updates, awareness), reconnecting with backoff. Edits made while
|
||||
* offline go out on reconnect through the state-vector exchange.
|
||||
*/
|
||||
export class LiveConnection {
|
||||
state: ConnectionState = 'connecting';
|
||||
synced = false;
|
||||
private socket?: WebSocket;
|
||||
private attempt = 0;
|
||||
private timer?: NodeJS.Timeout;
|
||||
private closed = false;
|
||||
private forceToken = false;
|
||||
|
||||
constructor(private readonly options: ConnectionOptions) {
|
||||
options.doc.on('update', this.onDocUpdate);
|
||||
options.awareness.on('update', this.onAwarenessUpdate);
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
/** Connects at once, e.g. after signing in. */
|
||||
reconnectNow(): void {
|
||||
if (this.closed || this.state === 'online' || this.state === 'connecting') return;
|
||||
clearTimeout(this.timer);
|
||||
this.attempt = 0;
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
this.options.onState(state);
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.setState('connecting');
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = await this.options.token(this.forceToken);
|
||||
} catch (error) {
|
||||
this.options.log(`sync: no token (${String(error)})`);
|
||||
}
|
||||
this.forceToken = false;
|
||||
if (this.closed) return;
|
||||
if (!token) {
|
||||
this.setState('signed-out');
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(this.options.url, { headers: { authorization: `Bearer ${token}` }, handshakeTimeout: 10_000 });
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
let refused = false;
|
||||
|
||||
socket.on('open', () => {
|
||||
const step1 = encoding.createEncoder();
|
||||
encoding.writeVarUint(step1, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep1(step1, this.options.doc);
|
||||
this.send(encoding.toUint8Array(step1));
|
||||
if (this.options.awareness.getLocalState() !== null) this.sendAwareness([this.options.doc.clientID]);
|
||||
});
|
||||
socket.on('message', (data: ArrayBuffer) => this.receive(new Uint8Array(data)));
|
||||
socket.on('unexpected-response', (_request, response) => {
|
||||
refused = true;
|
||||
const status = response.statusCode ?? 0;
|
||||
this.options.log(`sync: the server refused the connection (${status})`);
|
||||
if (status === 401) {
|
||||
// Try once more with a refreshed token; without a session, stop.
|
||||
this.forceToken = true;
|
||||
this.setState('signed-out');
|
||||
this.schedule(true);
|
||||
} else if (status === 403 || status === 404) {
|
||||
this.setState('forbidden');
|
||||
} else if (status === 426) {
|
||||
this.setState('outdated');
|
||||
} else {
|
||||
this.setState('offline');
|
||||
this.schedule();
|
||||
}
|
||||
socket.terminate();
|
||||
});
|
||||
socket.on('error', (error) => this.options.log(`sync: ${error.message}`));
|
||||
socket.on('close', () => {
|
||||
if (this.socket !== socket) return;
|
||||
this.socket = undefined;
|
||||
this.synced = false;
|
||||
const others = [...this.options.awareness.getStates().keys()].filter((id) => id !== this.options.doc.clientID);
|
||||
awarenessProtocol.removeAwarenessStates(this.options.awareness, others, this);
|
||||
if (this.closed || refused) return;
|
||||
this.setState('offline');
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
/** Backoff: 0.5 s doubling to 30 s, with jitter. */
|
||||
private schedule(once = false): void {
|
||||
if (this.closed) return;
|
||||
clearTimeout(this.timer);
|
||||
if (once && this.attempt > 0) return;
|
||||
const delay = Math.min(30_000, 500 * 2 ** this.attempt) * (0.75 + Math.random() / 2);
|
||||
this.attempt++;
|
||||
this.timer = setTimeout(() => void this.connect(), delay);
|
||||
}
|
||||
|
||||
private receive(bytes: Uint8Array): void {
|
||||
const decoder = decoding.createDecoder(bytes);
|
||||
const type = decoding.readVarUint(decoder);
|
||||
if (type === MESSAGE_SYNC) {
|
||||
const reply = encoding.createEncoder();
|
||||
encoding.writeVarUint(reply, MESSAGE_SYNC);
|
||||
const sub = syncProtocol.readSyncMessage(decoder, reply, this.options.doc, this);
|
||||
if (encoding.length(reply) > 1) this.send(encoding.toUint8Array(reply));
|
||||
if (sub === syncProtocol.messageYjsSyncStep2 && !this.synced) {
|
||||
this.synced = true;
|
||||
this.attempt = 0;
|
||||
this.setState('online');
|
||||
this.options.onSynced();
|
||||
}
|
||||
} else if (type === MESSAGE_AWARENESS) {
|
||||
awarenessProtocol.applyAwarenessUpdate(this.options.awareness, decoding.readVarUint8Array(decoder), this);
|
||||
} else if (type === MESSAGE_QUERY_AWARENESS) {
|
||||
this.sendAwareness([...this.options.awareness.getStates().keys()]);
|
||||
}
|
||||
}
|
||||
|
||||
private send(bytes: Uint8Array): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(bytes);
|
||||
}
|
||||
|
||||
private sendAwareness(clients: number[]): void {
|
||||
const message = encoding.createEncoder();
|
||||
encoding.writeVarUint(message, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(message, awarenessProtocol.encodeAwarenessUpdate(this.options.awareness, clients));
|
||||
this.send(encoding.toUint8Array(message));
|
||||
}
|
||||
|
||||
private readonly onDocUpdate = (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === this) return;
|
||||
const message = encoding.createEncoder();
|
||||
encoding.writeVarUint(message, MESSAGE_SYNC);
|
||||
syncProtocol.writeUpdate(message, update);
|
||||
this.send(encoding.toUint8Array(message));
|
||||
};
|
||||
|
||||
private readonly onAwarenessUpdate = (
|
||||
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||
origin: unknown,
|
||||
) => {
|
||||
if (origin === this) return;
|
||||
this.sendAwareness([...added, ...updated, ...removed]);
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
this.closed = true;
|
||||
clearTimeout(this.timer);
|
||||
// Tell the others this editor left before closing.
|
||||
awarenessProtocol.removeAwarenessStates(this.options.awareness, [this.options.doc.clientID], 'dispose');
|
||||
this.options.doc.off('update', this.onDocUpdate);
|
||||
this.options.awareness.off('update', this.onAwarenessUpdate);
|
||||
this.socket?.close();
|
||||
this.socket = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||
|
||||
/** One colour per person, the same in every window. */
|
||||
const COLORS = ['#e5534b', '#57ab5a', '#539bf5', '#c69026', '#b083f0', '#39c5cf', '#e0823d', '#d16d9e'];
|
||||
|
||||
export function colorFor(id: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
|
||||
/** What the text-editor side needs from the shared models. */
|
||||
export interface CursorSource {
|
||||
cursors(file: vscode.Uri): LiveCursor[];
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): boolean;
|
||||
}
|
||||
|
||||
interface Styles {
|
||||
caret: vscode.TextEditorDecorationType;
|
||||
selection: vscode.TextEditorDecorationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursors in VS Code's text editors: yours go to the others as you move, and
|
||||
* theirs are drawn as a coloured caret with their name and a tinted selection.
|
||||
*/
|
||||
export class EditorCursors implements vscode.Disposable {
|
||||
private readonly styles = new Map<string, Styles>();
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pending: vscode.TextEditor | undefined;
|
||||
|
||||
constructor(private readonly source: CursorSource) {
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeTextEditorSelection((event) => this.moved(event.textEditor)),
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => editor && this.moved(editor)),
|
||||
vscode.window.onDidChangeVisibleTextEditors(() => this.render()),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (vscode.window.visibleTextEditors.some((e) => e.document === event.document)) this.render();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sent at most every 80 ms; the last position wins. */
|
||||
private moved(editor: vscode.TextEditor): void {
|
||||
if (editor.document.uri.scheme !== 'file') return;
|
||||
this.pending = editor;
|
||||
if (this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
const e = this.pending;
|
||||
this.pending = undefined;
|
||||
if (!e || e.document.isClosed) return;
|
||||
const text = normalizeEol(e.document.getText());
|
||||
const { anchor, active } = e.selection;
|
||||
this.source.setCursor(e.document.uri, {
|
||||
anchor: offsetOf(text, anchor.line, anchor.character),
|
||||
head: offsetOf(text, active.line, active.character),
|
||||
});
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private stylesFor(color: string): Styles {
|
||||
let found = this.styles.get(color);
|
||||
if (!found) {
|
||||
found = {
|
||||
caret: vscode.window.createTextEditorDecorationType({
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
// A zero-width caret and the name above it; the CSS rides in textDecoration,
|
||||
// the usual way to place a decoration without moving the text.
|
||||
before: {
|
||||
contentText: '',
|
||||
textDecoration: `none; position: absolute; display: inline-block; height: 1.25em; border-left: 2px solid ${color}; margin-left: -1px; z-index: 1;`,
|
||||
},
|
||||
}),
|
||||
selection: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: `${color}38`,
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
}),
|
||||
};
|
||||
this.styles.set(color, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Draws everyone's cursors in the visible editors of shared files. */
|
||||
render(): void {
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (editor.document.uri.scheme !== 'file') continue;
|
||||
const cursors = this.source.cursors(editor.document.uri);
|
||||
const text = normalizeEol(editor.document.getText());
|
||||
const at = (offset: number) => {
|
||||
const { line, character } = positionOf(text, Math.max(0, Math.min(offset, text.length)));
|
||||
return new vscode.Position(line, character);
|
||||
};
|
||||
const byColor = new Map<string, { carets: vscode.DecorationOptions[]; selections: vscode.Range[] }>();
|
||||
for (const c of cursors) {
|
||||
let group = byColor.get(c.color);
|
||||
if (!group) byColor.set(c.color, (group = { carets: [], selections: [] }));
|
||||
const head = at(c.head);
|
||||
group.carets.push({
|
||||
range: new vscode.Range(head, head),
|
||||
hoverMessage: new vscode.MarkdownString(`**${c.name.replace(/[\\`*_[\]<>]/g, '')}** is here`),
|
||||
renderOptions: {
|
||||
after: {
|
||||
contentText: c.name,
|
||||
color: '#fff',
|
||||
backgroundColor: c.color,
|
||||
fontWeight: '500',
|
||||
textDecoration: 'none; position: absolute; transform: translateY(-1.15em); font-size: 0.75em; padding: 0 4px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 2; opacity: 0.9;',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (c.anchor !== c.head) {
|
||||
const [from, to] = c.anchor < c.head ? [c.anchor, c.head] : [c.head, c.anchor];
|
||||
group.selections.push(new vscode.Range(at(from), at(to)));
|
||||
}
|
||||
}
|
||||
for (const color of byColor.keys()) this.stylesFor(color);
|
||||
// Every colour is set, so a person who left or moved away disappears.
|
||||
for (const [color, styles] of this.styles) {
|
||||
const group = byColor.get(color);
|
||||
editor.setDecorations(styles.caret, group?.carets ?? []);
|
||||
editor.setDecorations(styles.selection, group?.selections ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
for (const styles of this.styles.values()) {
|
||||
styles.caret.dispose();
|
||||
styles.selection.dispose();
|
||||
}
|
||||
this.styles.clear();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as Y from 'yjs';
|
||||
import { normalizeEol, positionOf } from '../util/textDiff';
|
||||
import { FILES_MAP } from './protocol';
|
||||
import { applyToYText, safeChange } from './textSync';
|
||||
|
||||
const MODEL_GLOB = '**/*.{archify,archify-registry,archify-decision}';
|
||||
const SKIP_GLOB = '{**/node_modules/**,**/.git/**}';
|
||||
const MODEL_FILE = /\.(archify|archify-registry|archify-decision)$/i;
|
||||
|
||||
/** A model-root-relative path the bridge accepts: POSIX, no `..`, a model file. */
|
||||
export function isSafeModelPath(path: string): boolean {
|
||||
if (!MODEL_FILE.test(path) || path.startsWith('/') || path.includes('\\')) return false;
|
||||
return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && part !== '.git' && part !== 'node_modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a model's files and the space's Y.Text per file the same.
|
||||
*
|
||||
* Local edits (any editor, the Source view, a drag on a diagram, git, an
|
||||
* agent) become minimal Y.Text edits. Remote edits go back as WorkspaceEdits
|
||||
* for open documents, so undo, dirty state and the pages stay right, and as
|
||||
* file writes for closed ones. Each side compares texts before writing, so
|
||||
* an edit coming back from the other side ends as a no-op instead of an echo.
|
||||
*/
|
||||
export class FileBridge implements vscode.Disposable {
|
||||
readonly files: Y.Map<Y.Text>;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private listening = false;
|
||||
/**
|
||||
* Text the bridge last wrote per file. Its own write comes back as a change
|
||||
* event, possibly after the document moved on; pushing it then would undo
|
||||
* the newer edit, so it is skipped once.
|
||||
*/
|
||||
private readonly written = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly root: vscode.Uri,
|
||||
private readonly doc: Y.Doc,
|
||||
/** Transaction origin of edits made here. */
|
||||
private readonly origin: object,
|
||||
private readonly log: vscode.LogOutputChannel,
|
||||
) {
|
||||
this.files = doc.getMap<Y.Text>(FILES_MAP);
|
||||
}
|
||||
|
||||
/** The model-relative path of a file in this model, else undefined. */
|
||||
relative(uri: vscode.Uri): string | undefined {
|
||||
if (uri.scheme !== this.root.scheme) return undefined;
|
||||
const base = this.root.path.endsWith('/') ? this.root.path : `${this.root.path}/`;
|
||||
if (!uri.path.startsWith(base)) return undefined;
|
||||
const rel = uri.path.slice(base.length);
|
||||
return isSafeModelPath(rel) ? rel : undefined;
|
||||
}
|
||||
|
||||
uriOf(rel: string): vscode.Uri {
|
||||
return vscode.Uri.joinPath(this.root, ...rel.split('/'));
|
||||
}
|
||||
|
||||
/** Remote edits start going to disk; with `local`, local edits start going out. */
|
||||
start(local: boolean): void {
|
||||
if (!this.disposables.length) {
|
||||
const observer = (events: Y.YEvent<Y.AbstractType<unknown>>[], txn: Y.Transaction) => {
|
||||
if (txn.origin === this.origin) return;
|
||||
const paths = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.target === this.files) for (const key of event.changes.keys.keys()) paths.add(key);
|
||||
else if (typeof event.path[0] === 'string') paths.add(event.path[0]);
|
||||
}
|
||||
for (const path of paths) this.enqueue(() => this.writeOut(path));
|
||||
};
|
||||
this.files.observeDeep(observer);
|
||||
this.disposables.push({ dispose: () => this.files.unobserveDeep(observer) });
|
||||
}
|
||||
if (local && !this.listening) {
|
||||
this.listening = true;
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(this.root, MODEL_GLOB));
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
const rel = this.relative(e.document.uri);
|
||||
if (rel && e.contentChanges.length) this.pushLocal(rel, normalizeEol(e.document.getText()));
|
||||
}),
|
||||
watcher,
|
||||
watcher.onDidCreate((uri) => this.enqueue(() => this.pushFromDisk(uri))),
|
||||
watcher.onDidChange((uri) => this.enqueue(() => this.pushFromDisk(uri))),
|
||||
watcher.onDidDelete((uri) => {
|
||||
const rel = this.relative(uri);
|
||||
if (rel && this.files.has(rel)) this.enqueue(async () => {
|
||||
// Gone for good, not an editor's save-by-rename.
|
||||
if (!(await exists(uri))) this.doc.transact(() => this.files.delete(rel), this.origin);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private pushLocal(rel: string, text: string): void {
|
||||
if (this.written.get(rel) === text) {
|
||||
this.written.delete(rel);
|
||||
return;
|
||||
}
|
||||
this.push(rel, text);
|
||||
}
|
||||
|
||||
/** One file's text into the document. */
|
||||
push(rel: string, text: string): void {
|
||||
const existing = this.files.get(rel);
|
||||
if (existing instanceof Y.Text) {
|
||||
if (existing.toString() !== text) this.doc.transact(() => applyToYText(existing, text), this.origin);
|
||||
return;
|
||||
}
|
||||
this.doc.transact(() => {
|
||||
const created = new Y.Text();
|
||||
created.insert(0, text);
|
||||
this.files.set(rel, created);
|
||||
}, this.origin);
|
||||
}
|
||||
|
||||
private async pushFromDisk(uri: vscode.Uri): Promise<void> {
|
||||
const rel = this.relative(uri);
|
||||
// Open documents report through their change events.
|
||||
if (!rel || vscode.workspace.textDocuments.some((d) => d.uri.toString() === uri.toString())) return;
|
||||
const text = await readDisk(uri);
|
||||
if (text !== undefined) this.pushLocal(rel, normalizeEol(text));
|
||||
}
|
||||
|
||||
/** The model files as they are now: open editors' text, else the disk. */
|
||||
async scan(): Promise<Map<string, string>> {
|
||||
const found = await vscode.workspace.findFiles(new vscode.RelativePattern(this.root, MODEL_GLOB), SKIP_GLOB);
|
||||
const out = new Map<string, string>();
|
||||
for (const uri of found) {
|
||||
const rel = this.relative(uri);
|
||||
if (!rel) continue;
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
const text = open ? open.getText() : await readDisk(uri);
|
||||
if (text !== undefined) out.set(rel, normalizeEol(text));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings changes made while the extension was not running into the
|
||||
* document (git pull, an edit in another tool). With `deleteMissing`,
|
||||
* files in the document but gone from disk are removed.
|
||||
*/
|
||||
async pushAll(deleteMissing: boolean): Promise<void> {
|
||||
const local = await this.scan();
|
||||
for (const [rel, text] of local) this.push(rel, text);
|
||||
if (deleteMissing) {
|
||||
const gone = [...this.files.keys()].filter((rel) => !local.has(rel));
|
||||
if (gone.length) this.doc.transact(() => gone.forEach((rel) => this.files.delete(rel)), this.origin);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes every file of the document to disk; then sends local files the document lacks. */
|
||||
async pullAll(): Promise<void> {
|
||||
for (const rel of this.files.keys()) await this.enqueue(() => this.writeOut(rel));
|
||||
const local = await this.scan();
|
||||
for (const [rel, text] of local) if (!this.files.has(rel)) this.push(rel, text);
|
||||
}
|
||||
|
||||
private enqueue(work: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(work).catch((error) => this.log.error(`sync: ${error instanceof Error ? error.message : String(error)}`));
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
/** The document's text of one file onto disk or into its open editor. */
|
||||
private async writeOut(rel: string): Promise<void> {
|
||||
if (!isSafeModelPath(rel)) {
|
||||
this.log.warn(`sync: ignored a file outside the model: ${rel}`);
|
||||
return;
|
||||
}
|
||||
const uri = this.uriOf(rel);
|
||||
const value = this.files.get(rel);
|
||||
if (!(value instanceof Y.Text)) {
|
||||
if (await exists(uri)) await vscode.workspace.fs.delete(uri, { useTrash: true });
|
||||
return;
|
||||
}
|
||||
const text = value.toString();
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
if (open) {
|
||||
const current = normalizeEol(open.getText());
|
||||
const change = safeChange(current, text);
|
||||
if (!change) return;
|
||||
const wasDirty = open.isDirty;
|
||||
const from = positionOf(current, change.from);
|
||||
const to = positionOf(current, change.to);
|
||||
this.written.set(rel, text);
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(uri, new vscode.Range(from.line, from.character, to.line, to.character), change.insert);
|
||||
if (!(await vscode.workspace.applyEdit(edit))) {
|
||||
this.log.warn(`sync: could not apply a teammate's edit to ${rel}`);
|
||||
return;
|
||||
}
|
||||
// Teammates' edits land on disk, where git sees them; the person's own unsaved work stays unsaved.
|
||||
if (!wasDirty) await open.save();
|
||||
return;
|
||||
}
|
||||
const disk = await readDisk(uri);
|
||||
const eol = disk?.includes('\r\n') ? '\r\n' : '\n';
|
||||
const out = eol === '\n' ? text : text.replace(/\n/g, '\r\n');
|
||||
if (disk === out) return;
|
||||
this.written.set(rel, text);
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(uri, '..'));
|
||||
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(out));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
this.listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDisk(uri: vscode.Uri): Promise<string | undefined> {
|
||||
try {
|
||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// Archify Sync in the extension: live, shared models. Bundled on its own
|
||||
// (dist/sync.js) and loaded only when a model is shared or a sync command
|
||||
// runs, so the extension without a server works exactly as before.
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { MAIN_FILE } from '../model/format';
|
||||
import type { ModelService } from '../model/service';
|
||||
import { SyncApi, SyncApiError, checkServerUrl, parseInviteLink } from './api';
|
||||
import { SyncAuth } from './auth';
|
||||
import { LiveSpace, StartMode } from './liveSpace';
|
||||
import { LINK_FILE, Link, PROTOCOL_VERSION, Version, formatLink, parseLink, versionLabel } from './protocol';
|
||||
import { EditorCursors } from './cursors';
|
||||
import type { LiveAction, LiveBridge, LiveCursor, LivePanel, LiveProvider, LiveSelection } from './liveBridge';
|
||||
import { VERSION_SCHEME, VersionContent, compareVersion, restoreVersion, showVersions } from './versions';
|
||||
|
||||
export const SYNC_COMMANDS = [
|
||||
'archify.sync.signIn',
|
||||
'archify.sync.signOut',
|
||||
'archify.sync.share',
|
||||
'archify.sync.invite',
|
||||
'archify.sync.join',
|
||||
'archify.sync.publish',
|
||||
'archify.sync.versions',
|
||||
'archify.sync.stop',
|
||||
'archify.sync.menu',
|
||||
'archify.sync.setServer',
|
||||
] as const;
|
||||
|
||||
const SKIP = '{**/node_modules/**,**/.git/**}';
|
||||
/** How long the model page's list of versions is reused before it is asked for again. */
|
||||
const VERSIONS_TTL_MS = 20_000;
|
||||
|
||||
export class SyncFeature implements vscode.Disposable, LiveProvider {
|
||||
private readonly auth: SyncAuth;
|
||||
private readonly spaces = new Map<string, LiveSpace>();
|
||||
private readonly status = vscode.window.createStatusBarItem('archify.sync', vscode.StatusBarAlignment.Left, 50);
|
||||
private readonly content: VersionContent;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly versionCache = new Map<string, { at: number; versions: Promise<Version[]> }>();
|
||||
private readonly editorCursors: EditorCursors;
|
||||
private cursorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** Servers with a session on this machine, for the status bar before any model is shared. */
|
||||
private signedIn: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
private readonly log: vscode.LogOutputChannel,
|
||||
private readonly bridge?: LiveBridge,
|
||||
) {
|
||||
this.auth = new SyncAuth(context, log);
|
||||
this.content = new VersionContent((space) => {
|
||||
const live = [...this.spaces.values()].find((s) => s.link.space === space);
|
||||
return live ? this.api(live.link.server) : undefined;
|
||||
});
|
||||
this.status.name = 'Archify Sync';
|
||||
this.status.command = 'archify.sync.menu';
|
||||
const links = vscode.workspace.createFileSystemWatcher(`**/${LINK_FILE}`);
|
||||
this.disposables.push(
|
||||
this.auth,
|
||||
this.status,
|
||||
links,
|
||||
links.onDidCreate((uri) => void this.open(uri, 'open')),
|
||||
links.onDidDelete((uri) => this.close(vscode.Uri.joinPath(uri, '..'), false)),
|
||||
vscode.workspace.registerTextDocumentContentProvider(VERSION_SCHEME, this.content),
|
||||
this.auth.onDidChangeSessions(() => {
|
||||
for (const space of this.spaces.values()) space.reconnect();
|
||||
void this.refreshSignedIn();
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((e) => e.affectsConfiguration('archify.sync') && this.render()),
|
||||
);
|
||||
this.editorCursors = new EditorCursors({ cursors: (file) => this.cursors(file), setCursor: (file, selection) => this.setCursorIn(file, selection) });
|
||||
this.disposables.push(this.editorCursors);
|
||||
bridge?.setProvider(this);
|
||||
}
|
||||
|
||||
/** Opens every shared model in the workspace. */
|
||||
async start(): Promise<void> {
|
||||
const found = await vscode.workspace.findFiles(`**/${LINK_FILE}`, SKIP, 50);
|
||||
await Promise.all(found.map((uri) => this.open(uri, 'open')));
|
||||
await this.refreshSignedIn();
|
||||
}
|
||||
|
||||
private async refreshSignedIn(): Promise<void> {
|
||||
this.signedIn = await this.auth.servers();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private api(server: string): SyncApi {
|
||||
return new SyncApi(server, (force) => this.auth.accessToken(server, force));
|
||||
}
|
||||
|
||||
private async open(linkUri: vscode.Uri, mode: StartMode): Promise<LiveSpace | undefined> {
|
||||
const root = vscode.Uri.joinPath(linkUri, '..');
|
||||
if (this.spaces.has(root.toString())) return this.spaces.get(root.toString());
|
||||
let link: Link | undefined;
|
||||
try {
|
||||
link = parseLink(new TextDecoder().decode(await vscode.workspace.fs.readFile(linkUri)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!link) {
|
||||
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} is not a valid link file`);
|
||||
return undefined;
|
||||
}
|
||||
if (!checkServerUrl(link.server).url) {
|
||||
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} names a server that is not https: ${link.server}`);
|
||||
return undefined;
|
||||
}
|
||||
const space = new LiveSpace(root, link, mode, {
|
||||
context: this.context,
|
||||
auth: this.auth,
|
||||
log: this.log,
|
||||
onChange: () => this.render(),
|
||||
onCursors: () => this.cursorsMoved(),
|
||||
});
|
||||
this.spaces.set(root.toString(), space);
|
||||
try {
|
||||
await space.start();
|
||||
} catch (error) {
|
||||
this.log.error(`sync: ${link.name ?? root.fsPath} did not start: ${String(error)}`);
|
||||
}
|
||||
this.render();
|
||||
return space;
|
||||
}
|
||||
|
||||
private close(root: vscode.Uri, forget: boolean): void {
|
||||
const space = this.spaces.get(root.toString());
|
||||
if (!space) return;
|
||||
this.spaces.delete(root.toString());
|
||||
if (forget) space.forget();
|
||||
else space.dispose();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const spaces = [...this.spaces.values()];
|
||||
if (!spaces.length) {
|
||||
this.status.hide();
|
||||
void vscode.commands.executeCommand('setContext', 'archify.sync.active', false);
|
||||
this.bridge?.changed();
|
||||
return;
|
||||
}
|
||||
void vscode.commands.executeCommand('setContext', 'archify.sync.active', true);
|
||||
const signedOut = spaces.filter((s) => s.state === 'signed-out');
|
||||
const offline = spaces.filter((s) => s.state !== 'online' && s.state !== 'signed-out');
|
||||
const people = new Set(spaces.flatMap((s) => s.people().map((p) => p.user.id)));
|
||||
if (signedOut.length) {
|
||||
this.status.text = '$(account) Archify: sign in';
|
||||
this.status.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
} else if (offline.length) {
|
||||
this.status.text = offline.some((s) => s.state === 'connecting') ? '$(sync~spin) Archify connecting' : '$(cloud-offline) Archify offline';
|
||||
this.status.backgroundColor = undefined;
|
||||
} else {
|
||||
this.status.text = `$(broadcast) Archify live · ${people.size} here`;
|
||||
this.status.backgroundColor = undefined;
|
||||
}
|
||||
const tooltip = new vscode.MarkdownString(undefined, true);
|
||||
for (const s of spaces) {
|
||||
const who = s.people().map((p) => `${p.user.name}${p.file ? ` (${p.file})` : ''}`).join(', ');
|
||||
tooltip.appendMarkdown(`**${s.name}**: ${s.state}${who ? ` \n${who}` : ''}\n\n`);
|
||||
}
|
||||
tooltip.appendMarkdown('Edits are kept on this machine first and sent when online.');
|
||||
this.status.tooltip = tooltip;
|
||||
this.status.show();
|
||||
this.bridge?.changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the model page's Live section
|
||||
|
||||
private versionsOf(space: LiveSpace, fresh = false): Promise<Version[]> {
|
||||
const cached = this.versionCache.get(space.link.space);
|
||||
if (!fresh && cached && Date.now() - cached.at < VERSIONS_TTL_MS) return cached.versions;
|
||||
const versions = this.api(space.link.server).versions(space.link.space);
|
||||
this.versionCache.set(space.link.space, { at: Date.now(), versions });
|
||||
versions.catch(() => this.versionCache.delete(space.link.space));
|
||||
return versions;
|
||||
}
|
||||
|
||||
/** After a publish or a restore: the page shows the new version. */
|
||||
private versionsChanged(space: LiveSpace): void {
|
||||
this.versionCache.delete(space.link.space);
|
||||
this.bridge?.changed(space.root);
|
||||
}
|
||||
|
||||
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
|
||||
const space = this.spaces.get(root.toString());
|
||||
if (!space) return undefined;
|
||||
const me = (space.awareness.getLocalState() as { user?: { id?: string } } | null)?.user?.id;
|
||||
const panel: LivePanel = {
|
||||
name: space.name,
|
||||
host: new URL(space.link.server).host,
|
||||
state: space.state,
|
||||
people: space.people().map((p) => ({ name: p.user.name, file: p.file, me: p.user.id === me })),
|
||||
};
|
||||
if (space.state === 'signed-out' || space.state === 'forbidden') return panel;
|
||||
try {
|
||||
const versions = await this.versionsOf(space);
|
||||
const label = new Map(versions.map((v) => [v.id, versionLabel(v)]));
|
||||
panel.versions = versions.map((v) => ({
|
||||
id: v.id,
|
||||
label: versionLabel(v),
|
||||
kind: v.kind,
|
||||
name: v.name ?? undefined,
|
||||
at: v.created_at,
|
||||
restoredFrom: v.restored_from !== null ? label.get(v.restored_from) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
panel.versionsProblem = space.state === 'online' ? `Versions could not be loaded: ${error instanceof Error ? error.message : String(error)}` : 'Versions show when the server can be reached.';
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Cursors drawn at most every 50 ms, however many people move. */
|
||||
private cursorsMoved(): void {
|
||||
if (this.cursorTimer) return;
|
||||
this.cursorTimer = setTimeout(() => {
|
||||
this.cursorTimer = undefined;
|
||||
this.bridge?.cursorsChanged();
|
||||
this.editorCursors.render();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private spaceOf(file: vscode.Uri): LiveSpace | undefined {
|
||||
for (const space of this.spaces.values()) {
|
||||
const root = space.root.path.endsWith('/') ? space.root.path : `${space.root.path}/`;
|
||||
if (file.scheme === space.root.scheme && file.path.startsWith(root)) return space;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
cursors(file: vscode.Uri): LiveCursor[] {
|
||||
return this.spaceOf(file)?.cursors(file) ?? [];
|
||||
}
|
||||
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
|
||||
this.setCursorIn(file, selection);
|
||||
}
|
||||
|
||||
private setCursorIn(file: vscode.Uri, selection: LiveSelection | undefined): boolean {
|
||||
return this.spaceOf(file)?.setCursor(file, selection) ?? false;
|
||||
}
|
||||
|
||||
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
|
||||
const space = this.spaces.get(root.toString());
|
||||
if (!space) return;
|
||||
try {
|
||||
switch (action.kind) {
|
||||
case 'publish':
|
||||
await this.publish(space);
|
||||
break;
|
||||
case 'invite':
|
||||
await this.invite(space);
|
||||
break;
|
||||
case 'signIn':
|
||||
if (await this.checkServer(space.link.server)) await this.auth.signIn(space.link.server);
|
||||
break;
|
||||
case 'versions':
|
||||
await showVersions(space, this.api(space.link.server), this.content);
|
||||
this.versionsChanged(space);
|
||||
break;
|
||||
case 'compare':
|
||||
case 'restore': {
|
||||
const version = (await this.versionsOf(space)).find((v) => v.id === action.version);
|
||||
if (!version) throw new Error('That version is no longer listed.');
|
||||
if (action.kind === 'compare') await compareVersion(space, version, this.content);
|
||||
else if (await restoreVersion(space, this.api(space.link.server), version)) this.versionsChanged(space);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.report(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- commands
|
||||
|
||||
async run(command: (typeof SYNC_COMMANDS)[number], ...args: unknown[]): Promise<void> {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'archify.sync.signIn':
|
||||
await this.signInCommand();
|
||||
break;
|
||||
case 'archify.sync.signOut':
|
||||
await this.signOut();
|
||||
break;
|
||||
case 'archify.sync.share':
|
||||
await this.share(args[0]);
|
||||
break;
|
||||
case 'archify.sync.invite':
|
||||
await this.invite(args[0]);
|
||||
break;
|
||||
case 'archify.sync.join':
|
||||
await this.join(typeof args[0] === 'string' ? args[0] : undefined);
|
||||
break;
|
||||
case 'archify.sync.publish':
|
||||
await this.publish(args[0]);
|
||||
break;
|
||||
case 'archify.sync.versions': {
|
||||
const space = await this.pickSpace(args[0]);
|
||||
if (space) {
|
||||
await showVersions(space, this.api(space.link.server), this.content);
|
||||
this.versionsChanged(space);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'archify.sync.stop':
|
||||
await this.stop(args[0]);
|
||||
break;
|
||||
case 'archify.sync.menu':
|
||||
await this.menu();
|
||||
break;
|
||||
case 'archify.sync.setServer':
|
||||
await this.setServer();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.report(error);
|
||||
}
|
||||
}
|
||||
|
||||
private report(error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/cancelled/i.test(message)) return;
|
||||
this.log.error(`sync: ${message}`);
|
||||
void vscode.window.showErrorMessage(`Archify Sync: ${message}`);
|
||||
}
|
||||
|
||||
/** The invite and sign-in links: `…/auth?code=&state=`, `…/join?server=&invite=`. */
|
||||
async handleUri(uri: vscode.Uri): Promise<void> {
|
||||
if (uri.path === '/auth') this.auth.handleAuthUri(uri);
|
||||
else if (uri.path === '/join') await this.run('archify.sync.join', uri.toString(true));
|
||||
}
|
||||
|
||||
/** The server from the archify.sync.server setting, or asked for (and remembered). */
|
||||
private async askServer(): Promise<string | undefined> {
|
||||
return configuredServer() ?? this.promptServer();
|
||||
}
|
||||
|
||||
private async promptServer(): Promise<string | undefined> {
|
||||
const last = configuredServer() ?? this.context.globalState.get<string>('archifySync.lastServer');
|
||||
const value = await vscode.window.showInputBox({
|
||||
title: 'Archify | Sync server',
|
||||
prompt: 'The address of the Archify Sync server',
|
||||
placeHolder: 'https://archify.example.com',
|
||||
value: last,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => checkServerUrl(v).problem,
|
||||
});
|
||||
return value === undefined ? undefined : checkServerUrl(value).url;
|
||||
}
|
||||
|
||||
/** Checks the server answers and speaks this protocol; offers its setup page when it is new. */
|
||||
private async checkServer(server: string): Promise<boolean> {
|
||||
const info = await new SyncApi(server).info();
|
||||
if (info.protocol !== PROTOCOL_VERSION) {
|
||||
throw new Error(`${info.name} speaks protocol ${info.protocol}; this extension speaks ${PROTOCOL_VERSION}. Update ${info.protocol > PROTOCOL_VERSION ? 'the extension' : 'the server'}.`);
|
||||
}
|
||||
if (!info.set_up) {
|
||||
const open = await vscode.window.showInformationMessage(
|
||||
`${info.name} is new: its first admin sets it up with the first-start code from the server's log.`,
|
||||
'Open setup page',
|
||||
);
|
||||
if (open) await vscode.env.openExternal(vscode.Uri.parse(`${server}/setup`));
|
||||
return false;
|
||||
}
|
||||
await this.context.globalState.update('archifySync.lastServer', server);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Writes archify.sync.server: in the workspace when the workspace sets it, otherwise for the user. */
|
||||
private async setServer(): Promise<void> {
|
||||
const server = await this.promptServer();
|
||||
if (!server) return;
|
||||
const config = vscode.workspace.getConfiguration('archify.sync');
|
||||
const inWorkspace = config.inspect<string>('server')?.workspaceValue !== undefined;
|
||||
await config.update('server', server, inWorkspace ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global);
|
||||
vscode.window.showInformationMessage(`Archify Sync: server set to ${new URL(server).host}${inWorkspace ? ' for this workspace' : ''}.`);
|
||||
}
|
||||
|
||||
private async signInCommand(): Promise<void> {
|
||||
const server = await this.askServer();
|
||||
if (!server || !(await this.checkServer(server))) return;
|
||||
const session = await this.auth.signIn(server);
|
||||
vscode.window.showInformationMessage(`Archify: signed in to ${new URL(server).host} as ${session.account.display_name}.`);
|
||||
}
|
||||
|
||||
private async signOut(): Promise<void> {
|
||||
const servers = await this.auth.servers();
|
||||
if (!servers.length) {
|
||||
vscode.window.showInformationMessage('Archify: not signed in to a sync server.');
|
||||
return;
|
||||
}
|
||||
const server = servers.length === 1 ? servers[0] : await vscode.window.showQuickPick(servers, { title: 'Archify | Sign out of' });
|
||||
const session = server ? await this.auth.session(server) : undefined;
|
||||
if (session) await this.auth.removeSession(session.id);
|
||||
}
|
||||
|
||||
/** A server with a session: the only one, a picked one, or a new sign-in. */
|
||||
private async serverWithSession(): Promise<string | undefined> {
|
||||
const servers = await this.auth.servers();
|
||||
if (servers.length === 1) return servers[0];
|
||||
if (servers.length > 1) {
|
||||
const pick = await vscode.window.showQuickPick([...servers, '$(add) Another server…'], { title: 'Archify | Share to which server?' });
|
||||
if (pick && !pick.startsWith('$(add)')) return pick;
|
||||
if (!pick) return undefined;
|
||||
}
|
||||
const server = await this.askServer();
|
||||
if (!server || !(await this.checkServer(server))) return undefined;
|
||||
await this.auth.signIn(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
/** The model root a command applies to: the argument, the active file, or a pick. */
|
||||
private async modelRoot(arg: unknown): Promise<vscode.Uri | undefined> {
|
||||
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
|
||||
const root = uri ? await this.models.rootOf(uri) : undefined;
|
||||
if (root) return root;
|
||||
const mains = await vscode.workspace.findFiles(`**/${MAIN_FILE}`, SKIP, 50);
|
||||
if (mains.length === 1) return vscode.Uri.joinPath(mains[0], '..');
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
mains.map((m) => ({ label: vscode.workspace.asRelativePath(vscode.Uri.joinPath(m, '..')), root: vscode.Uri.joinPath(m, '..') })),
|
||||
{ title: 'Archify | Which model?' },
|
||||
);
|
||||
return pick?.root;
|
||||
}
|
||||
|
||||
private async pickSpace(arg: unknown): Promise<LiveSpace | undefined> {
|
||||
if (arg instanceof LiveSpace) return arg;
|
||||
const spaces = [...this.spaces.values()];
|
||||
if (!spaces.length) {
|
||||
vscode.window.showInformationMessage('Archify: no model in this workspace is shared. Share one with "Archify: Share Model…".');
|
||||
return undefined;
|
||||
}
|
||||
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
|
||||
const root = uri ? await this.models.rootOf(uri) : undefined;
|
||||
const here = root && this.spaces.get(root.toString());
|
||||
if (here) return here;
|
||||
if (spaces.length === 1) return spaces[0];
|
||||
const pick = await vscode.window.showQuickPick(spaces.map((s) => ({ label: s.name, description: new URL(s.link.server).host, space: s })), {
|
||||
title: 'Archify | Which shared model?',
|
||||
});
|
||||
return pick?.space;
|
||||
}
|
||||
|
||||
private async share(arg: unknown): Promise<void> {
|
||||
const root = await this.modelRoot(arg);
|
||||
if (!root) return;
|
||||
if (this.spaces.has(root.toString())) {
|
||||
await this.invite(root);
|
||||
return;
|
||||
}
|
||||
const server = await this.serverWithSession();
|
||||
if (!server) return;
|
||||
const index = await this.models.index(root).catch(() => undefined);
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | Share Model',
|
||||
prompt: `Name of the shared model on ${new URL(server).host}`,
|
||||
value: index?.meta.name ?? root.path.split('/').pop(),
|
||||
validateInput: (v) => (v.trim() ? undefined : 'Give it a name.'),
|
||||
});
|
||||
if (!name) return;
|
||||
const space = await this.api(server).createSpace(name.trim());
|
||||
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
|
||||
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: space.id, name: space.name })));
|
||||
await this.open(linkUri, 'share');
|
||||
const next = await vscode.window.showInformationMessage(
|
||||
`Archify: ${space.name} is shared. Edits now go to everyone you invite.`,
|
||||
'Copy invite link',
|
||||
);
|
||||
if (next) await this.invite(root);
|
||||
}
|
||||
|
||||
private async invite(arg: unknown): Promise<void> {
|
||||
const space = await this.pickSpace(arg);
|
||||
if (!space) return;
|
||||
const invite = await this.api(space.link.server).invite(space.link.space);
|
||||
await vscode.env.clipboard.writeText(invite.url);
|
||||
vscode.window.showInformationMessage(
|
||||
`Archify: invite link for ${space.name} copied. It works until ${new Date(invite.expires_at).toLocaleDateString()}; send it through any channel.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async join(raw?: string): Promise<void> {
|
||||
const text =
|
||||
raw ??
|
||||
(await vscode.window.showInputBox({
|
||||
title: 'Archify | Join Shared Model',
|
||||
prompt: 'Paste the invite link',
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => (parseInviteLink(v) ? undefined : 'This is not an Archify invite link.'),
|
||||
}));
|
||||
if (!text) return;
|
||||
const parsed = parseInviteLink(text);
|
||||
const server = parsed && checkServerUrl(parsed.server).url;
|
||||
if (!parsed || !server) throw new Error('This is not an Archify invite link.');
|
||||
if (!(await this.checkServer(server))) return;
|
||||
|
||||
const folder = await vscode.window.showOpenDialog({
|
||||
title: 'Archify | Folder for the shared model',
|
||||
openLabel: 'Put the model here',
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||
});
|
||||
const root = folder?.[0];
|
||||
if (!root) return;
|
||||
const existing = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.{archify,archify-registry,archify-decision}'), SKIP, 1);
|
||||
if (existing.length) {
|
||||
throw new Error('That folder already holds Archify files. Pick an empty folder for the shared model.');
|
||||
}
|
||||
|
||||
if (!(await this.auth.session(server))) await this.auth.signIn(server, parsed.invite);
|
||||
const joined = await this.api(server).acceptInvite(parsed.invite);
|
||||
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
|
||||
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: joined.space.id, name: joined.space.name })));
|
||||
|
||||
const inWorkspace = vscode.workspace.getWorkspaceFolder(root);
|
||||
if (inWorkspace) {
|
||||
await this.open(linkUri, 'join');
|
||||
vscode.window.showInformationMessage(`Archify: you joined ${joined.space.name}. Its files are arriving in ${vscode.workspace.asRelativePath(root)}.`);
|
||||
return;
|
||||
}
|
||||
const open = await vscode.window.showInformationMessage(
|
||||
`Archify: you joined ${joined.space.name}. Open its folder to start working together.`,
|
||||
'Open folder',
|
||||
'Open in new window',
|
||||
);
|
||||
if (open) await vscode.commands.executeCommand('vscode.openFolder', root, { forceNewWindow: open === 'Open in new window' });
|
||||
}
|
||||
|
||||
private async publish(arg: unknown): Promise<void> {
|
||||
const space = await this.pickSpace(arg);
|
||||
if (!space) return;
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: `Archify | Publish ${space.name}`,
|
||||
prompt: 'Name this version, for example "Q4 baseline"',
|
||||
validateInput: (v) => (v.trim() ? undefined : 'Give the version a name.'),
|
||||
});
|
||||
if (!name) return;
|
||||
const version = await this.api(space.link.server).publish(space.link.space, name.trim());
|
||||
this.versionsChanged(space);
|
||||
vscode.window.showInformationMessage(`Archify: published ${version.major}.${version.minor} "${version.name}" of ${space.name}.`);
|
||||
}
|
||||
|
||||
private async stop(arg: unknown): Promise<void> {
|
||||
const space = await this.pickSpace(arg);
|
||||
if (!space) return;
|
||||
const ok = await vscode.window.showWarningMessage(
|
||||
`Stop syncing ${space.name} here?`,
|
||||
{ modal: true, detail: `The files stay as they are. ${LINK_FILE} is removed, and edits here no longer go to the others. The model stays on the server.` },
|
||||
'Stop syncing',
|
||||
);
|
||||
if (ok !== 'Stop syncing') return;
|
||||
this.close(space.root, true);
|
||||
await vscode.workspace.fs.delete(vscode.Uri.joinPath(space.root, LINK_FILE)).then(undefined, () => undefined);
|
||||
}
|
||||
|
||||
private async menu(): Promise<void> {
|
||||
const spaces = [...this.spaces.values()];
|
||||
const signedOut = spaces.find((s) => s.state === 'signed-out');
|
||||
const servers = await this.auth.servers();
|
||||
const configured = configuredServer();
|
||||
type Item = vscode.QuickPickItem & { run?: () => Promise<void> };
|
||||
const items: Item[] = [];
|
||||
if (signedOut) {
|
||||
items.push({
|
||||
label: `$(account) Sign in to ${new URL(signedOut.link.server).host}`,
|
||||
run: async () => {
|
||||
if (await this.checkServer(signedOut.link.server)) await this.auth.signIn(signedOut.link.server);
|
||||
},
|
||||
});
|
||||
} else if (!servers.length) {
|
||||
items.push({
|
||||
label: '$(account) Sign in…',
|
||||
description: configured ? new URL(configured).host : 'asks for the server address',
|
||||
run: () => this.signInCommand(),
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{ label: '$(live-share) Share a model…', run: () => this.share(undefined) },
|
||||
{ label: '$(link) Join a shared model…', description: 'with an invite link', run: () => this.join(undefined) },
|
||||
);
|
||||
if (spaces.length) {
|
||||
items.push(
|
||||
{ label: 'Shared models', kind: vscode.QuickPickItemKind.Separator },
|
||||
{ label: '$(person-add) Copy invite link…', run: () => this.invite(undefined) },
|
||||
{ label: '$(tag) Publish version…', run: () => this.publish(undefined) },
|
||||
{ label: '$(history) Versions…', run: () => this.run('archify.sync.versions') },
|
||||
{ label: '$(debug-disconnect) Stop syncing a model…', run: () => this.stop(undefined) },
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
{ label: 'Settings', kind: vscode.QuickPickItemKind.Separator },
|
||||
{ label: '$(server) Server address…', description: configured ? new URL(configured).host : 'not set', run: () => this.setServer() },
|
||||
{ label: '$(settings-gear) Open Sync settings', run: async () => void vscode.commands.executeCommand('workbench.action.openSettings', 'archify.sync') },
|
||||
{ label: '$(output) Show log', run: async () => this.log.show() },
|
||||
);
|
||||
if (servers.length) items.push({ label: '$(sign-out) Sign out…', description: servers.map((s) => new URL(s).host).join(', '), run: () => this.signOut() });
|
||||
const pick = await vscode.window.showQuickPick(items, { title: 'Archify Sync' });
|
||||
await pick?.run?.();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.cursorTimer);
|
||||
this.bridge?.setProvider(undefined);
|
||||
for (const space of this.spaces.values()) space.dispose();
|
||||
this.spaces.clear();
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** archify.sync.server when it holds a usable address. */
|
||||
function configuredServer(): string | undefined {
|
||||
const value = vscode.workspace.getConfiguration('archify.sync').get<string>('server', '').trim();
|
||||
return value ? checkServerUrl(value).url : undefined;
|
||||
}
|
||||
|
||||
/** The file behind the active tab when it is a custom editor (the Archify pages). */
|
||||
function activeTabUri(): vscode.Uri | undefined {
|
||||
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||
return input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
|
||||
}
|
||||
|
||||
export { SyncApiError };
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// What the model page shows of a shared model. Lives in the main bundle; the
|
||||
// sync bundle plugs in as the provider once it loads. Without it, the model
|
||||
// page has no Live section and nothing else changes.
|
||||
|
||||
export interface LivePerson {
|
||||
name: string;
|
||||
/** Model-relative path of the file the person has open. */
|
||||
file?: string;
|
||||
me?: boolean;
|
||||
}
|
||||
|
||||
export interface LiveVersion {
|
||||
id: number;
|
||||
label: string;
|
||||
kind: 'snapshot' | 'publish' | 'restore';
|
||||
name?: string;
|
||||
/** ISO time. */
|
||||
at: string;
|
||||
restoredFrom?: string;
|
||||
}
|
||||
|
||||
export interface LivePanel {
|
||||
name: string;
|
||||
host: string;
|
||||
state: 'connecting' | 'online' | 'offline' | 'signed-out' | 'forbidden' | 'outdated';
|
||||
people: LivePerson[];
|
||||
versions?: LiveVersion[];
|
||||
versionsProblem?: string;
|
||||
}
|
||||
|
||||
/** Someone else's cursor in a file, as offsets into its "\n" text. */
|
||||
export interface LiveCursor {
|
||||
name: string;
|
||||
color: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
/** A selection as offsets into a file's "\n" text. */
|
||||
export interface LiveSelection {
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
export type LiveAction =
|
||||
| { kind: 'publish' }
|
||||
| { kind: 'invite' }
|
||||
| { kind: 'signIn' }
|
||||
| { kind: 'versions' }
|
||||
| { kind: 'compare'; version: number }
|
||||
| { kind: 'restore'; version: number };
|
||||
|
||||
export interface LiveProvider {
|
||||
panel(root: vscode.Uri): Promise<LivePanel | undefined>;
|
||||
act(root: vscode.Uri, action: LiveAction): Promise<void>;
|
||||
/** Others' cursors in a shared file; empty when the file is not shared. */
|
||||
cursors(file: vscode.Uri): LiveCursor[];
|
||||
/** This person's cursor in a page's Source view; undefined when it closes. */
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void;
|
||||
}
|
||||
|
||||
class LiveBridge implements vscode.Disposable {
|
||||
private provider: LiveProvider | undefined;
|
||||
private readonly emitter = new vscode.EventEmitter<vscode.Uri | undefined>();
|
||||
/** A model root whose Live section changed; undefined for all of them. */
|
||||
readonly onDidChange = this.emitter.event;
|
||||
private readonly cursorEmitter = new vscode.EventEmitter<void>();
|
||||
/** Someone's cursor moved somewhere; ask `cursors` for the file you show. */
|
||||
readonly onDidChangeCursors = this.cursorEmitter.event;
|
||||
|
||||
setProvider(provider: LiveProvider | undefined): void {
|
||||
this.provider = provider;
|
||||
this.emitter.fire(undefined);
|
||||
this.cursorEmitter.fire();
|
||||
}
|
||||
|
||||
changed(root?: vscode.Uri): void {
|
||||
this.emitter.fire(root);
|
||||
}
|
||||
|
||||
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
|
||||
return this.provider?.panel(root);
|
||||
}
|
||||
|
||||
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
|
||||
await this.provider?.act(root, action);
|
||||
}
|
||||
|
||||
cursorsChanged(): void {
|
||||
this.cursorEmitter.fire();
|
||||
}
|
||||
|
||||
cursors(file: vscode.Uri): LiveCursor[] {
|
||||
return this.provider?.cursors(file) ?? [];
|
||||
}
|
||||
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
|
||||
this.provider?.setCursor(file, selection);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.emitter.dispose();
|
||||
this.cursorEmitter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export const liveBridge = new LiveBridge();
|
||||
export type { LiveBridge };
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import * as Y from 'yjs';
|
||||
import { Awareness } from 'y-protocols/awareness';
|
||||
import { syncSocketUrl } from './api';
|
||||
import { SyncAuth } from './auth';
|
||||
import { ConnectionState, LiveConnection } from './connection';
|
||||
import { FileBridge } from './fileBridge';
|
||||
import { OfflineStore } from './offlineStore';
|
||||
import { colorFor } from './cursors';
|
||||
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||
import { Link } from './protocol';
|
||||
|
||||
/** How a space starts: as found in the workspace, just shared from here, or just joined. */
|
||||
export type StartMode = 'open' | 'share' | 'join';
|
||||
|
||||
/** Edits loaded from the offline copy; not stored again. */
|
||||
const FROM_COPY = Symbol('offline copy');
|
||||
const COMPACT_AFTER = 200;
|
||||
|
||||
export interface Presence {
|
||||
user: { id: string; name: string };
|
||||
/** Model-relative path of the file the person has in front of them. */
|
||||
file?: string;
|
||||
/** Where the person's cursor is: Yjs relative positions, so it stays put while others type. */
|
||||
cursor?: { file: string; anchor: unknown; head: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared model in this window: the Yjs document, its offline copy, the
|
||||
* connection to the server, the bridge to the files, and presence.
|
||||
*/
|
||||
export class LiveSpace implements vscode.Disposable {
|
||||
readonly doc = new Y.Doc();
|
||||
readonly awareness = new Awareness(this.doc);
|
||||
private readonly store: OfflineStore;
|
||||
private readonly local = { origin: 'local' };
|
||||
private readonly bridge: FileBridge;
|
||||
private connection?: LiveConnection;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private hadCopy = false;
|
||||
private firstSync = true;
|
||||
|
||||
constructor(
|
||||
readonly root: vscode.Uri,
|
||||
readonly link: Link,
|
||||
private readonly mode: StartMode,
|
||||
private readonly services: {
|
||||
context: vscode.ExtensionContext;
|
||||
auth: SyncAuth;
|
||||
log: vscode.LogOutputChannel;
|
||||
onChange: () => void;
|
||||
/** Someone's cursor moved. */
|
||||
onCursors?: () => void;
|
||||
},
|
||||
) {
|
||||
const dir = path.join(services.context.globalStorageUri.fsPath, 'sync');
|
||||
this.store = new OfflineStore(dir, link.space);
|
||||
this.bridge = new FileBridge(root, this.doc, this.local, services.log);
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this.connection?.state ?? 'connecting';
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.link.name ?? path.basename(this.root.fsPath);
|
||||
}
|
||||
|
||||
/** Distinct people in the space, this one included. */
|
||||
people(): Presence[] {
|
||||
const seen = new Map<string, Presence>();
|
||||
for (const state of this.awareness.getStates().values()) {
|
||||
const p = state as Partial<Presence>;
|
||||
if (p.user?.id) seen.set(p.user.id, { user: p.user, file: p.file });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/** Puts this person's cursor in a shared file; false when the file is not part of this model. */
|
||||
setCursor(uri: vscode.Uri, selection: LiveSelection | undefined): boolean {
|
||||
const rel = this.bridge.relative(uri);
|
||||
if (!rel) return false;
|
||||
const current = this.awareness.getLocalState() as Presence | null;
|
||||
if (!current) return true;
|
||||
if (!selection) {
|
||||
// Only the file the cursor is in can take it away.
|
||||
if (current.cursor?.file === rel) this.awareness.setLocalStateField('cursor', undefined);
|
||||
return true;
|
||||
}
|
||||
const text = this.doc.getMap<Y.Text>('files').get(rel);
|
||||
if (!(text instanceof Y.Text)) return true;
|
||||
const at = (offset: number) => Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(text, Math.max(0, Math.min(offset, text.length))));
|
||||
this.awareness.setLocalStateField('cursor', { file: rel, anchor: at(selection.anchor), head: at(selection.head) });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Other people's cursors in a file, as offsets into its text here. */
|
||||
cursors(uri: vscode.Uri): LiveCursor[] {
|
||||
const rel = this.bridge.relative(uri);
|
||||
if (!rel) return [];
|
||||
const found: LiveCursor[] = [];
|
||||
for (const [client, state] of this.awareness.getStates()) {
|
||||
if (client === this.doc.clientID) continue;
|
||||
const p = state as Partial<Presence>;
|
||||
if (!p.user?.id || p.cursor?.file !== rel) continue;
|
||||
const index = (json: unknown) => {
|
||||
try {
|
||||
return Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(json), this.doc)?.index;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const anchor = index(p.cursor.anchor);
|
||||
const head = index(p.cursor.head);
|
||||
if (anchor === undefined || head === undefined) continue;
|
||||
found.push({ name: p.user.name, color: colorFor(p.user.id), anchor, head });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const { log } = this.services;
|
||||
this.hadCopy = this.store.exists();
|
||||
for (const update of this.store.load()) Y.applyUpdate(this.doc, update, FROM_COPY);
|
||||
// Registered before the connection's listener, so every edit is on disk before it is sent.
|
||||
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === FROM_COPY) return;
|
||||
this.store.append(update);
|
||||
if (this.store.pending >= COMPACT_AFTER) this.compact();
|
||||
});
|
||||
|
||||
if (this.hadCopy) {
|
||||
// Edits made while the extension was not running (git pull, another tool).
|
||||
await this.bridge.pushAll(true);
|
||||
} else if (this.mode === 'share') {
|
||||
await this.bridge.pushAll(false);
|
||||
}
|
||||
// Without a copy, local edits wait for the server's files, so a file
|
||||
// does not start twice (once here, once there) and lose one side.
|
||||
this.bridge.start(this.hadCopy || this.mode === 'share');
|
||||
|
||||
const session = await this.services.auth.session(this.link.server);
|
||||
if (session) this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(() => this.updateFile()),
|
||||
vscode.window.tabGroups.onDidChangeTabs(() => this.updateFile()),
|
||||
{ dispose: () => this.awareness.destroy() },
|
||||
);
|
||||
this.updateFile();
|
||||
let people = '';
|
||||
this.awareness.on('change', () => {
|
||||
// Cursors move all the time; the status bar and pages only care who is where.
|
||||
const now = JSON.stringify(this.people());
|
||||
if (now !== people) {
|
||||
people = now;
|
||||
this.services.onChange();
|
||||
}
|
||||
this.services.onCursors?.();
|
||||
});
|
||||
|
||||
this.connection = new LiveConnection({
|
||||
url: syncSocketUrl(this.link.server, this.link.space),
|
||||
token: (force) => this.services.auth.accessToken(this.link.server, force),
|
||||
doc: this.doc,
|
||||
awareness: this.awareness,
|
||||
onState: (state) => {
|
||||
log.info(`sync: ${this.name} is ${state}`);
|
||||
this.services.onChange();
|
||||
},
|
||||
onSynced: () => void this.synced(),
|
||||
log: (message) => log.info(message),
|
||||
});
|
||||
}
|
||||
|
||||
private async synced(): Promise<void> {
|
||||
if (!this.firstSync) return;
|
||||
this.firstSync = false;
|
||||
if (!this.hadCopy && this.mode !== 'share') {
|
||||
// First time here: the server's files come to disk, then local-only files go up.
|
||||
await this.bridge.pullAll();
|
||||
this.bridge.start(true);
|
||||
this.hadCopy = true;
|
||||
}
|
||||
this.compact();
|
||||
}
|
||||
|
||||
private compact(): void {
|
||||
try {
|
||||
this.store.compact(Y.encodeStateAsUpdate(this.doc));
|
||||
} catch (error) {
|
||||
this.services.log.warn(`sync: could not compact the offline copy (${String(error)})`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateFile(): void {
|
||||
const current = this.awareness.getLocalState() as Presence | null;
|
||||
if (!current) return;
|
||||
// The text editor, or the tab of an Archify page (the model page, a diagram, a registry).
|
||||
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||
const tab = input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
|
||||
const uri = vscode.window.activeTextEditor?.document.uri ?? tab;
|
||||
const file = uri ? this.bridge.relative(uri) : undefined;
|
||||
if (current.file !== file) this.awareness.setLocalState({ ...current, file });
|
||||
}
|
||||
|
||||
/** After signing in again. */
|
||||
reconnect(): void {
|
||||
void this.services.auth.session(this.link.server).then((session) => {
|
||||
if (session && !this.awareness.getLocalState()) {
|
||||
this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
|
||||
}
|
||||
this.connection?.reconnectNow();
|
||||
});
|
||||
}
|
||||
|
||||
/** Stops syncing this model here and forgets the offline copy. */
|
||||
forget(): void {
|
||||
this.dispose();
|
||||
this.store.remove();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.connection?.dispose();
|
||||
this.connection = undefined;
|
||||
this.bridge.dispose();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
if (this.store.pending) this.compact();
|
||||
this.doc.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import type { ModelService } from '../model/service';
|
||||
import { log } from '../util/log';
|
||||
import type { SyncFeature } from './index';
|
||||
import { liveBridge } from './liveBridge';
|
||||
|
||||
// Kept in step with SYNC_COMMANDS in ./index (which lives in the other bundle).
|
||||
const COMMANDS = [
|
||||
'archify.sync.signIn',
|
||||
'archify.sync.signOut',
|
||||
'archify.sync.share',
|
||||
'archify.sync.invite',
|
||||
'archify.sync.join',
|
||||
'archify.sync.publish',
|
||||
'archify.sync.versions',
|
||||
'archify.sync.stop',
|
||||
'archify.sync.menu',
|
||||
'archify.sync.setServer',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Archify Sync is optional. This registers its commands and the URI handler,
|
||||
* and loads dist/sync.js (Yjs, the WebSocket) only when it is needed: a model
|
||||
* in the workspace is shared, someone signed in on this machine, or a sync
|
||||
* command or link runs. Without that, nothing else happens.
|
||||
*/
|
||||
export function registerSync(context: vscode.ExtensionContext, models: ModelService): vscode.Disposable[] {
|
||||
let feature: Promise<SyncFeature> | undefined;
|
||||
const load = (): Promise<SyncFeature> =>
|
||||
(feature ??= (async () => {
|
||||
// A runtime path, so esbuild leaves the sync bundle out of this one.
|
||||
const bundle = path.join(__dirname, 'sync.js');
|
||||
const mod = require(bundle) as typeof import('./index');
|
||||
const sync = new mod.SyncFeature(context, models, log(), liveBridge);
|
||||
context.subscriptions.push(sync);
|
||||
await sync.start();
|
||||
return sync;
|
||||
})());
|
||||
|
||||
void (async () => {
|
||||
const used = context.globalState.get<boolean>('archifySync.used') === true;
|
||||
const shared = used || (await vscode.workspace.findFiles('**/.archify-sync', '{**/node_modules/**,**/.git/**}', 1)).length > 0;
|
||||
if (shared) load().catch((error) => log().error(`sync: did not start: ${String(error)}`));
|
||||
})();
|
||||
|
||||
return [
|
||||
liveBridge,
|
||||
...COMMANDS.map((id) =>
|
||||
vscode.commands.registerCommand(id, async (...args: unknown[]) => (await load()).run(id, ...args)),
|
||||
),
|
||||
vscode.window.registerUriHandler({ handleUri: async (uri) => (await load()).handleUri(uri) }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* The offline copy of one shared model: the last compacted Yjs state and a
|
||||
* log of every update since, each written before it is sent. Lets people
|
||||
* keep working while the server is away and catch up afterwards.
|
||||
*/
|
||||
export class OfflineStore {
|
||||
private readonly statePath: string;
|
||||
private readonly logPath: string;
|
||||
private logged = 0;
|
||||
|
||||
constructor(dir: string, id: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
this.statePath = path.join(dir, `${id}.state`);
|
||||
this.logPath = path.join(dir, `${id}.log`);
|
||||
}
|
||||
|
||||
exists(): boolean {
|
||||
return fs.existsSync(this.statePath) || fs.existsSync(this.logPath);
|
||||
}
|
||||
|
||||
/** Updates to apply in order: the state, then the log. A torn last entry is skipped. */
|
||||
load(): Uint8Array[] {
|
||||
const out: Uint8Array[] = [];
|
||||
if (fs.existsSync(this.statePath)) out.push(new Uint8Array(fs.readFileSync(this.statePath)));
|
||||
if (fs.existsSync(this.logPath)) {
|
||||
const log = fs.readFileSync(this.logPath);
|
||||
let at = 0;
|
||||
while (at + 4 <= log.length) {
|
||||
const length = log.readUInt32LE(at);
|
||||
if (at + 4 + length > log.length) break;
|
||||
out.push(new Uint8Array(log.subarray(at + 4, at + 4 + length)));
|
||||
at += 4 + length;
|
||||
this.logged++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Appends one update; synchronous, so it is on disk before the caller sends it. */
|
||||
append(update: Uint8Array): void {
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUInt32LE(update.length);
|
||||
fs.appendFileSync(this.logPath, Buffer.concat([head, update]));
|
||||
this.logged++;
|
||||
}
|
||||
|
||||
/** Updates in the log since the last compaction. */
|
||||
get pending(): number {
|
||||
return this.logged;
|
||||
}
|
||||
|
||||
/** Replaces state and log with one encoded state. */
|
||||
compact(state: Uint8Array): void {
|
||||
const tmp = `${this.statePath}.tmp`;
|
||||
fs.writeFileSync(tmp, state);
|
||||
fs.renameSync(tmp, this.statePath);
|
||||
fs.rmSync(this.logPath, { force: true });
|
||||
this.logged = 0;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
fs.rmSync(this.statePath, { force: true });
|
||||
fs.rmSync(this.logPath, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// What the extension and the Archify Sync server agree on. Mirrors
|
||||
// `packages/protocol` (generated from Rust) in archify-vscode-ext-sync; keep
|
||||
// both in step when the protocol changes.
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
/** Root map of a space's Yjs document: model-root-relative POSIX path → Y.Text. */
|
||||
export const FILES_MAP = 'files';
|
||||
/** The link file in a model root: names server and space, holds no secrets. */
|
||||
export const LINK_FILE = '.archify-sync';
|
||||
|
||||
export type OrgRole = 'admin' | 'member';
|
||||
export type SpaceRole = 'owner' | 'editor';
|
||||
export type VersionKind = 'snapshot' | 'publish' | 'restore';
|
||||
|
||||
export interface ApiError { error: string; message: string }
|
||||
export interface Account { id: string; organisation_id: string; name: string; display_name: string; role: OrgRole }
|
||||
export interface TokenResponse { access_token: string; expires_in: number; refresh_token: string; account: Account }
|
||||
export interface ServerInfo { name: string; version: string; protocol: number; set_up: boolean }
|
||||
export interface Space { id: string; name: string; role: SpaceRole; created_at: string }
|
||||
export interface Member { account_id: string; name: string; display_name: string; role: SpaceRole }
|
||||
export interface Invite { token: string; url: string; expires_at: string }
|
||||
export interface JoinResponse { space: Space }
|
||||
export interface Version {
|
||||
id: number;
|
||||
major: number;
|
||||
minor: number;
|
||||
kind: VersionKind;
|
||||
name: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
restored_from: number | null;
|
||||
}
|
||||
export interface VersionFiles { version: Version; files: { path: string; text: string }[] }
|
||||
|
||||
/** The contents of `.archify-sync`. */
|
||||
export interface Link {
|
||||
server: string;
|
||||
space: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function parseLink(text: string): Link | undefined {
|
||||
try {
|
||||
const value = JSON.parse(text) as Partial<Link>;
|
||||
if (typeof value.server === 'string' && typeof value.space === 'string' && /^[0-9a-f-]{36}$/i.test(value.space)) {
|
||||
return { server: value.server.replace(/\/+$/, ''), space: value.space, name: typeof value.name === 'string' ? value.name : undefined };
|
||||
}
|
||||
} catch {
|
||||
// not a link
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatLink(link: Link): string {
|
||||
return `${JSON.stringify({ server: link.server, space: link.space, name: link.name }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function versionLabel(v: Version): string {
|
||||
return `${v.major}.${v.minor}`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TextChange, minimalChange } from '../util/textDiff';
|
||||
|
||||
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
||||
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/**
|
||||
* `minimalChange`, widened so it never splits a surrogate pair: Yjs would
|
||||
* replace half a pair with U+FFFD, and every copy would get the broken text.
|
||||
*/
|
||||
export function safeChange(before: string, after: string): TextChange | undefined {
|
||||
const change = minimalChange(before, after);
|
||||
if (!change) return undefined;
|
||||
let { from, to } = change;
|
||||
let endAfter = after.length - (before.length - to);
|
||||
// A common start ending in a high surrogate, or a common end starting with
|
||||
// a low one, would split a pair: take the whole character into the change.
|
||||
if (from > 0 && isHigh(before.charCodeAt(from - 1))) from--;
|
||||
if (to < before.length && isLow(before.charCodeAt(to))) {
|
||||
to++;
|
||||
endAfter++;
|
||||
}
|
||||
return { from, to, insert: after.slice(from, endAfter) };
|
||||
}
|
||||
|
||||
/** Makes the Y.Text read `after` with one minimal edit; false when it already does. */
|
||||
export function applyToYText(text: Y.Text, after: string): boolean {
|
||||
const change = safeChange(text.toString(), after);
|
||||
if (!change) return false;
|
||||
if (change.to > change.from) text.delete(change.from, change.to - change.from);
|
||||
if (change.insert) text.insert(change.from, change.insert);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SyncApi } from './api';
|
||||
import { LiveSpace } from './liveSpace';
|
||||
import { Version, VersionFiles, versionLabel } from './protocol';
|
||||
|
||||
export const VERSION_SCHEME = 'archify-sync-version';
|
||||
|
||||
/** Read-only documents of a version's files: `archify-sync-version:/<space>/<version>/<path>`. */
|
||||
export class VersionContent implements vscode.TextDocumentContentProvider {
|
||||
private readonly cache = new Map<string, Promise<VersionFiles>>();
|
||||
|
||||
constructor(private readonly api: (space: string) => SyncApi | undefined) {}
|
||||
|
||||
files(space: string, version: number): Promise<VersionFiles> {
|
||||
const key = `${space}/${version}`;
|
||||
let found = this.cache.get(key);
|
||||
if (!found) {
|
||||
const api = this.api(space);
|
||||
if (!api) return Promise.reject(new Error('This model is not shared from here.'));
|
||||
found = api.versionFiles(space, version);
|
||||
found.catch(() => this.cache.delete(key));
|
||||
this.cache.set(key, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
|
||||
const [, space, version, ...rest] = uri.path.split('/');
|
||||
const files = await this.files(space, Number(version));
|
||||
return files.files.find((f) => f.path === rest.join('/'))?.text ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function describe(v: Version): vscode.QuickPickItem & { version: Version } {
|
||||
const when = new Date(v.created_at).toLocaleString();
|
||||
const kind = v.kind === 'publish' ? 'published' : v.kind === 'restore' ? `restored ${v.restored_from ?? ''}`.trim() : 'snapshot';
|
||||
return {
|
||||
label: `${v.kind === 'publish' ? '$(tag)' : v.kind === 'restore' ? '$(history)' : '$(circle-small)'} ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}`,
|
||||
description: kind,
|
||||
detail: when,
|
||||
version: v,
|
||||
};
|
||||
}
|
||||
|
||||
/** Versions of a space: pick one, then compare with the files here or restore it. */
|
||||
export async function showVersions(space: LiveSpace, api: SyncApi, content: VersionContent): Promise<void> {
|
||||
const versions = await api.versions(space.link.space);
|
||||
if (!versions.length) {
|
||||
vscode.window.showInformationMessage(`Archify: ${space.name} has no versions yet. One is taken on its own after a pause in editing.`);
|
||||
return;
|
||||
}
|
||||
const picked = await vscode.window.showQuickPick(versions.map(describe), { title: `Archify | Versions of ${space.name}`, placeHolder: 'Pick a version' });
|
||||
if (!picked) return;
|
||||
const v = picked.version;
|
||||
const action = await vscode.window.showQuickPick(
|
||||
[
|
||||
{ label: '$(diff) Compare with the files here', id: 'compare' },
|
||||
{ label: '$(history) Restore this version', id: 'restore', detail: 'Brings these files back for everyone, as a new version. Nothing is lost.' },
|
||||
],
|
||||
{ title: `Archify | ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}` },
|
||||
);
|
||||
if (action?.id === 'compare') await compareVersion(space, v, content);
|
||||
if (action?.id === 'restore') await restoreVersion(space, api, v);
|
||||
}
|
||||
|
||||
/** Asks, then brings back a version's files for everyone as a new version. */
|
||||
export async function restoreVersion(space: LiveSpace, api: SyncApi, v: Version): Promise<boolean> {
|
||||
const ok = await vscode.window.showWarningMessage(
|
||||
`Restore ${versionLabel(v)} of ${space.name} for everyone?`,
|
||||
{ modal: true, detail: 'The files go back to this version for everyone in the space. It becomes a new version; the history stays.' },
|
||||
'Restore',
|
||||
);
|
||||
if (ok !== 'Restore') return false;
|
||||
const restored = await api.restore(space.link.space, v.id);
|
||||
vscode.window.showInformationMessage(`Archify: restored ${versionLabel(v)} as ${versionLabel(restored)}.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Lists the files that differ from a version and opens the diff of the one picked. */
|
||||
export async function compareVersion(space: LiveSpace, v: Version, content: VersionContent): Promise<void> {
|
||||
const files = await content.files(space.link.space, v.id);
|
||||
const here = new Map<string, string>();
|
||||
for (const [rel, text] of space.doc.getMap<{ toString(): string }>('files').entries()) here.set(rel, text.toString());
|
||||
const paths = [...new Set([...files.files.map((f) => f.path), ...here.keys()])].sort();
|
||||
const changed = paths
|
||||
.map((p) => ({ path: p, then: files.files.find((f) => f.path === p)?.text, now: here.get(p) }))
|
||||
.filter((f) => f.then !== f.now);
|
||||
if (!changed.length) {
|
||||
vscode.window.showInformationMessage(`Archify: the files here are the same as ${versionLabel(v)}.`);
|
||||
return;
|
||||
}
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
changed.map((f) => ({ label: f.path, description: f.then === undefined ? 'added since' : f.now === undefined ? 'removed since' : 'changed', path: f.path })),
|
||||
{ title: `Archify | Changes since ${versionLabel(v)}`, placeHolder: `${changed.length} file(s) differ` },
|
||||
);
|
||||
if (!pick) return;
|
||||
const left = vscode.Uri.from({ scheme: VERSION_SCHEME, path: `/${space.link.space}/${v.id}/${pick.path}` });
|
||||
const right = vscode.Uri.joinPath(space.root, ...pick.path.split('/'));
|
||||
await vscode.commands.executeCommand('vscode.diff', left, right, `${pick.path} (${versionLabel(v)} ↔ here)`);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { viewerTheme } from '../util/settings';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { graphScriptUri, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
import { liveBridge } from '../sync/liveBridge';
|
||||
import { SourceSession } from './sourceSession';
|
||||
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
@@ -55,6 +56,8 @@ class DiagramPreview implements vscode.Disposable {
|
||||
readonly source: SourceSession | undefined;
|
||||
private heldHtml: string | undefined;
|
||||
private disposed = false;
|
||||
/** main.archify: the model page has its Live slot and takes `live` messages. */
|
||||
private liveShown = false;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
@@ -80,6 +83,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
if (!shown && this.heldHtml !== undefined) {
|
||||
const html = this.heldHtml;
|
||||
this.heldHtml = undefined;
|
||||
this.liveShown = false;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
});
|
||||
@@ -115,10 +119,25 @@ class DiagramPreview implements vscode.Disposable {
|
||||
this.setGraph(false);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'liveReady') {
|
||||
this.liveShown = true;
|
||||
void this.postLive();
|
||||
return;
|
||||
}
|
||||
if (message.type === 'liveAction') {
|
||||
if (this.root) void liveBridge.act(this.root, message.action);
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
|
||||
if (isMainFile(uri.path)) {
|
||||
this.disposables.push(liveBridge.onDidChange((root) => {
|
||||
if (!root || (this.root && root.toString() === this.root.toString())) void this.postLive();
|
||||
}));
|
||||
}
|
||||
|
||||
if (uri.scheme === 'file') {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||
@@ -134,6 +153,13 @@ class DiagramPreview implements vscode.Disposable {
|
||||
void this.update();
|
||||
}
|
||||
|
||||
/** The Live section of a shared model's page; nothing when the model is not shared. */
|
||||
private async postLive(): Promise<void> {
|
||||
if (!this.liveShown || !this.root || this.disposed) return;
|
||||
const live = await liveBridge.panel(this.root).catch(() => undefined);
|
||||
if (this.liveShown) this.post({ type: 'live', live });
|
||||
}
|
||||
|
||||
/** Posts to the webview; a panel closed mid-update is not an error. */
|
||||
private post(message: unknown): void {
|
||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||
@@ -147,6 +173,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
return;
|
||||
}
|
||||
this.heldHtml = undefined;
|
||||
this.liveShown = false;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EditorSchemas } from '../language/editorSchemas';
|
||||
import { readSchemas } from '../language/schemaCheck';
|
||||
import { liveBridge } from '../sync/liveBridge';
|
||||
import { log } from '../util/log';
|
||||
import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||
|
||||
@@ -9,7 +10,8 @@ export type SourceMessage =
|
||||
| { type: 'sourceReady' }
|
||||
| { type: 'sourceOpen' }
|
||||
| { type: 'sourceClose' }
|
||||
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] };
|
||||
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] }
|
||||
| { type: 'sourceSelection'; anchor: number; head: number };
|
||||
|
||||
export type SourceKind = 'diagram' | 'registry' | 'decision';
|
||||
|
||||
@@ -27,7 +29,13 @@ function schemasFor(context: vscode.ExtensionContext): EditorSchemas {
|
||||
}
|
||||
|
||||
export function isSourceMessage(message: { type?: string }): message is SourceMessage {
|
||||
return message.type === 'sourceReady' || message.type === 'sourceOpen' || message.type === 'sourceClose' || message.type === 'sourceEdit';
|
||||
return (
|
||||
message.type === 'sourceReady' ||
|
||||
message.type === 'sourceOpen' ||
|
||||
message.type === 'sourceClose' ||
|
||||
message.type === 'sourceEdit' ||
|
||||
message.type === 'sourceSelection'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +76,8 @@ export class SourceSession implements vscode.Disposable {
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false });
|
||||
}),
|
||||
// Teammates' cursors, when the model is shared (Archify Sync); nothing otherwise.
|
||||
liveBridge.onDidChangeCursors(() => this.postCursors()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -94,10 +104,14 @@ export class SourceSession implements vscode.Disposable {
|
||||
break;
|
||||
case 'sourceClose':
|
||||
this.setShown(false);
|
||||
liveBridge.setCursor(this.uri, undefined);
|
||||
break;
|
||||
case 'sourceEdit':
|
||||
this.edit(message);
|
||||
break;
|
||||
case 'sourceSelection':
|
||||
liveBridge.setCursor(this.uri, { anchor: message.anchor, head: message.head });
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -108,6 +122,17 @@ export class SourceSession implements vscode.Disposable {
|
||||
this.onShown(shown);
|
||||
}
|
||||
|
||||
private lastCursors = '[]';
|
||||
|
||||
private postCursors(): void {
|
||||
if (!this.shown) return;
|
||||
const cursors = liveBridge.cursors(this.uri);
|
||||
const key = JSON.stringify(cursors);
|
||||
if (key === this.lastCursors) return;
|
||||
this.lastCursors = key;
|
||||
this.post({ type: 'sourceCursors', cursors });
|
||||
}
|
||||
|
||||
private post(message: unknown): void {
|
||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||
}
|
||||
@@ -123,6 +148,8 @@ export class SourceSession implements vscode.Disposable {
|
||||
this.schemaKey = undefined;
|
||||
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
|
||||
this.post({ type: 'sourceDirty', dirty: doc.isDirty });
|
||||
this.lastCursors = '';
|
||||
this.postCursors();
|
||||
if (withDiagnostics) await this.postDiagnostics();
|
||||
}
|
||||
|
||||
@@ -196,6 +223,7 @@ export class SourceSession implements vscode.Disposable {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.shown) liveBridge.setCursor(this.uri, undefined);
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { repoRootFor } from '../archify/runtime';
|
||||
import { log } from '../util/log';
|
||||
import type { LiveAction } from '../sync/liveBridge';
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'hash'; hash: string }
|
||||
@@ -14,6 +15,10 @@ export type WebviewMessage =
|
||||
| { type: 'openDecision'; file: string }
|
||||
| { type: 'showGraph'; query?: string }
|
||||
| { type: 'showModel' }
|
||||
| { type: 'liveReady' }
|
||||
| { type: 'liveAction'; action: LiveAction }
|
||||
| { type: 'syncMenu' }
|
||||
| { type: 'syncReady' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -63,6 +68,28 @@ async function openSource(
|
||||
vscode.window.showWarningMessage(`Archify: source file "${message.path}" was not found in the workspace.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file's model is shared: an .archify-sync file next to its
|
||||
* main.archify. Read from disk, so the sync bundle stays unloaded.
|
||||
*/
|
||||
async function isShared(file: vscode.Uri): Promise<boolean> {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(file);
|
||||
let dir = vscode.Uri.joinPath(file, '..');
|
||||
for (let depth = 0; depth < 16; depth++) {
|
||||
if (await exists(vscode.Uri.joinPath(dir, '.archify-sync'))) return true;
|
||||
if (await exists(vscode.Uri.joinPath(dir, 'main.archify'))) return false;
|
||||
const parent = vscode.Uri.joinPath(dir, '..');
|
||||
if (!folder || dir.path === folder.uri.path || parent.path === dir.path) return false;
|
||||
dir = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function postSyncState(file: vscode.Uri, panel: vscode.WebviewPanel): Promise<void> {
|
||||
const shared = file.scheme === 'file' && (await isShared(file));
|
||||
await Promise.resolve(panel.webview.postMessage({ type: 'syncState', shared })).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function handleWebviewMessage(
|
||||
message: WebviewMessage,
|
||||
context: vscode.Uri,
|
||||
@@ -96,10 +123,20 @@ export async function handleWebviewMessage(
|
||||
case 'error':
|
||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||
break;
|
||||
case 'syncReady':
|
||||
await postSyncState(context, panel);
|
||||
break;
|
||||
case 'syncMenu':
|
||||
await vscode.commands.executeCommand('archify.sync.menu');
|
||||
// Share or Stop may have changed it.
|
||||
await postSyncState(context, panel);
|
||||
break;
|
||||
case 'hash':
|
||||
case 'openDiagram':
|
||||
case 'showGraph':
|
||||
case 'showModel':
|
||||
case 'liveReady':
|
||||
case 'liveAction':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
|
||||
+200
-4
@@ -208,6 +208,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
}, true);
|
||||
|
||||
// Collapsible panels of the model page; open/closed is kept in the webview state.
|
||||
// The model page's top-level panels start closed: a model opens as its outline.
|
||||
function panelStates() { return (vscode.getState() || {}).modelPanels || {}; }
|
||||
function panel(key, className, title, count, defaultOpen) {
|
||||
// count: a number or a short text such as "3 files".
|
||||
@@ -618,8 +619,13 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
||||
|
||||
var body = el('section', 'archify-model archify-model-body');
|
||||
// Live: filled by the host when the model is shared (Archify Sync); hidden otherwise.
|
||||
var live = el('div', 'archify-live');
|
||||
live.id = 'archify-live';
|
||||
live.hidden = true;
|
||||
body.appendChild(live);
|
||||
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
||||
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
||||
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, false);
|
||||
body.appendChild(diagramsPanel);
|
||||
if (!count) diagramsPanel.appendChild(el('p', 'archify-model-empty', 'No diagrams yet. Add .archify files next to main.archify or in subfolders.'));
|
||||
model.groups.forEach(function (group) {
|
||||
@@ -635,6 +641,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
var button = el('button', 'archify-model-open');
|
||||
button.type = 'button';
|
||||
button.title = diagram.path;
|
||||
button.setAttribute('data-path', diagram.path);
|
||||
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
||||
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
||||
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
||||
@@ -652,7 +659,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
byFile[element.file].push(element);
|
||||
});
|
||||
files.sort();
|
||||
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), true);
|
||||
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), false);
|
||||
body.appendChild(registryPanel);
|
||||
files.forEach(function (file) {
|
||||
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
|
||||
@@ -670,13 +677,106 @@ function bridgeScript(options: PageOptions): string {
|
||||
}
|
||||
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
|
||||
if (model.decisions && model.decisions.length) {
|
||||
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
|
||||
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, false);
|
||||
body.appendChild(decisionsPanel);
|
||||
var decisionList = el('ul', 'archify-model-list archify-decision-list');
|
||||
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
|
||||
decisionsPanel.appendChild(decisionList);
|
||||
}
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
post({ type: 'liveReady' });
|
||||
}
|
||||
|
||||
var LIVE_STATE = {
|
||||
online: 'Live',
|
||||
connecting: 'Connecting\u2026',
|
||||
offline: 'Offline \u00b7 edits are kept here and sent when the server is back',
|
||||
'signed-out': 'Signed out \u00b7 edits are kept here until you sign in',
|
||||
forbidden: 'No access \u00b7 you are no longer a member of this model',
|
||||
outdated: 'The server needs a newer extension',
|
||||
};
|
||||
var KIND_LABEL = { snapshot: 'snapshot', publish: 'published', restore: 'restored' };
|
||||
|
||||
function liveButton(label, title, action) {
|
||||
var button = el('button', 'archify-model-used-link archify-live-action', label);
|
||||
button.type = 'button';
|
||||
button.title = title;
|
||||
button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'liveAction', action: action }); });
|
||||
return button;
|
||||
}
|
||||
|
||||
function when(iso) {
|
||||
var date = new Date(iso);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
var seconds = (Date.now() - date.getTime()) / 1000;
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + ' h ago';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// The Live section of a shared model: who is here, the state, and versions.
|
||||
function renderLive(info) {
|
||||
var holder = document.getElementById('archify-live');
|
||||
if (!holder) return;
|
||||
holder.textContent = '';
|
||||
document.querySelectorAll('.archify-live-here').forEach(function (n) { n.remove(); });
|
||||
if (!info) { holder.hidden = true; return; }
|
||||
holder.hidden = false;
|
||||
var others = info.people.filter(function (p) { return !p.me; });
|
||||
var livePanel = panel('live', 'archify-model-live is-' + info.state, 'Shared \u00b7 ' + info.name, others.length ? others.length + 1 + ' here' : info.host, false);
|
||||
var status = el('div', 'archify-live-status');
|
||||
status.appendChild(el('span', 'archify-live-dot'));
|
||||
status.appendChild(el('span', 'archify-live-state', (LIVE_STATE[info.state] || info.state) + (info.state === 'online' ? ' on ' + info.host : '')));
|
||||
if (info.state === 'signed-out') status.appendChild(liveButton('Sign in', 'Sign in to ' + info.host, { kind: 'signIn' }));
|
||||
var actions = el('span', 'archify-live-actions');
|
||||
actions.appendChild(liveButton('Publish version', 'Name the current state as a version', { kind: 'publish' }));
|
||||
actions.appendChild(liveButton('Copy invite link', 'Copy a link that lets someone join this model', { kind: 'invite' }));
|
||||
status.appendChild(actions);
|
||||
livePanel.appendChild(status);
|
||||
|
||||
var people = el('ul', 'archify-live-people');
|
||||
info.people.forEach(function (person) {
|
||||
var item = el('li', 'archify-live-person' + (person.me ? ' is-me' : ''));
|
||||
item.appendChild(el('strong', '', person.me ? person.name + ' (you)' : person.name));
|
||||
if (person.file) item.appendChild(el('small', '', person.file));
|
||||
people.appendChild(item);
|
||||
});
|
||||
livePanel.appendChild(people);
|
||||
|
||||
// Who is in which diagram, on the diagram rows.
|
||||
others.forEach(function (person) {
|
||||
if (!person.file) return;
|
||||
document.querySelectorAll('.archify-model-open[data-path]').forEach(function (button) {
|
||||
if (button.getAttribute('data-path') !== person.file) return;
|
||||
var meta = button.querySelector('.archify-live-here');
|
||||
if (!meta) { meta = el('span', 'archify-live-here', ''); button.appendChild(meta); }
|
||||
meta.textContent = (meta.textContent ? meta.textContent.replace(/ here$/, '') + ', ' : '') + person.name + ' here';
|
||||
});
|
||||
});
|
||||
|
||||
var versions = info.versions || [];
|
||||
var head = el('div', 'archify-live-versions-head');
|
||||
head.appendChild(el('span', 'archify-model-summary-title', 'Versions'));
|
||||
if (versions.length > 8) head.appendChild(liveButton('All ' + versions.length + '\u2026', 'Pick from every version', { kind: 'versions' }));
|
||||
livePanel.appendChild(head);
|
||||
if (info.versionsProblem) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', info.versionsProblem));
|
||||
else if (!versions.length) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', 'No versions yet. One is taken on its own after a pause in editing.'));
|
||||
var list = el('ul', 'archify-live-versions');
|
||||
versions.slice(0, 8).forEach(function (v) {
|
||||
var item = el('li', 'archify-live-version is-' + v.kind);
|
||||
item.appendChild(el('strong', 'archify-live-version-label', v.label));
|
||||
item.appendChild(el('span', 'archify-decision-status archify-live-kind', KIND_LABEL[v.kind] + (v.restoredFrom ? ' ' + v.restoredFrom : '')));
|
||||
if (v.name) item.appendChild(el('span', 'archify-live-version-name', v.name));
|
||||
item.appendChild(el('small', 'archify-live-version-when', when(v.at)));
|
||||
var buttons = el('span', 'archify-live-actions');
|
||||
buttons.appendChild(liveButton('Compare', 'Compare ' + v.label + ' with the files here', { kind: 'compare', version: v.id }));
|
||||
buttons.appendChild(liveButton('Restore', 'Bring back ' + v.label + ' for everyone, as a new version', { kind: 'restore', version: v.id }));
|
||||
item.appendChild(buttons);
|
||||
list.appendChild(item);
|
||||
});
|
||||
livePanel.appendChild(list);
|
||||
holder.appendChild(livePanel);
|
||||
}
|
||||
|
||||
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
||||
@@ -775,6 +875,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
||||
if (message.type === 'setView') setSource(message.view === 'source');
|
||||
if (message.type === 'live') renderLive(message.live);
|
||||
});
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
@@ -787,6 +888,78 @@ function bridgeScript(options: PageOptions): string {
|
||||
onReady(renderDecisionsRail);
|
||||
onReady(describeElements);
|
||||
onReady(renderModeSwitch);
|
||||
|
||||
// Archify Sync: one icon button, top right, on every page of an Archify
|
||||
// file (diagram, model, graph, registry, decision). It opens the Sync menu.
|
||||
function renderSyncButton() {
|
||||
if (!(config.source || config.model || config.registry || config.decision || config.graph)) return;
|
||||
if (document.getElementById('archify-sync-button')) return;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = SYNC_BUTTON_STYLE;
|
||||
document.head.appendChild(style);
|
||||
var button = document.createElement('button');
|
||||
button.id = 'archify-sync-button';
|
||||
button.type = 'button';
|
||||
button.title = 'Archify Sync: sign in, share this model with others, or join one';
|
||||
button.addEventListener('click', function (event) { event.stopPropagation(); post({ type: 'syncMenu' }); });
|
||||
// Last in the row that holds the view switch: Archify's toolbar on a
|
||||
// diagram (after Export), the Graph page's header actions (after Export,
|
||||
// built a moment later by graph.js), next to the switch on the other
|
||||
// pages. It floats only while there is no such row.
|
||||
document.body.appendChild(button);
|
||||
function place() {
|
||||
var modeSwitch = document.getElementById('archify-mode-switch');
|
||||
var row = document.querySelector('.ag-actions') || (modeSwitch && modeSwitch.parentElement) || document.querySelector('body > .toolbar');
|
||||
if (!row || row === document.body) return false;
|
||||
button.classList.add('in-toolbar');
|
||||
row.classList.add('archify-sync-row');
|
||||
row.appendChild(button);
|
||||
return true;
|
||||
}
|
||||
if (!place() && window.MutationObserver) {
|
||||
var watch = new MutationObserver(function () { if (place()) watch.disconnect(); });
|
||||
watch.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
showSyncState(false);
|
||||
post({ type: 'syncReady' });
|
||||
}
|
||||
// Not shared: a cloud. Shared (the model has an .archify-sync file): broadcast waves on green.
|
||||
var SYNC_ICONS = {
|
||||
off: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M12.5 13H4.25a3.25 3.25 0 0 1-.6-6.44 4.5 4.5 0 0 1 8.7-.81A3.6 3.6 0 0 1 12.5 13Zm-8.25-1h8.25a2.6 2.6 0 0 0 0-5.2h-.5l-.12-.39a3.5 3.5 0 0 0-6.82.66l-.04.4-.4.05A2.25 2.25 0 0 0 4.25 12Z"/></svg>',
|
||||
on: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="1.75" fill="currentColor"/><path fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" d="M5.2 5.2a4 4 0 0 0 0 5.6M10.8 5.2a4 4 0 0 1 0 5.6M3.1 3.1a7 7 0 0 0 0 9.8M12.9 3.1a7 7 0 0 1 0 9.8"/></svg>',
|
||||
};
|
||||
function showSyncState(shared) {
|
||||
var button = document.getElementById('archify-sync-button');
|
||||
if (!button) return;
|
||||
button.classList.toggle('is-shared', !!shared);
|
||||
button.innerHTML = shared ? SYNC_ICONS.on : SYNC_ICONS.off;
|
||||
button.title = shared
|
||||
? 'Archify Sync: this model is shared. Invite people, publish a version, see versions'
|
||||
: 'Archify Sync: sign in, share this model with others, or join one';
|
||||
button.setAttribute('aria-label', shared ? 'Archify Sync (shared)' : 'Archify Sync');
|
||||
}
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'syncState') showSyncState(message.shared);
|
||||
});
|
||||
// Every rule is !important: the page's own button styles (Archify's toolbar,
|
||||
// VS Code's high contrast themes) must not resize or recolour it.
|
||||
var SYNC_BUTTON_STYLE = '#archify-sync-button { all: unset; position: fixed !important; top: 12px !important; right: 28px !important; z-index: 1001 !important;'
|
||||
+ ' box-sizing: border-box !important; width: 30px !important; height: 30px !important; min-width: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important;'
|
||||
+ ' display: flex !important; align-items: center !important; justify-content: center !important; border-radius: 8px !important; cursor: pointer !important;'
|
||||
+ ' color: #fff !important; background: var(--vscode-button-background, #0e639c) !important; border: 1px solid var(--vscode-contrastBorder, transparent) !important;'
|
||||
+ ' box-shadow: 0 1px 4px rgba(0,0,0,.35) !important; }'
|
||||
+ ' .archify-model-switch-row.archify-sync-row { display: flex !important; align-items: center !important; gap: 0.5rem !important; }'
|
||||
+ ' #archify-sync-button.in-toolbar { position: relative !important; top: auto !important; right: auto !important; align-self: center !important; flex: none !important; width: 36px !important; height: 36px !important; }'
|
||||
+ ' .archify-model-switch-row #archify-sync-button.in-toolbar { width: 33px !important; height: 33px !important; }'
|
||||
+ ' .ag-actions #archify-sync-button.in-toolbar { width: 29px !important; height: 29px !important; }'
|
||||
+ ' body.vscode-high-contrast #archify-sync-button:not(.is-shared) { background: #0e639c !important; }'
|
||||
+ ' #archify-sync-button svg { display: block !important; width: 16px !important; height: 16px !important; flex: none !important; }'
|
||||
+ ' #archify-sync-button:hover { background: var(--vscode-button-hoverBackground, #1177bb) !important; }'
|
||||
+ ' #archify-sync-button.is-shared { background: #1f8f5f !important; }'
|
||||
+ ' #archify-sync-button.is-shared:hover { background: #23a36c !important; }'
|
||||
+ ' #archify-sync-button:focus-visible { outline: 2px solid var(--vscode-focusBorder, #007fd4) !important; outline-offset: 2px !important; }';
|
||||
onReady(renderSyncButton);
|
||||
if (config.source) {
|
||||
// The host answers with the problem count for the Source badge.
|
||||
post({ type: 'sourceReady' });
|
||||
@@ -854,6 +1027,26 @@ const MODEL_STYLE = `
|
||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
/* Live: a shared model's state, people and versions. */
|
||||
.archify-live-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||
.archify-live-dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--m-muted); flex: none; }
|
||||
.archify-model-live.is-online .archify-live-dot { background: var(--vscode-testing-iconPassed, #3fb950); }
|
||||
.archify-model-live.is-signed-out .archify-live-dot, .archify-model-live.is-forbidden .archify-live-dot, .archify-model-live.is-outdated .archify-live-dot { background: var(--vscode-editorWarning-foreground, #d29922); }
|
||||
.archify-live-actions { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
|
||||
.archify-live .archify-live-action { font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||
.archify-live-people { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0.9rem 0; padding: 0; }
|
||||
.archify-live-person { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.2rem 0.6rem; border: 1px solid var(--m-border); border-radius: 999px; font-size: 0.8125rem; }
|
||||
.archify-live-person small { color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; font-size: 0.6875rem; }
|
||||
.archify-live-person.is-me { opacity: 0.75; }
|
||||
.archify-live-here { font-size: 0.6875rem; color: var(--m-accent); font-weight: 600; }
|
||||
.archify-live-versions-head { display: flex; align-items: center; gap: 0.5rem; margin: 0.9rem 0.9rem 0.35rem; font-size: 0.8125rem; font-weight: 600; }
|
||||
.archify-live-note { margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||
.archify-live-versions { list-style: none; margin: 0 0.9rem; padding: 0; display: grid; gap: 0.3rem; }
|
||||
.archify-live-version { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.4rem 0.7rem; border: 1px solid var(--m-border); border-radius: 0.5rem; font-size: 0.8125rem; }
|
||||
.archify-live-version-label { font-family: ui-monospace, Menlo, monospace; }
|
||||
.archify-live-version.is-publish .archify-live-version-label { color: var(--m-accent); }
|
||||
.archify-live-version-name { font-weight: 600; }
|
||||
.archify-live-version-when { color: var(--m-muted); }
|
||||
/* Decisions: rows, chips and status badges. */
|
||||
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
||||
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
||||
@@ -893,10 +1086,13 @@ const MODEL_STYLE = `
|
||||
/* The Model | Graph switch: in Archify's toolbar it is the 2D | 3D switch's twin; on the model page it sits top right. */
|
||||
const SWITCH_STYLE = `
|
||||
.archify-mode-switch { display: inline-flex; align-items: center; padding: 0 0.25rem; gap: 0; color: var(--text-muted, var(--vscode-descriptionForeground)); position: relative; }
|
||||
.toolbar .archify-mode-switch { height: 2.75rem; box-sizing: border-box; }
|
||||
.toolbar .archify-mode-switch::before { content: ""; position: absolute; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); }
|
||||
.toolbar .archify-mode-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
||||
/* Anchored on the id: Archify's .toolbar button rules (one of them with :not(#btn-theme)) would win otherwise. */
|
||||
#archify-mode-switch .archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border: 0; border-radius: 0.5rem; background: transparent; color: inherit; font: inherit; font-weight: 550; font-size: 0.8125rem; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
#archify-mode-switch .archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; min-height: 0; margin: 0; padding: 0 0.7rem; border: 0; border-radius: 0.5rem; background: transparent; color: inherit; font: inherit; font-weight: 550; font-size: 0.8125rem; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
/* Archify's toolbar buttons are 2.75rem tall with a painted ::before; the segments are not, like 2D | 3D. */
|
||||
#archify-mode-switch .archify-mode-seg::before, #archify-mode-switch .archify-mode-seg::after { content: none; }
|
||||
#archify-mode-switch .archify-mode-seg.on[aria-pressed="true"] { color: var(--bg, var(--vscode-editor-background)); background: var(--text, var(--vscode-foreground)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||
#archify-mode-switch .archify-mode-seg:hover:not(.on) { color: var(--text, var(--vscode-foreground)); }
|
||||
#archify-mode-switch .archify-mode-seg:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 1px; }
|
||||
|
||||
@@ -3,9 +3,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
||||
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
|
||||
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
|
||||
import { Annotation, EditorState, Transaction } from '@codemirror/state';
|
||||
import { Annotation, EditorState, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
@@ -30,6 +33,72 @@ export interface SourceDiagnostic {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** A teammate's cursor (Archify Sync), as offsets into the editor's text. */
|
||||
export interface RemoteCursor {
|
||||
name: string;
|
||||
color: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
const setRemoteCursors = StateEffect.define<RemoteCursor[]>();
|
||||
|
||||
class RemoteCaret extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly color: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: RemoteCaret): boolean {
|
||||
return other.name === this.name && other.color === this.color;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'cm-remote-caret';
|
||||
caret.style.borderLeftColor = this.color;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-remote-label';
|
||||
label.textContent = this.name;
|
||||
label.style.backgroundColor = this.color;
|
||||
caret.appendChild(label);
|
||||
return caret;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet {
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
const clamp = (n: number) => Math.max(0, Math.min(n, length));
|
||||
for (const c of cursors) {
|
||||
const anchor = clamp(c.anchor);
|
||||
const head = clamp(c.head);
|
||||
if (anchor !== head) {
|
||||
ranges.push(
|
||||
Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)),
|
||||
);
|
||||
}
|
||||
ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head));
|
||||
}
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
/** Teammates' carets and selections; they move with the text as anyone types. */
|
||||
const remoteCursors = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(decorations, tr) {
|
||||
decorations = decorations.map(tr.changes);
|
||||
for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length);
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
/** Marks transactions that carry text from the host, so they are not sent back. */
|
||||
const fromHost = Annotation.define<boolean>();
|
||||
|
||||
@@ -56,6 +125,22 @@ const theme = EditorView.theme({
|
||||
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
|
||||
'.cm-diagnostic': { padding: '4px 8px' },
|
||||
'.cm-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' },
|
||||
'.cm-remote-label': {
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '-2px',
|
||||
padding: '0 4px',
|
||||
borderRadius: '3px 3px 3px 0',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: '5',
|
||||
opacity: '0.9',
|
||||
},
|
||||
'.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' },
|
||||
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
|
||||
});
|
||||
|
||||
@@ -71,7 +156,13 @@ export class SourceEditor {
|
||||
readonly view: EditorView;
|
||||
private schemaKey: string | undefined;
|
||||
|
||||
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
onEdit: (changes: TextChange[], text: string) => void,
|
||||
/** The main selection moved (or the editor got focus); offsets into the text. */
|
||||
onSelection: (anchor: number, head: number) => void = () => undefined,
|
||||
) {
|
||||
this.view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
@@ -105,7 +196,12 @@ export class SourceEditor {
|
||||
stateExtensions(undefined as never),
|
||||
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
|
||||
hoverTooltip(yamlSchemaHover()),
|
||||
remoteCursors,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) {
|
||||
const main = update.state.selection.main;
|
||||
onSelection(main.anchor, main.head);
|
||||
}
|
||||
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
|
||||
const changes: TextChange[] = [];
|
||||
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
|
||||
@@ -125,6 +221,11 @@ export class SourceEditor {
|
||||
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
|
||||
}
|
||||
|
||||
/** Teammates' cursors, from the host; they replace the ones shown. */
|
||||
setRemoteCursors(cursors: RemoteCursor[]): void {
|
||||
this.view.dispatch({ effects: setRemoteCursors.of(cursors) });
|
||||
}
|
||||
|
||||
/** The schema that completion and hover read; `undefined` turns them off. */
|
||||
setSchema(key: string | undefined, schema: object | undefined): void {
|
||||
if (key === this.schemaKey) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ArchifyHost } from '../host';
|
||||
import { TextChange, minimalChange } from '../../util/textDiff';
|
||||
import { SourceDiagnostic, SourceEditor } from './editor';
|
||||
import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor';
|
||||
|
||||
/**
|
||||
* The Source view of an Archify page: the file's YAML in an editor, in the
|
||||
@@ -15,13 +15,15 @@ type HostMessage =
|
||||
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
|
||||
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
|
||||
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
|
||||
| { type: 'sourceDirty'; dirty: boolean };
|
||||
| { type: 'sourceDirty'; dirty: boolean }
|
||||
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
|
||||
|
||||
const STYLE = `
|
||||
html.archify-source-on .container > :not(.header):not(#archify-source),
|
||||
html.archify-source-on body > :not(.toolbar):not(.container):not(.archify-model-head):not(.archify-model-switch-row):not(#archify-source):not(script):not(style):not(.archify-modal-backdrop),
|
||||
html.archify-source-on body > :not(.toolbar):not(.container):not(.archify-model-head):not(.archify-model-switch-row):not(#archify-source):not(#archify-sync-button):not(script):not(style):not(.archify-modal-backdrop),
|
||||
html.archify-source-on #btn-3d,
|
||||
html.archify-source-on .archify-vscode-notice { display: none !important; }
|
||||
html:not(.archify-source-on) #archify-source { display: none !important; }
|
||||
.archify-source { display: flex; flex-direction: column; box-sizing: border-box; min-height: 260px; margin: 0.75rem 0 1rem; }
|
||||
body > .archify-source { max-width: 1400px; margin: 0.75rem auto 1rem; padding: 0 2rem; }
|
||||
.archify-source-editor { flex: 1; min-height: 0; overflow: hidden; border: 1px solid var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); border-radius: 0.75rem;
|
||||
@@ -54,7 +56,10 @@ class SourcePane {
|
||||
/** The text the host last knew the page to have; edits are sent against it. */
|
||||
private text = '';
|
||||
private diagnostics: SourceDiagnostic[] = [];
|
||||
private cursors: RemoteCursor[] = [];
|
||||
private shown = false;
|
||||
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private selection: { anchor: number; head: number } | undefined;
|
||||
|
||||
constructor(private readonly host: ArchifyHost) {
|
||||
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
|
||||
@@ -139,8 +144,14 @@ class SourcePane {
|
||||
this.build();
|
||||
if (!this.editor) {
|
||||
this.text = message.text;
|
||||
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
|
||||
this.editor = new SourceEditor(
|
||||
this.editorHost,
|
||||
message.text,
|
||||
(changes, text) => this.onEdit(changes, text),
|
||||
(anchor, head) => this.onSelection(anchor, head),
|
||||
);
|
||||
this.editor.setDiagnostics(this.diagnostics);
|
||||
this.editor.setRemoteCursors(this.cursors);
|
||||
this.fit();
|
||||
if (this.shown) this.editor.focus();
|
||||
} else {
|
||||
@@ -162,9 +173,23 @@ class SourcePane {
|
||||
case 'sourceDirty':
|
||||
this.renderState(message.dirty);
|
||||
return;
|
||||
case 'sourceCursors':
|
||||
this.cursors = message.cursors;
|
||||
this.editor?.setRemoteCursors(message.cursors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
|
||||
private onSelection(anchor: number, head: number): void {
|
||||
this.selection = { anchor, head };
|
||||
if (this.selectionTimer) return;
|
||||
this.selectionTimer = setTimeout(() => {
|
||||
this.selectionTimer = undefined;
|
||||
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private onEdit(changes: TextChange[], text: string): void {
|
||||
const base = this.text;
|
||||
this.text = text;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Technical context
|
||||
output: technical-context.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: usher
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: tickets
|
||||
pos: [290, 160]
|
||||
size: [150, 60]
|
||||
- ref: psp
|
||||
pos: [560, 60]
|
||||
size: [150, 60]
|
||||
- ref: mail
|
||||
pos: [560, 160]
|
||||
size: [150, 60]
|
||||
- ref: pos
|
||||
pos: [560, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: customer, to: tickets, label: HTTPS JSON, variant: emphasis }
|
||||
- { from: usher, to: tickets, label: HTTPS app }
|
||||
- { from: tickets, to: psp, label: REST webhook, variant: security }
|
||||
- { from: tickets, to: mail, label: SMTP TLS }
|
||||
- { from: tickets, to: pos, label: REST VPN }
|
||||
cards:
|
||||
- dot: cyan
|
||||
title: 3 Domain interfaces
|
||||
items:
|
||||
- "Moviegoer: in shows, seats, payment start; out seat map, QR tickets"
|
||||
- "Usher: in scanned QR codes; out valid or invalid, seat"
|
||||
- "Payment provider: out checkout session; in paid or failed webhook"
|
||||
- "E-mail service: out ticket e-mails with QR and receipt"
|
||||
- "Box-office POS: in counter sales; out seat availability"
|
||||
@@ -0,0 +1,49 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Booking API - level 2
|
||||
output: booking.html
|
||||
decisions: [seat-holds-with-ttl]
|
||||
components:
|
||||
- ref: web
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- id: programme
|
||||
type: backend
|
||||
label: Programme
|
||||
sublabel: shows, halls
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- id: seats
|
||||
type: backend
|
||||
label: Seat Holds
|
||||
sublabel: lock, release
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- id: checkout
|
||||
type: backend
|
||||
label: Checkout
|
||||
sublabel: orders, webhook
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: seatlock
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
decisions: [seat-holds-with-ttl]
|
||||
- ref: db
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: psp
|
||||
pos: [460, 360]
|
||||
size: [150, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Booking API
|
||||
wraps: [programme, seats, checkout]
|
||||
connections:
|
||||
- { from: web, to: programme, label: browse }
|
||||
- { from: web, to: seats, label: hold, variant: emphasis }
|
||||
- { from: web, to: checkout, label: pay }
|
||||
- { from: programme, to: db, label: SQL }
|
||||
- { from: seats, to: seatlock, label: SET NX, decisions: [seat-holds-with-ttl] }
|
||||
- { from: checkout, to: psp, label: session, variant: security }
|
||||
@@ -0,0 +1,66 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Building blocks - level 1
|
||||
output: level-1.html
|
||||
decisions: [booking-owns-seat-inventory]
|
||||
components:
|
||||
- ref: web
|
||||
pos: [40, 110]
|
||||
size: [130, 60]
|
||||
- ref: mobile
|
||||
pos: [40, 210]
|
||||
size: [130, 60]
|
||||
- ref: scanner
|
||||
pos: [40, 360]
|
||||
size: [130, 60]
|
||||
- ref: booking
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: seatlock
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [460, 260]
|
||||
size: [130, 60]
|
||||
- ref: ticketing
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- ref: possync
|
||||
pos: [670, 260]
|
||||
size: [130, 60]
|
||||
- ref: psp
|
||||
pos: [900, 160]
|
||||
size: [150, 60]
|
||||
- ref: pos
|
||||
pos: [900, 260]
|
||||
size: [150, 60]
|
||||
- ref: mail
|
||||
pos: [900, 460]
|
||||
size: [150, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Cinema Tickets
|
||||
wraps: [web, mobile, scanner, booking, seatlock, db, ticketing, possync]
|
||||
connections:
|
||||
- { from: web, to: booking, label: JSON, variant: emphasis }
|
||||
- { from: mobile, to: booking, label: JSON }
|
||||
- { from: booking, to: seatlock, label: hold }
|
||||
- { from: booking, to: db, label: SQL }
|
||||
- { from: booking, to: psp, label: checkout, variant: security }
|
||||
- { from: booking, to: ticketing, label: issue, labelAt: [315, 234] }
|
||||
- { from: scanner, to: ticketing, label: check-in }
|
||||
- { from: ticketing, to: db, label: SQL }
|
||||
- { from: ticketing, to: mail, label: QR mail }
|
||||
- { from: possync, to: db, label: SQL }
|
||||
- { from: possync, to: pos, label: sales, decisions: [booking-owns-seat-inventory] }
|
||||
cards:
|
||||
- dot: amber
|
||||
title: 8 Crosscutting concepts
|
||||
items:
|
||||
- "Persistence: PostgreSQL is the truth; Redis only for holds"
|
||||
- "Security: OIDC for staff apps; card data never stored"
|
||||
- "Errors: idempotency keys; failed webhooks are retried"
|
||||
- "Times in UTC; shown in cinema local time"
|
||||
- "QR payload signed with an Ed25519 key"
|
||||
- "Structured logs with order id as trace key"
|
||||
@@ -0,0 +1,27 @@
|
||||
schema_version: 1
|
||||
diagram_type: sequence
|
||||
meta:
|
||||
title: Runtime - book seats
|
||||
output: book-seats.html
|
||||
viewBox: [1060, 680]
|
||||
column_fit: spread
|
||||
decisions: [seat-holds-with-ttl]
|
||||
participants:
|
||||
- { ref: customer }
|
||||
- { ref: web }
|
||||
- { ref: booking }
|
||||
- { ref: seatlock }
|
||||
- { ref: psp }
|
||||
- { ref: ticketing }
|
||||
- { ref: mail }
|
||||
messages:
|
||||
- { from: customer, to: web, y: 170, label: pick seats, variant: emphasis }
|
||||
- { from: web, to: booking, y: 214, label: POST /holds }
|
||||
- { from: booking, to: seatlock, y: 258, label: SET NX TTL 8m, decisions: [seat-holds-with-ttl] }
|
||||
- { from: seatlock, to: booking, y: 302, label: OK, variant: return }
|
||||
- { from: booking, to: web, y: 346, label: hold id, variant: return }
|
||||
- { from: web, to: psp, y: 390, label: hosted checkout, variant: security }
|
||||
- { from: psp, to: booking, y: 434, label: webhook paid, variant: security }
|
||||
- { from: booking, to: ticketing, y: 478, label: issue tickets }
|
||||
- { from: ticketing, to: mail, y: 522, label: QR e-mail }
|
||||
- { from: mail, to: customer, y: 566, label: tickets, variant: return }
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version: 1
|
||||
diagram_type: sequence
|
||||
meta:
|
||||
title: Runtime - check-in at the door
|
||||
output: check-in.html
|
||||
viewBox: [900, 480]
|
||||
column_fit: spread
|
||||
decisions: [signed-qr-codes]
|
||||
participants:
|
||||
- { ref: usher }
|
||||
- { ref: scanner }
|
||||
- { ref: ticketing }
|
||||
- { ref: db }
|
||||
messages:
|
||||
- { from: usher, to: scanner, y: 180, label: scan QR, variant: emphasis, note: signature checked offline, decisions: [signed-qr-codes] }
|
||||
- { from: scanner, to: ticketing, y: 230, label: POST /checkins }
|
||||
- { from: ticketing, to: db, y: 280, label: first scan wins }
|
||||
- { from: ticketing, to: scanner, y: 330, label: valid seat 7F, variant: return }
|
||||
- { from: scanner, to: usher, y: 380, label: green light, variant: return }
|
||||
@@ -0,0 +1,74 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Deployment - production
|
||||
output: production.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: mobile
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- id: ingress
|
||||
type: security
|
||||
label: Ingress
|
||||
sublabel: TLS, WAF
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: web
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: booking
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: ticketing
|
||||
pos: [460, 260]
|
||||
size: [130, 60]
|
||||
- ref: possync
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
- ref: seatlock
|
||||
pos: [710, 110]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [710, 260]
|
||||
size: [130, 60]
|
||||
- ref: pos
|
||||
pos: [250, 460]
|
||||
size: [150, 60]
|
||||
- ref: scanner
|
||||
pos: [40, 460]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: EU cloud - k8s cluster
|
||||
wraps: [ingress, web, booking, ticketing, possync]
|
||||
- kind: security-group
|
||||
label: Managed data zone
|
||||
wraps: [seatlock, db]
|
||||
- kind: region
|
||||
label: Customer phones
|
||||
wraps: [mobile]
|
||||
- kind: region
|
||||
label: Cinema site
|
||||
wraps: [pos, scanner]
|
||||
connections:
|
||||
- { from: customer, to: ingress, label: HTTPS, variant: emphasis }
|
||||
- { from: ingress, to: web, label: route }
|
||||
- { from: ingress, to: booking, label: route }
|
||||
- { from: booking, to: seatlock, label: TLS }
|
||||
- { from: booking, to: db, label: TLS }
|
||||
- { from: ticketing, to: db, label: TLS }
|
||||
- { from: possync, to: pos, label: VPN, variant: security }
|
||||
- { from: scanner, to: ingress, label: HTTPS }
|
||||
- { from: mobile, to: ingress, label: HTTPS }
|
||||
cards:
|
||||
- dot: slate
|
||||
title: 7 Mapping
|
||||
items:
|
||||
- "Web Shop, Booking API, Ticket Service, POS Sync -> EU k8s cluster"
|
||||
- "Seat Locks -> managed Redis; Tickets DB -> managed PostgreSQL"
|
||||
- "Mobile App -> customer phones"
|
||||
- "Scanner App -> door phones at the cinema site"
|
||||
- "Box-office POS -> cinema site, VPN to POS Sync"
|
||||
@@ -0,0 +1,75 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Quality tree
|
||||
output: quality-tree.html
|
||||
components:
|
||||
- id: quality
|
||||
type: cloud
|
||||
label: Quality
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- id: reliability
|
||||
type: backend
|
||||
label: Reliability
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- id: performance
|
||||
type: backend
|
||||
label: Performance
|
||||
pos: [250, 210]
|
||||
size: [130, 60]
|
||||
- id: availability
|
||||
type: backend
|
||||
label: Availability
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- id: security
|
||||
type: security
|
||||
label: Security
|
||||
pos: [250, 460]
|
||||
size: [130, 60]
|
||||
- id: s-nodouble
|
||||
type: external
|
||||
label: No double sale
|
||||
pos: [460, 10]
|
||||
size: [150, 60]
|
||||
- id: s-payfail
|
||||
type: external
|
||||
label: Failed pay frees seats
|
||||
pos: [460, 110]
|
||||
size: [170, 60]
|
||||
- id: s-peak
|
||||
type: external
|
||||
label: Checkout < 2 s at peak
|
||||
pos: [460, 210]
|
||||
size: [170, 60]
|
||||
- id: s-offline
|
||||
type: external
|
||||
label: Offline door scans
|
||||
pos: [460, 360]
|
||||
size: [170, 60]
|
||||
- id: s-forged
|
||||
type: external
|
||||
label: Forged QR rejected
|
||||
pos: [460, 460]
|
||||
size: [170, 60]
|
||||
connections:
|
||||
- { from: quality, to: reliability }
|
||||
- { from: quality, to: performance }
|
||||
- { from: quality, to: availability }
|
||||
- { from: quality, to: security }
|
||||
- { from: reliability, to: s-nodouble }
|
||||
- { from: reliability, to: s-payfail }
|
||||
- { from: performance, to: s-peak }
|
||||
- { from: availability, to: s-offline }
|
||||
- { from: security, to: s-forged }
|
||||
cards:
|
||||
- dot: emerald
|
||||
title: 10 Quality scenarios
|
||||
items:
|
||||
- "500 holds on one show in 1 min: no seat sold twice"
|
||||
- "Payment fails or is abandoned: seats free again within 8 min"
|
||||
- "Friday 19:00 peak, 300 checkouts/min: p95 under 2 s"
|
||||
- "Wi-Fi down at the door: scans go on, check-ins sync later"
|
||||
- "Edited or forged QR code scanned: rejected at once"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: seat-holds-with-ttl
|
||||
title: Seat holds as Redis keys with a TTL
|
||||
status: accepted
|
||||
date: 2026-09-08
|
||||
elements: [booking, seatlock]
|
||||
diagrams: [0500-building-blocks/booking.archify, 0600-runtime/book-seats.archify]
|
||||
context: >
|
||||
A customer may spend minutes at the payment page. Holding seats
|
||||
as rows in the database left orphans when a browser closed, and
|
||||
a nightly job freed them far too late for Friday shows.
|
||||
decision: >
|
||||
A hold is one Redis key per seat, set with SET NX and an
|
||||
8-minute TTL. The payment webhook turns holds into an order in
|
||||
PostgreSQL; an expired key frees the seat without any job.
|
||||
consequences: >
|
||||
Holds survive no Redis restart; at worst a customer re-picks
|
||||
seats. The webhook must check the hold still exists before it
|
||||
confirms the order, otherwise it refunds.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: booking-owns-seat-inventory
|
||||
title: The Booking API owns the seat inventory
|
||||
status: accepted
|
||||
date: 2026-09-22
|
||||
elements: [booking, db, possync, pos]
|
||||
diagrams: [0500-building-blocks/level-1.archify]
|
||||
context: >
|
||||
The box-office POS and the web shop both sold seats from their
|
||||
own copy of the seat map. Twice a month a seat was sold at the
|
||||
counter and online.
|
||||
decision: >
|
||||
The Tickets DB is the only seat inventory. The POS asks POS Sync
|
||||
for a seat before it prints a ticket; POS Sync writes the sale
|
||||
through the same rules as online orders.
|
||||
consequences: >
|
||||
The counter needs the network to sell; a fallback block of
|
||||
reserved seats per show covers outages. The POS vendor adapter
|
||||
is ours to maintain.
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: signed-qr-codes
|
||||
title: Signed QR codes checked offline at the door
|
||||
status: accepted
|
||||
date: 2026-10-06
|
||||
elements: [ticketing, scanner]
|
||||
diagrams: [0600-runtime/check-in.archify]
|
||||
context: >
|
||||
Cinema Wi-Fi drops when a full hall arrives. A scanner that
|
||||
needs the server for every ticket stops the queue.
|
||||
decision: >
|
||||
The Ticket Service signs each QR payload (ticket id, show,
|
||||
seat) with a private key. The scanner app verifies it with the
|
||||
public key and queues check-ins until it is online again.
|
||||
consequences: >
|
||||
A forwarded ticket scans twice while offline; the second scan
|
||||
is flagged when the queue syncs. Key rotation needs an app
|
||||
update window.
|
||||
@@ -0,0 +1,91 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: cinema-tickets
|
||||
name: Cinema Tickets
|
||||
description: >
|
||||
An arc42 example: seat booking for the fictional Lantern Cinemas.
|
||||
1 Requirements, quality goals, stakeholders; 2 Constraints;
|
||||
4 Solution strategy; 11 Risks; 12 Glossary: cards on this overview.
|
||||
3 Context: this overview (business) and 0300-context (technical).
|
||||
5 Building blocks: 0500-building-blocks, black boxes in registry/.
|
||||
6 Runtime: 0600-runtime. 7 Deployment: 0700-deployment.
|
||||
8 Crosscutting concepts: card on 0500-building-blocks/level-1.
|
||||
9 Decisions: decisions/. 10 Quality: 1000-quality.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Cinema Tickets - business context
|
||||
output: main.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: usher
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: tickets
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: psp
|
||||
pos: [540, 60]
|
||||
size: [150, 60]
|
||||
- ref: mail
|
||||
pos: [540, 160]
|
||||
size: [150, 60]
|
||||
- ref: pos
|
||||
pos: [540, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: customer, to: tickets, label: books seats, variant: emphasis }
|
||||
- { from: usher, to: tickets, label: checks in }
|
||||
- { from: tickets, to: psp, label: payments, variant: security }
|
||||
- { from: tickets, to: mail, label: tickets }
|
||||
- { from: tickets, to: pos, label: seat sales }
|
||||
cards:
|
||||
- dot: orange
|
||||
title: 1.1 Requirements
|
||||
items:
|
||||
- "Browse shows and pick seats on a seat map"
|
||||
- "Hold seats, pay online, get QR tickets by e-mail"
|
||||
- "Ushers check tickets in at the door"
|
||||
- "Box-office POS sells from the same seats"
|
||||
- dot: cyan
|
||||
title: 1.2 Quality goals
|
||||
items:
|
||||
- "No seat is ever sold twice"
|
||||
- "Checkout under 2 s at Friday peak"
|
||||
- "Door check-in works offline"
|
||||
- dot: amber
|
||||
title: 1.3 Stakeholders
|
||||
items:
|
||||
- "Moviegoer: quick booking, tickets on the phone"
|
||||
- "Usher: fast scans even without Wi-Fi"
|
||||
- "Box-office staff: one seat map with online sales"
|
||||
- "Lantern Cinemas management: fewer refunds, low cost"
|
||||
- dot: slate
|
||||
title: 2 Constraints
|
||||
items:
|
||||
- "Existing box-office POS stays"
|
||||
- "Card data only at the provider"
|
||||
- "Team of four; hosted in the EU"
|
||||
- dot: emerald
|
||||
title: 4 Solution strategy
|
||||
items:
|
||||
- "Node.js, PostgreSQL, Redis on managed EU k8s: fits a team of four"
|
||||
- "Three services by job: booking, tickets, POS sync"
|
||||
- "No double sale: Booking API sole seat owner (booking-owns-seat-inventory)"
|
||||
- "Checkout speed: Redis holds with TTL (seat-holds-with-ttl)"
|
||||
- "Offline check-in: signed QR codes (signed-qr-codes)"
|
||||
- dot: rose
|
||||
title: 11 Risks and technical debt
|
||||
items:
|
||||
- "Risk: POS vendor API is slow and undocumented"
|
||||
- "Risk: forwarded QR e-mails, first scan wins"
|
||||
- "Debt: POS Sync adapter has no automated tests"
|
||||
- "Debt: QR key rotation needs an app release"
|
||||
- dot: violet
|
||||
title: 12 Glossary
|
||||
items:
|
||||
- "Show: one screening of a film in a hall"
|
||||
- "Hold: a seat locked for one customer"
|
||||
- "Check-in: first valid scan of a ticket"
|
||||
- "POS: point-of-sale till at the box office"
|
||||
@@ -0,0 +1,72 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
web:
|
||||
type: frontend
|
||||
label: Web Shop
|
||||
sublabel: React
|
||||
brand: react
|
||||
description: >
|
||||
Responsibility: programme, seat map and checkout in the browser.
|
||||
Interfaces: provides the web UI; requires Booking API (JSON)
|
||||
and the provider's hosted checkout page.
|
||||
mobile:
|
||||
type: frontend
|
||||
label: Mobile App
|
||||
sublabel: iOS and Android
|
||||
description: >
|
||||
Responsibility: the web shop's booking flow plus a ticket wallet.
|
||||
Interfaces: provides the iOS/Android UI; requires Booking API
|
||||
(JSON).
|
||||
scanner:
|
||||
type: frontend
|
||||
label: Scanner App
|
||||
sublabel: door phones
|
||||
description: >
|
||||
Responsibility: reads QR codes at the door, checks signatures
|
||||
offline. Interfaces: provides the usher UI; requires Ticket
|
||||
Service (POST /checkins), queued while offline.
|
||||
booking:
|
||||
type: backend
|
||||
label: Booking API
|
||||
sublabel: Node.js
|
||||
brand: node-js
|
||||
description: >
|
||||
Responsibility: shows, seat maps, holds and orders; the only
|
||||
writer of the seat inventory. Interfaces: provides REST JSON to
|
||||
the apps and the payment webhook; requires Seat Locks, Tickets
|
||||
DB, payment provider, Ticket Service.
|
||||
diagram: 0500-building-blocks/booking.archify
|
||||
ticketing:
|
||||
type: backend
|
||||
label: Ticket Service
|
||||
sublabel: QR, check-in
|
||||
description: >
|
||||
Responsibility: issues signed QR tickets, mails them and records
|
||||
check-ins. Interfaces: provides issue and POST /checkins;
|
||||
requires Tickets DB and the e-mail service (SMTP).
|
||||
possync:
|
||||
type: backend
|
||||
label: POS Sync
|
||||
sublabel: adapter
|
||||
description: >
|
||||
Responsibility: exchanges seat sales with the box-office POS.
|
||||
Interfaces: provides a REST adapter to the POS over VPN;
|
||||
requires Tickets DB (SQL).
|
||||
seatlock:
|
||||
type: database
|
||||
label: Seat Locks
|
||||
sublabel: Redis
|
||||
brand: redis
|
||||
description: >
|
||||
Responsibility: short-lived seat holds with a TTL while the
|
||||
customer pays. Interfaces: provides Redis SET NX/GET/DEL to the
|
||||
Booking API; requires nothing.
|
||||
db:
|
||||
type: database
|
||||
label: Tickets DB
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: >
|
||||
Responsibility: shows, seats, orders and tickets; the source of
|
||||
truth. Interfaces: provides SQL to Booking API, Ticket Service
|
||||
and POS Sync; requires nothing.
|
||||
@@ -0,0 +1,14 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
customer:
|
||||
type: external
|
||||
label: Moviegoer
|
||||
sublabel: web and mobile
|
||||
description: >
|
||||
Picks a show, holds seats and pays; gets the tickets by e-mail.
|
||||
usher:
|
||||
type: external
|
||||
label: Usher
|
||||
sublabel: door staff
|
||||
description: >
|
||||
Scans tickets at the auditorium door with the scanner app.
|
||||
@@ -0,0 +1,30 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
tickets:
|
||||
type: cloud
|
||||
label: Cinema Tickets
|
||||
sublabel: the system
|
||||
description: >
|
||||
Online booking, seat holds, e-mail tickets and door check-in
|
||||
for the five cinemas of the fictional Lantern Cinemas chain.
|
||||
diagram: 0500-building-blocks/level-1.archify
|
||||
psp:
|
||||
type: external
|
||||
label: Payment provider
|
||||
sublabel: hosted checkout
|
||||
description: >
|
||||
Takes card and wallet payments on its own page; we never see
|
||||
card numbers.
|
||||
pos:
|
||||
type: external
|
||||
label: Box-office POS
|
||||
sublabel: in each cinema
|
||||
description: >
|
||||
Till system at the counter; sells walk-in seats from the same
|
||||
seat map.
|
||||
mail:
|
||||
type: external
|
||||
label: E-mail service
|
||||
sublabel: transactional
|
||||
description: >
|
||||
Delivers ticket e-mails with QR codes and receipts.
|
||||
@@ -0,0 +1,45 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Technical context
|
||||
output: technical-context.html
|
||||
decisions: [mqtt-with-mutual-tls]
|
||||
components:
|
||||
- ref: homeowner
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: ops-engineer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: installer
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: telemetry-platform
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: heat-pump
|
||||
pos: [540, 60]
|
||||
size: [130, 60]
|
||||
- ref: push-service
|
||||
pos: [540, 160]
|
||||
size: [130, 60]
|
||||
- ref: service-desk
|
||||
pos: [540, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: homeowner, to: telemetry-platform, label: HTTPS }
|
||||
- { from: ops-engineer, to: telemetry-platform, label: HTTPS, variant: security }
|
||||
- { from: installer, to: telemetry-platform, label: HTTPS }
|
||||
- { from: heat-pump, to: telemetry-platform, label: MQTT 8883, variant: emphasis, decisions: [mqtt-with-mutual-tls] }
|
||||
- { from: telemetry-platform, to: push-service, label: HTTP/2 }
|
||||
- { from: telemetry-platform, to: service-desk, label: webhook }
|
||||
cards:
|
||||
- dot: cyan
|
||||
title: 3 Domain interfaces
|
||||
items:
|
||||
- "Heat pump: in telemetry and fault codes; out none"
|
||||
- "Homeowner: in app requests; out status, history, alerts"
|
||||
- "Ops engineer: in queries; out fleet dashboards"
|
||||
- "Installer: in device serial; out device certificate"
|
||||
- "Push service: out alert notifications"
|
||||
- "Service desk: out tickets for faults needing a visit"
|
||||
@@ -0,0 +1,50 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Ingest, level 2
|
||||
output: ingest.html
|
||||
components:
|
||||
- ref: mqtt-broker
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- id: decoder
|
||||
type: backend
|
||||
label: Decoder
|
||||
sublabel: payload v1, v2
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- id: validator
|
||||
type: backend
|
||||
label: Validator
|
||||
sublabel: ranges, clock
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- id: writer
|
||||
type: backend
|
||||
label: Batch writer
|
||||
sublabel: 500 rows
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
- id: dead-letter
|
||||
type: database
|
||||
label: Dead letters
|
||||
sublabel: rejected readings
|
||||
pos: [460, 310]
|
||||
size: [130, 60]
|
||||
- ref: tsdb
|
||||
pos: [880, 160]
|
||||
size: [130, 60]
|
||||
- ref: rules
|
||||
pos: [670, 310]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Ingest
|
||||
wraps: [decoder, validator, writer, dead-letter]
|
||||
connections:
|
||||
- { from: mqtt-broker, to: decoder, label: messages, variant: emphasis }
|
||||
- { from: decoder, to: validator, label: readings, variant: emphasis }
|
||||
- { from: validator, to: writer, label: valid, variant: emphasis }
|
||||
- { from: validator, to: dead-letter, label: invalid, variant: dashed, labelAt: [525, 234] }
|
||||
- { from: writer, to: tsdb, label: COPY, variant: emphasis }
|
||||
- { from: writer, to: rules, label: stream, labelAt: [735, 234] }
|
||||
@@ -0,0 +1,80 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Building blocks, level 1
|
||||
output: level-1.html
|
||||
decisions: [timescaledb-for-telemetry]
|
||||
components:
|
||||
- ref: heat-pump
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: installer
|
||||
pos: [40, 360]
|
||||
size: [130, 60]
|
||||
- ref: mqtt-broker
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
decisions: [mqtt-with-mutual-tls]
|
||||
- ref: provisioning
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- ref: rules
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: ingest
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: device-registry
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
- ref: tsdb
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
- ref: device-api
|
||||
pos: [670, 360]
|
||||
size: [130, 60]
|
||||
- ref: ops-dashboard
|
||||
pos: [880, 160]
|
||||
size: [130, 60]
|
||||
- ref: customer-app
|
||||
pos: [880, 360]
|
||||
size: [130, 60]
|
||||
- ref: push-service
|
||||
pos: [1090, 60]
|
||||
size: [130, 60]
|
||||
- ref: ops-engineer
|
||||
pos: [1090, 160]
|
||||
size: [130, 60]
|
||||
- ref: homeowner
|
||||
pos: [1090, 360]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: IoT Telemetry
|
||||
wraps: [mqtt-broker, provisioning, rules, ingest, device-registry, tsdb, device-api, ops-dashboard, customer-app]
|
||||
connections:
|
||||
- { from: heat-pump, to: mqtt-broker, label: publish, variant: emphasis, decisions: [mqtt-with-mutual-tls] }
|
||||
- { from: mqtt-broker, to: ingest, label: subscribe, variant: emphasis }
|
||||
- { from: ingest, to: tsdb, label: write, variant: emphasis }
|
||||
- { from: ingest, to: rules, label: stream }
|
||||
- { from: ingest, to: device-registry, label: lookup, labelAt: [525, 234] }
|
||||
- { from: rules, to: push-service, label: alert }
|
||||
- { from: installer, to: provisioning, label: register }
|
||||
- { from: provisioning, to: mqtt-broker, label: certs, variant: security }
|
||||
- { from: provisioning, to: device-registry, label: store }
|
||||
- { from: device-api, to: tsdb, label: read }
|
||||
- { from: device-api, to: device-registry, label: read }
|
||||
- { from: tsdb, to: ops-dashboard, label: query }
|
||||
- { from: customer-app, to: device-api, label: REST }
|
||||
- { from: ops-engineer, to: ops-dashboard, label: views }
|
||||
- { from: homeowner, to: customer-app, label: uses }
|
||||
cards:
|
||||
- dot: amber
|
||||
title: 8 Crosscutting concepts
|
||||
items:
|
||||
- Device identity, one X.509 certificate per device
|
||||
- Security, OIDC login for people, mTLS for devices
|
||||
- Persistence, PostgreSQL dialect for all stores
|
||||
- Idempotent writes, keyed by device id and timestamp
|
||||
- Error handling, invalid readings to dead letters
|
||||
- Observability, metrics and traces on every service
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
diagram_type: sequence
|
||||
meta:
|
||||
title: Device provisioning
|
||||
output: device-provisioning.html
|
||||
decisions: [mqtt-with-mutual-tls]
|
||||
participants:
|
||||
- { ref: installer }
|
||||
- { ref: provisioning }
|
||||
- { ref: device-registry, label: Devices }
|
||||
- { ref: heat-pump }
|
||||
- { ref: mqtt-broker }
|
||||
messages:
|
||||
- { from: installer, to: provisioning, y: 160, label: register serial, variant: emphasis }
|
||||
- { from: provisioning, to: device-registry, y: 210, label: store device }
|
||||
- { from: provisioning, to: installer, y: 260, label: certificate, variant: return }
|
||||
- { from: installer, to: heat-pump, y: 310, label: load certificate }
|
||||
- { from: heat-pump, to: mqtt-broker, y: 360, label: connect mTLS, variant: emphasis }
|
||||
- { from: mqtt-broker, to: provisioning, y: 410, label: check revocation }
|
||||
- { from: mqtt-broker, to: heat-pump, y: 460, label: CONNACK, variant: return }
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
diagram_type: sequence
|
||||
meta:
|
||||
title: Fault reading to alert
|
||||
output: telemetry-to-alert.html
|
||||
participants:
|
||||
- { ref: heat-pump }
|
||||
- { ref: mqtt-broker }
|
||||
- { ref: ingest }
|
||||
- { ref: tsdb, label: TSDB }
|
||||
- { ref: rules }
|
||||
- { ref: push-service }
|
||||
messages:
|
||||
- { from: heat-pump, to: mqtt-broker, y: 160, label: publish fault, variant: emphasis }
|
||||
- { from: mqtt-broker, to: ingest, y: 210, label: deliver QoS 1 }
|
||||
- { from: ingest, to: tsdb, y: 260, label: write batch }
|
||||
- { from: tsdb, to: ingest, y: 310, label: committed, variant: return }
|
||||
- { from: ingest, to: mqtt-broker, y: 360, label: ack, variant: return }
|
||||
- { from: ingest, to: rules, y: 410, label: reading }
|
||||
- { from: rules, to: push-service, y: 460, label: send alert, variant: emphasis }
|
||||
@@ -0,0 +1,67 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Deployment, production
|
||||
output: production.html
|
||||
decisions: [timescaledb-for-telemetry]
|
||||
components:
|
||||
- ref: heat-pump
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: mqtt-broker
|
||||
pos: [280, 160]
|
||||
size: [130, 60]
|
||||
- ref: ingest
|
||||
pos: [490, 160]
|
||||
size: [130, 60]
|
||||
- ref: rules
|
||||
pos: [490, 60]
|
||||
size: [130, 60]
|
||||
- ref: provisioning
|
||||
pos: [280, 260]
|
||||
size: [130, 60]
|
||||
- ref: device-api
|
||||
pos: [490, 260]
|
||||
size: [130, 60]
|
||||
- ref: tsdb
|
||||
pos: [760, 160]
|
||||
size: [130, 60]
|
||||
- ref: device-registry
|
||||
pos: [760, 260]
|
||||
size: [130, 60]
|
||||
- ref: ops-dashboard
|
||||
pos: [1030, 160]
|
||||
size: [130, 60]
|
||||
- ref: customer-app
|
||||
pos: [490, 440]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Kubernetes cluster, eu-central
|
||||
wraps: [mqtt-broker, ingest, rules, provisioning, device-api]
|
||||
- kind: security-group
|
||||
label: Managed DB zone
|
||||
wraps: [tsdb, device-registry]
|
||||
- kind: region
|
||||
label: Ops network, managed Grafana
|
||||
wraps: [ops-dashboard]
|
||||
- kind: region
|
||||
label: Homeowner phones
|
||||
wraps: [customer-app]
|
||||
connections:
|
||||
- { from: heat-pump, to: mqtt-broker, label: "8883", variant: emphasis }
|
||||
- { from: mqtt-broker, to: ingest, label: in-cluster }
|
||||
- { from: ingest, to: tsdb, label: "5432", variant: emphasis }
|
||||
- { from: ingest, to: rules, label: gRPC }
|
||||
- { from: device-api, to: device-registry, label: "5432" }
|
||||
- { from: tsdb, to: ops-dashboard, label: read-only }
|
||||
- { from: customer-app, to: device-api, label: HTTPS }
|
||||
cards:
|
||||
- dot: slate
|
||||
title: 7 Mapping
|
||||
items:
|
||||
- "mqtt-broker, ingest, rules -> Kubernetes cluster, eu-central"
|
||||
- "provisioning, device-api -> Kubernetes cluster, eu-central"
|
||||
- "tsdb, device-registry -> managed DB zone, eu-central"
|
||||
- "ops-dashboard -> ops network, managed Grafana"
|
||||
- "customer-app -> homeowner phones, app stores"
|
||||
@@ -0,0 +1,75 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Quality tree
|
||||
output: quality-tree.html
|
||||
components:
|
||||
- id: quality
|
||||
type: cloud
|
||||
label: Quality
|
||||
pos: [40, 240]
|
||||
size: [130, 100]
|
||||
- id: reliability
|
||||
type: backend
|
||||
label: Reliability
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- id: performance
|
||||
type: backend
|
||||
label: Performance
|
||||
pos: [250, 210]
|
||||
size: [130, 60]
|
||||
- id: security
|
||||
type: security
|
||||
label: Security
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- id: operability
|
||||
type: backend
|
||||
label: Operability
|
||||
pos: [250, 460]
|
||||
size: [130, 60]
|
||||
- id: q-no-loss
|
||||
type: frontend
|
||||
label: Broker restart, no loss
|
||||
pos: [460, 60]
|
||||
size: [180, 60]
|
||||
- id: q-alert-60s
|
||||
type: frontend
|
||||
label: Fault alert in 60 s
|
||||
pos: [460, 160]
|
||||
size: [180, 60]
|
||||
- id: q-peak-load
|
||||
type: frontend
|
||||
label: 4,000 readings/s
|
||||
pos: [460, 260]
|
||||
size: [180, 60]
|
||||
- id: q-revoke
|
||||
type: frontend
|
||||
label: Revoke cert in 15 min
|
||||
pos: [460, 360]
|
||||
size: [180, 60]
|
||||
- id: q-new-device
|
||||
type: frontend
|
||||
label: New device in 10 min
|
||||
pos: [460, 460]
|
||||
size: [180, 60]
|
||||
connections:
|
||||
- { from: quality, to: reliability }
|
||||
- { from: quality, to: performance }
|
||||
- { from: quality, to: security }
|
||||
- { from: quality, to: operability }
|
||||
- { from: reliability, to: q-no-loss }
|
||||
- { from: performance, to: q-alert-60s }
|
||||
- { from: performance, to: q-peak-load }
|
||||
- { from: security, to: q-revoke }
|
||||
- { from: operability, to: q-new-device }
|
||||
cards:
|
||||
- dot: emerald
|
||||
title: 10 Quality scenarios
|
||||
items:
|
||||
- "Broker restarts under load: devices reconnect, no accepted reading lost"
|
||||
- "Heat pump sends a fault code: homeowner gets a push within 60 s"
|
||||
- "Fleet peaks at 4,000 readings/s: p95 ingest lag stays under 5 s"
|
||||
- "A device certificate is stolen: ops revoke it, broker refuses it within 15 min"
|
||||
- "Installer registers a new heat pump: data visible in the app within 10 min"
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: mqtt-with-mutual-tls
|
||||
title: MQTT with mutual TLS for device traffic
|
||||
status: accepted
|
||||
date: 2026-08-12
|
||||
elements: [heat-pump, mqtt-broker, provisioning]
|
||||
diagrams: [0300-context/technical-context.archify, 0500-building-blocks/level-1.archify]
|
||||
context: >
|
||||
Heat pumps sit behind home routers on slow and flaky links.
|
||||
The first prototype polled an HTTPS endpoint and drained
|
||||
mobile data plans; shared API keys leaked from one device.
|
||||
decision: >
|
||||
Devices publish over MQTT 3.1.1 on port 8883. Each device
|
||||
has its own client certificate issued by Provisioning;
|
||||
the broker rejects any connection without one.
|
||||
consequences: >
|
||||
Small messages and one long-lived connection per device.
|
||||
Certificates must be rotated and revoked, so Provisioning
|
||||
is a critical building block. Installers need a step to
|
||||
register the device before it can connect.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: timescaledb-for-telemetry
|
||||
title: TimescaleDB for telemetry
|
||||
status: accepted
|
||||
date: 2026-09-03
|
||||
elements: [tsdb, ingest, ops-dashboard]
|
||||
diagrams: [0500-building-blocks/level-1.archify, 0700-deployment/production.archify]
|
||||
context: >
|
||||
The fleet sends about 4,000 readings a second. A plain
|
||||
PostgreSQL table grew too fast and dashboards slowed down
|
||||
after a few weeks of data.
|
||||
decision: >
|
||||
Store telemetry in TimescaleDB hypertables with hourly
|
||||
continuous aggregates. Raw data is kept 90 days, rollups
|
||||
10 years. The team already runs PostgreSQL.
|
||||
consequences: >
|
||||
SQL stays the query language for the API and the
|
||||
dashboard. Retention jobs must be monitored. Very high
|
||||
device counts may need sharding later (see risk R1).
|
||||
@@ -0,0 +1,97 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: iot-telemetry
|
||||
name: IoT Telemetry
|
||||
description: >
|
||||
An arc42 example: a fictional heat pump maker collects device telemetry
|
||||
over MQTT, stores it, raises alerts and shows it to operations and customers.
|
||||
Where the 12 arc42 sections live:
|
||||
1 introduction and goals, 2 constraints, 4 solution strategy,
|
||||
11 risks and 12 glossary are cards on this overview;
|
||||
3 business context is this overview, technical context in 0300-context;
|
||||
5 building blocks in 0500-building-blocks (black boxes in the registry);
|
||||
6 runtime in 0600-runtime; 7 deployment in 0700-deployment;
|
||||
8 crosscutting concepts a card on 0500-building-blocks/level-1;
|
||||
9 decisions in decisions/; 10 quality tree in 1000-quality.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Business context
|
||||
output: main.html
|
||||
components:
|
||||
- ref: homeowner
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: ops-engineer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: installer
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: telemetry-platform
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: heat-pump
|
||||
pos: [540, 60]
|
||||
size: [130, 60]
|
||||
- ref: push-service
|
||||
pos: [540, 160]
|
||||
size: [130, 60]
|
||||
- ref: service-desk
|
||||
pos: [540, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: homeowner, to: telemetry-platform, label: checks }
|
||||
- { from: ops-engineer, to: telemetry-platform, label: monitors }
|
||||
- { from: installer, to: telemetry-platform, label: registers }
|
||||
- { from: heat-pump, to: telemetry-platform, label: telemetry, variant: emphasis }
|
||||
- { from: telemetry-platform, to: push-service, label: alerts }
|
||||
- { from: telemetry-platform, to: service-desk, label: tickets }
|
||||
cards:
|
||||
- dot: orange
|
||||
title: 1.1 Requirements
|
||||
items:
|
||||
- Collect telemetry from every heat pump every 30 s
|
||||
- Alert homeowner and ops on fault codes
|
||||
- Show live and historic data in app and dashboard
|
||||
- Register and provision new devices on site
|
||||
- dot: cyan
|
||||
title: 1.2 Quality goals
|
||||
items:
|
||||
- Reliability, no telemetry lost once accepted
|
||||
- Timeliness, alert within 60 s of a fault
|
||||
- Security, every device authenticated
|
||||
- dot: emerald
|
||||
title: 1.3 Stakeholders
|
||||
items:
|
||||
- "Homeowner: sees status, gets alerts quickly"
|
||||
- "Ops engineer: fleet view, few false alarms"
|
||||
- "Installer: register a device in minutes"
|
||||
- "Firmware partner: stable MQTT contract"
|
||||
- dot: slate
|
||||
title: 2 Constraints
|
||||
items:
|
||||
- Devices speak MQTT only; firmware by a partner
|
||||
- Data stays in one EU region
|
||||
- Team of six; PostgreSQL skills in house
|
||||
- dot: amber
|
||||
title: 4 Solution strategy
|
||||
items:
|
||||
- MQTT with mutual TLS for devices (security, ADR 0001)
|
||||
- TimescaleDB for telemetry, SQL everywhere (ADR 0002)
|
||||
- Ingest acks only after commit (reliability)
|
||||
- Rules engine on the ingest stream (timeliness)
|
||||
- Split by flow, ingest vs read vs provisioning
|
||||
- dot: rose
|
||||
title: 11 Risks and technical debt
|
||||
items:
|
||||
- "Risk: fleet growth beyond one TSDB node"
|
||||
- "Risk: partner firmware delays cert rotation"
|
||||
- "Debt: payload v1 decoder kept for old firmware"
|
||||
- "Debt: rules configured in code, no UI"
|
||||
- dot: violet
|
||||
title: 12 Glossary
|
||||
items:
|
||||
- Telemetry, readings a device sends
|
||||
- Rollup, hourly aggregate of telemetry
|
||||
- Provisioning, giving a device its identity
|
||||
- Fault code, error number from the device
|
||||
@@ -0,0 +1,75 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
mqtt-broker:
|
||||
type: messagebus
|
||||
label: MQTT broker
|
||||
sublabel: mutual TLS
|
||||
description: >
|
||||
Responsibility: terminates device connections, one topic per device.
|
||||
Interfaces: provides MQTT 8883 with mTLS; requires certs and
|
||||
revocation checks from Provisioning.
|
||||
ingest:
|
||||
type: backend
|
||||
label: Ingest
|
||||
sublabel: Go
|
||||
brand: go
|
||||
description: >
|
||||
Responsibility: decodes, validates and stores telemetry.
|
||||
Interfaces: requires MQTT subscription, TSDB, device registry;
|
||||
provides the reading stream to the rules engine.
|
||||
diagram: 0500-building-blocks/ingest.archify
|
||||
tsdb:
|
||||
type: database
|
||||
label: Time series DB
|
||||
sublabel: TimescaleDB
|
||||
brand: postgresql
|
||||
description: >
|
||||
Responsibility: raw telemetry 90 days, hourly rollups 10 years.
|
||||
Interfaces: provides SQL on 5432 to Device API and dashboard;
|
||||
requires batched writes from Ingest.
|
||||
rules:
|
||||
type: backend
|
||||
label: Rules engine
|
||||
sublabel: alerting
|
||||
description: >
|
||||
Responsibility: evaluates thresholds and fault codes, raises alerts.
|
||||
Interfaces: requires the reading stream from Ingest;
|
||||
provides alerts to the push service and service desk.
|
||||
provisioning:
|
||||
type: security
|
||||
label: Provisioning
|
||||
sublabel: device certs
|
||||
description: >
|
||||
Responsibility: registers devices and issues client certificates.
|
||||
Interfaces: provides a REST register call for installers and
|
||||
revocation checks for the broker; requires the device registry.
|
||||
device-registry:
|
||||
type: database
|
||||
label: Device registry
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: >
|
||||
Responsibility: devices, owners, firmware and certificate state.
|
||||
Interfaces: provides SQL on 5432 to Ingest, Provisioning, Device API.
|
||||
device-api:
|
||||
type: backend
|
||||
label: Device API
|
||||
sublabel: REST
|
||||
description: >
|
||||
Responsibility: serves telemetry and device data to the customer app.
|
||||
Interfaces: provides REST over HTTPS; requires TSDB and device registry.
|
||||
ops-dashboard:
|
||||
type: frontend
|
||||
label: Ops dashboard
|
||||
sublabel: Grafana
|
||||
brand: grafana
|
||||
description: >
|
||||
Responsibility: fleet view for the operations team.
|
||||
Interfaces: provides web UI over HTTPS; requires read-only SQL on TSDB.
|
||||
customer-app:
|
||||
type: frontend
|
||||
label: Customer app
|
||||
sublabel: mobile
|
||||
description: >
|
||||
Responsibility: shows the homeowner their heat pump and alerts.
|
||||
Interfaces: provides the mobile UI; requires Device API and push.
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
homeowner:
|
||||
type: external
|
||||
label: Homeowner
|
||||
sublabel: customer app
|
||||
description: >
|
||||
Owns a heat pump; checks temperatures and gets fault alerts.
|
||||
ops-engineer:
|
||||
type: external
|
||||
label: Ops engineer
|
||||
sublabel: operations team
|
||||
description: >
|
||||
Watches the fleet, handles alerts and plans service visits.
|
||||
installer:
|
||||
type: external
|
||||
label: Installer
|
||||
sublabel: field service
|
||||
description: >
|
||||
Mounts heat pumps on site and registers them with the platform.
|
||||
@@ -0,0 +1,27 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
telemetry-platform:
|
||||
type: cloud
|
||||
label: IoT Telemetry
|
||||
sublabel: the system
|
||||
description: >
|
||||
Everything we build and run; broker, ingest, stores, rules and apps.
|
||||
diagram: 0500-building-blocks/level-1.archify
|
||||
heat-pump:
|
||||
type: external
|
||||
label: Heat pump
|
||||
sublabel: field device
|
||||
description: >
|
||||
Sends telemetry every 30 seconds over MQTT; built by a partner.
|
||||
push-service:
|
||||
type: external
|
||||
label: Push service
|
||||
sublabel: mobile push
|
||||
description: >
|
||||
Delivers push notifications to the customer app.
|
||||
service-desk:
|
||||
type: external
|
||||
label: Service desk
|
||||
sublabel: ticketing
|
||||
description: >
|
||||
Opens service tickets for faults that need a visit.
|
||||
@@ -0,0 +1,34 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Technical context
|
||||
output: technical-context.html
|
||||
components:
|
||||
- ref: engineer
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: wiki-admin
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: wiki
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: idp
|
||||
pos: [520, 60]
|
||||
size: [150, 60]
|
||||
- ref: object-store
|
||||
pos: [520, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: engineer, to: wiki, label: HTTPS, variant: emphasis }
|
||||
- { from: wiki-admin, to: wiki, label: kubectl }
|
||||
- { from: wiki, to: idp, label: OIDC, variant: security }
|
||||
- { from: wiki, to: object-store, label: S3 API }
|
||||
cards:
|
||||
- dot: cyan
|
||||
title: 3 Domain interfaces
|
||||
items:
|
||||
- "Engineer: in edits, queries, files; out pages, results"
|
||||
- "Wiki admin: in deploys, restore commands; out status, logs"
|
||||
- "Identity provider: out sign-in code, groups; in token requests"
|
||||
- "Object storage: out attachments, backups; in signed URL reads"
|
||||
@@ -0,0 +1,50 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Building blocks - level 1
|
||||
output: level-1.html
|
||||
decisions: [postgres-full-text-search]
|
||||
components:
|
||||
- ref: engineer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: web
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: api
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
- ref: backup-job
|
||||
pos: [670, 360]
|
||||
size: [130, 60]
|
||||
- ref: idp
|
||||
pos: [670, 60]
|
||||
size: [130, 60]
|
||||
- ref: object-store
|
||||
pos: [880, 160]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Team Wiki
|
||||
wraps: [web, api, db, backup-job]
|
||||
connections:
|
||||
- { from: engineer, to: web, label: HTTPS, variant: emphasis }
|
||||
- { from: web, to: api, label: JSON, variant: emphasis }
|
||||
- { from: api, to: db, label: SQL, labelAt: [525, 234], decisions: [postgres-full-text-search] }
|
||||
- { from: api, to: idp, label: OIDC, variant: security }
|
||||
- { from: api, to: object-store, label: files }
|
||||
- { from: backup-job, to: db, label: dump }
|
||||
- { from: backup-job, to: object-store, label: upload }
|
||||
cards:
|
||||
- dot: amber
|
||||
title: 8 Crosscutting concepts
|
||||
items:
|
||||
- "Security: OIDC session, every request checked against space groups"
|
||||
- "Persistence: PostgreSQL is the only source of truth"
|
||||
- "Errors: API returns problem JSON; the web app shows a retry"
|
||||
- "Logging: JSON to stdout, collected by the cluster"
|
||||
- "Backups: nightly dump to object storage, 30 days"
|
||||
- "Config: environment variables, secrets from the vault"
|
||||
@@ -0,0 +1,55 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Building blocks - level 2, Wiki API
|
||||
output: wiki-api.html
|
||||
components:
|
||||
- ref: web
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- id: auth
|
||||
type: security
|
||||
label: Auth
|
||||
sublabel: OIDC session
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- id: pages
|
||||
type: backend
|
||||
label: Pages
|
||||
sublabel: revisions
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- id: search
|
||||
type: backend
|
||||
label: Search
|
||||
sublabel: full-text
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
decisions: [postgres-full-text-search]
|
||||
- id: attachments
|
||||
type: backend
|
||||
label: Attachments
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- ref: idp
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [460, 210]
|
||||
size: [130, 60]
|
||||
- ref: object-store
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Wiki API
|
||||
wraps: [auth, pages, search, attachments]
|
||||
connections:
|
||||
- { from: web, to: auth, label: sign in, variant: security }
|
||||
- { from: web, to: pages, label: edit, labelAt: [222, 182] }
|
||||
- { from: web, to: search, label: query }
|
||||
- { from: web, to: attachments, label: upload }
|
||||
- { from: auth, to: idp, label: OIDC, variant: security }
|
||||
- { from: pages, to: db, label: SQL }
|
||||
- { from: search, to: db, label: SQL }
|
||||
- { from: attachments, to: object-store, label: S3 }
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
diagram_type: sequence
|
||||
meta:
|
||||
title: Runtime - search and open a page
|
||||
output: search-page.html
|
||||
participants:
|
||||
- ref: engineer
|
||||
- ref: web
|
||||
- ref: api
|
||||
- { ref: db, label: DB }
|
||||
- { ref: object-store, label: Object store }
|
||||
messages:
|
||||
- { from: engineer, to: web, y: 160, label: type query, variant: emphasis }
|
||||
- { from: web, to: api, y: 210, label: GET /search }
|
||||
- { from: api, to: db, y: 260, label: full-text query }
|
||||
- { from: db, to: api, y: 310, label: ranked pages, variant: return }
|
||||
- { from: api, to: web, y: 360, label: results, variant: return }
|
||||
- { from: web, to: api, y: 410, label: GET /pages/id }
|
||||
- { from: api, to: object-store, y: 460, label: signed URL }
|
||||
- { from: api, to: web, y: 510, label: page, variant: return }
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version: 1
|
||||
diagram_type: sequence
|
||||
meta:
|
||||
title: Runtime - sign in
|
||||
output: sign-in.html
|
||||
decisions: [sso-only-via-company-idp]
|
||||
participants:
|
||||
- ref: engineer
|
||||
- ref: web
|
||||
- ref: api
|
||||
- { ref: idp, label: Company IdP }
|
||||
messages:
|
||||
- { from: engineer, to: web, y: 160, label: open wiki, variant: emphasis }
|
||||
- { from: web, to: api, y: 210, label: GET /session }
|
||||
- { from: api, to: web, y: 260, label: redirect, variant: return }
|
||||
- { from: web, to: idp, y: 310, label: OIDC login, variant: security }
|
||||
- { from: idp, to: api, y: 360, label: code, variant: return }
|
||||
- { from: api, to: idp, y: 410, label: token exchange, variant: security }
|
||||
- { from: api, to: web, y: 460, label: session cookie, variant: return }
|
||||
@@ -0,0 +1,60 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Deployment - production
|
||||
output: production.html
|
||||
components:
|
||||
- ref: engineer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- id: ingress
|
||||
type: security
|
||||
label: Ingress
|
||||
sublabel: TLS
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: web
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: api
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
- ref: backup-job
|
||||
pos: [670, 360]
|
||||
size: [130, 60]
|
||||
- ref: idp
|
||||
pos: [460, 460]
|
||||
size: [130, 60]
|
||||
- ref: object-store
|
||||
pos: [880, 460]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Company cluster
|
||||
wraps: [ingress, web, api, db, backup-job]
|
||||
- kind: security-group
|
||||
label: App node pool
|
||||
wraps: [web, api]
|
||||
- kind: security-group
|
||||
label: Data node
|
||||
wraps: [db, backup-job]
|
||||
connections:
|
||||
- { from: engineer, to: ingress, label: HTTPS, variant: emphasis }
|
||||
- { from: ingress, to: web, label: static }
|
||||
- { from: ingress, to: api, label: /api, variant: emphasis }
|
||||
- { from: api, to: db, label: SQL }
|
||||
- { from: api, to: idp, label: OIDC, variant: security, labelAt: [525, 234] }
|
||||
- { from: backup-job, to: db, label: dump }
|
||||
- { from: backup-job, to: object-store, label: S3 }
|
||||
cards:
|
||||
- dot: slate
|
||||
title: 7 Mapping
|
||||
items:
|
||||
- "Web App -> app node pool, nginx pod, namespace team-wiki"
|
||||
- "Wiki API -> app node pool, 2 pods behind the ingress"
|
||||
- "Database -> data node, PostgreSQL stateful set with volume"
|
||||
- "Backup job -> data node, Kubernetes CronJob"
|
||||
- "Attachments, backups -> company object storage buckets"
|
||||
@@ -0,0 +1,77 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Quality tree
|
||||
output: quality-tree.html
|
||||
legend:
|
||||
mode: hidden
|
||||
components:
|
||||
- id: quality
|
||||
type: cloud
|
||||
label: Quality
|
||||
pos: [40, 200]
|
||||
size: [130, 120]
|
||||
- id: usability
|
||||
type: frontend
|
||||
label: Usability
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- id: security
|
||||
type: security
|
||||
label: Security
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- id: reliability
|
||||
type: backend
|
||||
label: Reliability
|
||||
pos: [250, 310]
|
||||
size: [130, 60]
|
||||
- id: maintainability
|
||||
type: backend
|
||||
label: Maintainability
|
||||
pos: [250, 460]
|
||||
size: [130, 60]
|
||||
- id: q-search
|
||||
type: external
|
||||
label: Search under 1 s
|
||||
pos: [460, 60]
|
||||
size: [170, 60]
|
||||
- id: q-leaver
|
||||
type: external
|
||||
label: Leaver out in 5 min
|
||||
pos: [460, 160]
|
||||
size: [170, 60]
|
||||
- id: q-restore
|
||||
type: external
|
||||
label: Restore in 30 min
|
||||
pos: [460, 260]
|
||||
size: [170, 60]
|
||||
- id: q-pod
|
||||
type: external
|
||||
label: Pod back in 1 min
|
||||
pos: [460, 360]
|
||||
size: [170, 60]
|
||||
- id: q-upgrade
|
||||
type: external
|
||||
label: Upgrade in 1 hour
|
||||
pos: [460, 460]
|
||||
size: [170, 60]
|
||||
connections:
|
||||
- { from: quality, to: usability }
|
||||
- { from: quality, to: security }
|
||||
- { from: quality, to: reliability }
|
||||
- { from: quality, to: maintainability }
|
||||
- { from: usability, to: q-search }
|
||||
- { from: security, to: q-leaver }
|
||||
- { from: reliability, to: q-restore }
|
||||
- { from: reliability, to: q-pod }
|
||||
- { from: maintainability, to: q-upgrade }
|
||||
cards:
|
||||
- dot: emerald
|
||||
title: 10 Quality scenarios
|
||||
items:
|
||||
- "Search: an engineer searches 5000 pages; results in under 1 s"
|
||||
- "Leaver: HR disables an account; wiki access ends within 5 minutes"
|
||||
- "Restore: the database is lost; the admin restores last night in 30 minutes"
|
||||
- "Crash: an API pod dies; the cluster restarts it, no data lost, back in 1 minute"
|
||||
- "Upgrade: a new wiki version ships; one admin rolls it out in 1 hour"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: postgres-full-text-search
|
||||
title: PostgreSQL full-text search, no search engine
|
||||
status: accepted
|
||||
date: 2026-10-05
|
||||
elements: [api, db]
|
||||
diagrams: [0500-building-blocks/level-1.archify, 0500-building-blocks/wiki-api.archify]
|
||||
context: >
|
||||
The wiki holds a few thousand pages for one team. A
|
||||
separate search engine would be one more service to run,
|
||||
back up and keep in sync.
|
||||
decision: >
|
||||
Search uses PostgreSQL full-text search on a generated
|
||||
column of each page. The API queries it in the same
|
||||
transaction as the page data.
|
||||
consequences: >
|
||||
Results are never stale and backups cover search too.
|
||||
Ranking is simpler than a search engine. Revisit when
|
||||
the wiki grows past 100000 pages.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: sso-only-via-company-idp
|
||||
title: Sign-in only through the company identity provider
|
||||
status: accepted
|
||||
date: 2026-10-12
|
||||
elements: [idp, api, web]
|
||||
diagrams: [0300-context/technical-context.archify, 0600-runtime/sign-in.archify]
|
||||
context: >
|
||||
Leavers kept access to the old wiki because it had its
|
||||
own passwords. Nobody owned the user list.
|
||||
decision: >
|
||||
The wiki has no local accounts. Users sign in with OIDC
|
||||
at the company identity provider; team groups map to
|
||||
wiki spaces.
|
||||
consequences: >
|
||||
Offboarding removes wiki access at once. The wiki cannot
|
||||
be used when the identity provider is down. The admin
|
||||
needs a break-glass account kept in the vault.
|
||||
@@ -0,0 +1,85 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: team-wiki
|
||||
name: Team Wiki
|
||||
description: >
|
||||
An arc42 example: a small self-hosted wiki for an engineering team.
|
||||
1 Requirements, quality goals, stakeholders; 2 Constraints;
|
||||
4 Solution strategy; 11 Risks; 12 Glossary: cards on this overview.
|
||||
3 Context: this overview (business) and 0300-context (technical).
|
||||
5 Building blocks: 0500-building-blocks, black boxes in registry/.
|
||||
6 Runtime: 0600-runtime. 7 Deployment: 0700-deployment.
|
||||
8 Crosscutting concepts: card on 0500-building-blocks/level-1.
|
||||
9 Decisions: decisions/. 10 Quality: 1000-quality.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Team Wiki - business context
|
||||
output: main.html
|
||||
decisions: [sso-only-via-company-idp]
|
||||
components:
|
||||
- ref: engineer
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: wiki-admin
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: wiki
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: idp
|
||||
pos: [520, 60]
|
||||
size: [150, 60]
|
||||
- ref: object-store
|
||||
pos: [520, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: engineer, to: wiki, label: reads, variant: emphasis }
|
||||
- { from: wiki-admin, to: wiki, label: runs }
|
||||
- { from: wiki, to: idp, label: sign-in, variant: security }
|
||||
- { from: wiki, to: object-store, label: files }
|
||||
cards:
|
||||
- dot: orange
|
||||
title: 1.1 Requirements
|
||||
items:
|
||||
- "Create, edit and version pages in team spaces"
|
||||
- "Full-text search over every page a user may read"
|
||||
- "Attach files to pages"
|
||||
- "Admin restores the wiki from a nightly backup"
|
||||
- dot: cyan
|
||||
title: 1.2 Quality goals
|
||||
items:
|
||||
- "Findability: any page found in two searches"
|
||||
- "Security: only current staff can read"
|
||||
- "Operability: one person runs it in an hour a week"
|
||||
- dot: amber
|
||||
title: 1.3 Stakeholders
|
||||
items:
|
||||
- "Engineer: fast search, simple editor"
|
||||
- "Wiki admin: little routine work, easy restore"
|
||||
- "Security officer: no access after leaving"
|
||||
- "Team lead: one place for team knowledge"
|
||||
- dot: slate
|
||||
title: 2 Constraints
|
||||
items:
|
||||
- "Runs on the company Kubernetes cluster"
|
||||
- "Sign-in only via the company identity provider"
|
||||
- dot: emerald
|
||||
title: 4 Solution strategy
|
||||
items:
|
||||
- "React, Node.js API, PostgreSQL: one stack the team knows"
|
||||
- "Three blocks plus a backup job: operability"
|
||||
- "Findability: PostgreSQL full-text search (postgres-full-text-search)"
|
||||
- "Security: OIDC at the company IdP only (sso-only-via-company-idp)"
|
||||
- "Operability: files and backups in company object storage"
|
||||
- dot: rose
|
||||
title: 11 Risks and technical debt
|
||||
items:
|
||||
- "Risk: identity provider outage blocks all sign-ins"
|
||||
- "Risk: backups untested until a restore drill is run"
|
||||
- "Debt: attachments are not virus-scanned yet"
|
||||
- dot: violet
|
||||
title: 12 Glossary
|
||||
items:
|
||||
- "Space: pages of one team, mapped to a group"
|
||||
- "Revision: one saved version of a page"
|
||||
- "Attachment: a file stored beside a page"
|
||||
@@ -0,0 +1,37 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
web:
|
||||
type: frontend
|
||||
label: Web App
|
||||
sublabel: React
|
||||
brand: react
|
||||
description: >
|
||||
Responsibility: page editor, viewer and search box in the
|
||||
browser. Interfaces: provides the UI over HTTPS; requires
|
||||
the Wiki API (JSON).
|
||||
api:
|
||||
type: backend
|
||||
label: Wiki API
|
||||
sublabel: REST
|
||||
description: >
|
||||
Responsibility: pages, search, attachments and access checks;
|
||||
the only writer of the database. Interfaces: provides REST
|
||||
JSON; requires the IdP (OIDC), the database (SQL), object storage (S3).
|
||||
diagram: 0500-building-blocks/wiki-api.archify
|
||||
db:
|
||||
type: database
|
||||
label: Database
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: >
|
||||
Responsibility: pages, revisions, users and the full-text
|
||||
index; the source of truth. Interfaces: provides SQL to the
|
||||
API and dumps to the backup job.
|
||||
backup-job:
|
||||
type: backend
|
||||
label: Backup job
|
||||
sublabel: nightly
|
||||
description: >
|
||||
Responsibility: dumps the database at 02:00, keeps 30 days.
|
||||
Interfaces: requires the database (pg_dump) and the backup
|
||||
bucket (S3); provides none.
|
||||
@@ -0,0 +1,15 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
engineer:
|
||||
type: external
|
||||
label: Engineer
|
||||
sublabel: team member
|
||||
description: >
|
||||
Reads and edits pages, searches, attaches files; signs in
|
||||
with the company account.
|
||||
wiki-admin:
|
||||
type: external
|
||||
label: Wiki admin
|
||||
sublabel: platform team
|
||||
description: >
|
||||
Runs the wiki, restores backups and manages spaces.
|
||||
@@ -0,0 +1,24 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
wiki:
|
||||
type: cloud
|
||||
label: Team Wiki
|
||||
sublabel: the system
|
||||
description: >
|
||||
Everything we build and run; web app, API, database and
|
||||
the backup job.
|
||||
diagram: 0500-building-blocks/level-1.archify
|
||||
idp:
|
||||
type: security
|
||||
label: Identity provider
|
||||
sublabel: company SSO
|
||||
description: >
|
||||
The company identity provider; signs users in over OIDC
|
||||
and owns team membership.
|
||||
object-store:
|
||||
type: external
|
||||
label: Object storage
|
||||
sublabel: S3-compatible
|
||||
description: >
|
||||
Company object storage; holds attachments and nightly
|
||||
backups in separate buckets.
|
||||
@@ -0,0 +1,36 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Business actors and services
|
||||
output: business-services.html
|
||||
components:
|
||||
- ref: hr
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: manager
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: service-desk
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: onboard
|
||||
pos: [250, 160]
|
||||
size: [150, 60]
|
||||
diagram: 0100-business/onboard-employee.archify
|
||||
- ref: onboarding-svc
|
||||
pos: [480, 60]
|
||||
size: [130, 60]
|
||||
- ref: workplace-svc
|
||||
pos: [480, 260]
|
||||
size: [130, 60]
|
||||
- ref: new-hire
|
||||
pos: [690, 160]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: hr, to: onboard, label: assigned }
|
||||
- { from: manager, to: onboard, label: assigned }
|
||||
- { from: service-desk, to: onboard, label: assigned }
|
||||
- { from: onboard, to: onboarding-svc, label: realizes }
|
||||
- { from: onboard, to: workplace-svc, label: realizes }
|
||||
- { from: onboarding-svc, to: new-hire, label: serves, variant: emphasis }
|
||||
- { from: workplace-svc, to: new-hire, label: serves }
|
||||
@@ -0,0 +1,25 @@
|
||||
schema_version: 2
|
||||
diagram_type: workflow
|
||||
meta:
|
||||
title: Onboard Employee
|
||||
output: onboard-employee.html
|
||||
viewBox: [1040, 660]
|
||||
lanes:
|
||||
- { id: hr-lane, label: HR }
|
||||
- { id: manager-lane, label: Hiring manager }
|
||||
- { id: desk-lane, label: IT service desk }
|
||||
- { id: hire-lane, label: New hire }
|
||||
mainPath: [contract-signed, record, request, create-accounts, issue-laptop, first-day]
|
||||
nodes:
|
||||
- { ref: contract-signed, lane: hr-lane, col: 0, width: 120 }
|
||||
- { id: record, lane: hr-lane, col: 1, type: backend, label: Create record, tag: Business Process }
|
||||
- { id: request, lane: manager-lane, col: 2, type: frontend, label: Request access, tag: Business Process }
|
||||
- { ref: create-accounts, lane: desk-lane, col: 3, width: 120, decisions: [hr-system-drives-identity] }
|
||||
- { ref: issue-laptop, lane: desk-lane, col: 4, decisions: [zero-touch-laptops] }
|
||||
- { ref: first-day, lane: hire-lane, col: 5 }
|
||||
edges:
|
||||
- { from: contract-signed, to: record, label: triggers }
|
||||
- { from: record, to: request, label: notifies }
|
||||
- { from: request, to: create-accounts, label: ticket }
|
||||
- { from: create-accounts, to: issue-laptop, label: then }
|
||||
- { from: issue-laptop, to: first-day, label: hand over }
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Application components, services and data
|
||||
output: application-components.html
|
||||
components:
|
||||
- ref: provisioning-svc
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- ref: portal
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: device-svc
|
||||
pos: [670, 60]
|
||||
size: [130, 60]
|
||||
- ref: hr-system
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: iam
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: itsm
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: mdm-app
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
- ref: employee-record
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: account
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: asset
|
||||
pos: [460, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: iam, to: provisioning-svc, label: realizes }
|
||||
- { from: itsm, to: portal, label: realizes }
|
||||
- { from: mdm-app, to: device-svc, label: realizes }
|
||||
- { from: hr-system, to: employee-record, label: writes, labelAt: [105, 234] }
|
||||
- { from: iam, to: account, label: writes, labelAt: [315, 234] }
|
||||
- { from: itsm, to: asset, label: writes, labelAt: [525, 234] }
|
||||
- { from: mdm-app, to: asset, label: reads }
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Application cooperation
|
||||
output: application-cooperation.html
|
||||
decisions: [hr-system-drives-identity]
|
||||
components:
|
||||
- ref: hr-system
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: iam
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
diagram: 0200-application/application-components.archify
|
||||
- ref: itsm
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: collab
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: mdm-app
|
||||
pos: [460, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: hr-system, to: iam, label: joiner event, variant: emphasis, decisions: [hr-system-drives-identity] }
|
||||
- { from: hr-system, to: itsm, label: laptop request }
|
||||
- { from: iam, to: collab, label: provisions }
|
||||
- { from: iam, to: mdm-app, label: users }
|
||||
- { from: itsm, to: mdm-app, label: assigns }
|
||||
@@ -0,0 +1,42 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Technology layer
|
||||
output: infrastructure.html
|
||||
decisions: [zero-touch-laptops]
|
||||
components:
|
||||
- ref: hr-saas
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: sso
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- ref: directory
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: itsm-saas
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: mdm-saas
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: laptop
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
- ref: office-net
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: SaaS tenants
|
||||
wraps: [hr-saas, sso, directory, itsm-saas, mdm-saas]
|
||||
- kind: security-group
|
||||
label: Office
|
||||
wraps: [laptop, office-net]
|
||||
connections:
|
||||
- { from: directory, to: sso, label: realizes }
|
||||
- { from: sso, to: hr-saas, label: serves }
|
||||
- { from: sso, to: itsm-saas, label: serves }
|
||||
- { from: directory, to: mdm-saas, label: devices, labelAt: [525, 134] }
|
||||
- { from: mdm-saas, to: laptop, label: manages, labelAt: [525, 234], variant: security, decisions: [zero-touch-laptops] }
|
||||
- { from: laptop, to: office-net, label: connects }
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: hr-system-drives-identity
|
||||
title: The HR system drives identity
|
||||
status: accepted
|
||||
date: 2026-09-08
|
||||
elements: [hr-system, iam, account, employee-record]
|
||||
diagrams: [0200-application/application-cooperation.archify, 0100-business/onboard-employee.archify]
|
||||
context: >
|
||||
Accounts were created by hand from emails sent by HR.
|
||||
New hires waited days for access, and leavers kept
|
||||
accounts for weeks after their last day.
|
||||
decision: >
|
||||
The HR system is the source of truth for joiners and
|
||||
leavers. Identity management reads its events and
|
||||
creates, changes and disables accounts automatically.
|
||||
consequences: >
|
||||
No account without an employee record. HR data quality
|
||||
now decides access, so start dates must be right. The
|
||||
service desk only handles exceptions.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: zero-touch-laptops
|
||||
title: Zero-touch enrollment for laptops
|
||||
status: accepted
|
||||
date: 2026-10-05
|
||||
elements: [mdm-app, mdm-saas, laptop, device-svc, asset]
|
||||
diagrams: [0300-technology/infrastructure.archify, 0100-business/onboard-employee.archify]
|
||||
context: >
|
||||
Every laptop was imaged by hand at the service desk,
|
||||
which took half a day per device and left settings that
|
||||
differed from one laptop to the next.
|
||||
decision: >
|
||||
Laptops are registered in device management at purchase.
|
||||
They configure themselves at first sign-in over any
|
||||
internet connection; the office network needs no VPN.
|
||||
consequences: >
|
||||
Laptops can be shipped straight to the new hire. A lost
|
||||
laptop is wiped remotely. Setup depends on the MDM
|
||||
tenant being reachable on the first day.
|
||||
@@ -0,0 +1,59 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: hr-onboarding
|
||||
name: HR Onboarding
|
||||
description: >
|
||||
An ArchiMate example: how a new employee is onboarded, from signed
|
||||
contract to first day. Business, application and technology layers
|
||||
map to folders; each element's ArchiMate type is shown as its tag.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: HR Onboarding - layered view
|
||||
output: main.html
|
||||
components:
|
||||
- ref: new-hire
|
||||
pos: [40, 70]
|
||||
size: [170, 60]
|
||||
- ref: onboarding-svc
|
||||
pos: [270, 70]
|
||||
size: [170, 60]
|
||||
- ref: onboard
|
||||
pos: [500, 70]
|
||||
size: [170, 60]
|
||||
- ref: portal
|
||||
pos: [40, 270]
|
||||
size: [170, 60]
|
||||
- ref: iam
|
||||
pos: [270, 270]
|
||||
size: [170, 60]
|
||||
- ref: provisioning-svc
|
||||
pos: [500, 270]
|
||||
size: [170, 60]
|
||||
- ref: office-net
|
||||
pos: [40, 470]
|
||||
size: [170, 60]
|
||||
- ref: sso
|
||||
pos: [270, 470]
|
||||
size: [170, 60]
|
||||
- ref: directory
|
||||
pos: [500, 470]
|
||||
size: [170, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Business layer
|
||||
wraps: [new-hire, onboarding-svc, onboard]
|
||||
- kind: region
|
||||
label: Application layer
|
||||
wraps: [portal, iam, provisioning-svc]
|
||||
- kind: region
|
||||
label: Technology layer
|
||||
wraps: [office-net, sso, directory]
|
||||
connections:
|
||||
- { from: onboarding-svc, to: new-hire, label: serves, variant: emphasis }
|
||||
- { from: onboard, to: onboarding-svc, label: realizes }
|
||||
- { from: portal, to: new-hire, label: serves, labelAt: [125, 197] }
|
||||
- { from: provisioning-svc, to: onboard, label: serves, labelAt: [585, 197] }
|
||||
- { from: iam, to: provisioning-svc, label: realizes }
|
||||
- { from: office-net, to: portal, label: serves, labelAt: [125, 397] }
|
||||
- { from: sso, to: iam, label: serves, labelAt: [355, 397] }
|
||||
- { from: directory, to: sso, label: realizes }
|
||||
@@ -0,0 +1,81 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
hr-system:
|
||||
type: backend
|
||||
label: HR system
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Holds employee records and contracts; the source of truth for joiners.
|
||||
diagram: 0200-application/application-cooperation.archify
|
||||
iam:
|
||||
type: security
|
||||
label: Identity mgmt
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Creates and removes accounts from HR events; single sign-on for all apps.
|
||||
diagram: 0200-application/application-components.archify
|
||||
itsm:
|
||||
type: backend
|
||||
label: IT service mgmt
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Tickets, requests and the asset inventory of the IT service desk.
|
||||
mdm-app:
|
||||
type: backend
|
||||
label: Device mgmt
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Enrolls laptops, pushes policies and apps, wipes lost devices.
|
||||
collab:
|
||||
type: backend
|
||||
label: Mail and chat
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Mailbox, calendar and chat; accounts provisioned by identity management.
|
||||
provisioning-svc:
|
||||
type: cloud
|
||||
label: Provisioning
|
||||
sublabel: App Service
|
||||
tag: App Service
|
||||
description: >
|
||||
Account create, change and disable, triggered by the HR system.
|
||||
device-svc:
|
||||
type: cloud
|
||||
label: Enrollment
|
||||
sublabel: App Service
|
||||
tag: App Service
|
||||
description: >
|
||||
Zero-touch enrollment; the laptop configures itself at first sign-in.
|
||||
portal:
|
||||
type: frontend
|
||||
label: Service portal
|
||||
sublabel: App Interface
|
||||
tag: App Interface
|
||||
description: >
|
||||
Where managers request equipment and access for a new hire.
|
||||
employee-record:
|
||||
type: database
|
||||
label: Employee record
|
||||
sublabel: Data Object
|
||||
tag: Data Object
|
||||
description: >
|
||||
Name, role, department, manager and start date of an employee.
|
||||
account:
|
||||
type: database
|
||||
label: Account
|
||||
sublabel: Data Object
|
||||
tag: Data Object
|
||||
description: >
|
||||
User id, groups and licences of one person.
|
||||
asset:
|
||||
type: database
|
||||
label: Asset
|
||||
sublabel: Data Object
|
||||
tag: Data Object
|
||||
description: >
|
||||
A laptop with serial number, owner and enrollment state.
|
||||
@@ -0,0 +1,81 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
new-hire:
|
||||
type: external
|
||||
label: New hire
|
||||
sublabel: Business Actor
|
||||
tag: Business Actor
|
||||
description: >
|
||||
The person joining the company; signs the contract and starts on day one.
|
||||
hr:
|
||||
type: external
|
||||
label: HR officer
|
||||
sublabel: Business Role
|
||||
tag: Business Role
|
||||
description: >
|
||||
Prepares the contract and the employee record; owns the onboarding process.
|
||||
manager:
|
||||
type: external
|
||||
label: Hiring manager
|
||||
sublabel: Business Role
|
||||
tag: Business Role
|
||||
description: >
|
||||
Requests access and equipment for the new hire and plans the first day.
|
||||
service-desk:
|
||||
type: external
|
||||
label: IT service desk
|
||||
sublabel: Business Role
|
||||
tag: Business Role
|
||||
description: >
|
||||
Creates accounts, prepares the laptop and hands it over.
|
||||
onboard:
|
||||
type: backend
|
||||
label: Onboard Employee
|
||||
sublabel: Business Process
|
||||
tag: Business Process
|
||||
description: >
|
||||
From signed contract to a productive first day.
|
||||
diagram: 0100-business/onboard-employee.archify
|
||||
onboarding-svc:
|
||||
type: cloud
|
||||
label: Onboarding
|
||||
sublabel: Business Service
|
||||
tag: Business Service
|
||||
description: >
|
||||
What the new hire gets from HR; contract, record, welcome and first day plan.
|
||||
diagram: 0100-business/business-services.archify
|
||||
workplace-svc:
|
||||
type: cloud
|
||||
label: Workplace setup
|
||||
sublabel: Business Service
|
||||
tag: Business Service
|
||||
description: >
|
||||
Accounts and a ready laptop, delivered by the IT service desk.
|
||||
contract-signed:
|
||||
type: messagebus
|
||||
label: Contract signed
|
||||
sublabel: Business Event
|
||||
tag: Business Event
|
||||
description: >
|
||||
Starts the process; HR records the start date.
|
||||
create-accounts:
|
||||
type: backend
|
||||
label: Create accounts
|
||||
sublabel: Business Process
|
||||
tag: Business Process
|
||||
description: >
|
||||
Service desk confirms the accounts the identity system provisioned.
|
||||
issue-laptop:
|
||||
type: backend
|
||||
label: Issue laptop
|
||||
sublabel: Business Process
|
||||
tag: Business Process
|
||||
description: >
|
||||
An enrolled laptop is assigned and handed over.
|
||||
first-day:
|
||||
type: messagebus
|
||||
label: First day
|
||||
sublabel: Business Event
|
||||
tag: Business Event
|
||||
description: >
|
||||
The new hire signs in for the first time; the process ends.
|
||||
@@ -0,0 +1,53 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
hr-saas:
|
||||
type: cloud
|
||||
label: HR tenant
|
||||
sublabel: Node
|
||||
tag: Node
|
||||
description: >
|
||||
The SaaS tenant that runs the HR system.
|
||||
diagram: 0300-technology/infrastructure.archify
|
||||
itsm-saas:
|
||||
type: cloud
|
||||
label: ITSM tenant
|
||||
sublabel: Node
|
||||
tag: Node
|
||||
description: >
|
||||
The SaaS tenant that runs IT service management.
|
||||
mdm-saas:
|
||||
type: cloud
|
||||
label: MDM tenant
|
||||
sublabel: Node
|
||||
tag: Node
|
||||
description: >
|
||||
The SaaS tenant that runs device management.
|
||||
directory:
|
||||
type: backend
|
||||
label: Directory
|
||||
sublabel: System Software
|
||||
tag: System Software
|
||||
description: >
|
||||
Cloud directory service; users, groups and devices.
|
||||
diagram: 0300-technology/infrastructure.archify
|
||||
sso:
|
||||
type: security
|
||||
label: Single sign-on
|
||||
sublabel: Tech Service
|
||||
tag: Tech Service
|
||||
description: >
|
||||
SAML and OIDC sign-in for every SaaS tenant, backed by the directory.
|
||||
laptop:
|
||||
type: frontend
|
||||
label: Laptop
|
||||
sublabel: Device
|
||||
tag: Device
|
||||
description: >
|
||||
Company laptop, enrolled before it is handed over.
|
||||
office-net:
|
||||
type: messagebus
|
||||
label: Office network
|
||||
sublabel: Network
|
||||
tag: Network
|
||||
description: >
|
||||
Wired and wireless network of the office; internet only, no VPN.
|
||||
@@ -0,0 +1,31 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Business layer
|
||||
output: business-layer.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: insurant
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: claim-registration
|
||||
pos: [460, 60]
|
||||
size: [150, 60]
|
||||
- ref: claim-payment
|
||||
pos: [460, 260]
|
||||
size: [150, 60]
|
||||
- ref: handle-claim
|
||||
pos: [690, 160]
|
||||
size: [150, 60]
|
||||
- ref: claims-handler
|
||||
pos: [920, 160]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: customer, to: insurant, label: assigned to }
|
||||
- { from: claim-registration, to: insurant, label: serves, variant: emphasis }
|
||||
- { from: claim-payment, to: insurant, label: serves }
|
||||
- { from: handle-claim, to: claim-registration, label: realizes }
|
||||
- { from: handle-claim, to: claim-payment, label: realizes }
|
||||
- { from: claims-handler, to: handle-claim, label: assigned to }
|
||||
@@ -0,0 +1,29 @@
|
||||
schema_version: 2
|
||||
diagram_type: workflow
|
||||
meta:
|
||||
title: Handle Claim
|
||||
output: handle-claim.html
|
||||
viewBox: [840, 534]
|
||||
lanes:
|
||||
- { id: insurant-lane, label: Insurant }
|
||||
- { id: handler-lane, label: Claims handler }
|
||||
- { id: systems-lane, label: Applications }
|
||||
mainPath: [damage, register, accept, valuate, pay]
|
||||
nodes:
|
||||
- { id: damage, lane: insurant-lane, col: 0, type: external, label: Damage occurs, tag: Business Event }
|
||||
- { ref: insurant, lane: insurant-lane, col: 5 }
|
||||
- { id: register, lane: handler-lane, col: 1, type: backend, label: Register, tag: Business Process }
|
||||
- { id: accept, lane: handler-lane, col: 2, type: security, label: Accept, sublabel: check coverage, tag: Business Process }
|
||||
- { id: valuate, lane: handler-lane, col: 3, type: backend, label: Valuate, tag: Business Process }
|
||||
- { id: pay, lane: handler-lane, col: 4, type: backend, label: Pay, tag: Business Process }
|
||||
- { ref: claims-app, lane: systems-lane, col: 2 }
|
||||
- { ref: payment, lane: systems-lane, col: 4 }
|
||||
edges:
|
||||
- { from: damage, to: register, label: report }
|
||||
- { from: register, to: accept }
|
||||
- { from: accept, to: valuate, label: covered }
|
||||
- { from: valuate, to: pay, label: approved }
|
||||
- { from: pay, to: insurant, label: paid }
|
||||
- { from: accept, to: insurant, label: rejected, role: branch, variant: dashed }
|
||||
- { from: claims-app, to: accept, label: serves, role: async }
|
||||
- { from: payment, to: pay, label: serves, role: async }
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Application cooperation
|
||||
output: application-cooperation.html
|
||||
components:
|
||||
- ref: portal
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: claims-app
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: crm
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- ref: policy-admin
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
decisions: [policies-stay-on-mainframe]
|
||||
- ref: doc-mgmt
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
decisions: [documents-in-object-storage]
|
||||
- ref: payment
|
||||
pos: [250, 460]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: portal, to: claims-app, label: claims, variant: emphasis }
|
||||
- { from: crm, to: claims-app, label: customer, labelAt: [315, 134] }
|
||||
- { from: policy-admin, to: claims-app, label: coverage, decisions: [policies-stay-on-mainframe] }
|
||||
- { from: claims-app, to: doc-mgmt, label: documents }
|
||||
- { from: claims-app, to: payment, label: payments, labelAt: [315, 334] }
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Application structure
|
||||
output: application-structure.html
|
||||
components:
|
||||
- ref: portal
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: claims-admin
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: claims-app
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: claim
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
- ref: crm
|
||||
pos: [670, 60]
|
||||
size: [130, 60]
|
||||
- ref: customer-file
|
||||
pos: [880, 60]
|
||||
size: [130, 60]
|
||||
- ref: policy-admin
|
||||
pos: [670, 260]
|
||||
size: [130, 60]
|
||||
- ref: policy
|
||||
pos: [880, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: claims-admin, to: portal, label: serves, variant: emphasis }
|
||||
- { from: claims-app, to: claims-admin, label: realizes }
|
||||
- { from: claims-app, to: claim, label: accesses, labelAt: [525, 234] }
|
||||
- { from: crm, to: customer-file, label: accesses }
|
||||
- { from: policy-admin, to: policy, label: accesses }
|
||||
@@ -0,0 +1,45 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Technology layer
|
||||
output: infrastructure.html
|
||||
components:
|
||||
- ref: dmz
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: doc-store
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
decisions: [documents-in-object-storage]
|
||||
- ref: app-servers
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: db-service
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: db-cluster
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- ref: mq
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: mainframe
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
decisions: [policies-stay-on-mainframe]
|
||||
- ref: dc-lan
|
||||
pos: [670, 360]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Data centre
|
||||
wraps: [doc-store, app-servers, db-service, db-cluster, mq, mainframe, dc-lan]
|
||||
connections:
|
||||
- { from: dmz, to: app-servers, label: HTTPS, variant: security }
|
||||
- { from: app-servers, to: doc-store, label: S3 API }
|
||||
- { from: db-service, to: app-servers, label: serves }
|
||||
- { from: db-cluster, to: db-service, label: realizes }
|
||||
- { from: app-servers, to: mq, label: MQ }
|
||||
- { from: mq, to: mainframe, label: bridge }
|
||||
- { from: db-cluster, to: dc-lan, label: LAN }
|
||||
- { from: mainframe, to: dc-lan, label: LAN, labelAt: [735, 234] }
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: policies-stay-on-mainframe
|
||||
title: Policies stay on the mainframe
|
||||
status: accepted
|
||||
date: 2026-09-08
|
||||
elements: [policy-admin, mainframe, mq, claims-app]
|
||||
diagrams: [0200-application/application-cooperation.archify, 0300-technology/infrastructure.archify]
|
||||
context: >
|
||||
The new claims application needs coverage data for every
|
||||
claim. Policy administration has run on the mainframe for
|
||||
twenty years and a migration was estimated at three years.
|
||||
decision: >
|
||||
Policy administration stays on the mainframe. The claims
|
||||
application reads coverage through the message queue bridge
|
||||
and never copies policies into its own database.
|
||||
consequences: >
|
||||
Accepting a claim depends on the mainframe being up during
|
||||
office hours. The queue bridge must be kept and tested.
|
||||
A later migration needs its own decision.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: documents-in-object-storage
|
||||
title: Claim documents in object storage
|
||||
status: accepted
|
||||
date: 2026-10-14
|
||||
elements: [doc-mgmt, doc-store, claims-app]
|
||||
diagrams: [0200-application/application-cooperation.archify, 0300-technology/infrastructure.archify]
|
||||
context: >
|
||||
Photos and expert reports were stored as blobs in the claims
|
||||
database. The database grew by forty gigabytes a month and
|
||||
backups no longer fitted the nightly window.
|
||||
decision: >
|
||||
Document management stores every file in the document store
|
||||
and keeps only a reference in the claim. The claims
|
||||
application never writes files itself.
|
||||
consequences: >
|
||||
Database backups shrink and stay fast. Documents get their own
|
||||
retention rules. A claim and its files are restored from two
|
||||
places after an incident.
|
||||
@@ -0,0 +1,59 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: insurance-claims
|
||||
name: Insurance Claims
|
||||
description: >
|
||||
ArchiMate example: how Halbrook Mutual handles claims. The business,
|
||||
application and technology layers are mapped to folders, and each
|
||||
element's ArchiMate type is shown as its tag.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Insurance Claims - layered view
|
||||
output: main.html
|
||||
components:
|
||||
- ref: insurant
|
||||
pos: [40, 70]
|
||||
size: [170, 60]
|
||||
- ref: claim-registration
|
||||
pos: [270, 70]
|
||||
size: [170, 60]
|
||||
- ref: handle-claim
|
||||
pos: [500, 70]
|
||||
size: [170, 60]
|
||||
- ref: portal
|
||||
pos: [40, 270]
|
||||
size: [170, 60]
|
||||
- ref: claims-app
|
||||
pos: [270, 270]
|
||||
size: [170, 60]
|
||||
- ref: claims-admin
|
||||
pos: [500, 270]
|
||||
size: [170, 60]
|
||||
- ref: dmz
|
||||
pos: [40, 470]
|
||||
size: [170, 60]
|
||||
- ref: app-servers
|
||||
pos: [270, 470]
|
||||
size: [170, 60]
|
||||
- ref: db-cluster
|
||||
pos: [500, 470]
|
||||
size: [170, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Business layer
|
||||
wraps: [insurant, claim-registration, handle-claim]
|
||||
- kind: region
|
||||
label: Application layer
|
||||
wraps: [portal, claims-app, claims-admin]
|
||||
- kind: region
|
||||
label: Technology layer
|
||||
wraps: [dmz, app-servers, db-cluster]
|
||||
connections:
|
||||
- { from: claim-registration, to: insurant, label: serves, variant: emphasis }
|
||||
- { from: handle-claim, to: claim-registration, label: realizes }
|
||||
- { from: portal, to: insurant, label: serves, labelAt: [125, 197] }
|
||||
- { from: claims-admin, to: handle-claim, label: serves, labelAt: [585, 197] }
|
||||
- { from: claims-app, to: claims-admin, label: realizes }
|
||||
- { from: dmz, to: portal, label: serves, labelAt: [125, 397] }
|
||||
- { from: app-servers, to: claims-app, label: serves, labelAt: [355, 397] }
|
||||
- { from: db-cluster, to: app-servers, label: serves }
|
||||
@@ -0,0 +1,76 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
portal:
|
||||
type: frontend
|
||||
label: Customer Portal
|
||||
sublabel: App Interface
|
||||
tag: App Interface
|
||||
description: >
|
||||
Web and mobile front end where customers report claims and
|
||||
follow their status.
|
||||
claims-admin:
|
||||
type: cloud
|
||||
label: Claims Admin
|
||||
sublabel: App Service
|
||||
tag: App Service
|
||||
description: >
|
||||
Register, look up and update claims; used by the portal and
|
||||
by claims handlers.
|
||||
claims-app:
|
||||
type: backend
|
||||
label: Claims App
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Owns claims from registration to payment; the system of
|
||||
record for claims.
|
||||
diagram: 0200-application/application-structure.archify
|
||||
crm:
|
||||
type: backend
|
||||
label: CRM
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Customer contacts, addresses and the customer file.
|
||||
policy-admin:
|
||||
type: backend
|
||||
label: Policy Admin
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Policies, coverage and premiums; runs on the mainframe.
|
||||
doc-mgmt:
|
||||
type: backend
|
||||
label: Document Mgmt
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Scanned letters, photos and expert reports attached to claims.
|
||||
payment:
|
||||
type: backend
|
||||
label: Payment
|
||||
sublabel: App Component
|
||||
tag: App Component
|
||||
description: >
|
||||
Sends approved claim payments to the bank in daily batches.
|
||||
claim:
|
||||
type: database
|
||||
label: Claim
|
||||
sublabel: Data Object
|
||||
tag: Data Object
|
||||
description: >
|
||||
One reported damage, its valuation and its payment status.
|
||||
policy:
|
||||
type: database
|
||||
label: Policy
|
||||
sublabel: Data Object
|
||||
tag: Data Object
|
||||
description: >
|
||||
Coverage, deductible and term of an insurance contract.
|
||||
customer-file:
|
||||
type: database
|
||||
label: Customer File
|
||||
sublabel: Data Object
|
||||
tag: Data Object
|
||||
description: >
|
||||
Everything known about a customer; contacts, policies, claims.
|
||||
@@ -0,0 +1,50 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
customer:
|
||||
type: external
|
||||
label: Customer
|
||||
sublabel: Business Actor
|
||||
tag: Business Actor
|
||||
description: >
|
||||
A person or small business holding one or more policies
|
||||
with Halbrook Mutual.
|
||||
insurant:
|
||||
type: external
|
||||
label: Insurant
|
||||
sublabel: Business Role
|
||||
tag: Business Role
|
||||
description: >
|
||||
The role a customer plays while a claim is open; reports
|
||||
damage and receives the payment.
|
||||
claims-handler:
|
||||
type: external
|
||||
label: Claims handler
|
||||
sublabel: Business Role
|
||||
tag: Business Role
|
||||
description: >
|
||||
Employee of the claims department who accepts, valuates
|
||||
and approves claims.
|
||||
handle-claim:
|
||||
type: backend
|
||||
label: Handle Claim
|
||||
sublabel: Business Process
|
||||
tag: Business Process
|
||||
description: >
|
||||
Register, accept, valuate and pay a claim; the core process
|
||||
of the claims department.
|
||||
diagram: 0100-business/handle-claim.archify
|
||||
claim-registration:
|
||||
type: frontend
|
||||
label: Claim Registration
|
||||
sublabel: Business Service
|
||||
tag: Business Service
|
||||
description: >
|
||||
Lets an insurant report damage by phone, mail or the
|
||||
customer portal.
|
||||
claim-payment:
|
||||
type: frontend
|
||||
label: Claim Payment
|
||||
sublabel: Business Service
|
||||
tag: Business Service
|
||||
description: >
|
||||
Pays the valuated amount to the insurant or a repairer.
|
||||
@@ -0,0 +1,62 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
app-servers:
|
||||
type: backend
|
||||
label: App Servers
|
||||
sublabel: Node
|
||||
tag: Node
|
||||
description: >
|
||||
Two virtual machine hosts running the claims app, CRM and the
|
||||
portal back end.
|
||||
diagram: 0300-technology/infrastructure.archify
|
||||
db-cluster:
|
||||
type: database
|
||||
label: DB Cluster
|
||||
sublabel: Node
|
||||
brand: postgresql
|
||||
tag: Node
|
||||
description: >
|
||||
Primary and standby database servers for claims and CRM data.
|
||||
doc-store:
|
||||
type: database
|
||||
label: Document Store
|
||||
sublabel: System Software
|
||||
tag: System Software
|
||||
description: >
|
||||
Object storage for scans and photos; written by document
|
||||
management only.
|
||||
mainframe:
|
||||
type: backend
|
||||
label: Mainframe
|
||||
sublabel: Node
|
||||
tag: Node
|
||||
description: >
|
||||
Hosts policy administration and the nightly payment batch.
|
||||
mq:
|
||||
type: messagebus
|
||||
label: Message Queue
|
||||
sublabel: System Software
|
||||
tag: System Software
|
||||
description: >
|
||||
Queue manager between the app servers and the mainframe.
|
||||
db-service:
|
||||
type: cloud
|
||||
label: DB Service
|
||||
sublabel: Tech Service
|
||||
tag: Tech Service
|
||||
description: >
|
||||
Managed SQL access with backups, offered to all applications.
|
||||
dc-lan:
|
||||
type: messagebus
|
||||
label: Data Centre LAN
|
||||
sublabel: Network
|
||||
tag: Network
|
||||
description: >
|
||||
Internal network linking app servers, databases and mainframe.
|
||||
dmz:
|
||||
type: security
|
||||
label: DMZ Gateway
|
||||
sublabel: Network
|
||||
tag: Network
|
||||
description: >
|
||||
Reverse proxy and firewall between the internet and the LAN.
|
||||
@@ -0,0 +1,34 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Business actors and services
|
||||
output: business-services.html
|
||||
components:
|
||||
- ref: shipper
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: recipient
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: delivery-service
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- ref: tracking-service
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: deliver-shipment
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: driver
|
||||
pos: [670, 60]
|
||||
size: [130, 60]
|
||||
- ref: customer-service
|
||||
pos: [670, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: delivery-service, to: shipper, label: serves, variant: emphasis }
|
||||
- { from: tracking-service, to: recipient, label: serves }
|
||||
- { from: deliver-shipment, to: delivery-service, label: realizes }
|
||||
- { from: deliver-shipment, to: tracking-service, label: realizes }
|
||||
- { from: driver, to: deliver-shipment, label: assigned to }
|
||||
- { from: customer-service, to: tracking-service, label: assigned to }
|
||||
@@ -0,0 +1,25 @@
|
||||
schema_version: 2
|
||||
diagram_type: workflow
|
||||
meta:
|
||||
title: Deliver Shipment
|
||||
output: deliver-shipment.html
|
||||
viewBox: [840, 534]
|
||||
lanes:
|
||||
- { id: customer-lane, label: Shipper and recipient }
|
||||
- { id: driver-lane, label: Driver }
|
||||
- { id: hub-lane, label: Hub }
|
||||
mainPath: [book, pick-up, sort, line-haul, last-mile, pod-step]
|
||||
nodes:
|
||||
- { id: book, lane: customer-lane, col: 0, type: external, label: Book shipment, tag: Business Event }
|
||||
- { id: pick-up, lane: driver-lane, col: 1, type: backend, label: Pick up, tag: Business Process }
|
||||
- { id: sort, lane: hub-lane, col: 2, type: backend, label: Sort at hub, tag: Business Process }
|
||||
- { id: line-haul, lane: hub-lane, col: 3, type: backend, label: Line haul, tag: Business Process }
|
||||
- { id: last-mile, lane: driver-lane, col: 4, type: backend, label: Last mile, tag: Business Process }
|
||||
- { id: pod-step, lane: customer-lane, col: 5, type: security, label: Delivered, sublabel: proof of delivery, tag: Business Process }
|
||||
edges:
|
||||
- { from: book, to: pick-up, label: triggers }
|
||||
- { from: pick-up, to: sort, label: parcel }
|
||||
- { from: sort, to: line-haul, label: trailer }
|
||||
- { from: line-haul, to: last-mile, label: route }
|
||||
- { from: last-mile, to: pod-step, label: hand over }
|
||||
- { from: last-mile, to: sort, label: not home, role: return, variant: dashed }
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Application cooperation
|
||||
output: application-cooperation.html
|
||||
decisions: [edi-gateway-in-front-of-tms]
|
||||
components:
|
||||
- ref: shipper
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: edi-gateway
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
decisions: [edi-gateway-in-front-of-tms]
|
||||
- ref: tms
|
||||
pos: [460, 260]
|
||||
size: [130, 60]
|
||||
- ref: driver-app
|
||||
pos: [460, 60]
|
||||
size: [130, 60]
|
||||
- ref: telematics
|
||||
pos: [460, 460]
|
||||
size: [130, 60]
|
||||
- ref: tracking-portal
|
||||
pos: [670, 260]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: shipper, to: edi-gateway, label: bookings, variant: emphasis }
|
||||
- { from: edi-gateway, to: tms, label: shipments, variant: emphasis }
|
||||
- { from: driver-app, to: tms, label: scans, labelAt: [525, 134], decisions: [broker-for-tracking-events] }
|
||||
- { from: telematics, to: tms, label: positions, decisions: [broker-for-tracking-events] }
|
||||
- { from: tms, to: tracking-portal, label: status }
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Application services and data
|
||||
output: application-services.html
|
||||
components:
|
||||
- ref: tracking-portal
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: tracking-svc
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: tms
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: driver-app
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
- ref: shipment
|
||||
pos: [250, 360]
|
||||
size: [130, 60]
|
||||
- ref: tracking-event
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
- ref: pod
|
||||
pos: [670, 360]
|
||||
size: [130, 60]
|
||||
connections:
|
||||
- { from: tracking-svc, to: tracking-portal, label: serves, variant: emphasis }
|
||||
- { from: tms, to: tracking-svc, label: realizes }
|
||||
- { from: driver-app, to: tms, label: uses }
|
||||
- { from: tms, to: shipment, label: accesses }
|
||||
- { from: tms, to: tracking-event, label: accesses, labelAt: [525, 234] }
|
||||
- { from: driver-app, to: pod, label: accesses, labelAt: [735, 234] }
|
||||
@@ -0,0 +1,44 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Technology infrastructure
|
||||
output: infrastructure.html
|
||||
decisions: [broker-for-tracking-events]
|
||||
components:
|
||||
- ref: handhelds
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: telematics-units
|
||||
pos: [40, 360]
|
||||
size: [130, 60]
|
||||
- ref: mobile-network
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: cloud-cluster
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: message-broker
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
decisions: [broker-for-tracking-events]
|
||||
- ref: db-server
|
||||
pos: [670, 160]
|
||||
size: [130, 60]
|
||||
- ref: event-streaming
|
||||
pos: [670, 360]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Field devices
|
||||
wraps: [handhelds, telematics-units]
|
||||
- kind: security-group
|
||||
label: Cloud platform
|
||||
wraps: [cloud-cluster, message-broker, db-server, event-streaming]
|
||||
connections:
|
||||
- { from: handhelds, to: mobile-network, label: LTE }
|
||||
- { from: telematics-units, to: mobile-network, label: LTE }
|
||||
- { from: mobile-network, to: cloud-cluster, label: HTTPS, variant: security }
|
||||
- { from: mobile-network, to: message-broker, label: MQTT, variant: security }
|
||||
- { from: cloud-cluster, to: message-broker, label: hosts, labelAt: [525, 234] }
|
||||
- { from: cloud-cluster, to: db-server, label: SQL }
|
||||
- { from: message-broker, to: event-streaming, label: realizes }
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user