@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.
@@ -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.