@thatopen/services 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/built-in/index.d.ts +184 -0
- package/dist/cli/commands/rhino.d.ts +11 -0
- package/dist/cli/lib/plugin-install.d.ts +24 -0
- package/dist/cli.js +120 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +5 -4
- package/docs/ai-quickstart.md +22 -0
- package/docs/builtin/paths.json +4 -0
- package/docs/flow-architecture.md +389 -0
- package/docs/flow-plugin-guide.md +337 -0
- package/docs/revit-collab-quickstart.md +294 -175
- package/package.json +1 -1
- package/src/built-in/UIManager/src/git-history-panel/example.ts +70 -0
- package/src/built-in/index.ts +185 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# Writing a flow plugin
|
|
2
|
+
|
|
3
|
+
The concrete contract. [The architecture notes](./flow-architecture.md) say why any of this is
|
|
4
|
+
shaped the way it is; this says what the bytes look like.
|
|
5
|
+
|
|
6
|
+
Nothing here is aspirational. Every rule below is what the Revit and Rhino plugins already do, and
|
|
7
|
+
was written from their implementation rather than ahead of it — which is why the awkward parts are
|
|
8
|
+
stated as plainly as the tidy ones. Where a rule exists because something broke, it says so.
|
|
9
|
+
|
|
10
|
+
The most reliable way to check any of it is the same one available to you: publish something and
|
|
11
|
+
read it back. The formats are open and the platform's file API is the whole transport.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 0. Which of these do you actually need?
|
|
16
|
+
|
|
17
|
+
Three different jobs, three different amounts of work. Pick yours before reading further.
|
|
18
|
+
|
|
19
|
+
| You want to | Implement | Sections |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Send changes to somebody else's model | Publish a proposal | 1, 2, 5 |
|
|
22
|
+
| Show a model your application did not author | Read a `.frag` | 1, 3 |
|
|
23
|
+
| Let people collaborate in YOUR application | A history, plus whatever arbitration it needs | 1, 3, 4, 6 |
|
|
24
|
+
|
|
25
|
+
Publishing a proposal needs no ownership, no history and no collaboration layer. That is the point:
|
|
26
|
+
most integrations are the first row.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Where things live
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
The platform's file API is the whole transport. There is no flow server.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
project
|
|
37
|
+
└── bimterop/ a folder, created on demand
|
|
38
|
+
└── revit-<documentId>/ one per model
|
|
39
|
+
├── <modelname>.frag the model, as of the last full export
|
|
40
|
+
│ └── (hidden children) per-commit delta .frag, addressed by id
|
|
41
|
+
├── revitflow_history.json the commit log
|
|
42
|
+
├── revitflow_ops.json the proposal index
|
|
43
|
+
│ └── (hidden children) proposal payloads, addressed by id
|
|
44
|
+
├── revitflow_btree.json file -> blocks, current state
|
|
45
|
+
├── revitflow_btree_<n>.json file -> blocks, as of version n
|
|
46
|
+
├── revitflow_bindex.json block -> which pack holds it
|
|
47
|
+
├── revitflow_bpack_<n>.zip the blocks first seen at version n
|
|
48
|
+
├── revitflow_version.txt the current version number
|
|
49
|
+
├── revitflow_manifest.json version, who, when
|
|
50
|
+
├── bimterop-locks-anchor a file whose hidden children are claims
|
|
51
|
+
└── revitflow-queue-anchor a file whose hidden children are queue tickets
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Visible files are named; hidden files are not.** The platform's hidden-file API has no queryable
|
|
55
|
+
name: listing by parent and fetching by id both return ids and sizes only. So every hidden file's id
|
|
56
|
+
has to be written down somewhere visible when it is created, or it is orphaned for good. That single
|
|
57
|
+
fact explains several shapes below — `deltaId` on a commit, `fragId` on a proposal, and the use of
|
|
58
|
+
an ordinary file as an "anchor" whose only job is to be a parent.
|
|
59
|
+
|
|
60
|
+
**Two things come free with a listing, and they matter.** A hidden file's `_id` is a Mongo ObjectId
|
|
61
|
+
whose first four bytes are its creation time, so a set of hidden files is ordered by arrival without
|
|
62
|
+
downloading any of them. `owningEntity` is the platform user who created it. The queue and the
|
|
63
|
+
ownership registry are both built entirely out of those two facts.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 2. Proposals
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
### The index
|
|
71
|
+
|
|
72
|
+
`revitflow_ops.json`, a visible file. Schema `revitflow/ops@1`.
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"schema": "revitflow/ops@1",
|
|
77
|
+
"ops": [{
|
|
78
|
+
"opId": "op-9a68aceffc4e",
|
|
79
|
+
"batchId": "batch-...",
|
|
80
|
+
"fragId": "<hidden file id of the payload>",
|
|
81
|
+
"title": "4 columns from Rhino",
|
|
82
|
+
"verb": "add",
|
|
83
|
+
"author": "anton",
|
|
84
|
+
"userId": "<platform user id>",
|
|
85
|
+
"createdAt": "2026-07-31T12:00:00.000Z",
|
|
86
|
+
"baseCommit": 18,
|
|
87
|
+
"targets": 4,
|
|
88
|
+
"state": "pending",
|
|
89
|
+
"appliedInCommit": 19,
|
|
90
|
+
"affectedIds": ["...", "..."]
|
|
91
|
+
}]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`state` is `pending`, `applied` or `discarded`. `verb` is a label for a timeline to show — `add`,
|
|
96
|
+
`set`, `remove` or `mixed` — and is never read to decide anything, because one proposal may add,
|
|
97
|
+
change and remove at once.
|
|
98
|
+
|
|
99
|
+
The index is read-modify-write today, so two authors writing at the same moment can lose one
|
|
100
|
+
another's entry. Acceptable while a document's queue is written by a handful of people. It is not
|
|
101
|
+
the final answer.
|
|
102
|
+
|
|
103
|
+
### The payload
|
|
104
|
+
|
|
105
|
+
A `.frag` with **no geometry at all**, stored as a hidden child of the index.
|
|
106
|
+
|
|
107
|
+
- one geometry-less item per change
|
|
108
|
+
- the target's native id in the item's `guid` — the slot the format already has for a global id
|
|
109
|
+
- the envelope, as JSON, in `Model.metadata`
|
|
110
|
+
|
|
111
|
+
A geometry-less item is a real thing the format supports, but writers get it wrong in a way that is
|
|
112
|
+
worth stating: only items that actually have vertices and indices may appear in the mesh table, and
|
|
113
|
+
an item's bounding box must not be all zeros (a zero-size box reads as absent and the reader stops
|
|
114
|
+
with "Dimension not found").
|
|
115
|
+
|
|
116
|
+
### The envelope
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"schema": "revitflow/op@2",
|
|
121
|
+
"opId": "op-9a68aceffc4e",
|
|
122
|
+
"batchId": "batch-...",
|
|
123
|
+
"targetProject": "<project id>",
|
|
124
|
+
"targetDocument": "<document id>",
|
|
125
|
+
"baseCommitGuid": "<commit guid this was authored against>",
|
|
126
|
+
"baseCommit": 18,
|
|
127
|
+
"author": "anton",
|
|
128
|
+
"userId": "<platform user id>",
|
|
129
|
+
"title": "4 columns from Rhino",
|
|
130
|
+
"summary": "add",
|
|
131
|
+
"transform": { "translate": [0,0,0], "rotate": {"origin":[0,0,0],"axis":[0,0,1],"degrees":90} }
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`schema` is checked exactly. An applier that reads `@1` the `@2` way would not fail, it would
|
|
136
|
+
quietly do the wrong thing, so an unknown schema is refused and the proposal is re-authored.
|
|
137
|
+
|
|
138
|
+
`baseCommitGuid` names the commit the proposal was written against. By the time somebody applies it
|
|
139
|
+
the elements it names may be gone, which is reported rather than skipped quietly.
|
|
140
|
+
|
|
141
|
+
`transform` is optional and applies to every `set` item, on top of whatever its attributes change.
|
|
142
|
+
It exists alongside the per-item transform because the two answer different questions: an item's own
|
|
143
|
+
transform says "put it HERE", which is what an `add` needs; this says "move it BY this", which needs
|
|
144
|
+
no knowledge of where anything currently is.
|
|
145
|
+
|
|
146
|
+
**There is no `units` field, deliberately.** A proposal edits the data an application published, so
|
|
147
|
+
its numbers are in the terms of that data — exactly as a patch over a JSON document writes the
|
|
148
|
+
fields as the document holds them. Read a position out of a published model, write it back
|
|
149
|
+
unchanged, and nothing moves. Converting from whatever the author's document uses is the
|
|
150
|
+
translator's job, because the translator is the only thing that knows both sides. An earlier version
|
|
151
|
+
made every proposal declare its units, which looked careful and was a bug waiting to happen: it let
|
|
152
|
+
an author state a unit while writing coordinates in an axis convention the target does not use, a
|
|
153
|
+
mistake no declared unit can catch.
|
|
154
|
+
|
|
155
|
+
### The verbs
|
|
156
|
+
|
|
157
|
+
One per item, in the attribute `flow:op`:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
add no guid (it does not exist yet), attributes describe it, transform says where it goes
|
|
161
|
+
set names an item by its guid; an attribute present is written, absent is left alone,
|
|
162
|
+
and an EMPTY value clears it
|
|
163
|
+
remove names an item by its guid
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### The reserved namespace
|
|
167
|
+
|
|
168
|
+
`flow:` is reserved. An applier skips **the whole namespace**, not the names it happens to know, so
|
|
169
|
+
an author can record something for its own later use without the applier being taught what it means
|
|
170
|
+
and without it being mistaken for a parameter of the thing being changed.
|
|
171
|
+
|
|
172
|
+
- `flow:op` — the verb above
|
|
173
|
+
- `flow:source` — where this change came from, in the AUTHOR's own terms
|
|
174
|
+
|
|
175
|
+
`flow:source` is not a link between elements. Nothing is written into either model. It lives on the
|
|
176
|
+
proposal, which is the record of what somebody asked for.
|
|
177
|
+
|
|
178
|
+
**Every other attribute name belongs to the target application.** `Family`, `Type`, `CategoryId`,
|
|
179
|
+
`Level`, `Position` are what Revit calls its own fields in what Revit publishes. A translator copies
|
|
180
|
+
them without needing to know what any of them mean; the plugin receiving them is the only thing that
|
|
181
|
+
does. Anything not in the receiving plugin's list of structural fields is treated as a parameter of
|
|
182
|
+
the element, which is what keeps the proposal vocabulary and the export vocabulary the same thing
|
|
183
|
+
rather than two things that have to be kept in step.
|
|
184
|
+
|
|
185
|
+
### What comes back
|
|
186
|
+
|
|
187
|
+
When a proposal is applied, its index entry gains:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
appliedInCommit: 19
|
|
191
|
+
affectedIds: ["...", "..."]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**N changes produce N ids, in the order the changes were written.** The applier runs its verbs in
|
|
195
|
+
passes — adds first, so a `set` can address something the same proposal just made — but each change
|
|
196
|
+
records its own result, so the pass order never reaches the answer. Elements a delete took with it
|
|
197
|
+
are appended AFTER all of them, because they correspond to no change and putting them in line would
|
|
198
|
+
shift every id after the delete by an amount the author cannot predict.
|
|
199
|
+
|
|
200
|
+
That list can therefore be longer than `targets`, and that is not a bug: it is what happened, not
|
|
201
|
+
what was asked for.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 3. The history
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
`revitflow_history.json`, a visible file.
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"model": "<stable model guid>",
|
|
213
|
+
"commits": [{
|
|
214
|
+
"guid": "36095fba-1931-4ae2-963a-fed88f26b9b2",
|
|
215
|
+
"parents": ["<guid>", "<guid>"],
|
|
216
|
+
"version": 19,
|
|
217
|
+
"message": "Moved the grid, everything follows",
|
|
218
|
+
"author": "anton",
|
|
219
|
+
"source": "revit",
|
|
220
|
+
"timestamp": "2026-07-31T12:00:00.000Z",
|
|
221
|
+
"changes": [{ "type": "create", "uniqueId": "..." }],
|
|
222
|
+
"counts": { "added": 1, "modified": 0, "removed": 0 },
|
|
223
|
+
"deltaId": "<hidden file id of this commit's delta .frag>",
|
|
224
|
+
"userId": "<platform user id>",
|
|
225
|
+
"full": false,
|
|
226
|
+
"appliedOps": ["op-9a68aceffc4e"]
|
|
227
|
+
}]
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Rules, restated here because they are the part an implementer gets wrong:
|
|
232
|
+
|
|
233
|
+
- **`guid` is identity.** Minted by whoever writes the commit, never reused, meaningful outside the
|
|
234
|
+
file it lives in.
|
|
235
|
+
- **`parents` is the structure.** Absent on a root. First entry is the line this commit continues;
|
|
236
|
+
more than one means a merge. Order by walking it, breaking ties on `timestamp`.
|
|
237
|
+
- **`version` is data, never a name, and nothing may sort by it.** It is where the commit landed in
|
|
238
|
+
the CDE. An application with no such number omits it entirely.
|
|
239
|
+
- **`message` is the author's own words, and is optional.** Free text, never parsed, absent when
|
|
240
|
+
nobody said anything. Take it from wherever the application already asks: Revit's Synchronize
|
|
241
|
+
with Central dialog has had a Comment box forever, so the add-in passes that through rather than
|
|
242
|
+
generating a sentence about element counts, which `changes` already says better.
|
|
243
|
+
- **`full`** means this commit re-exported the whole model, so the visible `.frag` shows the model
|
|
244
|
+
as of here. Otherwise the commit ships only its delta, in a hidden child named by `deltaId`.
|
|
245
|
+
- **`appliedOps`** joins the two halves of a deferred edit: the proposal on the platform, and the
|
|
246
|
+
native commit that made it real. It is also the idempotency record — "have I already applied
|
|
247
|
+
this?" is a question about the proposal, not about identity.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## 4. Collaboration in Revit, as one worked example
|
|
252
|
+
|
|
253
|
+
None of this is required by the contract. It is what a file needs when its application works the way
|
|
254
|
+
Revit does, and it is here because it is the hardest case: read it as an example, not as the shape
|
|
255
|
+
your application must take.
|
|
256
|
+
|
|
257
|
+
### The native model
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
The `.rvt` central and its local are stored as content-addressed 64K blocks. A push walks the folder,
|
|
261
|
+
uploads only blocks nobody has seen, and writes the file-to-blocks map. Blocks are only ever added,
|
|
262
|
+
so every state the model has been in is still there, and each version's map is kept under its own
|
|
263
|
+
name so a past state can be rebuilt.
|
|
264
|
+
|
|
265
|
+
### Ownership
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
Revit already arbitrates ownership — against **that machine's copy of the central**. Since the
|
|
269
|
+
central here is a snapshot that syncs rather than a live shared file, two people on two machines can
|
|
270
|
+
both borrow the same wall and both succeed, and neither finds out until one syncs. So arbitration
|
|
271
|
+
has to happen where both machines can see it.
|
|
272
|
+
|
|
273
|
+
A claim is **state, not an event**: one file per client, holding the keys it currently holds.
|
|
274
|
+
Releasing rewrites or deletes it. Reads cost one download per live claim set, roughly per person
|
|
275
|
+
working, rather than one per edit anybody has ever made.
|
|
276
|
+
|
|
277
|
+
The race is arbitrated by **the earliest claim**, and the order comes from the file's own `_id` —
|
|
278
|
+
read from the listing, needing no download. Two clients that both find a key free and both write
|
|
279
|
+
will therefore agree, on re-reading, about which of them has it.
|
|
280
|
+
|
|
281
|
+
The two failure orders are opposite on purpose. Acquiring writes the corrected claim before deleting
|
|
282
|
+
the old one, so a crash between the two leaves you holding too much (released at the next sync).
|
|
283
|
+
Releasing deletes before writing, so a crash leaves you holding too little. Never the reverse: too
|
|
284
|
+
little is recoverable, too much locks a colleague out of an element nobody is editing.
|
|
285
|
+
|
|
286
|
+
### Turn-taking
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
One sync at a time, first come first served. A ticket **is** a hidden file, and leaving the queue
|
|
290
|
+
deletes it, so asking whose turn it is costs one listing and never grows.
|
|
291
|
+
|
|
292
|
+
It was not always. The first version was an append-only log of enqueue and dequeue documents, so
|
|
293
|
+
every reader downloaded every event ever written and paired them up. Measured on one dev document:
|
|
294
|
+
100 hidden files, refetched every 800ms while polling, which is where seven and a half seconds of a
|
|
295
|
+
sync went with nobody else in the queue. It grew with every sync anybody had ever done, forever.
|
|
296
|
+
|
|
297
|
+
A ticket older than 180 seconds is stepped over, so a client that dies mid-sync cannot hold the queue
|
|
298
|
+
for good.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 5. Publishing a proposal, end to end
|
|
303
|
+
|
|
304
|
+
The whole job, for the common case:
|
|
305
|
+
|
|
306
|
+
1. **Read the target model's published data.** The `.frag` the plugin exported. You need it to know
|
|
307
|
+
what ids exist and what the fields are called.
|
|
308
|
+
2. **Build a geometry-less `.frag`**: one item per change, the target's id in the item's guid,
|
|
309
|
+
`flow:op` on each item, the application's own field names for everything else.
|
|
310
|
+
3. **Put the envelope in `Model.metadata`.**
|
|
311
|
+
4. **Create the index** `revitflow_ops.json` if it does not exist, and **upload the payload as a
|
|
312
|
+
hidden child of it**.
|
|
313
|
+
5. **Write the entry**, including the id the upload returned. Miss this and the payload is
|
|
314
|
+
unreachable forever.
|
|
315
|
+
|
|
316
|
+
That is all. No lock, no history, no ownership. Somebody working in the native application will see
|
|
317
|
+
it waiting and decide.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 6. Building a collaboration layer for a new application
|
|
322
|
+
|
|
323
|
+
The contract is section 3, and nothing else. What you decide for yourself:
|
|
324
|
+
|
|
325
|
+
- **What a commit contains.** Whatever your application can honestly report as changed.
|
|
326
|
+
- **Whether ownership exists at all.** Revit's model is claimed-before-editing arbitrated centrally.
|
|
327
|
+
For an application where teams already divide work by file, no locking may be the right answer.
|
|
328
|
+
- **How concurrent edits reconcile.** Revit has a merge engine; most applications do not, and this
|
|
329
|
+
is the expensive half. A central that is *reconstructed* from per-object deltas rather than
|
|
330
|
+
overwritten is buildable on stable object ids and per-commit deltas, but that is rebuilding
|
|
331
|
+
worksharing rather than configuring it.
|
|
332
|
+
- **Whether history branches.** The format allows it. Whether your application does is yours to
|
|
333
|
+
decide, and Revit's cannot: its central is a single line.
|
|
334
|
+
|
|
335
|
+
The one thing not up for grabs: **order comes from the graph, never from a counter.** Write commits
|
|
336
|
+
with guids and parents, and every reader on the platform can already show your history alongside
|
|
337
|
+
everybody else's.
|