Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions packages/deckd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ When Hub uses `DECKD_URL`, Node watchers publish + HMR only — they do not
| ensure / seal / restore | per-workspace **write** lock |
| list / file-at / file-list-at | per-workspace **read** lock (concurrent) |
| commit / outcome | per-repo **file** lock (cross-process) |
| tree / read / tree-hash | none; the file lock only on a miss |

## Explicit commits

`/commit`, `/outcome`, `/head` and `/tree-hash` record a revision from content
the caller staged, never from a working copy. A writer such as realm-server
the caller staged, never from a working copy. `/tree` and `/read` read a
revision back. A writer such as realm-server
can then record each write exactly once, even when deckd's response is lost.

Each call names `repo`, the absolute path of one history directory (for
Expand Down Expand Up @@ -169,12 +171,19 @@ Each commit's message ends with the caller's trailers, then
```json
{
"revision": "6f1c2d8e0a9b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
"parent": "0000000000000000000000000000000000000000",
"tree": "9a3e1b7c5d2f4e6a8b0c1d3e5f7a9b1c3d5e7f9a",
"created": true,
"replayed": false
}
```

- `parent` is `revision`'s own parent, which on a replay may be older than
the request's `parent`. It is `null` only for the root commit.
- `created` is false when the changes left HEAD's tree as it was, and
`revision` is that HEAD. On a replay (`replayed: true`) it says whether the
operation's first commit wrote `revision` or found the tree unchanged.

- `operationId`: 1–128 characters from `[A-Za-z0-9._-]`, not starting with `.`.
- `changes`: each entry has `path` plus exactly one of `contentBase64` or
`delete: true`. A path appears at most once. Paths are relative and may not
Expand All @@ -193,10 +202,16 @@ Each commit's message ends with the caller's trailers, then
```

```json
{ "state": "committed", "revision": "6f1c2d8e0a9b4c3d2e1f0a9b8c7d6e5f4a3b2c1d" }
{
"state": "committed",
"revision": "6f1c2d8e0a9b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
"parent": "0000000000000000000000000000000000000000",
"created": true
}
```

or `{ "state": "absent" }`, after which the ID is fenced. It is a POST because
`parent` and `created` mean what they do in `/commit`'s answer. Or the answer
is `{ "state": "absent" }`, after which the ID is fenced. It is a POST because
it can change state.

### `POST /head`
Expand Down Expand Up @@ -230,6 +245,59 @@ any repo, whoever committed it and whatever the message. The revision must be
in the repo's history; a commit object that was written but never published
is `revision-not-found`.

### `POST /tree`

```json
{
"repo": "/realms/acme/.history",
"revision": "6f1c2d8e0a9b4c3d2e1f0a9b8c7d6e5f4a3b2c1d"
}
```

```json
{
"entries": [
{
"path": "Author/jane.json",
"blob": "3b18e512dba79e4c8300dd08aeb37f8e728b8dad",
"executable": false
}
]
}
```

Every file in the revision's tree, in path order. `blob` is the git blob ID

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] "In path order" doesn't describe the order /tree actually returns. A caller that merge-joins the list against a sorted disk listing will get mismatches. CommitService::tree passes along jj's MergedTree::entries(), which walks depth-first and sorts each directory by component name (jj-lib 0.46 TreeEntriesIterator; GitBackend::read_tree re-sorts git's entries by component). So a directory comes before a sibling file whose name it prefixes:

  • /tree returns Author/jane.json, then Author.gts
  • full-path byte order, git order, and JS Array.prototype.sort all put Author.gts first (. 0x2e < / 0x2f)

Realms routinely have a Foo.gts next to a Foo/ instance directory, so recovery will hit this case.

Either sort entries by the full path string before returning (entries.sort_by(|a, b| a.path.cmp(&b.path)), then "byte order of path" is a precise contract), or document the order as it is. I'd sort, since a flat byte order is what a caller can reproduce. Either way, tree_lists_every_file_with_its_git_blob_id should cover a.txt + a/b.txt: the current b.json / dir/a.gts pair has the same order under both rules, so it can't tell them apart. The doc comment on CommitService::tree makes the same claim.

Regression (new contract in this PR), non-blocking unless the realm-server consumer relies on the order.

of the file's bytes (`git hash-object`), so a caller can compare files on disk
with a revision without reading them out of history. The revision must be in
the repo's history, as for `/tree-hash`.

### `POST /read`

```json
{
"repo": "/realms/acme/.history",
"revision": "6f1c2d8e0a9b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
"paths": ["Author/jane.json", "Author/old.json"]
}
```

```json
{
"files": [
{ "path": "Author/jane.json", "contentBase64": "eyJuYW1lIjoiSmFuZSJ9" },
{ "path": "Author/old.json", "absent": true }
],
"remaining": []
}
```

- Each path's bytes at the revision, in the order asked. A path the revision
has no file at, including a directory, is `absent`.
- One answer carries at most 32 MiB of content. Paths past that point are not
read and are listed in `remaining`, in order, for the caller to ask for
again. The first path is always read, so every file can be read.
- Paths follow `/commit`'s rules.

### Errors

Every error body is `{ "code", "error" }`, plus fields named below, on every
Expand All @@ -238,7 +306,7 @@ route, including a request deckd can't parse.
| Status | `code` | When |
| ----------- | -------------------- | ----------------------------------------------------------------------- |
| 400/415/422 | `invalid-request` | Unparseable JSON, a missing field, the wrong content type, or bad input |
| 404 | `revision-not-found` | `/tree-hash` for a revision the repo doesn't have |
| 404 | `revision-not-found` | `/tree-hash`, `/tree` or `/read` for a revision the repo doesn't have |
| 409 | `parent-mismatch` | `parent` isn't HEAD; body has `head` |
| 409 | `operation-fenced` | The operation ID was reported `absent` by `/outcome` |
| 409 | `operation-mismatch` | The operation ID committed a different request; body has `revision` |
Expand Down
68 changes: 67 additions & 1 deletion packages/deckd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use tower_http::trace::TraceLayer;

use crate::history::commit::{
Author, Change, CommitError, CommitOutcome, CommitRequest, CommitService, CommitTrailer,
OperationOutcome, RevisionTree,
OperationOutcome, RevisionTree, TreeEntry,
};
use crate::history::{Actor, HistoryBackend, HistoryBackendError, HistoryEntry, RestorePlan};
use crate::watch::WatchPlane;
Expand Down Expand Up @@ -54,6 +54,8 @@ pub fn router(state: AppState) -> Router {
.route("/outcome", post(outcome))
.route("/head", post(head))
.route("/tree-hash", post(tree_hash))
.route("/tree", post(tree))
.route("/read", post(read))
.route("/ensure-build", post(ensure_build))
.route("/artifact-at", post(artifact_at))
.layer(TraceLayer::new_for_http())
Expand All @@ -77,6 +79,8 @@ pub fn router(state: AppState) -> Router {
.route("/outcome", post(outcome))
.route("/head", post(head))
.route("/tree-hash", post(tree_hash))
.route("/tree", post(tree))
.route("/read", post(read))
.layer(TraceLayer::new_for_http())
.with_state(state);

Expand Down Expand Up @@ -312,6 +316,34 @@ struct TreeHashResponse {
tree: String,
}

#[derive(Debug, Serialize)]
struct TreeResponse {
entries: Vec<TreeEntry>,
}

#[derive(Debug, Deserialize)]
struct ReadBody {
repo: String,
revision: String,
paths: Vec<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReadFile {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
content_base64: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
absent: bool,
}

#[derive(Debug, Serialize)]
struct ReadResponse {
files: Vec<ReadFile>,
remaining: Vec<String>,
}

async fn commit(
State(st): State<AppState>,
Body(body): Body<CommitBody>,
Expand Down Expand Up @@ -383,6 +415,40 @@ async fn tree_hash(
Ok(Json(TreeHashResponse { tree: found.tree }))
}

async fn tree(
State(st): State<AppState>,
Body(body): Body<TreeHashBody>,
) -> Result<Json<TreeResponse>, ApiError> {
let repo = std::path::PathBuf::from(body.repo);
let revision = body.revision;
let entries = run_commits(&st, move |c| c.tree(&repo, &revision)).await?;
Ok(Json(TreeResponse { entries }))
}

async fn read(
State(st): State<AppState>,
Body(body): Body<ReadBody>,
) -> Result<Json<ReadResponse>, ApiError> {
let repo = std::path::PathBuf::from(body.repo);
let revision = body.revision;
let paths = body.paths;
let found = run_commits(&st, move |c| c.read(&repo, &revision, &paths)).await?;
Ok(Json(ReadResponse {
files: found
.files
.into_iter()
.map(|file| ReadFile {
path: file.path,
absent: file.content.is_none(),
content_base64: file
.content
.map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)),
})
.collect(),
remaining: found.remaining,
}))
}

/// Commit work waits on repo locks and drives jj-lib's `!Send` futures, so it
/// runs on a blocking thread.
async fn run_commits<T, F>(st: &AppState, f: F) -> Result<T, ApiError>
Expand Down
Loading
Loading