Skip to content

Commit 4361fc3

Browse files
committed
Fix
1 parent 714b7ae commit 4361fc3

12 files changed

Lines changed: 159 additions & 19 deletions

‎docs/user_guide/web_app/l_sessions_sharing/01_session_mental_model.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,13 @@ Practical implication:
7272
3) Open **Session** → **Session state**.
7373
4) Click **Save State**.
7474
5) Find the downloaded file (usually in your Downloads folder).
75-
6) Send the file to your collaborator **together with the dataset export folder** (or tell them where the dataset is hosted).
75+
6) Send the file to your collaborator. A session saved on a sample, a data link
76+
or a server records where the dataset is and opens it; for a dataset opened
77+
from your own computer, send the dataset export folder with it.
7678

77-
To reopen later:
78-
1) Load the same dataset again.
79-
2) Click **Load State** and pick your `.cellucid-session` file.
79+
To reopen later, click **Load State** and pick your `.cellucid-session` file.
80+
Its dataset opens first if it is not open already; a session saved on a local
81+
file needs that file opened again before it is loaded.
8082

8183
---
8284

‎docs/user_guide/web_app/l_sessions_sharing/03_save_restore_ux.md‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ In the left sidebar:
2828
:width: 524px
2929
3030
The **Session** accordion. Everything that brings a dataset in sits above, and
31-
**Session state** sits at the bottom — the order matches the rule that the
32-
dataset must be loaded before a session is restored onto it.
31+
**Session state** sits at the bottom — a session is restored onto a dataset,
32+
which Load State opens first when the session records where it is.
3333
```
3434

3535
---
@@ -66,12 +66,22 @@ save time, so repeated saves never overwrite each other.
6666

6767
### Load State
6868

69-
1) Load the same dataset first.
70-
2) Click **Load State**.
71-
3) Choose the `.cellucid-session` file.
72-
4) Wait for the restore's progress notification to finish as **Loaded** with
69+
1) Click **Load State**.
70+
2) Choose the `.cellucid-session` file.
71+
3) Wait for the restore's progress notification to finish as **Loaded** with
7372
**Session fully restored.**
7473

74+
A session records where its dataset was opened from: a sample dataset, a data
75+
link (**Open from link** or `?data=`), or a Cellucid server. When another
76+
dataset — or none — is open, Load State opens that one first ("Opening the
77+
session's dataset from …") and then applies the session. A session saved on a
78+
dataset opened from your own computer or in a notebook records no source:
79+
open that dataset yourself before loading the session.
80+
81+
A session saved on a link records the link. A link that carries its own access
82+
key — a Dropbox share link, a signed bucket address — is then in the session
83+
file too, so share such a session only with people who may open the data.
84+
7585
If a dataset is still loading when you choose the file, a notification says
7686
the session is waiting for it, and the session is applied as soon as that
7787
dataset has loaded. Choosing another dataset in the meantime means the session
@@ -228,9 +238,12 @@ to fail the whole restore rather than diverge quietly.
228238

229239
### 1) Treat the dataset + session as a pair
230240

231-
Because sessions do not contain the dataset:
241+
Sessions do not contain the dataset. A session saved on a sample, a data link
242+
or a server points to it and opens it; one saved on a local file or in a
243+
notebook does not:
232244
- store the `.cellucid-session` file next to the dataset export folder,
233-
- or store a clear pointer to where the dataset is hosted (URL/GitHub path).
245+
- or host the dataset (see {doc}`../b_data_loading/14_open_from_link`) and save
246+
the session on the hosted copy, so the session opens it by itself.
234247

235248
### 2) Use a naming convention that encodes intent
236249

‎docs/user_guide/web_app/l_sessions_sharing/12_reference.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ identity before reporting success.
2525

2626
## Exact manifest
2727

28-
The root has exactly `createdAt`, `datasetFingerprint`, and `chunks`:
28+
The root has exactly `createdAt`, `datasetFingerprint`, `source`, and `chunks`:
2929

3030
```json
3131
{
@@ -57,11 +57,20 @@ The root has exactly `createdAt`, `datasetFingerprint`, and `chunks`:
5757
"vectorFields": []
5858
}
5959
},
60+
"source": { "kind": "sample", "dataset": "suo" },
6061
"chunks": []
6162
}
6263
```
6364

6465
- `createdAt` is a canonical ISO timestamp.
66+
- `source` says where the dataset was opened from, so **Load State** can open
67+
it first: `{"kind": "sample", "dataset"}` for a sample of the page's
68+
catalog, `{"kind": "link", "locator", "dataset"}` for a data link (the
69+
`locator` is the `?data=` value, see {doc}`../b_data_loading/14_open_from_link`),
70+
`{"kind": "remote", "url", "dataset"}` for a Cellucid server, or `null` for a
71+
local file or a notebook, which nobody else can open from the session alone.
72+
A published sample view's `source` is always `null`. It is only a pointer:
73+
the fingerprint, not the source, decides whether the session applies.
6574
- `datasetFingerprint` has exactly `datasetId`, `cellCount`, `varCount`,
6675
`cellOrder`, and `dataMapping`.
6776
- `cellOrder` has exactly `embedding`, `dimension`, and `digest`. `embedding` is

‎docs/user_guide/web_app/p_developer_docs/10_sessions_persistence_and_serialization.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ The container is:
5656
4. for every manifest chunk, an unsigned 32-bit stored length plus exact stored
5757
payload bytes.
5858

59-
The manifest has exactly `createdAt`, `datasetFingerprint`, and `chunks`.
59+
The manifest has exactly `createdAt`, `datasetFingerprint`, `source`, and
60+
`chunks`. `source` (`session/dataset-source-record.js`) is where the dataset
61+
was opened from — a sample, a data link's locator or a server address, with
62+
the dataset id — or `null`; **Load State** reads only the manifest first and,
63+
when another dataset (or none) is open, opens that source through the same
64+
connection controls a person would use before the restore runs.
6065
`datasetFingerprint` has exactly `datasetId`, `cellCount`, `varCount`,
6166
`cellOrder`, and `dataMapping`. `cellOrder` has exactly `embedding`
6267
(the embedding reference of the layout the live view shows), `dimension` (that

‎docs/user_guide/web_app/r_screenshot_checklist/06_pages_developer.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ Secondary (cheaper, complements it): a **DIAGRAM** of the coordinator's import g
319319

320320
**needs** — Two diagrams. Both describe byte-level and control-flow contracts, which is the one category of diagram that does **not** rot with refactors — it changes only when the format breaks, and a format break is exactly when the docs must be rewritten anyway.
321321

322-
*(a) Container byte-layout figure.* A horizontal byte-ribbon, left to right: `"CELLUCID_SESSION\n"` (magic) │ `u32 LE manifest length` │ `manifest JSON (UTF-8, exactly that many bytes)` │ then a repeating group `u32 LE stored length` │ `stored payload bytes` × N chunks. Below the ribbon, an expansion of the manifest object showing its exactly-three keys (`createdAt`, `datasetFingerprint`, `chunks`), the fingerprint's exactly-five keys, and a chunk's exactly-nine keys. Derived from: `cellucid/assets/js/app/session/bundle/format.js` (framing) and `session/session-context.js` (`getDatasetFingerprint`). This turns `:51–74` — currently three dense paragraphs a reader must reconstruct mentally — into one glance.
322+
*(a) Container byte-layout figure.* A horizontal byte-ribbon, left to right: `"CELLUCID_SESSION\n"` (magic) │ `u32 LE manifest length` │ `manifest JSON (UTF-8, exactly that many bytes)` │ then a repeating group `u32 LE stored length` │ `stored payload bytes` × N chunks. Below the ribbon, an expansion of the manifest object showing its exactly-four keys (`createdAt`, `datasetFingerprint`, `source`, `chunks`), the fingerprint's exactly-five keys, and a chunk's exactly-nine keys. Derived from: `cellucid/assets/js/app/session/bundle/format.js` (framing) and `session/session-context.js` (`getDatasetFingerprint`). This turns `:51–74` — currently three dense paragraphs a reader must reconstruct mentally — into one glance.
323323

324324
*(b) Restore pipeline with the rollback edge.* Ten numbered steps down the page as stated at `:127–139` (supersede → frame under limits → validate manifest/profile/fingerprint → register reversible snapshots → eager decode/apply → lazy decode/apply → prepare all participants → commit in order → final UI refresh → publish "Session fully restored"), with a **red return edge** from any step back through "rollback in reverse participant order". The all-or-nothing property (`:24`, `:142–143`) is the page's most important claim and the reverse edge is the only way to show it. Derived from `session/session-serializer.js`.
325325

‎src/cellucid/anndata_session/_chunks.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def _validate_current_chunk_inventory(
8484
) -> _CurrentChunkInventory:
8585
manifest = _require_exact_keys(
8686
bundle.manifest,
87-
{"createdAt", "datasetFingerprint", "chunks"},
87+
{"createdAt", "datasetFingerprint", "source", "chunks"},
8888
label="bundle.manifest",
8989
)
9090
if manifest["datasetFingerprint"] != bundle.dataset_fingerprint:

‎src/cellucid/session_bundle.py‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
3) manifest JSON bytes (UTF-8)
99
4) repeated chunks: [chunkByteLength (u32 LE), chunkBytes...]
1010
11-
The manifest contains exactly ``createdAt``, ``datasetFingerprint``, and ordered
12-
``chunks`` metadata. Chunk payloads are either JSON or binary and may be
11+
The manifest contains exactly ``createdAt``, ``datasetFingerprint``, ``source``
12+
(where the dataset can be opened from, or null) and ordered ``chunks`` metadata. Chunk payloads are either JSON or binary and may be
1313
gzip-compressed.
1414
"""
1515

@@ -43,8 +43,18 @@
4343
_MANIFEST_KEYS = {
4444
"createdAt",
4545
"datasetFingerprint",
46+
"source",
4647
"chunks",
4748
}
49+
# Where the session's dataset can be opened from, so loading the session in the
50+
# viewer can open it first: a sample of the page's catalog, a data link (its
51+
# ``?data=`` value) or a Cellucid server. Null for a local file or a notebook.
52+
_SOURCE_KEYS_BY_KIND = {
53+
"sample": {"kind", "dataset"},
54+
"link": {"kind", "locator", "dataset"},
55+
"remote": {"kind", "url", "dataset"},
56+
}
57+
_CONTROL_CHARACTER_RE = re.compile(r"[\x00-\x1f\x7f]")
4858
# The fingerprint ties a session to one dataset and to one data mapping, not to
4959
# the route the dataset was opened through. Every key is required: the viewer
5060
# refuses a session without its cell-order record, and a reader that accepted
@@ -206,6 +216,36 @@ def _validate_cell_order(value: Any, *, kind: str) -> dict[str, Any]:
206216
return {"embedding": embedding, "dimension": dimension, "digest": digest}
207217

208218

219+
def _validate_text(value: Any, label: str) -> str:
220+
if (
221+
not isinstance(value, str)
222+
or not value
223+
or value != value.strip()
224+
or _CONTROL_CHARACTER_RE.search(value) is not None
225+
):
226+
raise ValueError(f"Invalid session manifest ({label} must be a non-empty, unpadded string)")
227+
return value
228+
229+
230+
def _validate_source(value: Any) -> dict[str, str] | None:
231+
"""Validate the recorded dataset source (the viewer's dataset-source-record)."""
232+
if value is None:
233+
return None
234+
kind = value.get("kind") if isinstance(value, dict) else None
235+
keys = _SOURCE_KEYS_BY_KIND.get(kind) if isinstance(kind, str) else None
236+
if keys is None:
237+
raise ValueError('Invalid session manifest (source kind must be "sample", "link" or "remote")')
238+
if set(value) != keys:
239+
raise ValueError(
240+
"Invalid session manifest (source fields must be exactly " + ", ".join(sorted(keys)) + ")"
241+
)
242+
for key in sorted(keys - {"kind"}):
243+
_validate_text(value[key], f"source.{key}")
244+
if kind == "remote" and not value["url"].startswith(("https://", "http://")):
245+
raise ValueError("Invalid session manifest (source.url must be an http(s) address)")
246+
return dict(value)
247+
248+
209249
def _validate_manifest(manifest: Any) -> list[dict[str, Any]]:
210250
if not isinstance(manifest, dict):
211251
raise ValueError("Invalid session manifest (expected an object)")
@@ -224,6 +264,7 @@ def _validate_manifest(manifest: Any) -> list[dict[str, Any]]:
224264
"Invalid session manifest (createdAt must be an exact UTC millisecond timestamp)"
225265
)
226266
_validate_fingerprint(manifest["datasetFingerprint"])
267+
_validate_source(manifest["source"])
227268

228269
chunks = manifest["chunks"]
229270
if not isinstance(chunks, list):
@@ -402,6 +443,18 @@ def dataset_fingerprint(self) -> dict[str, Any]:
402443
assert isinstance(fp, dict)
403444
return fp
404445

446+
@property
447+
def dataset_source(self) -> dict[str, str] | None:
448+
"""Where the session's dataset was opened from, or ``None``.
449+
450+
``{"kind": "sample", "dataset"}``, ``{"kind": "link", "locator",
451+
"dataset"}`` (``locator`` is the ``?data=`` value) or ``{"kind":
452+
"remote", "url", "dataset"}``; ``None`` for a session saved on a local
453+
file or in a notebook.
454+
"""
455+
source = self.manifest["source"]
456+
return None if source is None else dict(source)
457+
405458
def list_chunk_ids(self) -> list[str]:
406459
self._ensure_indexed()
407460
assert self._chunks_by_id is not None

‎tests/test_feature_docs_exact_contract.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ def test_session_docs_publish_only_the_atomic_current_contract() -> None:
401401
developer_normalized = " ".join(developer.split())
402402

403403
for exact in (
404-
"`createdAt`, `datasetFingerprint`, and `chunks`",
404+
"`createdAt`, `datasetFingerprint`, `source`, and `chunks`",
405405
# The fingerprint carries five fields: the viewer records a `cellOrder`
406406
# digest so a dataset republished re-sorted under an unchanged identity
407407
# cannot silently re-point saved selections, and the data mapping the

‎tests/test_jupyter_event_security_contract.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ def _empty_session_bundle() -> bytes:
271271
manifest = {
272272
"createdAt": "2026-07-26T00:00:00.000Z",
273273
"datasetFingerprint": _FINGERPRINT,
274+
"source": None,
274275
"chunks": [],
275276
}
276277
manifest_bytes = json.dumps(
@@ -287,6 +288,7 @@ def _corrupt_gzip_session_bundle() -> bytes:
287288
manifest = {
288289
"createdAt": "2026-07-26T00:00:00.000Z",
289290
"datasetFingerprint": _FINGERPRINT,
291+
"source": None,
290292
"chunks": [
291293
{
292294
"id": "corrupt",

‎tests/test_session_data_mapping_contract.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def __init__(self, fingerprint: dict[str, Any], fields: list[dict[str, Any]] | N
7777
self.manifest = {
7878
"createdAt": "2026-10-18T00:00:00.000Z",
7979
"datasetFingerprint": fingerprint,
80+
"source": None,
8081
"chunks": chunks,
8182
}
8283

0 commit comments

Comments
 (0)