@estiva-app/interop 0.9.0 → 0.9.1

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +109 -5
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,41 @@ how a declaration is read, is a MAJOR — in `0.x`, a MINOR — even when no
6
6
  TypeScript signature moved. A consumer upgrading must be able to tell whether
7
7
  manifests already published still mean what they meant.
8
8
 
9
+ ## 0.9.1 — 2026-09-03
10
+
11
+ **No behaviour change. The README was wrong, and this is the release that fixes
12
+ the page people are told to start from** (PRO-9).
13
+
14
+ - **§2's example did not compile, and its copy-paste path published an error
15
+ message.** It called `buildActionEvent({ object, actionId, value })`, which is
16
+ not the signature: the function takes the manifest, the kind, the address, the
17
+ object's author, the folder, the signer's pubkey and a clock, because this
18
+ package reaches for none of them. And it returns `UnsignedActionEvent | string`
19
+ where **the string is the refusal** — the example signed and published the
20
+ result without checking, so a reader following it would have put the words
21
+ "This app does not offer …" on the relay.
22
+
23
+ - **The content model was undocumented.** `ResolvedSlot.format` shipped in 0.7.0
24
+ and a body cannot be rendered safely without it: §13 forbids reading either
25
+ content model as the other, and the page said nothing about which one you
26
+ have. Now a table of the three values, `toRenderTree`, and the rule that the
27
+ format is decided by the declaration and never by looking at the body.
28
+
29
+ - **Object-creating actions were undocumented** — `control: 'form'`, `fields`,
30
+ `createsUnder`, and passing an object plus a `newId`, all shipped in 0.6.0.
31
+ With the producer's side too, since `required` is a list of names on `input`
32
+ rather than a flag on each property, which is the sort of thing a page is for.
33
+
34
+ ### Added
35
+
36
+ - **`test/readme.test.ts` — the README, executed.** Every assertion is a claim
37
+ the page makes in prose, and it lives in `test/` so `npm run typecheck`
38
+ compiles it against the real declarations. The published example, pasted in
39
+ verbatim, fails that pass with `TS2353: 'object' does not exist in type`.
40
+
41
+ The page's examples had been run once, in a scratch project, when the package
42
+ was first published. That is a snapshot; this is a check.
43
+
9
44
  ## 0.9.0 — 2026-09-02
10
45
 
11
46
  **Behaviour unchanged, and no export moved.** `contentFormatOf`,
package/README.md CHANGED
@@ -55,6 +55,7 @@ What comes back:
55
55
  title: { value: 'Billing entry' },
56
56
  status: { value: 'In Progress', colour: 'blue' },
57
57
  subtitle: { value: 'Settings entry point.' },
58
+ body: { value: '**Stripe Checkout** for billing.', format: 'marker' },
58
59
  },
59
60
  meta: [{ label: 'Assignee', value: 'abc…', isPubkey: true }],
60
61
  children: [ /* … more of the same, if the owner declared a list */ ],
@@ -69,6 +70,36 @@ politeness, it is required: the slot set is closed but it *grows*, and producers
69
70
  upgrade before consumers do. `title` is always present, so an object you only
70
71
  half understand still renders as a named, resolvable thing.
71
72
 
73
+ ### A `body` says which content model it is in — read it, do not guess
74
+
75
+ `body` is the one slot that carries structure, and it arrives in one of two
76
+ models that must never be read as each other. **The slot tells you which**, so
77
+ you never have to look at the text:
78
+
79
+ | `slot.body.format` | what you have |
80
+ | --- | --- |
81
+ | `'marker'` | the marker dialect — `**bold**`, `# heading`, `> quote`, fenced code |
82
+ | `'blocks'` | a JSON block document |
83
+ | `'unknown'` | a model published after your app was written |
84
+
85
+ ```ts
86
+ import { toRenderTree } from '@estiva-app/protocol'
87
+
88
+ // One tree, whichever model it came in. Marks decided, blocks decided,
89
+ // nothing left to parse — so you cannot render markup by accident.
90
+ const blocks = toRenderTree(object.slots.body.value, object.slots.body.format)
91
+ ```
92
+
93
+ **Never decide the model by inspecting the body.** A description that happens to
94
+ begin with `{` is marker text if its event carries no `content-format` tag, and
95
+ there are hundreds of those already published. `'unknown'` exists so you can
96
+ decline: rendering a body you do not understand as plain text is correct, and
97
+ guessing at it is not.
98
+
99
+ A slot that is *not* a body carries no `format` at all — that is how you tell
100
+ "this is prose in the marker dialect" from "this is a title, and models do not
101
+ apply to it".
102
+
72
103
  ### The states you must draw, and the one that catches everyone
73
104
 
74
105
  A foreign object has more failure states than anything else on your screen,
@@ -94,16 +125,89 @@ An action is **an event to publish**, never an endpoint to call. There is no
94
125
  server in the loop, and the owning app can be offline.
95
126
 
96
127
  ```ts
97
- import { buildActionEvent } from '@estiva-app/interop'
98
-
99
- const unsigned = buildActionEvent({ object, actionId: 'set-issue-status', value: 'done' })
100
- const signed = await signer.sign(unsigned)
101
- await relay.publish(signed)
128
+ import { buildActionEvent, resolveManifest } from '@estiva-app/interop'
129
+
130
+ const resolved = await resolveManifest(object.kind, query)
131
+
132
+ const built = buildActionEvent({
133
+ manifest: resolved.manifest,
134
+ kind: object.kind,
135
+ address: object.ref,
136
+ objectAuthor: pointer.pubkey,
137
+ folder, // the object's channel — see below
138
+ actionId: 'set-issue-status',
139
+ value: 'done',
140
+ pubkey: me, // whoever is about to sign
141
+ createdAtMs: Date.now(),
142
+ })
143
+
144
+ // A refusal is a string, and it is written for a person to read.
145
+ if (typeof built === 'string') return show(built)
146
+
147
+ await relay.publish(await signer.sign(built))
102
148
  ```
103
149
 
150
+ **Check the string.** `buildActionEvent` returns `UnsignedActionEvent | string`,
151
+ and the string is why it refused — an undeclared field, a required one left
152
+ empty, a value outside the declared vocabulary. Treat the result as an event
153
+ without checking and you will sign the refusal.
154
+
155
+ Everything it needs is passed in rather than reached for: this package opens no
156
+ socket, reads no clock and holds no identity, so the built event is a pure
157
+ function of its inputs and the whole suite runs against an array.
158
+
104
159
  `object.actions` is already resolved against the owner's vocabularies, so a
105
160
  `select` arrives with its options and the value it currently holds.
106
161
 
162
+ ### Actions that create an object, not just change one
163
+
164
+ An action with `control: 'form'` makes a whole new object rather than setting a
165
+ field on this one. It arrives with the schema already resolved:
166
+
167
+ ```ts
168
+ const action = object.actions.find((a) => a.control === 'form')
169
+
170
+ action.fields // [{ name: 'title', type: 'string', required: true }, …]
171
+ action.createsUnder // the address the new object will hang under
172
+ ```
173
+
174
+ Draw the fields, then pass an object rather than a scalar, plus an id for the
175
+ thing being made:
176
+
177
+ ```ts
178
+ const built = buildActionEvent({
179
+ /* …as above… */
180
+ actionId: action.id,
181
+ value: { title: 'Payment fails on retry' },
182
+ newId: crypto.randomUUID(),
183
+ })
184
+ ```
185
+
186
+ Declaring one, from the producer's side — `required` is a list of names, not a
187
+ flag on each property:
188
+
189
+ ```jsonc
190
+ {
191
+ "id": "add-issue", "label": "Add issue", "appliesTo": "31800",
192
+ "emits": { "kind": 31801, "setTag": "a", "toAddressOf": "self" },
193
+ "input": {
194
+ "type": "object",
195
+ "properties": { "title": { "type": "string" }, "note": { "type": "string" } },
196
+ "required": ["title"]
197
+ }
198
+ }
199
+ ```
200
+
201
+ `newId` is **required** for an addressable kind and is supplied by you rather
202
+ than generated here — an object published without one has no address at all, so
203
+ nothing could reference it, comment on it or act on it afterwards.
204
+
205
+ Two rules worth knowing before you draw the form. **A property's name is the tag
206
+ its value is written to**, which is what lets a consumer build an event for an
207
+ app it has never seen. And **nothing can target an event's `content`** — an app
208
+ whose body lives there cannot have it filled from outside, which is why an
209
+ action may declare a `description` field that ends up in a tag.
210
+
107
211
  Two consequences worth knowing before you ship it. **Validation is an honour
108
212
  system** — nothing stops you publishing a status outside the declared
109
213
  vocabulary, and a consumer that skips the check is the one putting junk in a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/interop",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Render and act on another app's objects from its published NIP-89 manifest. The owner defines the projection; the consumer decides how it looks.",
5
5
  "license": "MIT",
6
6
  "type": "module",