@raybase/record-mount 0.0.2 → 0.3.4
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/LICENSE +21 -0
- package/README.md +432 -2
- package/dist/body.d.ts +165 -0
- package/dist/body.d.ts.map +1 -0
- package/dist/body.js +538 -0
- package/dist/body.js.map +1 -0
- package/dist/client.d.ts +132 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +376 -0
- package/dist/client.js.map +1 -0
- package/dist/contract.d.ts +261 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +83 -0
- package/dist/contract.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +81 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/mount.d.ts +219 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +372 -0
- package/dist/mount.js.map +1 -0
- package/dist/testing.d.ts +149 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +764 -0
- package/dist/testing.js.map +1 -0
- package/package.json +34 -8
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Itself & Co
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,433 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@raybase/record-mount`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Optional, explicitly opt-in Raybase module for mounting an **external
|
|
4
|
+
append-only record layer** into a Raybase app.
|
|
5
|
+
|
|
6
|
+
The record layer is a separate service — a Cloudflare Worker in front of an
|
|
7
|
+
append-only store — that the app reaches over a service binding. This package
|
|
8
|
+
is the app's half of that seam: a typed client pinned to the service's wire
|
|
9
|
+
contract, the two authorization gates that belong in front of it, and the body
|
|
10
|
+
composer a filing needs. It is a **wire client**, not a database: it stores
|
|
11
|
+
nothing, owns no schema, and knows no family names.
|
|
12
|
+
|
|
13
|
+
## Why this is in Core and not Pro
|
|
14
|
+
|
|
15
|
+
Recorded at adoption, as a placement decision should be.
|
|
16
|
+
|
|
17
|
+
**Core.** The mount is client machinery over a published wire contract. It
|
|
18
|
+
touches nothing Pro owns — no D1 control plane, no organizations or
|
|
19
|
+
memberships, no SSO, no Hub, no licensing — and it needs nothing from Pro to
|
|
20
|
+
work. It sits exactly where `@raybase/api` and `@raybase/openai-subscription`
|
|
21
|
+
sit: an optional first-party module a Core app adds explicitly. Putting it in
|
|
22
|
+
Pro would deny it to Core apps and buy nothing.
|
|
23
|
+
|
|
24
|
+
Placement in Core prices this capability at zero, permanently, and that is
|
|
25
|
+
accepted deliberately. It also draws the seam in the right place for later:
|
|
26
|
+
**Core owns the read-policy hook; an organization-aware implementation of that
|
|
27
|
+
hook is legitimate Pro work.** The interface is the open half; the
|
|
28
|
+
sophisticated filler can be the commercial half without moving anything.
|
|
29
|
+
|
|
30
|
+
## Provenance
|
|
31
|
+
|
|
32
|
+
This package folds **two independent builds** of the same brief, written the
|
|
33
|
+
same day against the same request.
|
|
34
|
+
|
|
35
|
+
An HQ-application session, working ahead toward this package as a dependency,
|
|
36
|
+
built the contract declarations, the error class and envelope rehydration, the
|
|
37
|
+
client with its construction-time address checks and per-stub contract memo,
|
|
38
|
+
the body composer, the mount's two-gate design, and the first draft of the
|
|
39
|
+
in-memory door double. A provider-side session built the read-policy semantics,
|
|
40
|
+
reviewed and finished the above, and wrote the packaging, the suite and this
|
|
41
|
+
file.
|
|
42
|
+
|
|
43
|
+
Where the two designs differed, the merits decided and the reasons are in the
|
|
44
|
+
code. Named plainly because a package's history is part of what a reader is
|
|
45
|
+
owed, and because "who wrote this" is not a question a changelog should have to
|
|
46
|
+
guess at later.
|
|
47
|
+
|
|
48
|
+
## The shape
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import {
|
|
52
|
+
createRecordBodyComposer,
|
|
53
|
+
defineRecordMount,
|
|
54
|
+
openRecordDoor
|
|
55
|
+
} from "@raybase/record-mount";
|
|
56
|
+
import { allowAll } from "@raybase/core";
|
|
57
|
+
import type { RaybaseEnv } from "@raybase/core/worker";
|
|
58
|
+
import type { RecordDoorStub } from "@raybase/record-mount";
|
|
59
|
+
|
|
60
|
+
type Env = RaybaseEnv & { RECORDS: RecordDoorStub };
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The binding is an ordinary Cloudflare service binding in the app's own
|
|
64
|
+
`wrangler.jsonc`. Raybase adds no obstacle here: `RaybaseEnv` is an
|
|
65
|
+
intersection type and unknown bindings ride the app's config untouched.
|
|
66
|
+
|
|
67
|
+
```jsonc
|
|
68
|
+
{
|
|
69
|
+
"services": [
|
|
70
|
+
{
|
|
71
|
+
"binding": "RECORDS",
|
|
72
|
+
"service": "your-record-door",
|
|
73
|
+
"entrypoint": "WriteDoor",
|
|
74
|
+
"props": { "actor": "your-app", "namespace": "prod" }
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then, per request:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const door = openRecordDoor({ env, binding: "RECORDS", namespace: "prod" });
|
|
84
|
+
|
|
85
|
+
const mount = defineRecordMount({
|
|
86
|
+
name: "records",
|
|
87
|
+
door,
|
|
88
|
+
auth: { audience: "signed-in", policy: allowAll },
|
|
89
|
+
readPolicy: ({ envelope, viewer }) => visibleTo(envelope, viewer),
|
|
90
|
+
unfilteredReads: ["materialize"],
|
|
91
|
+
composer
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const page = await mount.list({ family: "decision", status: ["proposed"] }, viewer);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## The address is deploy configuration, not an argument
|
|
98
|
+
|
|
99
|
+
No verb takes an actor or a namespace. Both ride the binding's `props`, which
|
|
100
|
+
only the app's deploy config can write and which the calling code has no
|
|
101
|
+
runtime channel to set or forge. That one-wayness is what makes the address
|
|
102
|
+
trustworthy, and it is why `namespace` on `openRecordDoor` is documented as a
|
|
103
|
+
**declaration, not a verification**: it buys one namespace per mount, a legible
|
|
104
|
+
name in refusals, and a written claim a reviewer can diff against the deployed
|
|
105
|
+
config. It cannot catch a config that says one thing here and another in the
|
|
106
|
+
props, and it does not pretend to.
|
|
107
|
+
|
|
108
|
+
Which entrypoint the app binds decides what it may do. A read door genuinely
|
|
109
|
+
lacks `append` and `transition`, so the runtime refuses a write before any
|
|
110
|
+
policy code runs. That wall is real and it always held.
|
|
111
|
+
|
|
112
|
+
### What a client can and cannot see across the seam
|
|
113
|
+
|
|
114
|
+
**A Cloudflare RPC service-binding stub answers every property access with a
|
|
115
|
+
callable.** `typeof stub.append === "function"` is true against a read door,
|
|
116
|
+
true against a service that is not a record door at all, and true for a
|
|
117
|
+
property nobody ever defined. Any check written on property presence is
|
|
118
|
+
reading its own assumption back.
|
|
119
|
+
|
|
120
|
+
So the client asks the door instead. `version()` carries an optional
|
|
121
|
+
`writes` field — the entrypoint's own declaration of whether it holds the write
|
|
122
|
+
verbs — and `door.writable` is a **getter with three states**:
|
|
123
|
+
|
|
124
|
+
| `door.writable` | Means |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `true` | a write door: a plain binding really has the methods, or an RPC door declared `writes: true` |
|
|
127
|
+
| `false` | a read door: a plain binding really lacks them, or an RPC door declared `writes: false` |
|
|
128
|
+
| `"unknown"` | an RPC door whose declaration has not arrived yet, or one old enough not to declare at all |
|
|
129
|
+
|
|
130
|
+
`"unknown"` is an honest third state, not a placeholder. For a definite answer,
|
|
131
|
+
`await door.assertContract()` first and then read it: the contract check is the
|
|
132
|
+
call that fetches the declaration, and it is memoized per binding stub, so a
|
|
133
|
+
later client on the same isolate reads the answer without a second round trip.
|
|
134
|
+
|
|
135
|
+
`"unknown"` never blocks a write. The call goes out, and if the door turns out
|
|
136
|
+
to lack the verb, the runtime's method-missing `TypeError` is **translated**
|
|
137
|
+
into this package's typed `forbidden` refusal, with the original runtime
|
|
138
|
+
message in `detail.cause`. A consumer switches on the taxonomy either way and
|
|
139
|
+
never has to sniff a runtime error's wording.
|
|
140
|
+
|
|
141
|
+
The translation is deliberately narrow: it fires only when the `TypeError`'s
|
|
142
|
+
message carries the observed method-missing shape *naming the verb itself*
|
|
143
|
+
(`does not implement … "append"`). Two cases therefore propagate untouched: a
|
|
144
|
+
door that declared `writes: true` and throws anyway (lying or broken — dressing
|
|
145
|
+
a real defect as deploy configuration would bury it), and any `TypeError` that
|
|
146
|
+
is not this verb's own method-missing (a broken door, a serialization failure —
|
|
147
|
+
one such mislabel hid a real invocation bug for an evening,
|
|
148
|
+
fnd-2026-08-31-mount-write-call-invocation). If workerd's wording ever shifts,
|
|
149
|
+
the translation stops firing and the raw error surfaces: loud beats mislabeled.
|
|
150
|
+
|
|
151
|
+
For the same reason, the "this binding is not a record door" check at
|
|
152
|
+
`openRecordDoor` runs **only on a plain binding**, where absence is real. On an
|
|
153
|
+
RPC stub it would pass against anything, so it is skipped rather than performed
|
|
154
|
+
and believed; a binding pointing at the wrong service is caught at its first
|
|
155
|
+
call, as `invalid_binding`.
|
|
156
|
+
|
|
157
|
+
## The contract pin is fail-closed
|
|
158
|
+
|
|
159
|
+
The client pins `RECORD_DOOR_CONTRACT_VERSION` and calls `version()` on its
|
|
160
|
+
first verb — memoized per binding stub, which is per isolate. A door serving a
|
|
161
|
+
different version refuses `contract_mismatch` and **nothing proceeds**. There is
|
|
162
|
+
no degrade path and no negotiation.
|
|
163
|
+
|
|
164
|
+
The check lands at the first verb rather than at construction because a Worker
|
|
165
|
+
constructs its handle on every request, and an RPC round trip there would price
|
|
166
|
+
the check per request. `assertContract()` is the eager form for an app with a
|
|
167
|
+
real boot hook. Two codes are terminal and stay memoized — `contract_mismatch`
|
|
168
|
+
(this door serves another shape) and `invalid_binding` (this binding points at a
|
|
169
|
+
service that is not a record door); both are deploy configuration, and neither
|
|
170
|
+
resolves by being asked again inside one isolate. A failure to *reach* the door
|
|
171
|
+
clears the memo, so a flap does not poison an isolate for its lifetime.
|
|
172
|
+
|
|
173
|
+
The memo holds the **reply**, not merely the verdict. A Worker builds a client
|
|
174
|
+
per request over one isolate-lifetime stub, so every client after the first
|
|
175
|
+
reads the door's declared capabilities out of the memo rather than never
|
|
176
|
+
learning them.
|
|
177
|
+
|
|
178
|
+
### When the pin moves, and when it does not
|
|
179
|
+
|
|
180
|
+
It moves for any change that breaks either direction of the seam: a verb added
|
|
181
|
+
or removed, an argument or return shape altered, a taxonomy code appearing on a
|
|
182
|
+
verb that could not raise it.
|
|
183
|
+
|
|
184
|
+
It does **not** move for an additive optional field with defined absence
|
|
185
|
+
semantics, because both directions stay compatible — an old client ignores the
|
|
186
|
+
field, and a new client reads absence as "undeclared" and degrades toward
|
|
187
|
+
`unknown`. `version().writes` is exactly that, and it is why the pin is still
|
|
188
|
+
`"1"`. The alternative was worse than pedantic: the pin is fail-closed, so a
|
|
189
|
+
bump to `"2"` would have refused every currently deployed door at once, and this
|
|
190
|
+
package cannot execute that flag day — the doors are deployed by the consuming
|
|
191
|
+
organization, not from this repository. A version bump nobody can land is an
|
|
192
|
+
outage wearing a migration.
|
|
193
|
+
|
|
194
|
+
## Two gates, and they answer different questions
|
|
195
|
+
|
|
196
|
+
**Gate 1 — the route's Raybase auth declaration.** `audience` and `policy`,
|
|
197
|
+
exactly as a Raybase action declares them, validated through Core's own
|
|
198
|
+
machinery at mount time and evaluated through it on every call.
|
|
199
|
+
`defineRecordMount` **refuses to mount without one**: a record layer is not a
|
|
200
|
+
surface on which "I forgot" may be spellable, so an explicitly public mount says
|
|
201
|
+
`{ audience: "public", policy: allowAll }` and means it.
|
|
202
|
+
|
|
203
|
+
Every principal kind is answered for, because the declared policy is evaluable
|
|
204
|
+
against only two of them:
|
|
205
|
+
|
|
206
|
+
| Principal | Gate 1 |
|
|
207
|
+
|---|---|
|
|
208
|
+
| `public` | judged by the audience alone |
|
|
209
|
+
| `user` | evaluated against the declared policy |
|
|
210
|
+
| `operator` | **also** evaluated against the declared policy — an operator is not exempt |
|
|
211
|
+
| `service` | **refused**, unless the mount declares `allowServicePrincipals` |
|
|
212
|
+
| `agent` | judged as the `user`, `public` or `service` principal it acts for |
|
|
213
|
+
|
|
214
|
+
A service principal carries no user, so a role policy against it is not merely
|
|
215
|
+
unevaluated but unevaluable — and "the policy could not be applied" must never
|
|
216
|
+
read as "the policy passed".
|
|
217
|
+
|
|
218
|
+
The same rule applies one step earlier, where it is knowable at mount time: a
|
|
219
|
+
mount whose audience admits `public` and whose policy is narrower than
|
|
220
|
+
`allowAll` is **refused at mount time**. That declaration is contradictory — it
|
|
221
|
+
admits anonymous callers, who carry no user for the restriction to be evaluated
|
|
222
|
+
against — and the alternative is for it to resolve silently in the permissive
|
|
223
|
+
direction. Narrow the audience, or say `allowAll` and mean it.
|
|
224
|
+
|
|
225
|
+
**Gate 2 — the read-policy hook.** Envelope in, allow or deny out, applied to
|
|
226
|
+
every row and every `get` the mount serves. The default is
|
|
227
|
+
`allowAnyAuthenticated`: any non-anonymous, non-disabled principal reads
|
|
228
|
+
everything, and an agent counts as the principal it acts for. What replaces it
|
|
229
|
+
is the consuming app's design. A hook receives the viewer as given, so an agent
|
|
230
|
+
run arrives as kind `agent`; read `principalOnBehalfOf(viewer.principal)` to
|
|
231
|
+
judge by whom it acts for.
|
|
232
|
+
|
|
233
|
+
A hook that **throws** is not a denial. It propagates: a policy that cannot
|
|
234
|
+
decide has not decided, and turning that into "deny" would render an outage as a
|
|
235
|
+
silently empty page.
|
|
236
|
+
|
|
237
|
+
### Deny means absence
|
|
238
|
+
|
|
239
|
+
A denied `get` answers `not_found`. A denied row is simply not in its page. The
|
|
240
|
+
two are indistinguishable on purpose: a `forbidden` on a specific id confirms
|
|
241
|
+
that the id exists, which is the leak the policy was installed to close.
|
|
242
|
+
|
|
243
|
+
Indistinguishable means **byte-identical**, not merely same-code. The denial's
|
|
244
|
+
message is built from the same default a record door's own `not_found` uses
|
|
245
|
+
(`` `${code}: ${JSON.stringify(detail)}` ``) rather than written out here.
|
|
246
|
+
Matching on `detail` alone is not enough: a denial that said `not_found: <id>`
|
|
247
|
+
against a door whose absence says `not_found: {"id":"<id>"}` differs in the
|
|
248
|
+
half most likely to reach a log, a toast or an error page, and one probe per id
|
|
249
|
+
is an existence oracle again.
|
|
250
|
+
|
|
251
|
+
### A filtered page is a shorter page
|
|
252
|
+
|
|
253
|
+
Filtering shrinks a page. A `limit: 50` list whose viewer may read twelve of
|
|
254
|
+
them returns twelve rows **and the page's own cursor**. The cursor is still
|
|
255
|
+
correct — it is the door's keyset position, not a count — so a consumer that
|
|
256
|
+
needs full pages loops until it has enough. This is stated rather than hidden
|
|
257
|
+
because a shrinking page is exactly what a row policy costs.
|
|
258
|
+
|
|
259
|
+
The hook is asked about a page at bounded concurrency (`readPolicyConcurrency`,
|
|
260
|
+
default 16), because a hook is expected to read app tables and a page may carry
|
|
261
|
+
a thousand rows. Order is preserved regardless of completion order.
|
|
262
|
+
|
|
263
|
+
### `count` and `materialize` are fail-closed
|
|
264
|
+
|
|
265
|
+
Neither can be filtered by a per-envelope hook, for different reasons. `count`
|
|
266
|
+
is folded inside the door and returns totals, not records: filtering it would
|
|
267
|
+
mean counting client-side over the whole namespace, and serving it unfiltered
|
|
268
|
+
discloses the shape of a corpus the viewer may not read. `materialize` streams
|
|
269
|
+
stored bytes — whole files, several records to a file in some forms — so there
|
|
270
|
+
is no envelope for the hook to judge at all.
|
|
271
|
+
|
|
272
|
+
So both are gated, and **the gate arms on a pair, decided once at mount time:
|
|
273
|
+
can the effective read policy deny a viewer this mount's audience admits?**
|
|
274
|
+
Armed, they refuse `forbidden` unless the mount declares them:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
unfilteredReads: ["materialize"] // this mount folds whole pages, deliberately
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| Mount | Deniable admitted viewer? | Gate |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| `{signed-in, allowAnyAuthenticated}` (the default) | no — the audience admits only authenticated viewers, and the default allows all of them | **unarmed**, both verbs flow |
|
|
283
|
+
| `{public, allowAll}` with the default read policy | yes — public principals are admitted, and the default denies them | **armed** |
|
|
284
|
+
| `{["public","signed-in"], …}` with the default | yes — the public class is still admitted | **armed** |
|
|
285
|
+
| any policy this package cannot see inside | assumed yes | **armed** |
|
|
286
|
+
| `denyAllRecords` | yes, on every audience | **armed** |
|
|
287
|
+
|
|
288
|
+
Both halves of the pair matter. Arming only on "is there a custom policy?"
|
|
289
|
+
fails **open** — the default denies public principals, so a public-audience
|
|
290
|
+
mount served an anonymous caller the totals and the bytes while `list` served
|
|
291
|
+
that same caller nothing. Arming on "can this policy deny anybody?" fails
|
|
292
|
+
**noisy** — the default denies *some* principal, so every mount would arm and
|
|
293
|
+
`unfilteredReads` would become mandatory boilerplate instead of a signal.
|
|
294
|
+
|
|
295
|
+
A policy this package did not write is always armed, wrapper or not. That is
|
|
296
|
+
deliberate: being unrecognised is never the permissive answer, which is why
|
|
297
|
+
`readPolicy: (i) => allowAnyAuthenticated(i)` arms even though it behaves
|
|
298
|
+
exactly like the default. Erring toward a declaration is the safe direction.
|
|
299
|
+
|
|
300
|
+
`unguardedDoor` remains the general escape hatch for a caller that wants the
|
|
301
|
+
client with no gate at all and is willing to say so at the call site.
|
|
302
|
+
|
|
303
|
+
### A paged `materialize` is not a snapshot
|
|
304
|
+
|
|
305
|
+
The cursor is a position in a deterministic order, not a point in time. A
|
|
306
|
+
multi-page walk taken while writes land under the same prefix can repeat an
|
|
307
|
+
entry or miss one. Walk a quiesced namespace when that matters.
|
|
308
|
+
|
|
309
|
+
## The body composer
|
|
310
|
+
|
|
311
|
+
The door takes **bytes, not fields** — it parses a record's envelope out of the
|
|
312
|
+
body it is handed, which is what makes a record immutable and its digest
|
|
313
|
+
meaningful. So a filing surface needs a composer, and without one every form
|
|
314
|
+
field is a string concatenation.
|
|
315
|
+
|
|
316
|
+
This one is generic over consumer-supplied family descriptors. No family, no
|
|
317
|
+
field name and no id prefix is hardcoded anywhere in this package; the
|
|
318
|
+
consumer's schema domain stays the consumer's. What the package owns is the
|
|
319
|
+
**dialect** and the discipline around it:
|
|
320
|
+
|
|
321
|
+
- Field order is the descriptor's, so the same fields always compose to the same
|
|
322
|
+
bytes and therefore the same digest.
|
|
323
|
+
- Only declared fields may appear, and required ones must. An unknown input
|
|
324
|
+
field refuses rather than being dropped — a silently ignored field is a filing
|
|
325
|
+
that lies.
|
|
326
|
+
- Four render forms only — `scalar`, `list`, `folded`, `map` — because the
|
|
327
|
+
reader on the other end is a minimal one. A value that cannot be rendered
|
|
328
|
+
unambiguously in that subset is **refused**, never escaped: a value that needs
|
|
329
|
+
escaping to survive is a value the far reader will get wrong.
|
|
330
|
+
- **Composition self-checks.** Every composed body is parsed back with the
|
|
331
|
+
dialect's own reader and compared field by field before it is returned. The
|
|
332
|
+
canonical-bytes discipline is an assertion here, not a promise.
|
|
333
|
+
- **The reader accepts a superset of what the writer emits**, deliberately. A
|
|
334
|
+
mount reads records other writers filed, so `parseRecordFrontmatter` tracks
|
|
335
|
+
the record substrate's own reader rather than this composer: inside a
|
|
336
|
+
one-level map a sub-value may itself fold (` sub: >-`, or a bare ` sub:`,
|
|
337
|
+
followed by indented continuation lines rejoined with single spaces), and a
|
|
338
|
+
tab-led line counts as a continuation rather than as a new top-level key.
|
|
339
|
+
The composer emits neither form. Reading *less* than the substrate would
|
|
340
|
+
make the mount misread real records the substrate serves correctly — which
|
|
341
|
+
is a silent wrong answer, the one failure this package will not ship.
|
|
342
|
+
|
|
343
|
+
Refusals arrive as `RecordDoorError` with the door's own `invalid_record` code,
|
|
344
|
+
so a form handler has one error surface for "this filing is malformed" whether
|
|
345
|
+
the composer caught it or the door did — and neither becomes a 500.
|
|
346
|
+
|
|
347
|
+
### Writing: `by` is provenance, and the door checks it against the actor
|
|
348
|
+
|
|
349
|
+
`mount.transition(...)` passes the caller's whole event through, `by` included.
|
|
350
|
+
A door may guard that field: ours matches `by` against the provenance prefixes
|
|
351
|
+
the *binding's actor* is allowed to stamp, and refuses `forbidden` when they
|
|
352
|
+
disagree. So an app that stamps `by` from the signed-in user, while its binding
|
|
353
|
+
carries some other actor, will meet a `forbidden` whose cause is not obvious
|
|
354
|
+
from the call site.
|
|
355
|
+
|
|
356
|
+
`by` is per-act provenance, never identity, and no consumer should build an
|
|
357
|
+
authorization on it. Stamp it with something the binding's actor is entitled to
|
|
358
|
+
claim, and keep the viewer's identity in the event's `fields` if the record
|
|
359
|
+
should carry it.
|
|
360
|
+
|
|
361
|
+
## Testing
|
|
362
|
+
|
|
363
|
+
`@raybase/record-mount/testing` exports `createRecordDoorDouble()`, a
|
|
364
|
+
contract-conformant in-memory door: the result envelope, the closed taxonomy,
|
|
365
|
+
the Row projection, keyset paging, append idempotency by content, and the
|
|
366
|
+
ordering rule that `forbidden` outranks `not_found`. It is generic — family, id
|
|
367
|
+
shape, lifecycle vocabulary, grants and headline derivation are all hooks with
|
|
368
|
+
documented defaults — and it lets a consuming app exercise its mount with no
|
|
369
|
+
deployed Worker, no service binding and no network.
|
|
370
|
+
|
|
371
|
+
**It is not the door.** Bytes the double accepts are not thereby proven
|
|
372
|
+
acceptable to a deployed door: its frontmatter reader is this package's own, so
|
|
373
|
+
a suite green against it is a builder-authored oracle for wire acceptance and
|
|
374
|
+
nothing more. The real-door cross-check is a consumer-side act against a running
|
|
375
|
+
service, and the double does not stand in for it.
|
|
376
|
+
|
|
377
|
+
### The double presents as an RPC stub by default
|
|
378
|
+
|
|
379
|
+
It used to hand back a plain object that simply omitted `append`/`transition`
|
|
380
|
+
for a read door, and that cost a real defect: the production seam answers every
|
|
381
|
+
property access with a callable, so every property-presence check read one way
|
|
382
|
+
against the fixture and the other way against a deployed door. The suite was
|
|
383
|
+
green on a shape that does not exist in production.
|
|
384
|
+
|
|
385
|
+
So `createRecordDoorDouble()` now returns a **Proxy that answers every property
|
|
386
|
+
with a function**, where calling a verb the door does not carry rejects with a
|
|
387
|
+
`TypeError` imitating the runtime's method-missing shape — and the manners go
|
|
388
|
+
one level down: a callable read off the stub answers *its* property accesses
|
|
389
|
+
(`call`, `apply`, anything) with further method-missing callables, as the real
|
|
390
|
+
proxy does, so an extracted-and-re-invoked verb fails in the fixture the same
|
|
391
|
+
way it fails in production. The message shape (`does not implement` naming the
|
|
392
|
+
method) is what the client's translation keys on; **do not pin the full
|
|
393
|
+
sentence in a test** — workerd's wording is not ours to guarantee, and when it
|
|
394
|
+
changes the client deliberately stops translating rather than guessing.
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
createRecordDoorDouble({ writable: false }); // RPC read door, declares writes: false
|
|
398
|
+
createRecordDoorDouble({ writable: false, declaredWrites: "omit" }); // an older door: declares nothing
|
|
399
|
+
createRecordDoorDouble({ rpc: false }); // a plain object, where absence is absence
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Pass `rpc: false` when the thing you are standing in for really is an
|
|
403
|
+
in-process implementation. Everything else should keep the default: a fixture
|
|
404
|
+
kinder than the seam is a fixture that lies.
|
|
405
|
+
|
|
406
|
+
Two more specifics worth knowing before you build on it. Its **`materialize` path
|
|
407
|
+
layout (`family/id`) is invented** — a real door's tree is its substrate's, and
|
|
408
|
+
is not that — so a prefix convention tested against the double is testing the
|
|
409
|
+
double. And its **refusal messages are all one shape**, the error type's
|
|
410
|
+
default; a real door authors bespoke prose at some sites, so match on `code` and
|
|
411
|
+
`detail`, not on message text. The single exception is `not_found`, where the
|
|
412
|
+
shapes genuinely agree and the agreement is load-bearing for deny-means-absence
|
|
413
|
+
above.
|
|
414
|
+
|
|
415
|
+
## Status
|
|
416
|
+
|
|
417
|
+
Version 0.1.0. This repository's own suite runs against the in-memory double —
|
|
418
|
+
a **Proxy in the test process** standing in for a Cloudflare service binding;
|
|
419
|
+
nothing here reaches workerd.
|
|
420
|
+
|
|
421
|
+
The real seam has now been walked by a consumer, though: as of 428c3a47
|
|
422
|
+
(2026-08-31) the first consumer's topology suite drives every read and write
|
|
423
|
+
verb of this client against a **deployed record door over a real service
|
|
424
|
+
binding** — including doors that declare `version().writes` on both entrypoints
|
|
425
|
+
— and runs green. That walkthrough is what surfaced (and now pins the fix for)
|
|
426
|
+
the two real-seam defects the double alone could not see: writability read off
|
|
427
|
+
stub property presence, and write verbs invoked via an extracted
|
|
428
|
+
`Function.prototype.call` that the RPC proxy answers as a method named "call".
|
|
429
|
+
The proof lives in the consumer's suite, not this one — this package's own
|
|
430
|
+
tests remain a simulation, and the double is kept faithful to what the real
|
|
431
|
+
seam has actually been observed to do. One deliberately open corner: whether a
|
|
432
|
+
real stub answers `toString`/`valueOf` as RPC methods, as the double does, has
|
|
433
|
+
not been observed; if the real seam differs, the double follows the seam.
|
package/dist/body.d.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-family body builder: filing from a form means composing a
|
|
3
|
+
* canonical record body, and this is the machinery that composes it.
|
|
4
|
+
*
|
|
5
|
+
* **It is generic over consumer-supplied family descriptors.** No family,
|
|
6
|
+
* no field name and no id prefix is hardcoded anywhere in this package —
|
|
7
|
+
* the consumer's schema domain stays the consumer's. What this module owns
|
|
8
|
+
* is the *dialect*: which frontmatter forms it will emit, deterministically,
|
|
9
|
+
* and which inputs it refuses rather than rendering ambiguously.
|
|
10
|
+
*
|
|
11
|
+
* ## The dialect, and why it is small
|
|
12
|
+
*
|
|
13
|
+
* A record body is frontmatter plus prose, and the parser on the other end
|
|
14
|
+
* is a minimal one — the record substrate parses the envelope out of the
|
|
15
|
+
* body itself, with a hand-written reader rather than a YAML library.
|
|
16
|
+
* Emitting the full YAML surface would produce bytes that round-trip
|
|
17
|
+
* through a real YAML parser and are silently misread by a minimal one, so
|
|
18
|
+
* this composer emits four forms only:
|
|
19
|
+
*
|
|
20
|
+
* - `scalar` — `key: value`
|
|
21
|
+
* - `list` — `key: [a, b]`
|
|
22
|
+
* - `folded` — `key: >-` with two-space continuation lines, rejoined with
|
|
23
|
+
* single spaces on read
|
|
24
|
+
* - `map` — a bare `key:` with two-space `sub: value` lines
|
|
25
|
+
*
|
|
26
|
+
* and it **refuses** any value it cannot render unambiguously in that
|
|
27
|
+
* subset. A refusal is loud and typed; there is no escaping fallback,
|
|
28
|
+
* because a value that needs escaping to survive is a value the reader on
|
|
29
|
+
* the other end will get wrong.
|
|
30
|
+
*
|
|
31
|
+
* That list is the WRITE dialect, and the reader in this module is wider
|
|
32
|
+
* than it: a mount reads records other writers filed, so the reader tracks
|
|
33
|
+
* the record substrate's own reader (folded map sub-values, tab-led
|
|
34
|
+
* continuations) rather than this writer. See `parseRecordFrontmatter` —
|
|
35
|
+
* the asymmetry is deliberate and documented there.
|
|
36
|
+
*
|
|
37
|
+
* Keys are restricted to letters, `_` and `-` for the same reason: the
|
|
38
|
+
* minimal readers this dialect targets match keys with that charset and
|
|
39
|
+
* SKIP a line they do not match, which turns a key containing a digit into
|
|
40
|
+
* a silently dropped field. A refusal at compose time is the better
|
|
41
|
+
* failure.
|
|
42
|
+
*
|
|
43
|
+
* ## Refusals arrive in the door's own taxonomy
|
|
44
|
+
*
|
|
45
|
+
* A composition refusal throws `RecordDoorError` with code
|
|
46
|
+
* `invalid_record` — exactly what the door answers for a body it cannot
|
|
47
|
+
* accept. A form handler therefore has ONE error surface for "this filing
|
|
48
|
+
* is malformed", whether the composer caught it or the store did, and
|
|
49
|
+
* neither becomes a 500.
|
|
50
|
+
*/
|
|
51
|
+
/** How one field renders into the frontmatter block. */
|
|
52
|
+
export type RecordFieldRender = "scalar" | "list" | "folded" | "map";
|
|
53
|
+
export type RecordFieldDescriptor = {
|
|
54
|
+
/** The frontmatter key. Letters, `_` and `-` only. */
|
|
55
|
+
key: string;
|
|
56
|
+
/** The input key this field reads. Defaults to `key`. */
|
|
57
|
+
from?: string;
|
|
58
|
+
/** Default `"scalar"`. */
|
|
59
|
+
render?: RecordFieldRender;
|
|
60
|
+
/** A missing or empty required field refuses. */
|
|
61
|
+
required?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* A fixed value, written whatever the input says — the mechanical
|
|
64
|
+
* fields a family always carries (`type: finding`). An input that also
|
|
65
|
+
* supplies the key refuses, rather than one of the two silently winning.
|
|
66
|
+
*/
|
|
67
|
+
constant?: string;
|
|
68
|
+
};
|
|
69
|
+
export type RecordFamilyDescriptor = {
|
|
70
|
+
/** The family name, as the consumer's schema domain spells it. */
|
|
71
|
+
family: string;
|
|
72
|
+
/** The fields, in the order they are written. Order is the bytes. */
|
|
73
|
+
fields: RecordFieldDescriptor[];
|
|
74
|
+
/**
|
|
75
|
+
* The input key carrying the markdown prose that follows the block.
|
|
76
|
+
* Omitted, the family files as frontmatter alone.
|
|
77
|
+
*/
|
|
78
|
+
prose?: string;
|
|
79
|
+
/** Whether the prose must be present and non-empty. */
|
|
80
|
+
proseRequired?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* The id prefix this family's records carry, e.g. `"dec"`. Declared, the
|
|
83
|
+
* composer checks that the composed id matches the contract's id scheme
|
|
84
|
+
* AND carries this prefix — so a form that files a decision under a
|
|
85
|
+
* case's id is refused here rather than at the door.
|
|
86
|
+
*
|
|
87
|
+
* The prefix vocabulary is the consumer's; only the *scheme* is the
|
|
88
|
+
* contract's.
|
|
89
|
+
*/
|
|
90
|
+
idPrefix?: string;
|
|
91
|
+
/** Which frontmatter key holds the id. Defaults to `"id"`. */
|
|
92
|
+
idKey?: string;
|
|
93
|
+
};
|
|
94
|
+
export type RecordBodyComposer = {
|
|
95
|
+
/** The families this composer knows, in declaration order. */
|
|
96
|
+
readonly families: readonly string[];
|
|
97
|
+
/** Compose one family's canonical body bytes from a form's input. */
|
|
98
|
+
compose(family: string, input: Record<string, unknown>): string;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* The contract's id scheme: `<prefix>-<yyyy-mm-dd>-<slug>`, with an
|
|
102
|
+
* optional trailing ordinal that the slug charset already admits. The
|
|
103
|
+
* scheme belongs to the door, which refuses `invalid_id` on anything else;
|
|
104
|
+
* the *prefix vocabulary* belongs to the consumer, which is why a
|
|
105
|
+
* descriptor supplies the prefix and this pattern supplies the shape.
|
|
106
|
+
*/
|
|
107
|
+
export declare const RECORD_ID_PATTERN: RegExp;
|
|
108
|
+
/** Mint an id under that scheme. Refuses rather than emitting one the door
|
|
109
|
+
* would reject. */
|
|
110
|
+
export declare function mintRecordId(input: {
|
|
111
|
+
prefix: string;
|
|
112
|
+
date: string;
|
|
113
|
+
slug: string;
|
|
114
|
+
ordinal?: number;
|
|
115
|
+
}): string;
|
|
116
|
+
/**
|
|
117
|
+
* Build a composer over the consumer's family descriptors.
|
|
118
|
+
*
|
|
119
|
+
* Descriptors are validated once, here, rather than at every filing: a
|
|
120
|
+
* malformed descriptor is a programming error and belongs at module load,
|
|
121
|
+
* not in a user's form submission.
|
|
122
|
+
*/
|
|
123
|
+
export declare function createRecordBodyComposer(descriptors: readonly RecordFamilyDescriptor[]): RecordBodyComposer;
|
|
124
|
+
/**
|
|
125
|
+
* This package's frontmatter reader: `key: value`, `key: [a, b]`, `key: >-`
|
|
126
|
+
* folded blocks rejoined with single spaces, and one-level maps of two-space
|
|
127
|
+
* `sub: value` lines whose sub-values may THEMSELVES fold. Keys are letters,
|
|
128
|
+
* `_` and `-`; a line matching nothing is skipped, which is what minimal
|
|
129
|
+
* readers do and why the composer refuses to emit one.
|
|
130
|
+
*
|
|
131
|
+
* ## The reader accepts a superset of what the writer emits
|
|
132
|
+
*
|
|
133
|
+
* The reader is deliberately NOT the writer's mirror, and saying so plainly
|
|
134
|
+
* is the point of this paragraph. The composer's write dialect is the small,
|
|
135
|
+
* refusing one documented at the top of this module — it never emits a folded
|
|
136
|
+
* map sub-value, and it refuses any input it cannot render in its four forms.
|
|
137
|
+
* The reader, though, reads a corpus written by OTHER writers, and the record
|
|
138
|
+
* substrate's own reader accepts more than we emit. Reading less than the
|
|
139
|
+
* substrate does would make a mount unable to read real records the substrate
|
|
140
|
+
* happily serves, so the reader matches the substrate's reader instead:
|
|
141
|
+
*
|
|
142
|
+
* - inside an open map, ` sub: >-` **or** ` sub:` (an empty sub-value)
|
|
143
|
+
* opens a folded sub-value; the following indented lines that are not
|
|
144
|
+
* themselves two-space `sub: value` lines append, rejoined with single
|
|
145
|
+
* spaces. A new two-space `sub: value` line closes the open fold; a
|
|
146
|
+
* non-indented line closes the map;
|
|
147
|
+
* - a TAB-led line counts as indented, exactly as the substrate's reader
|
|
148
|
+
* counts it — treating it as top-level would silently drop a continuation.
|
|
149
|
+
*
|
|
150
|
+
* Note the asymmetry is one-directional and safe in the direction that
|
|
151
|
+
* matters: every byte the composer emits still reads back to what it wrote
|
|
152
|
+
* (the round-trip assertion in `composeOne` is what enforces that), while
|
|
153
|
+
* bytes the composer would refuse to write may still be read.
|
|
154
|
+
*
|
|
155
|
+
* At top level `key:` (empty) opens a MAP, not a fold — the substrate does
|
|
156
|
+
* the same, and the `sub:`-opens-a-fold rule applies only INSIDE a map.
|
|
157
|
+
*
|
|
158
|
+
* It lives beside the writer on purpose. A dialect with its reader and its
|
|
159
|
+
* writer in two modules is a dialect with two definitions, and the drift
|
|
160
|
+
* shows up as bytes nobody can explain.
|
|
161
|
+
*/
|
|
162
|
+
export declare function parseRecordFrontmatter(text: string): Record<string, unknown>;
|
|
163
|
+
/** The prose beneath a composed body's frontmatter block. */
|
|
164
|
+
export declare function recordProseOf(text: string): string;
|
|
165
|
+
//# sourceMappingURL=body.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"body.d.ts","sourceRoot":"","sources":["../src/body.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAIH,wDAAwD;AACxD,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErE,MAAM,MAAM,qBAAqB,GAAG;IAClC,sDAAsD;IACtD,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0BAA0B;IAC1B,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,qBAAqB,EAAE,CAAC;IAChC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,qEAAqE;IACrE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;CACjE,CAAC;AAcF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,QAAqD,CAAC;AAEpF;oBACoB;AACpB,wBAAgB,YAAY,CAAC,KAAK,EAAE;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAuBT;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,SAAS,sBAAsB,EAAE,GAC7C,kBAAkB,CA2DpB;AAoJD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAsE5E;AAED,6DAA6D;AAC7D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD"}
|