@nervur-org/dock 0.3.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.
Files changed (285) hide show
  1. package/GETTING_STARTED.md +128 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +37 -0
  5. package/api/quo-api.md +196 -0
  6. package/api/route.ts +227 -0
  7. package/beings/GLOSSARY.md +118 -0
  8. package/beings/WORLDS.md +567 -0
  9. package/beings/avatar.ts +101 -0
  10. package/beings/carry.ts +159 -0
  11. package/beings/clock.ts +552 -0
  12. package/beings/courier.ts +107 -0
  13. package/beings/desk.ts +128 -0
  14. package/beings/doorbell.ts +129 -0
  15. package/beings/envoy.ts +218 -0
  16. package/beings/index.ts +16 -0
  17. package/beings/join.ts +51 -0
  18. package/beings/link.ts +104 -0
  19. package/beings/look.ts +139 -0
  20. package/beings/porter.ts +43 -0
  21. package/beings/presence.ts +39 -0
  22. package/beings/quo-app-bells.md +280 -0
  23. package/beings/quo-dock.md +1942 -0
  24. package/beings/setup.ts +45 -0
  25. package/beings/side.ts +60 -0
  26. package/beings/user.ts +459 -0
  27. package/beings/way.ts +65 -0
  28. package/cli/bundles.ts +20 -0
  29. package/cli/client.ts +73 -0
  30. package/cli/daemon.ts +581 -0
  31. package/cli/estate/Caddyfile +33 -0
  32. package/cli/estate/quo.service +35 -0
  33. package/cli/estate/systems.quo.plist +44 -0
  34. package/cli/estate.ts +48 -0
  35. package/cli/folder.ts +27 -0
  36. package/cli/oauth.ts +32 -0
  37. package/cli/pilot.ts +38 -0
  38. package/cli/quo-dock.md +1942 -0
  39. package/cli/quo.ts +203 -0
  40. package/cli/stand.ts +160 -0
  41. package/dist/api/route.d.ts +21 -0
  42. package/dist/api/route.js +193 -0
  43. package/dist/beings/avatar.d.ts +32 -0
  44. package/dist/beings/avatar.js +100 -0
  45. package/dist/beings/carry.d.ts +12 -0
  46. package/dist/beings/carry.js +174 -0
  47. package/dist/beings/clock.d.ts +161 -0
  48. package/dist/beings/clock.js +528 -0
  49. package/dist/beings/courier.d.ts +50 -0
  50. package/dist/beings/courier.js +96 -0
  51. package/dist/beings/desk.d.ts +53 -0
  52. package/dist/beings/desk.js +135 -0
  53. package/dist/beings/doorbell.d.ts +78 -0
  54. package/dist/beings/doorbell.js +133 -0
  55. package/dist/beings/envoy.d.ts +54 -0
  56. package/dist/beings/envoy.js +211 -0
  57. package/dist/beings/index.d.ts +14 -0
  58. package/dist/beings/index.js +16 -0
  59. package/dist/beings/join.d.ts +15 -0
  60. package/dist/beings/join.js +42 -0
  61. package/dist/beings/link.d.ts +14 -0
  62. package/dist/beings/link.js +91 -0
  63. package/dist/beings/look.d.ts +30 -0
  64. package/dist/beings/look.js +104 -0
  65. package/dist/beings/porter.d.ts +14 -0
  66. package/dist/beings/porter.js +41 -0
  67. package/dist/beings/presence.d.ts +8 -0
  68. package/dist/beings/presence.js +21 -0
  69. package/dist/beings/setup.d.ts +3 -0
  70. package/dist/beings/setup.js +39 -0
  71. package/dist/beings/side.d.ts +32 -0
  72. package/dist/beings/side.js +37 -0
  73. package/dist/beings/user.d.ts +315 -0
  74. package/dist/beings/user.js +462 -0
  75. package/dist/beings/way.d.ts +19 -0
  76. package/dist/beings/way.js +28 -0
  77. package/dist/cli/bundles.d.ts +5 -0
  78. package/dist/cli/bundles.js +17 -0
  79. package/dist/cli/client.d.ts +15 -0
  80. package/dist/cli/client.js +58 -0
  81. package/dist/cli/daemon.d.ts +50 -0
  82. package/dist/cli/daemon.js +584 -0
  83. package/dist/cli/estate/Caddyfile +33 -0
  84. package/dist/cli/estate/quo.service +35 -0
  85. package/dist/cli/estate/systems.quo.plist +44 -0
  86. package/dist/cli/estate.d.ts +5 -0
  87. package/dist/cli/estate.js +50 -0
  88. package/dist/cli/folder.d.ts +2 -0
  89. package/dist/cli/folder.js +25 -0
  90. package/dist/cli/oauth.d.ts +2 -0
  91. package/dist/cli/oauth.js +32 -0
  92. package/dist/cli/pilot.d.ts +5 -0
  93. package/dist/cli/pilot.js +22 -0
  94. package/dist/cli/quo.d.ts +2 -0
  95. package/dist/cli/quo.js +201 -0
  96. package/dist/cli/stand.d.ts +37 -0
  97. package/dist/cli/stand.js +110 -0
  98. package/dist/dock/agents.d.ts +102 -0
  99. package/dist/dock/agents.js +105 -0
  100. package/dist/dock/apns.d.ts +27 -0
  101. package/dist/dock/apns.js +98 -0
  102. package/dist/dock/console.d.ts +6 -0
  103. package/dist/dock/console.js +20 -0
  104. package/dist/dock/dialers.d.ts +56 -0
  105. package/dist/dock/dialers.js +75 -0
  106. package/dist/dock/faculty.d.ts +24 -0
  107. package/dist/dock/faculty.js +142 -0
  108. package/dist/dock/fcm.d.ts +13 -0
  109. package/dist/dock/fcm.js +77 -0
  110. package/dist/dock/grants.d.ts +41 -0
  111. package/dist/dock/grants.js +99 -0
  112. package/dist/dock/http2.d.ts +4 -0
  113. package/dist/dock/http2.js +68 -0
  114. package/dist/dock/index.d.ts +41 -0
  115. package/dist/dock/index.js +113 -0
  116. package/dist/dock/local.d.ts +13 -0
  117. package/dist/dock/local.js +44 -0
  118. package/dist/dock/location.d.ts +19 -0
  119. package/dist/dock/location.js +11 -0
  120. package/dist/dock/notifications.d.ts +38 -0
  121. package/dist/dock/notifications.js +60 -0
  122. package/dist/dock/push.d.ts +79 -0
  123. package/dist/dock/push.js +132 -0
  124. package/dist/dock/service.d.ts +4 -0
  125. package/dist/dock/service.js +107 -0
  126. package/dist/dock/timer.d.ts +52 -0
  127. package/dist/dock/timer.js +149 -0
  128. package/dist/dock/webpush.d.ts +12 -0
  129. package/dist/dock/webpush.js +144 -0
  130. package/dist/harbor/browser.d.ts +15 -0
  131. package/dist/harbor/browser.js +82 -0
  132. package/dist/harbor/capacitor.d.ts +28 -0
  133. package/dist/harbor/capacitor.js +218 -0
  134. package/dist/harbor/disk.d.ts +27 -0
  135. package/dist/harbor/disk.js +249 -0
  136. package/dist/harbor/edge/alarm.d.ts +10 -0
  137. package/dist/harbor/edge/alarm.js +38 -0
  138. package/dist/harbor/edge/edge.d.ts +54 -0
  139. package/dist/harbor/edge/edge.js +370 -0
  140. package/dist/harbor/edge/exercise.d.ts +7 -0
  141. package/dist/harbor/edge/exercise.js +232 -0
  142. package/dist/harbor/edge/given.d.ts +26 -0
  143. package/dist/harbor/edge/given.js +42 -0
  144. package/dist/harbor/edge/platform.d.ts +39 -0
  145. package/dist/harbor/edge/storage.d.ts +30 -0
  146. package/dist/harbor/edge/storage.js +180 -0
  147. package/dist/harbor/edge/worker.d.ts +17 -0
  148. package/dist/harbor/edge/worker.js +67 -0
  149. package/dist/harbor/files.d.ts +15 -0
  150. package/dist/harbor/files.js +220 -0
  151. package/dist/harbor/http.d.ts +19 -0
  152. package/dist/harbor/http.js +177 -0
  153. package/dist/harbor/idb.d.ts +15 -0
  154. package/dist/harbor/idb.js +0 -0
  155. package/dist/harbor/quo.d.ts +10 -0
  156. package/dist/harbor/quo.js +42 -0
  157. package/dist/harbor/resolve.d.ts +16 -0
  158. package/dist/harbor/resolve.js +17 -0
  159. package/dist/harbor/seal.d.ts +3 -0
  160. package/dist/harbor/seal.js +25 -0
  161. package/dist/harbor/sealed.d.ts +35 -0
  162. package/dist/harbor/sealed.js +162 -0
  163. package/dist/harbor/tauri.d.ts +16 -0
  164. package/dist/harbor/tauri.js +59 -0
  165. package/dist/harbor/ward.d.ts +9 -0
  166. package/dist/harbor/ward.js +15 -0
  167. package/dist/human/app.d.ts +32 -0
  168. package/dist/human/app.js +527 -0
  169. package/dist/human/apps.d.ts +10 -0
  170. package/dist/human/apps.js +29 -0
  171. package/dist/human/dom.d.ts +2 -0
  172. package/dist/human/dom.js +15 -0
  173. package/dist/human/door.d.ts +19 -0
  174. package/dist/human/door.js +92 -0
  175. package/dist/human/guest.d.ts +3 -0
  176. package/dist/human/guest.js +33 -0
  177. package/dist/human/html.d.ts +55 -0
  178. package/dist/human/html.js +451 -0
  179. package/dist/human/local.d.ts +7 -0
  180. package/dist/human/local.js +10 -0
  181. package/dist/human/mark.d.ts +8 -0
  182. package/dist/human/mark.js +47 -0
  183. package/dist/human/move.d.ts +17 -0
  184. package/dist/human/move.js +90 -0
  185. package/dist/human/screen.d.ts +19 -0
  186. package/dist/human/screen.js +119 -0
  187. package/dist/human/style.d.ts +2 -0
  188. package/dist/human/style.js +60 -0
  189. package/dist/human/tab.bundle.txt +4775 -0
  190. package/dist/human/tab.d.ts +15 -0
  191. package/dist/human/tab.js +409 -0
  192. package/dist/human/tree.d.ts +50 -0
  193. package/dist/human/tree.js +157 -0
  194. package/dist/human/wake.d.ts +34 -0
  195. package/dist/human/wake.js +179 -0
  196. package/dist/human/web.d.ts +33 -0
  197. package/dist/human/web.js +184 -0
  198. package/dist/human/worker.bundle.txt +3868 -0
  199. package/dist/human/worker.d.ts +1 -0
  200. package/dist/human/worker.js +81 -0
  201. package/dist/human/worlds.d.ts +24 -0
  202. package/dist/human/worlds.js +99 -0
  203. package/dist/mcp/agent.d.ts +10 -0
  204. package/dist/mcp/agent.js +78 -0
  205. package/dist/mcp/allow.d.ts +20 -0
  206. package/dist/mcp/allow.js +88 -0
  207. package/dist/mcp/http.d.ts +27 -0
  208. package/dist/mcp/http.js +92 -0
  209. package/dist/mcp/oauth.d.ts +91 -0
  210. package/dist/mcp/oauth.js +241 -0
  211. package/dist/mcp/route.d.ts +15 -0
  212. package/dist/mcp/route.js +42 -0
  213. package/dist/mcp/runner.d.ts +45 -0
  214. package/dist/mcp/runner.js +99 -0
  215. package/dist/mcp/server.d.ts +11 -0
  216. package/dist/mcp/server.js +102 -0
  217. package/dock/agents.ts +121 -0
  218. package/dock/apns.ts +138 -0
  219. package/dock/console.ts +23 -0
  220. package/dock/dialers.ts +85 -0
  221. package/dock/faculty.ts +148 -0
  222. package/dock/fcm.ts +103 -0
  223. package/dock/grants.ts +111 -0
  224. package/dock/http2.ts +70 -0
  225. package/dock/index.ts +133 -0
  226. package/dock/local.ts +48 -0
  227. package/dock/location.ts +22 -0
  228. package/dock/notifications.ts +67 -0
  229. package/dock/push.ts +161 -0
  230. package/dock/quo-app-bells.md +280 -0
  231. package/dock/quo-dock.md +1942 -0
  232. package/dock/service.ts +100 -0
  233. package/dock/timer.ts +174 -0
  234. package/dock/webpush.ts +182 -0
  235. package/harbor/browser.ts +97 -0
  236. package/harbor/capacitor.ts +223 -0
  237. package/harbor/disk.ts +237 -0
  238. package/harbor/edge/alarm.ts +48 -0
  239. package/harbor/edge/edge.ts +407 -0
  240. package/harbor/edge/exercise.ts +241 -0
  241. package/harbor/edge/given.ts +106 -0
  242. package/harbor/edge/platform.d.ts +39 -0
  243. package/harbor/edge/storage.ts +179 -0
  244. package/harbor/edge/text.d.ts +15 -0
  245. package/harbor/edge/worker.ts +70 -0
  246. package/harbor/edge/wrangler.toml +30 -0
  247. package/harbor/files.ts +221 -0
  248. package/harbor/http.ts +188 -0
  249. package/harbor/idb.ts +0 -0
  250. package/harbor/quo-harbor.md +850 -0
  251. package/harbor/quo.ts +44 -0
  252. package/harbor/resolve.ts +31 -0
  253. package/harbor/seal.ts +26 -0
  254. package/harbor/sealed.ts +183 -0
  255. package/harbor/tauri.ts +79 -0
  256. package/harbor/ward.ts +28 -0
  257. package/human/app.ts +580 -0
  258. package/human/apps.ts +47 -0
  259. package/human/dom.ts +21 -0
  260. package/human/door.ts +113 -0
  261. package/human/guest.ts +34 -0
  262. package/human/html.ts +490 -0
  263. package/human/local.ts +30 -0
  264. package/human/mark.ts +53 -0
  265. package/human/move.ts +122 -0
  266. package/human/quo-human.md +1042 -0
  267. package/human/screen.ts +136 -0
  268. package/human/style.ts +63 -0
  269. package/human/tab.bundle.txt +4775 -0
  270. package/human/tab.ts +446 -0
  271. package/human/tree.ts +217 -0
  272. package/human/wake.ts +191 -0
  273. package/human/web.ts +259 -0
  274. package/human/worker.bundle.txt +3868 -0
  275. package/human/worker.ts +124 -0
  276. package/human/worlds.ts +127 -0
  277. package/mcp/agent.ts +94 -0
  278. package/mcp/allow.ts +131 -0
  279. package/mcp/http.ts +92 -0
  280. package/mcp/oauth.ts +270 -0
  281. package/mcp/quo-mcp.md +343 -0
  282. package/mcp/route.ts +45 -0
  283. package/mcp/runner.ts +136 -0
  284. package/mcp/server.ts +101 -0
  285. package/package.json +121 -0
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // What a side mounted at the edge is given. `Given` is the one shape: the
3
+ // code a page runs, the design a world wears, the public origins, the apps
4
+ // table and the bell key. On the daemon the same list is what `cli/daemon.ts`
5
+ // reads off a disk and out of `routes.json`; here it has two provenances,
6
+ // and the split is what the value is, not an accident of this file.
7
+ //
8
+ // `Deployed` is the half the deploy carries: the tab's bundle, the service
9
+ // worker's, and whatever being classes the deployment adds. They change only
10
+ // when something is built, so they are text modules the `wrangler.toml`
11
+ // names and the worker module holds them from its first line.
12
+ //
13
+ // `Settings` is the half that is worn and configured while the worker runs:
14
+ // the origins the proxy serves, the apps whose links this world's hostname
15
+ // opens, and the public half of the doorbell key. It is one row in the
16
+ // object's own storage, put there by the root's door, because a root
17
+ // reconfigures a box without redeploying it. The design a world wears is
18
+ // rows beside it, one per file, reached through `Folder` like every other
19
+ // terrain's.
20
+ import type { BeingClass } from '@nervur-org/nervur';
21
+ import { GRANTS } from '../../dock/grants.ts';
22
+ import { TIMER } from '../../dock/index.ts';
23
+ import type { Apps, Folder } from '../../human/web.ts';
24
+ import type { Routes } from '../../mcp/route.ts';
25
+
26
+ // What a worker lends the beings of the wards it holds, by name to the class
27
+ // that stands for it. The box's state has one home on every terrain, the box
28
+ // ward's partition, so the edge stands that ward like every other terrain;
29
+ // what differs is this list, and a faculty the platform has no answer for is
30
+ // not lent at all rather than lent and hollow.
31
+ //
32
+ // `grants` and `timer` are lent, and they are the whole of it. A bearer to an
33
+ // identity in a world is a record in her cells and nothing else, so the
34
+ // grants are the same code here as on a disk and the routes that honour a
35
+ // bearer, `api.` and `mcp.`, work the moment she stands. The timer is the
36
+ // same class too, with the one method that knows a terrain replaced: she arms
37
+ // the object's own storage alarm, which is the clock here, so a home on this
38
+ // terrain wakes for its schedules with nobody asking. `Alarm` in
39
+ // `edge/alarm.ts` is that subclass.
40
+ //
41
+ // The three others every box ships are not lent here, each for its own
42
+ // reason. `location` and `notifications` are a place and a screen, and a
43
+ // worker is at neither: it runs wherever the platform put it and no human is
44
+ // looking at it. `push` rings under this world's own web origin, and a worker
45
+ // learns its origin from the request it is answering and not at boot, so a
46
+ // ring signed at the moment the faculty stood would be signed for nowhere.
47
+ //
48
+ // There is no process on the other side of a worker either, so `local`,
49
+ // `dialers` and `agents`, which the daemon adds, name nothing here: no
50
+ // socket a process was seen on, no line this box holds open, and no command
51
+ // to run an agent in.
52
+ export const LENT: Record<string, string> = { [GRANTS]: 'Grants', [TIMER]: 'Alarm' };
53
+
54
+ // The ceiling on one row in this object's storage, key and value together,
55
+ // in bytes. The platform publishes it for a SQLite-backed Durable Object,
56
+ // which is what `wrangler.toml` declares this class to be, and past it the
57
+ // storage refuses the write. It is written here once, beside the rest of
58
+ // what this terrain is, because a number a store carries in its own body is
59
+ // a number nobody finds when the platform moves it.
60
+ //
61
+ // A being is one row, so this is the ceiling on one being's cells and her
62
+ // bind table. The store measures the JSON it is about to write and refuses
63
+ // over it rather than handing the platform a value it will refuse anyway, so
64
+ // the refusal is the same on the day the platform's own message changes and
65
+ // the same in a suite that never reaches a real object.
66
+ export const ROW_CEILING = 2_000_000;
67
+
68
+ // The tab's bundle and the service worker's, as code. Built once by whoever
69
+ // built this package, never by a route and never here.
70
+ export type Code = { tab: string; worker: string };
71
+
72
+ // The rows. Every field is absent until a root writes it, and a side asked
73
+ // with none of them serves what a box with no configuration can: pages on
74
+ // the worker's own origin, no apps, no doorbell.
75
+ export type Settings = {
76
+ // the public faces of the four routes, as the proxy serves them, the same
77
+ // shape `routes.json` holds on a disk
78
+ routes?: Routes;
79
+ // the apps this world's links open, or none
80
+ apps?: Apps | null;
81
+ // the public half of this world's doorbell key. Without it no service
82
+ // worker is served and no page registers one.
83
+ bell?: string | null;
84
+ };
85
+
86
+ // The row, read and written whole: three small fields that a root sets
87
+ // together and a side reads at every request.
88
+ export type SettingsStore = {
89
+ load(): Promise<Settings>;
90
+ save(settings: Settings): Promise<void>;
91
+ };
92
+
93
+ // What the deploy carries, which the worker module is constructed with.
94
+ // A deployment that names no bundles serves no pages: the web side is not
95
+ // mounted at all, because a page carrying no tab code is a page that loads
96
+ // and then does nothing. That is what the conformance exercise inside the
97
+ // platform is, and never a deploy.
98
+ export type Deployed = {
99
+ code?: Code;
100
+ // being classes beyond the built-ins, this deployment's own
101
+ classes?: Record<string, BeingClass>;
102
+ };
103
+
104
+ // The whole of it, as a side is handed it: the deploy's half, the rows, and
105
+ // the folder those rows make.
106
+ export type Given = Deployed & { settings: Settings; folder: Folder };
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The edge platform, as much of it as the harbor touches and no more. The
3
+ // vendor's own type package declares every global a worker has, and it
4
+ // collides with the DOM library this tree compiles against; these few
5
+ // lines are what the edge harbor and worker actually call.
6
+ // The rows: everything the object keeps under a key. Whatever answers these
7
+ // five is a place to keep rows, which is why what is kept in storage is
8
+ // written against this and not against the object.
9
+ export type Rows = {
10
+ get<T = unknown>(key: string): Promise<T | undefined>;
11
+ get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
12
+ put(key: string, value: unknown): Promise<void>;
13
+ delete(key: string): Promise<boolean>;
14
+ list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
15
+ };
16
+ // The object's storage: its rows and its clock. One alarm per object,
17
+ // replaced by the next set and gone when it is deleted, and the object's
18
+ // `alarm` handler runs when it goes off, with no request behind it.
19
+ export type Storage = Rows & {
20
+ setAlarm(at: number | Date): Promise<void>;
21
+ getAlarm(): Promise<number | null>;
22
+ deleteAlarm(): Promise<void>;
23
+ };
24
+ export type State = { id: { toString(): string; name?: string }; storage: Storage };
25
+ export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
26
+ export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
27
+ export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
28
+ // The server end of a WebSocketPair: the standard surface plus accept.
29
+ export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
30
+ declare global {
31
+ // eslint-disable-next-line no-var
32
+ var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
33
+ interface ResponseInit {
34
+ webSocket?: unknown;
35
+ }
36
+ interface Response {
37
+ readonly webSocket?: unknown;
38
+ }
39
+ }
@@ -0,0 +1,179 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store as Durable Object storage: one object per harbor, its storage
3
+ // holding one row per ward under `ward:<name>`, seed and record, one row per
4
+ // row of its partition under `row:<name> <row>`, and one row per hint under
5
+ // `hint:<pk>`. A being is a row of her own because a being's cells are the
6
+ // one part of a partition that grows without limit, and a ward kept as one
7
+ // value would write every being in it down whenever any one was asked.
8
+ //
9
+ // The seed is kept sealed under a
10
+ // key from the platform's secrets, `QUO_SEED_KEY`, with the seal in
11
+ // `../seal.ts`, so the storage holds ciphertext and the secret store holds
12
+ // the one key, which is what the harbor document's table says the edge
13
+ // does. Values cross as JSON, as they do into a file, because the ward
14
+ // hands the partition out through a guard that structured clone refuses.
15
+ // The same storage answers the mcp route's dance, under `oauth`, the box's
16
+ // own settings, under `settings`, and the design a world wears, one row per
17
+ // file under `design:<file>`, because the object is the whole of what this
18
+ // terrain has to keep anything in.
19
+ import { fromRows, rowsIn, rowsOf, type Kept, type Store, type WardRecord } from '@nervur-org/nervur/harbor';
20
+ import { seal, open } from '../seal.ts';
21
+ import { emptyDance, type Dance, type Store as DanceStore } from '../../mcp/oauth.ts';
22
+ import type { Folder } from '../../human/web.ts';
23
+ import { ROW_CEILING, type Settings, type SettingsStore } from './given.ts';
24
+ import type { Rows, Storage } from './platform.d.ts';
25
+
26
+ // The box's settings: the origins, the apps table and the bell key, one row
27
+ // read whole and written whole. Nothing of Quo's is in it, so nothing here
28
+ // is sealed, and a box a root has never configured reads an empty one.
29
+ export function settingsInStorage(storage: Rows, prefix = ''): SettingsStore {
30
+ const key = `${prefix}settings`;
31
+ return {
32
+ load: async () => (await storage.get<Settings>(key)) ?? {},
33
+ save: (settings: Settings) => storage.put(key, settings),
34
+ };
35
+ }
36
+
37
+ // The design a world wears and the world's own code for the tab, as rows:
38
+ // what a disk answers out of `design/` and `tab/index.js`, answered out of
39
+ // storage instead. A row is text, because a stylesheet is text and so is a
40
+ // bundle; a file the root has not put there answers nothing and the route
41
+ // declines the path.
42
+ export const DESIGN_ROW = 'design:';
43
+ export const BEINGS_ROW = 'beings';
44
+
45
+ export function folderInStorage(storage: Rows, prefix = ''): Folder {
46
+ const design = (file: string) => `${prefix}${DESIGN_ROW}${file}`;
47
+ const held = (file: string) => storage.get<string>(design(file)).then((v) => v ?? null);
48
+ return {
49
+ wearing: async () => ((await held('tokens.css')) === null ? { tokens: false, fonts: false } : { tokens: true, fonts: (await held('fonts.css')) !== null }),
50
+ design: (file) => held(file),
51
+ serving: async () => (await storage.get<string>(`${prefix}${BEINGS_ROW}`)) !== undefined,
52
+ beings: async () => (await storage.get<string>(`${prefix}${BEINGS_ROW}`)) ?? null,
53
+ };
54
+ }
55
+
56
+ // The writing half of that folder, which is the root's door and nothing
57
+ // else: a file put there, a file taken away with null, and the names now
58
+ // held so that a root sees what it wrote.
59
+ export function wearInStorage(storage: Rows, prefix = '') {
60
+ const head = `${prefix}${DESIGN_ROW}`;
61
+ return {
62
+ put: async (file: string, text: string | null): Promise<void> => {
63
+ if (text === null) await storage.delete(head + file);
64
+ else await storage.put(head + file, text);
65
+ },
66
+ worn: async (): Promise<string[]> => [...(await storage.list({ prefix: head })).keys()].map((k) => k.slice(head.length)),
67
+ };
68
+ }
69
+
70
+ // The mcp route's dance, kept in the same object's storage under one key.
71
+ // It is one value and not a row apiece: clients, pending requests and codes
72
+ // all die in minutes, and the whole of it is smaller than one ward's seed.
73
+ // Nothing of Quo's is in it, so nothing here is sealed.
74
+ export function danceInStorage(storage: Rows, prefix = ''): DanceStore {
75
+ const key = `${prefix}oauth`;
76
+ return {
77
+ load: async () => ({ ...emptyDance(), ...((await storage.get<Partial<Dance>>(key)) ?? {}) }),
78
+ save: (dance: Dance) => storage.put(key, dance),
79
+ };
80
+ }
81
+
82
+ type Row = { seed: string; record: WardRecord };
83
+
84
+ // `prefix` keeps more than one harbor apart in one object's storage: the
85
+ // exercise does that, a deployment never does.
86
+ // `ceiling` is the platform's, and is a parameter for the one suite that
87
+ // has to see a row refused: a row of the real size crossing a real door
88
+ // costs more than the fault it proves, and the ceiling a deployment runs
89
+ // under is the platform's number and never an argument.
90
+ export class DurableStorage implements Store {
91
+ readonly storage: Storage;
92
+ readonly key: CryptoKey;
93
+ readonly prefix: string;
94
+ readonly ceiling: number;
95
+ constructor(storage: Storage, key: CryptoKey, prefix = '', ceiling = ROW_CEILING) {
96
+ this.storage = storage;
97
+ this.key = key;
98
+ this.prefix = prefix;
99
+ this.ceiling = ceiling;
100
+ }
101
+ #k(kind: 'ward' | 'hint' | 'row', name: string) {
102
+ return `${this.prefix}${kind}:${name}`;
103
+ }
104
+ // A ward's rows sort together under one prefix, and nothing else falls
105
+ // between them: no name carries the separator.
106
+ #rows(name: string) {
107
+ return this.#k('row', `${name} `);
108
+ }
109
+
110
+ async list(): Promise<string[]> {
111
+ const head = this.#k('ward', '');
112
+ return [...(await this.storage.list({ prefix: head })).keys()].map((k) => k.slice(head.length));
113
+ }
114
+ async load(name: string): Promise<Kept | undefined> {
115
+ const row = await this.storage.get<Row>(this.#k('ward', name));
116
+ if (!row) return undefined;
117
+ const head = this.#rows(name);
118
+ const kept = [...(await this.storage.list<Record<string, unknown>>({ prefix: head }))].map(([k, v]) => [k.slice(head.length), v] as const);
119
+ return { seed: await open(this.key, row.seed), partition: fromRows(Object.fromEntries(kept)), record: row.record };
120
+ }
121
+ async put(name: string, kept: Kept): Promise<void> {
122
+ if ((await this.storage.get(this.#k('ward', name))) !== undefined) throw new Error(`ward ${name} already exists here`);
123
+ await this.storage.put(this.#k('ward', name), { seed: await seal(this.key, kept.seed), record: kept.record } satisfies Row);
124
+ await this.#keep(name, kept.partition, rowsIn(kept.partition));
125
+ }
126
+ async save(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
127
+ if ((await this.storage.get(this.#k('ward', name))) === undefined) return; // a name not kept is nothing
128
+ await this.#keep(name, partition, rows);
129
+ }
130
+ // The rows named, written or deleted. A row whose being has left comes back
131
+ // undefined and her value goes with her.
132
+ //
133
+ // A row past the object's ceiling is refused here, before the platform is
134
+ // asked. The platform refuses it too, and the two refusals must be the
135
+ // same one: a store that let the platform decide would keep a row on the
136
+ // day the limit moved and refuse it the day after, with nothing in this
137
+ // tree saying so. The harbor turns the throw into the answer of the ask
138
+ // that wrote the row, and the being is stood back at what is kept.
139
+ //
140
+ // Every row is measured before any is written, so a save that is refused
141
+ // writes nothing at all. A store that wrote half a save and threw would
142
+ // leave one being moved and her counterpart not, and the harbor standing
143
+ // the ward back at what is kept would find a torn state to stand at: a
144
+ // caller's numbers rolled back and the door's mark not, which is a
145
+ // relation that can never speak again.
146
+ async #keep(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
147
+ const head = this.#rows(name);
148
+ const write = rowsOf(partition, rows).map(([row, value]) => {
149
+ const key = head + row;
150
+ if (value === undefined) return { key, value };
151
+ const size = new TextEncoder().encode(JSON.stringify(value)).length + new TextEncoder().encode(key).length;
152
+ if (size > this.ceiling) throw new Error(`the row ${key} is ${size} bytes, past this object's ceiling of ${this.ceiling}`);
153
+ return { key, value };
154
+ });
155
+ for (const { key, value } of write) {
156
+ if (value === undefined) await this.storage.delete(key);
157
+ else await this.storage.put(key, value);
158
+ }
159
+ }
160
+ async record(name: string, record: WardRecord): Promise<void> {
161
+ const row = await this.storage.get<Row>(this.#k('ward', name));
162
+ if (row) await this.storage.put(this.#k('ward', name), { ...row, record } satisfies Row);
163
+ }
164
+ async take(name: string): Promise<Kept | undefined> {
165
+ const kept = await this.load(name);
166
+ if (!kept) return undefined;
167
+ const head = this.#rows(name);
168
+ for (const key of (await this.storage.list({ prefix: head })).keys()) await this.storage.delete(key); // her rows go with her, or the next put reads them
169
+ await this.storage.delete(this.#k('ward', name));
170
+ return kept;
171
+ }
172
+ async hints(): Promise<Record<string, string>> {
173
+ const head = this.#k('hint', '');
174
+ return Object.fromEntries([...(await this.storage.list<string>({ prefix: head }))].map(([k, v]) => [k.slice(head.length), v]));
175
+ }
176
+ async hint(pk: string, url: string): Promise<void> {
177
+ await this.storage.put(this.#k('hint', pk), url);
178
+ }
179
+ }
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A bundle, imported as text. The platform's build carries a file named by a
3
+ // `type = "Text"` rule in the `wrangler.toml` into the worker as a string,
4
+ // which is how a built artefact of a deploy reaches a side mounted there
5
+ // without any of it being a row a ward keeps. This file says what that
6
+ // import is worth to the typechecker, and nothing else in the tree imports
7
+ // one: the daemon reads the same file off its disk instead.
8
+ //
9
+ // It declares an ambient module, so it holds no import and no export of its
10
+ // own; a file with either would be a module and the declaration would not be
11
+ // ambient.
12
+ declare module '*.bundle.txt' {
13
+ const text: string;
14
+ export default text;
15
+ }
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The worker: the edge harbor deployed. One Durable Object class, `Quo`,
3
+ // one instance per harbor name, and the worker routes by the first path
4
+ // segment: `/h/<name>/...` goes to that harbor. A world's quo. route on the
5
+ // edge is `<origin>/h/<name>/quo`, and its health `<origin>/h/<name>/health`.
6
+ // `wrangler.toml` beside this file names the class and the secrets.
7
+ //
8
+ // One thing lives here for the terrain test alone: with `QUO_EXERCISE` set,
9
+ // which no deployment sets, `/exercise` runs the library's conformance
10
+ // suite inside the platform against two harbors in this one object, over
11
+ // two prefixed views of its storage, reaching each other by request
12
+ // through the worker under `/x/<sub>/quo`. One object cannot touch
13
+ // another's storage, the platform says so, and so the two harbors share an
14
+ // object the way two tabs share a page.
15
+ import { EdgeHarbor } from './edge.ts';
16
+ import { exercise } from './exercise.ts';
17
+ import type { Deployed } from './given.ts';
18
+ import type { State, Env } from './platform.d.ts';
19
+ import tab from '../../human/tab.bundle.txt';
20
+ import serviceWorker from '../../human/worker.bundle.txt';
21
+
22
+ // What the deploy carries, from this module's first line: the tab's bundle
23
+ // and the service worker's, as text modules the `wrangler.toml` names. They
24
+ // are built artefacts and never rows, so they arrive with the code and not
25
+ // out of the object's storage. Everything a root configures while the worker
26
+ // runs is a row, and `given.ts` holds the whole shape.
27
+ export const DEPLOYED: Deployed = { code: { tab, worker: serviceWorker } };
28
+
29
+ export class Quo {
30
+ readonly state: State;
31
+ readonly env: Env;
32
+ #harbor: Promise<EdgeHarbor> | undefined;
33
+ constructor(state: State, env: Env) {
34
+ this.state = state;
35
+ this.env = env;
36
+ }
37
+ harbor(): Promise<EdgeHarbor> {
38
+ return (this.#harbor ??= EdgeHarbor.open(this.state, this.env, DEPLOYED));
39
+ }
40
+ // The platform's one way of waking an object with nobody asking. The
41
+ // harbor opens as it does for a request, every ward boots from storage,
42
+ // and the box's timer runs her round: a home here with a sweep at three in
43
+ // the morning wakes once a night, and no request is made to cause it.
44
+ async alarm(): Promise<void> {
45
+ const harbor = await this.harbor();
46
+ await harbor.ready();
47
+ await harbor.rang();
48
+ }
49
+
50
+ async fetch(request: Request): Promise<Response> {
51
+ const url = new URL(request.url);
52
+ const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
53
+ if (!m) return new Response(JSON.stringify({ error: 'no such route' }), { status: 404, headers: { 'content-type': 'application/json' } });
54
+ const path = m[2] ?? '/';
55
+ // Where this harbor answers, as the world outside sees it: the routes a
56
+ // page hands its tab are these unless a root has named public ones.
57
+ const base = `${url.origin}/h/${m[1]}`;
58
+ if (this.env.QUO_EXERCISE && (path === '/exercise' || path.startsWith('/x/'))) return exercise(request, base, path, this.state, this.env);
59
+ return (await this.harbor()).fetch(request, path, base);
60
+ }
61
+ }
62
+
63
+ export default {
64
+ async fetch(request: Request, env: Env): Promise<Response> {
65
+ const url = new URL(request.url);
66
+ const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
67
+ if (!m) return new Response(JSON.stringify({ error: 'a harbor is /h/<name>/...' }), { status: 404, headers: { 'content-type': 'application/json' } });
68
+ return env.HARBOR.get(env.HARBOR.idFromName(m[1])).fetch(request);
69
+ },
70
+ };
@@ -0,0 +1,30 @@
1
+ # The edge harbor as a worker. Deploy from `dock/` with
2
+ # npx wrangler deploy --config harbor/edge/wrangler.toml
3
+ # and set the two secrets once, never in this file:
4
+ # npx wrangler secret put QUO_SEED_KEY --config harbor/edge/wrangler.toml 32 bytes as hex
5
+ # npx wrangler secret put QUO_ROOT --config harbor/edge/wrangler.toml the root's bearer
6
+ name = "quo-edge"
7
+ main = "worker.ts"
8
+ compatibility_date = "2026-09-01"
9
+ # A deployed worker answers on the account's workers.dev subdomain; a custom
10
+ # domain wants a token with the zone's Workers Routes permission, which the
11
+ # account's token does not hold. A world's quo. route on it is
12
+ # <origin>/h/main/quo.
13
+ workers_dev = true
14
+
15
+ # The bundles a page runs, carried into the worker as strings. They are built
16
+ # artefacts of a deploy, so the deploy carries them and no row keeps them; the
17
+ # dock's build emits them beside `human/`, and this rule is what makes the
18
+ # worker's import of one a string rather than a module.
19
+ [[rules]]
20
+ type = "Text"
21
+ globs = ["**/*.bundle.txt"]
22
+ fallthrough = false
23
+
24
+ [[durable_objects.bindings]]
25
+ name = "HARBOR"
26
+ class_name = "Quo"
27
+
28
+ [[migrations]]
29
+ tag = "v1"
30
+ new_sqlite_classes = ["Quo"]
@@ -0,0 +1,221 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store as a folder on disk, one folder per ward, in one of two forms.
3
+ //
4
+ // Plain, when the store has no key, what the droplets run:
5
+ //
6
+ // <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
7
+ // <dir>/wards/<name>/rows/head.json the partition's head
8
+ // <dir>/wards/<name>/rows/b-<hex>.json one being, her key as hex utf8
9
+ // <dir>/wards/<name>/ward.json the ward record
10
+ //
11
+ // A being is a file of her own because a being's cells are the one part of a
12
+ // partition that grows without limit, and a ward that wrote one file would
13
+ // write every being in it down whenever any one of them was asked. Her key is
14
+ // a being's own word and can be anything a string can be, so the file is
15
+ // named for its bytes and never for the key itself.
16
+ //
17
+ // Sealed, when the store holds a key, what a device's daemon runs with the
18
+ // key from its Keychain:
19
+ //
20
+ // <dir>/wards/<name>/ward.sealed seed, partition and record as one
21
+ // JSON, sealed under the key, hex
22
+ //
23
+ // and in both:
24
+ //
25
+ // <dir>/reach.json the directory's hints
26
+ //
27
+ // A ward is whole in either form, and a store that meets the other form
28
+ // refuses it by name: a daemon started without its key, or with one against
29
+ // a plain folder, fails loudly instead of booting on what it cannot read.
30
+ // Sealing a plain folder is a deliberate command, never a boot's doing.
31
+ // Every write of a ward goes through a temp file and a rename, and writes
32
+ // are queued per ward so two calls never race on one file.
33
+ import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
34
+ import { existsSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import { arithmetic } from '@nervur-org/nervur/ward';
37
+ import { HEAD, fromRows, rowsIn, rowsOf, type Kept, type Store, type WardRecord } from '@nervur-org/nervur/harbor';
38
+ import { sealKey, seal, open } from './seal.ts';
39
+
40
+ const { hex, unhex } = arithmetic;
41
+ type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
42
+
43
+ // A row's file. The head has a name of its own so that the empty key it goes
44
+ // by never becomes an empty file name; a being's file is named for the digest
45
+ // of her key, and her key is written inside it.
46
+ //
47
+ // The digest, and not the key itself in some spelling: a being's key is her
48
+ // owner's own word and can be as long as a string can be, and a ward's own
49
+ // key is a hundred and twenty-eight characters before anything is added to
50
+ // it. Every encoding of a key grows it, and a file system stops at a length.
51
+ // A digest is the same size whatever it is given.
52
+ const utf8 = new TextEncoder();
53
+ type Kind = { row: string; value: Record<string, unknown> };
54
+ const fileOf = async (row: string): Promise<string> => (row === HEAD ? 'head.json' : `b-${hex(await arithmetic.sha256(utf8.encode(row)))}.json`);
55
+
56
+ export class Files implements Store {
57
+ readonly dir: string;
58
+ readonly #key?: Promise<CryptoKey>;
59
+ readonly #queues = new Map<string, Promise<void>>();
60
+ constructor(dir: string, key?: string) {
61
+ this.dir = dir;
62
+ if (key) this.#key = sealKey(key);
63
+ }
64
+ #ward(name: string) {
65
+ return join(this.dir, 'wards', name);
66
+ }
67
+ get sealed(): boolean {
68
+ return this.#key !== undefined;
69
+ }
70
+ // The form a folder holds, checked against the form this store speaks.
71
+ #form(name: string): 'plain' | 'sealed' | undefined {
72
+ const wd = this.#ward(name);
73
+ const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
74
+ if (form === 'sealed' && !this.sealed) throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
75
+ if (form === 'plain' && this.sealed) throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
76
+ return form;
77
+ }
78
+ // One write at a time per ward, through a temp file and a rename.
79
+ #write(name: string, file: string, body: string): Promise<void> {
80
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
81
+ const tmp = join(this.#ward(name), `${file}.tmp`);
82
+ await writeFile(tmp, body, { mode: 0o600 });
83
+ await rename(tmp, join(this.#ward(name), file));
84
+ });
85
+ this.#queues.set(name, next.catch(() => {}));
86
+ return next;
87
+ }
88
+ async #read(name: string): Promise<Blob> {
89
+ const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
90
+ return JSON.parse(new TextDecoder().decode(await open(await this.#key!, blob))) as Blob;
91
+ }
92
+ // A sealed ward is rewritten whole, so the read sits inside the queue
93
+ // with the write: two changes to one ward never lose each other's part.
94
+ #keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
95
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
96
+ const wd = this.#ward(name);
97
+ const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
98
+ const sealed = await seal(await this.#key!, new TextEncoder().encode(JSON.stringify(blob)));
99
+ await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
100
+ await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
101
+ });
102
+ this.#queues.set(name, next.catch(() => {}));
103
+ return next;
104
+ }
105
+
106
+ async list(): Promise<string[]> {
107
+ const wards = join(this.dir, 'wards');
108
+ return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
109
+ }
110
+
111
+ async load(name: string): Promise<Kept | undefined> {
112
+ const form = this.#form(name);
113
+ if (!form) return undefined;
114
+ if (form === 'sealed') {
115
+ const b = await this.#read(name);
116
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
117
+ }
118
+ const wd = this.#ward(name);
119
+ return {
120
+ seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
121
+ partition: fromRows(await this.#rows(name)),
122
+ record: JSON.parse(await readFile(join(wd, 'ward.json'), 'utf8')) as WardRecord,
123
+ };
124
+ }
125
+
126
+ // Every row a ward's folder holds. A file the naming does not recognise is
127
+ // not a row and is left where it is: a folder is a place a person looks.
128
+ async #rows(name: string): Promise<Record<string, Record<string, unknown>>> {
129
+ const dir = join(this.#ward(name), 'rows');
130
+ if (!existsSync(dir)) return {};
131
+ const out: Record<string, Record<string, unknown>> = {};
132
+ for (const file of await readdir(dir)) {
133
+ if (file === 'head.json') out[HEAD] = JSON.parse(await readFile(join(dir, file), 'utf8')) as Record<string, unknown>;
134
+ else if (/^b-[0-9a-f]{64}\.json$/.test(file)) {
135
+ const kept = JSON.parse(await readFile(join(dir, file), 'utf8')) as Kind;
136
+ out[kept.row] = kept.value;
137
+ }
138
+ // anything else is not a row and is left where it is: a folder is a
139
+ // place a person looks
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // The rows named, written or removed. A row whose being has left comes back
145
+ // undefined and her file goes with her.
146
+ //
147
+ // The whole of it is one job in the ward's line, the folder included, so a
148
+ // save cannot lay a file down inside a ward that a take is removing: `take`
149
+ // waits for this line before it removes anything, and a mkdir outside it
150
+ // would build the folder again underneath.
151
+ #keepRows(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
152
+ const written = rowsOf(partition, rows);
153
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
154
+ // Read here and not before: a ward taken while this waited its turn is
155
+ // a ward this must not write, and a mkdir would build its folder again
156
+ // under whoever removed it. A name not kept is nothing, and that is as
157
+ // true at the head of the line as it was at the back of it.
158
+ if (!existsSync(this.#ward(name))) return;
159
+ const dir = join(this.#ward(name), 'rows');
160
+ await mkdir(dir, { recursive: true, mode: 0o700 });
161
+ for (const [row, value] of written) {
162
+ const file = join(dir, await fileOf(row));
163
+ if (value === undefined) {
164
+ await rm(file, { force: true });
165
+ continue;
166
+ }
167
+ const tmp = `${file}.tmp`;
168
+ await writeFile(tmp, JSON.stringify(row === HEAD ? value : ({ row, value } satisfies Kind)) + '\n', { mode: 0o600 });
169
+ await rename(tmp, file);
170
+ }
171
+ });
172
+ this.#queues.set(name, next.catch(() => {}));
173
+ return next;
174
+ }
175
+
176
+ async put(name: string, kept: Kept): Promise<void> {
177
+ if (this.#form(name)) throw new Error(`ward ${name} already exists in ${this.dir}`);
178
+ const wd = this.#ward(name);
179
+ await mkdir(wd, { recursive: true, mode: 0o700 });
180
+ if (this.sealed) return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
181
+ await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
182
+ await chmod(join(wd, 'seed'), 0o600);
183
+ await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
184
+ await this.#keepRows(name, kept.partition, rowsIn(kept.partition));
185
+ }
186
+
187
+ // A sealed ward is one sealed value and is written whole; a plain one is
188
+ // rows, and only the rows the ward named are touched.
189
+ async save(name: string, partition: Record<string, unknown>, rows: readonly string[]): Promise<void> {
190
+ const form = this.#form(name);
191
+ if (!form) return; // a name not kept is nothing
192
+ if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, partition }));
193
+ return this.#keepRows(name, partition, rows);
194
+ }
195
+
196
+ async record(name: string, record: WardRecord): Promise<void> {
197
+ const form = this.#form(name);
198
+ if (!form) return;
199
+ if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, record }));
200
+ return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
201
+ }
202
+
203
+ async take(name: string): Promise<Kept | undefined> {
204
+ const kept = await this.load(name);
205
+ if (!kept) return undefined;
206
+ await this.#queues.get(name);
207
+ await rm(this.#ward(name), { recursive: true, force: true });
208
+ return kept;
209
+ }
210
+
211
+ async hints(): Promise<Record<string, string>> {
212
+ const p = join(this.dir, 'reach.json');
213
+ return existsSync(p) ? (JSON.parse(await readFile(p, 'utf8')) as Record<string, string>) : {};
214
+ }
215
+
216
+ async hint(pk: string, url: string): Promise<void> {
217
+ const all = await this.hints();
218
+ all[pk] = url;
219
+ await writeFile(join(this.dir, 'reach.json'), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
220
+ }
221
+ }