# Files and versions

Spaces, paths and nodes; uploading and downloading through the block gateway; version history, names and comparisons.

## Spaces and paths

```sh
curl -H "Authorization: Bearer $SIDESPACE_TOKEN" https://side.example.com/v1/spaces
```

Each space has a `root_node_id`. Below it, every file and folder is a node with an id (`nod_…`). You can work by id or by path:

| To | Call |
|---|---|
| Find the node at a path | `GET /v1/spaces/{sid}/paths/Ep01/Shots/A001_C003.mov` |
| List a folder (by name, paged) | `GET /v1/spaces/{sid}/nodes/{nid}/children?limit=200&cursor=…` |
| Create a folder or an empty file | `POST /v1/spaces/{sid}/nodes` with `parent_id`, `name`, `kind` (`dir` or `file`) |
| Rename or move | `PATCH /v1/spaces/{sid}/nodes/{nid}` with `name` and/or `parent_id` |
| Delete (to the trash, 30 days) | `DELETE /v1/spaces/{sid}/nodes/{nid}` |
| Restore from the trash | `POST /v1/spaces/{sid}/nodes/{nid}/restore` |

Names are unique in a folder, ignoring case. When you create a node whose name is taken, the default `if_exists: "rename"` makes `A001_C003 (2).mov`; `if_exists: "reuse"` returns the existing node instead, so two programs saving the same path end up with one file and two versions.

`PATCH` takes an optional `If-Match: <meta_rev>` header. If someone renamed or moved the node since you read it, you get `412` instead of overwriting their change.

The SDKs and the CLI take paths everywhere and resolve them for you:

```sh
sidespace ls "Film A" Ep01/Shots
sidespace mv "Film A" Ep01/old.mov Ep01/Archive/old.mov
sidespace rm "Film A" Ep01/tmp
```

## How bytes move

The API server never carries file content. It hands out short **tickets** for the **block gateway**, which stores content as encrypted, deduplicated chunks (content-defined, BLAKE3-hashed) and streams it.

- Uploading the same footage twice, or a re-export that changed only the metadata, sends only the chunks the gateway doesn't have.
- Reads support HTTP `Range`, and the gateway reads only the chunks a range touches. A player can start in the middle of a 200 GB file at once.
- Tickets last 15 minutes, or 6 hours with `"stream": true` for a player.

## Upload

The SDKs and the CLI do all of this in one call (`side.upload(space, path, data)`). Over plain HTTP, an upload is three steps.

**1. Get the node** (create it, or reuse the file already at that name):

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/nodes \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"parent_id": "'$FOLDER'", "name": "A001_C003.mov", "kind": "file", "if_exists": "reuse"}'
```

**2. Send the bytes.** Open an upload session to get a put ticket, then stream the whole file to the gateway. It chunks and stores it and answers with the **manifest**.

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/upload-sessions \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" -d '{"hashes": []}'
# {"missing": [], "ticket": "…", "blockgw_url": "https://blocks.side.example.com", "expires_at": "…"}

curl -X POST "https://blocks.side.example.com/stream-upload?t=$TICKET" --data-binary @A001_C003.mov
# {"algo": "fastcdc-v1", "size": 734003200, "chunks": [{"hash": "9f2c…", "offset": 0, "len": 1048576}, …]}
```

Clients that chunk locally (the Mac app and the CLI do) send their chunk hashes in `hashes`, upload only the `missing` ones with `PUT /chunks/{hash}?t=…`, and build the manifest themselves.

**3. Commit the version:**

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/nodes/$NODE/versions \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"base_version_id": "ver_…", "manifest": {…}, "mime": "video/quicktime", "client_mtime": "2026-11-14T09:30:00Z"}'
```

`base_version_id` is the version you started from (`null` for a new file). If someone else saved in between, your version still becomes current (last save wins), the answer's `superseded` names the version you replaced, and its author gets a notification. Nothing is lost: both are in the history. A missing chunk answers `409 missing_chunks`.

## Download

Ask for a ticket, then read from the gateway:

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/download-tickets \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"version_id": "ver_…"}'
# {"ticket": "…", "manifest": {…}, "blockgw_url": "https://blocks.side.example.com", "expires_at": "…"}

curl -H "Range: bytes=0-1048575" "https://blocks.side.example.com/files/ver_…?t=$TICKET" -o head.bin
```

Add `download=<file name>` to the URL to get a `Content-Disposition: attachment` header, which is handy for links in a browser. The gateway answers CORS for `/files/*`, so a web page can play or fetch a file directly.

```ts tab=TypeScript
const bytes = await side.download("Film A", "/Ep01/notes.md");
const res = await side.open("Film A", "/Ep01/Shots/A001_C003.mov", { range: [0, 1_048_575] }); // a fetch Response
```

```python tab=Python
data = side.download("Film A", "/Ep01/notes.md")
with side.open("Film A", "/Ep01/Shots/A001_C003.mov", range=(0, 1048575)) as r:
    head = r.read()
```

### Renditions

The server makes a `thumb` (poster image) for images and videos, and a `proxy` for videos: a 720p H.264 stream that plays at once in any browser, whatever the original's codec or size. Ask for one with `"rendition": "thumb"` or `"proxy"` in the ticket request. Your own tools can attach renditions with `PUT /v1/spaces/{sid}/versions/{vid}/renditions/{kind}` (scope `renditions:write`).

## Versions

```sh
sidespace versions "Film A" Ep01/cut.mp4
sidespace get "Film A" Ep01/cut.mp4 cut_v3.mp4 --version 3
sidespace label "Film A" Ep01/cut.mp4 3 "Client cut"
sidespace compare "Film A" Ep01/cut.mp4 "Client cut"     # against the current version
sidespace restore "Film A" ver_01JD…
```

| To | Call |
|---|---|
| History, newest first | `GET /v1/spaces/{sid}/nodes/{nid}/versions` |
| Make an old version current again (a copy, history kept) | `POST /v1/spaces/{sid}/versions/{vid}/restore` |
| Name a version ("Client cut"), or clear it with `""` | `PUT /v1/spaces/{sid}/versions/{vid}/label` |
| What changed between two versions | `GET /v1/spaces/{sid}/versions/{a}/compare/{b}` |

A name belongs to one version of a file at a time, ignoring case: naming another version "Client cut" moves the name. Anywhere the CLI and MCP take a version you can give an id, a number (`3` or `v3`) or a name.

**Compare** answers without downloading anything, from the manifests and sidecars:

```json
{
  "same_content": false,
  "size_delta": -1048576,
  "bytes": { "shared": 690000000, "only_a": 44003200, "only_b": 42954624, "shared_ratio": 0.94 },
  "media": [{ "field": "duration_s", "a": 92.4, "b": 88.0 }],
  "sidecars": [{ "namespace": "ai.tags", "change": "changed", "fields": ["tags"] }],
  "renditions": { "ver_A…": ["thumb", "proxy"], "ver_B…": ["thumb"] }
}
```

`shared_ratio` close to 1 means a metadata-only re-save or a trim; close to 0 means new footage.

To see a whole folder as it was at a moment, or put it back, see [History and rewind](/docs/history).

## Change part of a file

Change a few bytes in the middle, insert, cut a range, append or truncate without sending the whole file. The result is saved as a new version. Only the chunks near your changes are new, so this is quick even on large files.

```sh tab=curl
curl -X POST -H "Authorization: Bearer $SIDESPACE_TOKEN" -H 'If-Match: "ver_…"' -H "Content-Type: application/json" \
  "$SIDESPACE_URL/v1/spaces/$SPACE/nodes/$NODE/edits" \
  -d '{"ops": [{"at": 120, "delete": 4, "text": "night"}, {"at": 2048, "text": "\nINT. HARBOR"}]}'
```

```ts tab=TypeScript
await side.edit("Film A", "/Ep01/script.md", [{ at: 120, delete: 4, text: "night" }], { ifVersionId: "ver_…" });
await side.appendText("Film A", "/Ep01/log.txt", "09:41 pick-ups done\n");
```

```python tab=Python
side.edit("Film A", "/Ep01/script.md", [{"at": 120, "delete": 4, "text": "night"}], if_version_id="ver_…")
side.append_text("Film A", "/Ep01/log.txt", "09:41 pick-ups done\n")
```

- Each op replaces `delete` bytes starting at `at` with `text` (or `base64` bytes). `delete: 0` inserts, no data deletes, and `at` equal to the file's size appends. `truncate` cuts the result to that many bytes last.
- Offsets count in the version you edit, before any of your changes, and ranges must not overlap. A request takes at most 10,000 ops and 4 MiB of new data.
- `If-Match` (or `if_version_id` in the body) names the version you read. If someone saved since, the answer is `412` and nothing changes, so you never edit on top of a save you haven't seen. Without it, the edit applies to the current version, and a save that lands while it runs still makes it fail.
- Over MCP, `edit_text` replaces an exact piece of text and `append_text` adds to the end; see [MCP](/docs/mcp#tools).

## Sync and presence

- `GET /v1/spaces/{sid}/snapshot` pages through every live node for a first sync. Then follow the [journal](/docs/changes) from the snapshot's `at_seq`.
- `POST /v1/spaces/{sid}/presence` with `{"node_id", "state": "viewing" | "editing" | "none"}` tells others what you have open. Reports last 90 seconds unless renewed. `GET` lists them, and the realtime socket pushes them.
