@starlove/orbit 1.4.0 → 2.0.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 (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.62905fcb.js +0 -952
  204. package/dist/full/js/app.62905fcb.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -229
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The memory adapter: Orbit's storage port over plain objects. It is the
3
+ * reference the conformance suite is written against, the fast store for the
4
+ * core's own tests, and the smallest example of an adapter.
5
+ *
6
+ * Several stores can share one MemoryData, as several processes share a data
7
+ * folder: each write is serialised across all of them, and each hears the
8
+ * others' changes. A write works on a copy of the board's tables and swaps it
9
+ * in when the body returns, so a read always holds one committed state.
10
+ */
11
+ import { OrbitError } from "../../core/errors.js";
12
+ import { changeOf } from "../../core/model.js";
13
+ import { byCodePoint, emptyTables, reader, writer } from "./board.js";
14
+ const yieldTurn = () => Promise.resolve();
15
+ /** Everything one Orbit keeps, shared by every memory store opened over it. */
16
+ export class MemoryData {
17
+ /** In the order boards were registered. */
18
+ directory = [];
19
+ settings = new Map();
20
+ /** Each board's committed tables. A write replaces them whole and never changes them in place. */
21
+ tables = new Map();
22
+ tails = new Map();
23
+ listeners = new Map();
24
+ /** Runs `body` once every earlier body with the same key has finished. */
25
+ async exclusive(key, body) {
26
+ const previous = this.tails.get(key) ?? Promise.resolve();
27
+ let release = () => undefined;
28
+ const done = new Promise((resolve) => {
29
+ release = resolve;
30
+ });
31
+ const tail = previous.then(() => done);
32
+ this.tails.set(key, tail);
33
+ await previous;
34
+ try {
35
+ return await body();
36
+ }
37
+ finally {
38
+ release();
39
+ if (this.tails.get(key) === tail)
40
+ this.tails.delete(key);
41
+ }
42
+ }
43
+ listen(boardId, listener) {
44
+ let set = this.listeners.get(boardId);
45
+ if (!set) {
46
+ set = new Set();
47
+ this.listeners.set(boardId, set);
48
+ }
49
+ set.add(listener);
50
+ return () => {
51
+ set.delete(listener);
52
+ };
53
+ }
54
+ /** Tells every listener on the board, after the write that made them has committed. */
55
+ announce(boardId, log) {
56
+ const listeners = [...(this.listeners.get(boardId) ?? [])];
57
+ for (const event of log.events) {
58
+ const change = changeOf(boardId, event);
59
+ for (const listener of listeners) {
60
+ queueMicrotask(() => {
61
+ listener(structuredClone(change));
62
+ });
63
+ }
64
+ }
65
+ }
66
+ entry(id) {
67
+ return this.directory.find((entry) => entry.id === id);
68
+ }
69
+ slugTaken(slug, exceptId) {
70
+ return this.directory.some((entry) => entry.slug === slug && entry.id !== exceptId);
71
+ }
72
+ /** Swaps in a write's tables and, when the write changed the board's record, brings its listing in line (as SQLite does). */
73
+ commit(boardId, draft) {
74
+ const board = draft.board;
75
+ if (board && this.slugTaken(board.slug, boardId))
76
+ throw new OrbitError("board_slug_taken");
77
+ const before = this.tables.get(boardId)?.board;
78
+ this.tables.set(boardId, draft);
79
+ if (!board || JSON.stringify(before) === JSON.stringify(board))
80
+ return;
81
+ this.directory = this.directory.map((entry) => entry.id === boardId
82
+ ? { ...entry, slug: board.slug, name: board.name, repo_url: board.repo_url, default_branch: board.default_branch, updated_at: board.updated_at }
83
+ : entry);
84
+ }
85
+ }
86
+ /** One write on a board's draft, with the writer closed once it settles. */
87
+ async function runWrite(data, boardId, base, body) {
88
+ const draft = structuredClone(base);
89
+ const log = { events: [] };
90
+ let open = true;
91
+ const guard = async () => {
92
+ await yieldTurn();
93
+ if (!open)
94
+ throw new OrbitError("write_closed");
95
+ };
96
+ try {
97
+ const result = await body(writer(boardId, draft, log, { slugTaken: (slug) => data.slugTaken(slug, boardId) }, guard));
98
+ return { result, draft, log };
99
+ }
100
+ finally {
101
+ open = false;
102
+ }
103
+ }
104
+ function boardStore(data, id) {
105
+ const committed = () => {
106
+ const tables = data.entry(id) ? data.tables.get(id) : undefined;
107
+ if (!tables)
108
+ throw new OrbitError("board_not_found");
109
+ return tables;
110
+ };
111
+ return {
112
+ id,
113
+ async read(body) {
114
+ await yieldTurn();
115
+ const snapshot = committed();
116
+ return body(reader(() => snapshot));
117
+ },
118
+ write(body) {
119
+ return data.exclusive(`board:${id}`, async () => {
120
+ const { result, draft, log } = await runWrite(data, id, committed(), body);
121
+ data.commit(id, draft);
122
+ data.announce(id, log);
123
+ return result;
124
+ });
125
+ },
126
+ watch(listener) {
127
+ return data.listen(id, listener);
128
+ }
129
+ };
130
+ }
131
+ function directory(data) {
132
+ const listed = (id) => {
133
+ const entry = data.entry(id);
134
+ if (!entry)
135
+ throw new OrbitError("board_not_found");
136
+ return entry;
137
+ };
138
+ return {
139
+ async list() {
140
+ await yieldTurn();
141
+ return [...data.directory].sort((a, b) => byCodePoint(a.name, b.name)).map((entry) => ({ ...entry }));
142
+ },
143
+ async get(id) {
144
+ await yieldTurn();
145
+ const entry = data.entry(id);
146
+ return entry ? { ...entry } : null;
147
+ },
148
+ async bySlug(slug) {
149
+ await yieldTurn();
150
+ const entry = data.directory.find((candidate) => candidate.slug === slug);
151
+ return entry ? { ...entry } : null;
152
+ },
153
+ async latest() {
154
+ await yieldTurn();
155
+ let best = null;
156
+ for (const entry of data.directory) {
157
+ if (best === null || (entry.last_active_at ?? "") >= (best.last_active_at ?? ""))
158
+ best = entry;
159
+ }
160
+ return best ? { ...best } : null;
161
+ },
162
+ create(board, seed) {
163
+ return data.exclusive("directory", async () => {
164
+ if (data.entry(board.id) || data.tables.has(board.id))
165
+ throw new OrbitError("duplicate_id");
166
+ if (data.slugTaken(board.slug, board.id))
167
+ throw new OrbitError("board_slug_taken");
168
+ const base = emptyTables();
169
+ base.board = { ...board };
170
+ const { draft, log } = await runWrite(data, board.id, base, seed);
171
+ const entry = {
172
+ id: board.id,
173
+ slug: board.slug,
174
+ name: board.name,
175
+ db_path: "",
176
+ repo_url: board.repo_url,
177
+ default_branch: board.default_branch,
178
+ last_active_at: board.created_at,
179
+ created_at: board.created_at,
180
+ updated_at: board.updated_at
181
+ };
182
+ data.directory = [...data.directory, entry];
183
+ try {
184
+ data.commit(board.id, draft);
185
+ }
186
+ catch (error) {
187
+ data.directory = data.directory.filter((other) => other.id !== board.id);
188
+ throw error;
189
+ }
190
+ data.announce(board.id, log);
191
+ return { ...listed(board.id) };
192
+ });
193
+ },
194
+ async touch(id, at) {
195
+ await yieldTurn();
196
+ listed(id);
197
+ data.directory = data.directory.map((entry) => (entry.id === id ? { ...entry, last_active_at: at, updated_at: at } : entry));
198
+ },
199
+ remove(id, options = {}) {
200
+ return data.exclusive("directory", () => data.exclusive(`board:${id}`, async () => {
201
+ await yieldTurn();
202
+ listed(id);
203
+ data.directory = data.directory.filter((entry) => entry.id !== id);
204
+ if (options.keepData)
205
+ return { removed: [] };
206
+ data.tables.delete(id);
207
+ return { removed: [`memory:${id}`] };
208
+ }));
209
+ }
210
+ };
211
+ }
212
+ /** A store over `data`; pass the same MemoryData to two stores to have two views of one Orbit. */
213
+ export function createMemoryStore(data = new MemoryData()) {
214
+ const boards = directory(data);
215
+ return {
216
+ boards,
217
+ async board(id) {
218
+ await yieldTurn();
219
+ if (!data.entry(id))
220
+ throw new OrbitError("board_not_found");
221
+ return boardStore(data, id);
222
+ },
223
+ async locate(kind, id, hint) {
224
+ await yieldTurn();
225
+ const holds = (tables) => {
226
+ switch (kind) {
227
+ case "state":
228
+ return tables.states.some((state) => state.id === id);
229
+ case "ticket":
230
+ return tables.tickets.some((ticket) => ticket.id === id);
231
+ case "entry":
232
+ return tables.entries.some((entry) => entry.id === id);
233
+ case "relation":
234
+ return tables.relations.some((relation) => relation.id === id);
235
+ }
236
+ };
237
+ const order = [...data.directory].sort((a, b) => byCodePoint(a.name, b.name)).sort((a, b) => Number(b.id === hint) - Number(a.id === hint));
238
+ const found = order.find((entry) => {
239
+ const tables = data.tables.get(entry.id);
240
+ return tables !== undefined && holds(tables);
241
+ });
242
+ return found ? { ...found } : null;
243
+ },
244
+ async relationsInto(boardId) {
245
+ await yieldTurn();
246
+ return [...data.directory].sort((a, b) => byCodePoint(a.name, b.name)).flatMap((entry) => [...(data.tables.get(entry.id)?.crossBoardRelations ?? [])]
247
+ .sort((a, b) => (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0))
248
+ .filter((relation) => relation.target_board_id === boardId)
249
+ .map((relation) => ({ board_id: entry.id, relation: { ...relation } })));
250
+ },
251
+ settings: {
252
+ async get(key) {
253
+ await yieldTurn();
254
+ return data.settings.get(key) ?? null;
255
+ },
256
+ async set(key, value) {
257
+ await yieldTurn();
258
+ data.settings.set(key, value);
259
+ }
260
+ },
261
+ backups: null,
262
+ async close() {
263
+ await yieldTurn();
264
+ }
265
+ };
266
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Point-in-time copies of the data folder, under `backups/`: SQLite's
3
+ * `VACUUM INTO` writes a whole, consistent copy of a database while it stays
4
+ * in use. Each copy is written under a temporary name and renamed when done,
5
+ * with a small manifest beside it, and only the newest copies are kept.
6
+ *
7
+ * backups/boards/<board id>/<time>-<reason>-<slug>.board.db
8
+ * backups/boards/<board id>/<time>-<reason>-<slug>.files/ attachments/ and artifacts/
9
+ * backups/registry/<time>-<reason>.registry.db
10
+ *
11
+ * A board's files are hard-linked into the copy's folder (a copy of the bytes
12
+ * where a link is refused). Blob writes never change a file in place (each is
13
+ * a new file renamed over the old name, and a remove unlinks), so a link keeps
14
+ * the bytes it saw while the board moves on, and a file that never changes
15
+ * costs no more disk across all its copies.
16
+ *
17
+ * The core writes a file's bytes before the record that names them and
18
+ * removes them after the record is gone. A copy keeps that order by holding
19
+ * SQLite's write lock (taken here with BEGIN IMMEDIATE, so another process is
20
+ * held off too) while it copies the database and takes the last links:
21
+ * - a record in the copy was committed before the lock, so its bytes were
22
+ * written before the lock and are linked inside it, unless they were
23
+ * removed, which only happens after the record is gone from the copy;
24
+ * - a remove whose record is still there when the lock is taken can't commit
25
+ * its record until the lock is let go, so its bytes are still there to link.
26
+ * The bulk of the linking (or copying) happens before the lock, and only what
27
+ * changed since is looked at inside it. An extra file in a copy, one whose
28
+ * record isn't there, is harmless.
29
+ */
30
+ import type { Id } from "../../core/model.ts";
31
+ import type { Backups } from "../../store/types.ts";
32
+ export interface BackupOptions {
33
+ dataDir: string;
34
+ /** Copies kept per board, and for the registry. */
35
+ retention: number;
36
+ busyMs: number;
37
+ now: () => Date;
38
+ /** The board's database and slug, or null for a board this Orbit doesn't have. */
39
+ board: (id: Id) => {
40
+ dbPath: string;
41
+ slug: string;
42
+ name: string;
43
+ } | null;
44
+ /** Told when a copy's files couldn't be kept (its database copy stands); the console by default. */
45
+ warn?: (message: string) => void;
46
+ }
47
+ /** What a files folder holds, for the manifest. */
48
+ export interface FilesTally {
49
+ count: number;
50
+ bytes: number;
51
+ how: "linked" | "copied" | "mixed" | "none";
52
+ }
53
+ export declare function createSqliteBackups(options: BackupOptions): Backups;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Point-in-time copies of the data folder, under `backups/`: SQLite's
3
+ * `VACUUM INTO` writes a whole, consistent copy of a database while it stays
4
+ * in use. Each copy is written under a temporary name and renamed when done,
5
+ * with a small manifest beside it, and only the newest copies are kept.
6
+ *
7
+ * backups/boards/<board id>/<time>-<reason>-<slug>.board.db
8
+ * backups/boards/<board id>/<time>-<reason>-<slug>.files/ attachments/ and artifacts/
9
+ * backups/registry/<time>-<reason>.registry.db
10
+ *
11
+ * A board's files are hard-linked into the copy's folder (a copy of the bytes
12
+ * where a link is refused). Blob writes never change a file in place (each is
13
+ * a new file renamed over the old name, and a remove unlinks), so a link keeps
14
+ * the bytes it saw while the board moves on, and a file that never changes
15
+ * costs no more disk across all its copies.
16
+ *
17
+ * The core writes a file's bytes before the record that names them and
18
+ * removes them after the record is gone. A copy keeps that order by holding
19
+ * SQLite's write lock (taken here with BEGIN IMMEDIATE, so another process is
20
+ * held off too) while it copies the database and takes the last links:
21
+ * - a record in the copy was committed before the lock, so its bytes were
22
+ * written before the lock and are linked inside it, unless they were
23
+ * removed, which only happens after the record is gone from the copy;
24
+ * - a remove whose record is still there when the lock is taken can't commit
25
+ * its record until the lock is let go, so its bytes are still there to link.
26
+ * The bulk of the linking (or copying) happens before the lock, and only what
27
+ * changed since is looked at inside it. An extra file in a copy, one whose
28
+ * record isn't there, is harmless.
29
+ */
30
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
31
+ import { copyFile, link, lstat, mkdir, readdir, rm, utimes } from "node:fs/promises";
32
+ import { dirname, join } from "node:path";
33
+ import { Connection, untilFree } from "./database.js";
34
+ /** A name part made of letters, digits, `.`, `_` and `-` only. */
35
+ function segment(value) {
36
+ return value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "unknown";
37
+ }
38
+ /** The trees a board keeps beside its database. */
39
+ const FILE_TREES = ["attachments", "artifacts"];
40
+ const gone = (error) => {
41
+ const code = error.code;
42
+ return code === "ENOENT" || code === "ENOTDIR";
43
+ };
44
+ /** A file written by a blob put that hasn't been renamed into place yet. */
45
+ const inFlight = (name) => name.startsWith(".") && name.endsWith(".partial");
46
+ async function walk(folder, visit, relative = "") {
47
+ let entries;
48
+ try {
49
+ entries = await readdir(folder, { withFileTypes: true });
50
+ }
51
+ catch (error) {
52
+ if (gone(error))
53
+ return;
54
+ throw error;
55
+ }
56
+ for (const entry of entries) {
57
+ const next = relative ? `${relative}/${entry.name}` : entry.name;
58
+ // A symlink is never followed: a board's folder is Orbit's, but a copy must not reach outside it.
59
+ if (entry.isDirectory())
60
+ await walk(join(folder, entry.name), visit, next);
61
+ else if (entry.isFile() && !inFlight(entry.name))
62
+ await visit(join(folder, entry.name), next);
63
+ }
64
+ }
65
+ /**
66
+ * Makes `target` hold every file of `source`'s trees: linked, else copied.
67
+ * Safe to run again: a file already there (the same inode, or a copy with the
68
+ * same size and time) is left alone, a changed one is put in again, one that
69
+ * has since left `source` stays (an extra file is harmless).
70
+ */
71
+ async function syncFiles(source, target) {
72
+ await mkdir(target, { recursive: true });
73
+ for (const tree of FILE_TREES) {
74
+ await walk(join(source, tree), async (file, relative) => {
75
+ const destination = join(target, tree, ...relative.split("/"));
76
+ let from;
77
+ try {
78
+ from = await lstat(file);
79
+ }
80
+ catch (error) {
81
+ if (gone(error))
82
+ return;
83
+ throw error;
84
+ }
85
+ const have = await lstat(destination).catch(() => null);
86
+ if (have && ((have.ino === from.ino && have.dev === from.dev) || (have.nlink === 1 && have.size === from.size && Math.floor(have.mtimeMs) === Math.floor(from.mtimeMs))))
87
+ return;
88
+ await mkdir(dirname(destination), { recursive: true });
89
+ await rm(destination, { force: true });
90
+ try {
91
+ await link(file, destination);
92
+ }
93
+ catch (error) {
94
+ if (gone(error))
95
+ return;
96
+ // Another device, no permission, a file system without links: keep the bytes by copying.
97
+ try {
98
+ await copyFile(file, destination);
99
+ await utimes(destination, from.atime, from.mtime);
100
+ }
101
+ catch (copyError) {
102
+ await rm(destination, { force: true });
103
+ if (gone(copyError))
104
+ return;
105
+ throw copyError;
106
+ }
107
+ }
108
+ });
109
+ }
110
+ }
111
+ /** Counts a files folder: files, bytes, and whether they are links or copies. */
112
+ async function tallyFiles(target) {
113
+ let count = 0;
114
+ let bytes = 0;
115
+ let linked = 0;
116
+ for (const tree of FILE_TREES) {
117
+ await walk(join(target, tree), async (file) => {
118
+ const found = await lstat(file);
119
+ count += 1;
120
+ bytes += found.size;
121
+ if (found.nlink > 1)
122
+ linked += 1;
123
+ });
124
+ }
125
+ return { count, bytes, how: count === 0 ? "none" : linked === count ? "linked" : linked === 0 ? "copied" : "mixed" };
126
+ }
127
+ export function createSqliteBackups(options) {
128
+ const root = join(options.dataDir, "backups");
129
+ const warn = options.warn ?? ((message) => { console.warn(message); });
130
+ /**
131
+ * Holds SQLite's write lock on `source` (every writer, in this process or
132
+ * another, waits for it) until the returned function lets go. Taking it
133
+ * changes nothing: it is rolled back.
134
+ */
135
+ async function holdWrites(source) {
136
+ const conn = new Connection(source);
137
+ try {
138
+ await untilFree(() => { conn.exec("BEGIN IMMEDIATE"); }, Date.now() + options.busyMs);
139
+ }
140
+ catch (error) {
141
+ conn.close();
142
+ throw error;
143
+ }
144
+ return () => {
145
+ conn.rollback();
146
+ conn.close();
147
+ };
148
+ }
149
+ /** `filesFrom`: a board's folder, whose attachments and artifacts go beside the database copy. */
150
+ async function copy(source, filesFrom, dir, name, suffix, manifest, oncePerDay) {
151
+ const when = options.now().toISOString();
152
+ if (oncePerDay && existsSync(dir) && readdirSync(dir).some((file) => file.startsWith(`${when.slice(0, 10)}T`) && file.endsWith(suffix)))
153
+ return null;
154
+ mkdirSync(dir, { recursive: true });
155
+ const base = `${when.replace(/[:.]/g, "-")}-${name}`;
156
+ const target = join(dir, `${base}${suffix}`);
157
+ const partial = `${target}.partial`;
158
+ const filesTarget = join(dir, `${base}.files`);
159
+ const filesPartial = `${filesTarget}.partial`;
160
+ rmSync(partial, { force: true });
161
+ rmSync(filesPartial, { recursive: true, force: true });
162
+ // Most of the linking (or copying) first, with the board still writing.
163
+ const problem = { error: null };
164
+ if (filesFrom !== null)
165
+ await syncFiles(filesFrom, filesPartial).catch((error) => { problem.error = error instanceof Error ? error : new Error(String(error)); });
166
+ // Then the database and what changed since, with writes held off.
167
+ try {
168
+ const release = filesFrom !== null && problem.error === null ? await holdWrites(source) : null;
169
+ try {
170
+ const conn = new Connection(source);
171
+ try {
172
+ await untilFree(() => conn.run("VACUUM INTO ?", [partial]), Date.now() + options.busyMs);
173
+ }
174
+ finally {
175
+ conn.close();
176
+ }
177
+ if (filesFrom !== null && problem.error === null)
178
+ await syncFiles(filesFrom, filesPartial).catch((error) => { problem.error = error instanceof Error ? error : new Error(String(error)); });
179
+ }
180
+ finally {
181
+ release?.();
182
+ }
183
+ }
184
+ catch (error) {
185
+ rmSync(filesPartial, { recursive: true, force: true });
186
+ throw error;
187
+ }
188
+ renameSync(partial, target);
189
+ const record = { ...manifest, created_at: when };
190
+ if (filesFrom !== null) {
191
+ try {
192
+ if (problem.error !== null)
193
+ throw problem.error;
194
+ const tally = await tallyFiles(filesPartial);
195
+ renameSync(filesPartial, filesTarget);
196
+ record.files = tally;
197
+ }
198
+ catch (error) {
199
+ problem.error = error instanceof Error ? error : new Error(String(error));
200
+ }
201
+ if (problem.error !== null) {
202
+ rmSync(filesPartial, { recursive: true, force: true });
203
+ const reason = problem.error.message;
204
+ warn(`[orbit] Backup ${base}${suffix} has its database but not the board's files: ${reason}`);
205
+ record.files = { error: "The board's files could not be kept in this copy; see Orbit's log." };
206
+ }
207
+ }
208
+ writeFileSync(`${target}.json`, `${JSON.stringify(record, null, 2)}\n`);
209
+ prune(dir, suffix);
210
+ return target;
211
+ }
212
+ function prune(dir, suffix) {
213
+ const copies = readdirSync(dir).filter((file) => file.endsWith(suffix)).sort().reverse();
214
+ for (const stale of copies.slice(options.retention)) {
215
+ rmSync(join(dir, stale), { force: true });
216
+ rmSync(join(dir, `${stale}.json`), { force: true });
217
+ rmSync(join(dir, `${stale.slice(0, -suffix.length)}.files`), { recursive: true, force: true });
218
+ }
219
+ }
220
+ return {
221
+ async board(id, reason, opts = {}) {
222
+ const board = options.board(id);
223
+ if (board === null)
224
+ return null;
225
+ const manifest = { kind: "board", reason, board_id: id, slug: board.slug, name: board.name };
226
+ return copy(board.dbPath, dirname(board.dbPath), join(root, "boards", segment(id)), `${segment(reason)}-${segment(board.slug)}`, ".board.db", manifest, opts.oncePerDay === true);
227
+ },
228
+ async directory(reason, opts = {}) {
229
+ return copy(join(options.dataDir, "registry.db"), null, join(root, "registry"), segment(reason), ".registry.db", { kind: "registry", reason }, opts.oncePerDay === true);
230
+ }
231
+ };
232
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Bytes beside each board's database: `boards/<folder>/attachments/...` and
3
+ * `boards/<folder>/artifacts/...`, the port's paths taken as they are. Each
4
+ * file is written under a temporary name and renamed into place, so a crash
5
+ * leaves the old bytes or the new ones, never half a file.
6
+ */
7
+ import type { Id } from "../../core/model.ts";
8
+ import type { BlobStore } from "../../store/types.ts";
9
+ /**
10
+ * A blob store over board folders. `folderOf` gives a board's folder, or null
11
+ * for a board this Orbit doesn't have: nothing can be stored for it, and
12
+ * nothing is there to read or remove.
13
+ */
14
+ export declare function createSqliteBlobStore(folderOf: (boardId: Id) => string | null): BlobStore;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Bytes beside each board's database: `boards/<folder>/attachments/...` and
3
+ * `boards/<folder>/artifacts/...`, the port's paths taken as they are. Each
4
+ * file is written under a temporary name and renamed into place, so a crash
5
+ * leaves the old bytes or the new ones, never half a file.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { link, mkdir, open, readFile, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
9
+ import { basename, dirname, join } from "node:path";
10
+ import { OrbitError } from "../../core/errors.js";
11
+ import { checkBlobPath } from "../../store/blob-path.js";
12
+ const missing = (error) => {
13
+ const code = error.code;
14
+ return code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR";
15
+ };
16
+ /**
17
+ * A blob store over board folders. `folderOf` gives a board's folder, or null
18
+ * for a board this Orbit doesn't have: nothing can be stored for it, and
19
+ * nothing is there to read or remove.
20
+ */
21
+ export function createSqliteBlobStore(folderOf) {
22
+ const fileOf = (boardId, path) => {
23
+ checkBlobPath(path);
24
+ const folder = folderOf(boardId);
25
+ return folder === null ? null : join(folder, ...path.split("/"));
26
+ };
27
+ return {
28
+ async put(boardId, path, bytes, options = {}) {
29
+ const file = fileOf(boardId, path);
30
+ if (file === null)
31
+ throw new OrbitError("board_not_found");
32
+ await mkdir(dirname(file), { recursive: true });
33
+ const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.partial`);
34
+ try {
35
+ const handle = await open(temporary, "wx");
36
+ try {
37
+ await handle.writeFile(bytes);
38
+ await handle.sync();
39
+ }
40
+ finally {
41
+ await handle.close();
42
+ }
43
+ if (options.exclusive) {
44
+ // A hard link fails when the name is taken, so two exclusive puts can't both win.
45
+ await link(temporary, file).catch((error) => {
46
+ throw error.code === "EEXIST" ? new OrbitError("blob_exists") : error;
47
+ });
48
+ }
49
+ else {
50
+ await rename(temporary, file);
51
+ }
52
+ }
53
+ catch (error) {
54
+ throw error instanceof OrbitError ? error : new OrbitError("storage_failed", { cause: error });
55
+ }
56
+ finally {
57
+ await rm(temporary, { force: true });
58
+ }
59
+ },
60
+ async get(boardId, path) {
61
+ const file = fileOf(boardId, path);
62
+ if (file === null)
63
+ return null;
64
+ try {
65
+ return new Uint8Array(await readFile(file));
66
+ }
67
+ catch (error) {
68
+ if (missing(error))
69
+ return null;
70
+ throw new OrbitError("storage_failed", { cause: error });
71
+ }
72
+ },
73
+ async size(boardId, path) {
74
+ const file = fileOf(boardId, path);
75
+ if (file === null)
76
+ return null;
77
+ try {
78
+ const found = await stat(file);
79
+ return found.isFile() ? found.size : null;
80
+ }
81
+ catch (error) {
82
+ if (missing(error))
83
+ return null;
84
+ throw new OrbitError("storage_failed", { cause: error });
85
+ }
86
+ },
87
+ async remove(boardId, path) {
88
+ const file = fileOf(boardId, path);
89
+ if (file === null)
90
+ return;
91
+ await unlink(file).catch((error) => {
92
+ if (!missing(error))
93
+ throw new OrbitError("storage_failed", { cause: error });
94
+ });
95
+ },
96
+ async removeBoard(boardId) {
97
+ const folder = folderOf(boardId);
98
+ if (folder === null)
99
+ return;
100
+ for (const kind of ["attachments", "artifacts"])
101
+ await rm(join(folder, kind), { recursive: true, force: true });
102
+ // The folder goes too once nothing else is in it (its database is the store's to delete).
103
+ await rmdir(folder).catch(() => undefined);
104
+ },
105
+ location(boardId, path) {
106
+ return fileOf(boardId, path) ?? "";
107
+ }
108
+ };
109
+ }