@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.
- package/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.62905fcb.js +0 -952
- package/dist/full/js/app.62905fcb.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -229
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- 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
|
+
}
|