diff --git a/remotestate-py/CHANGES.md b/remotestate-py/CHANGES.md index f7ad88c..3a41de4 100644 --- a/remotestate-py/CHANGES.md +++ b/remotestate-py/CHANGES.md @@ -1,5 +1,20 @@ ## Version 0.3.0 (in development) +- `Store` now accepts any root state value, exposes it through the typed + `state` property, and supports root reads/writes with the empty path. +- `Store.get()` and the built-in `Service.get()` query now default to the root + state value when no path is passed. +- Added notebook-friendly `Store.__getitem__()` and `Store.__setitem__()` + aliases: + - `store["items[0].label"]` uses RemoteState string path syntax. + - `store["items", 0, "label"]` uses tuple path segments. + - `store[()]` addresses the root state value. +- Relaxed the path grammar so the empty string addresses the root value and + paths may start with a bracketed array index or string key, such as + `[0].label` or `["display name"].value`. +- Updated JSONPath conversion helpers so `""` maps to `$` and `[0]` maps to + `$[0]`. + ## Version 0.2.0 diff --git a/remotestate-py/README.md b/remotestate-py/README.md index e4d0646..ed8723a 100644 --- a/remotestate-py/README.md +++ b/remotestate-py/README.md @@ -76,12 +76,17 @@ The public Python API is exported from `remotestate`: `Store(initial, *, default_factory=None)` holds the Python-side application state. -- the root state is a mapping +- the root state may be any JSON-serializable value, including a mapping, list, + scalar, Pydantic model, or dataclass - nested dicts, lists, Pydantic models, and dataclasses are all supported - `default_factory` receives the missing prefix as a `rs.path.Path` tuple -- `get(path, require=False)` reads a value from a path such as `user.name` or `items[0].label` +- `state` returns the current root state value +- `get(path=(), require=False)` reads a value from a path such as ``, `user.name`, + `[0].label`, or `items[0].label`; omit `path` to read the root state value - `set(path, value)` writes a value and notifies subscribers +- `store[path]` and `store[path] = value` are notebook-friendly aliases for + `get()` and `set()` - `subscribe(callback)` receives batched path-to-value updates after changes flush - `default_factory` can materialize missing parents while setting nested values @@ -106,9 +111,12 @@ def defaults(path: rs.path.Path): store = rs.Store({}, default_factory=defaults) store.set("user.city", "Hamburg") store.set("items[0].label", "foo") +store["items", 0, "label"] = "bar" assert store.get("user.city") == "Hamburg" -assert store.get("items") == [{"label": "foo"}] +assert store.get() is store.state +assert store["items"] == [{"label": "bar"}] +assert store[()] is store.state ``` `get()` never calls the default factory. Reads stay side-effect free, and missing @@ -141,7 +149,7 @@ class Counter(rs.Service): `Service` also provides built-in methods that power the generic TypeScript bridge: -- `get(path)` reads a store value by path +- `get(path="")` reads a store value by path - `set(path, value)` writes a store value by path - `notify(name=None, detail=None, progress=None)` emits `update_task` progress messages for tracked calls @@ -189,19 +197,23 @@ advanced integrations: RemoteState paths use a simplified [JSONPath](https://www.rfc-editor.org/info/rfc9535/) subset without the `"$."` prefix: -- the root segment is an identifier +- the empty string addresses the root state value +- the first segment may be an identifier, bracketed integer index, or bracketed + JSON string key - later segments may be dotted identifiers, bracketed integer indices, or bracketed JSON string keys - bracketed string keys may use either single or double quotes; canonical output uses double quotes - the whole string must match the grammar; prefix parsing is not allowed -| Example | Valid? | Notes | -|------------------------|--------|-------------------------------------------------------| -| `user` | yes | root identifier only | -| `items[0].label` | yes | dotted identifier plus integer index | -| `user["display name"]` | yes | bracketed string key | -| `$.user` | no | `"$."` prefix is not part of the syntax | -| `["root"]` | no | root must be an identifier | -| `items[01]` | no | indices are canonical integers without leading zeroes | +| Example | Valid? | Notes | +|---------------------------|--------|-------------------------------------------------------| +| empty string | yes | root state value | +| `user` | yes | root property shorthand | +| `[0].label` | yes | array root plus child property | +| `items[0].label` | yes | dotted identifier plus integer index | +| `["display name"].value` | yes | bracketed string key at the root | +| `user["display name"]` | yes | bracketed string key | +| `$.user` | no | `"$."` prefix is not part of the syntax | +| `items[01]` | no | indices are canonical integers without leading zeroes | Use `parse_path()` and `format_path()` when you need to inspect, validate, or construct paths. diff --git a/remotestate-py/src/remotestate/path.py b/remotestate-py/src/remotestate/path.py index 6ea5c74..0faf76b 100644 --- a/remotestate-py/src/remotestate/path.py +++ b/remotestate-py/src/remotestate/path.py @@ -44,7 +44,9 @@ def parse_path(path: str) -> Path: RemoteState paths use a strict subset of JSONPath without the ``"$."`` prefix: - - the root segment must be an identifier + - an empty path addresses the root state value + - the first segment may be an identifier, bracketed integer index, or + bracketed JSON string key - later segments may be dotted identifiers, bracketed integer indices, or bracketed JSON string keys - identifiers must match ``[a-zA-Z_][a-zA-Z0-9_]*`` @@ -55,8 +57,11 @@ def parse_path(path: str) -> Path: Examples: + - ``""`` - ``"user"`` + - ``"[0].label"`` - ``"items[0].label"`` + - ``"[\"display name\"]"`` - ``"user[\"display name\"]"`` Args: @@ -69,13 +74,22 @@ def parse_path(path: str) -> Path: Raises: ValueError: If ``path`` is not a valid RemoteState path. """ + if path == "": + return () + first = _read_identifier(path, 0) - if first is None: + if first is not None: + segments: list[PathSegment] = [Property(first[0])] + pos = first[1] + elif path[0] == "[": + bracket = _read_bracket_segment(path, 0) + if bracket is None: + raise _invalid_path(path) + segments = [bracket[0]] + pos = bracket[1] + else: raise _invalid_path(path) - segments: list[PathSegment] = [Property(first[0])] - pos = first[1] - while pos < len(path): match path[pos]: case ".": @@ -98,13 +112,14 @@ def parse_path(path: str) -> Path: def prefixes(path: Path) -> list[Path]: - """Return all non-empty prefixes of a parsed path. + """Return all non-root prefixes of a parsed path. Args: path: Parsed path. Returns: - Prefix paths ordered from shortest to longest. + Prefix paths ordered from shortest to longest. The root path ``()`` has + no non-root prefixes. """ return [path[:i] for i in range(1, len(path) + 1)] @@ -119,12 +134,14 @@ def format_path(path: Path) -> str: String representation of ``path``. """ _validate_path(path) + if len(path) == 0: + return "" parts: list[str] = [] for index, seg in enumerate(path): match seg: case Property(key): - if index == 0: + if index == 0 and _IDENTIFIER_RE.fullmatch(key): parts.append(key) elif _IDENTIFIER_RE.fullmatch(key): parts.append(f".{key}") @@ -144,6 +161,10 @@ def to_jsonpath(path: str) -> str: Returns: JSONPath string for the same location. """ + if path == "": + return "$" + if path.startswith("["): + return f"${path}" return f"$.{path}" @@ -151,25 +172,26 @@ def from_jsonpath(path: str) -> str: """Convert a simple JSONPath string to a RemoteState path. Args: - path: JSONPath string that starts with ``"$."``. + path: JSONPath string that is ``"$"``, starts with ``"$."``, or starts + with ``"$["``. Returns: RemoteState path string. Raises: - ValueError: If ``path`` does not start with ``"$."``. + ValueError: If ``path`` is not a supported simple JSONPath string. """ + if path == "$": + return "" + if path.startswith("$["): + return path[1:] if not path.startswith("$."): raise ValueError(f"Not a JSONPath: {path!r}") return path[2:] def _validate_path(path: Path) -> None: - if len(path) == 0: - raise ValueError(_INVALID_PATH_MESSAGE) - if not isinstance(path[0], Property) or not _IDENTIFIER_RE.fullmatch(path[0].key): - raise ValueError(_INVALID_PATH_MESSAGE) - for segment in path[1:]: + for segment in path: match segment: case Property(key): if not isinstance(key, str): @@ -177,6 +199,8 @@ def _validate_path(path: Path) -> None: case Index(i): if not isinstance(i, int) or i < 0: raise ValueError(_INVALID_PATH_MESSAGE) + case _: + raise ValueError(_INVALID_PATH_MESSAGE) def _read_identifier(path: str, start: int) -> tuple[str, int] | None: diff --git a/remotestate-py/src/remotestate/service.py b/remotestate-py/src/remotestate/service.py index 626fcf7..45f68d7 100644 --- a/remotestate-py/src/remotestate/service.py +++ b/remotestate-py/src/remotestate/service.py @@ -154,14 +154,15 @@ def store(self) -> Store: return self._store @query - def get(self, path: str) -> Any: + def get(self, path: str = "") -> Any: """Built-in query that returns a store value by path. This is the read side of the generic bridge used by the TypeScript ``useRemoteState()`` hook and related helpers. Args: - path: RemoteState path to read. + path: RemoteState path to read. If omitted, reads the root state + value. Returns: The value at ``path``, or ``None`` when the path is missing. diff --git a/remotestate-py/src/remotestate/store.py b/remotestate-py/src/remotestate/store.py index 2a8ef79..7450458 100644 --- a/remotestate-py/src/remotestate/store.py +++ b/remotestate-py/src/remotestate/store.py @@ -3,7 +3,7 @@ from collections.abc import Callable from contextvars import ContextVar -from typing import Any +from typing import Any, Generic, TypeVar, cast from .context import _call_context from .path import ( @@ -17,9 +17,12 @@ type PendingUpdates = dict[str, Any] type DefaultFactory = Callable[[Path], Any] +type PathKeySegment = str | int | PathSegment +type PathKey = str | int | tuple[PathKeySegment, ...] +T = TypeVar("T") -class Store: +class Store(Generic[T]): """Reactive Python-side state container addressed by ``remotestate`` paths. Values live here as the single source of truth. Actions and queries read @@ -28,14 +31,15 @@ class Store: def __init__( self, - initial: dict[str, Any], + initial: T, *, default_factory: DefaultFactory | None = None, ) -> None: """Create a store. Args: - initial: Initial application state. + initial: Initial application state. Any JSON-serializable Python + value is supported, including mappings, lists, and scalars. default_factory: Optional callable used by ``set()`` to create missing intermediate path values. It receives the missing prefix path as a ``Path`` tuple, such as one @@ -47,7 +51,28 @@ def __init__( self._default_factory = default_factory self._subscribers: list[Callable[[PendingUpdates], None]] = [] - def get(self, path: str, *, require: bool = False) -> Any: + @property + def state(self) -> T: + """The current root state value.""" + return self._state + + def __getitem__(self, path: PathKey) -> Any: + """Return the value at ``path``. + + ``path`` may be a RemoteState path string, an integer root index, or a + tuple of path segments such as ``("items", 0, "label")``. The empty + tuple ``()`` addresses the root state value. + """ + return self.get(path) + + def __setitem__(self, path: PathKey, value: Any) -> None: + """Set ``value`` at ``path``. + + ``path`` follows the same rules as ``__getitem__``. + """ + self.set(path, value) + + def get(self, path: PathKey = (), *, require: bool = False) -> Any: """Return the value at ``path``. Missing values return ``None`` by default. Pass ``require=True`` to @@ -55,8 +80,9 @@ def get(self, path: str, *, require: bool = False) -> Any: calls the default factory. Args: - path: RemoteState path to read, such as ``"user.name"`` or - ``"items[0].label"``. + path: RemoteState path to read, such as ``""``, ``"user.name"``, + ``"[0].label"``, or ``("items", 0, "label")``. If omitted, + the root state value is returned. require: If true, raise when the path is missing instead of returning ``None``. @@ -70,10 +96,10 @@ def get(self, path: str, *, require: bool = False) -> Any: IndexError: If a required list index is missing. AttributeError: If a required object attribute is missing. """ - parsed = parse_path(path) + parsed = _normalize_path(path) return _get_at(self._state, parsed, require) - def set(self, path: str, value: Any) -> None: + def set(self, path: PathKey, value: Any) -> None: """Set ``value`` at ``path`` and notify subscribers. If this store has a default factory, missing intermediate path @@ -82,8 +108,8 @@ def set(self, path: str, value: Any) -> None: ``IndexError``. Args: - path: RemoteState path to write, such as ``"user.name"`` or - ``"items[0].label"``. + path: RemoteState path to write, such as ``""``, ``"user.name"``, + ``"[0].label"``, or ``("items", 0, "label")``. value: New value to assign at ``path``. Raises: @@ -98,12 +124,15 @@ def set(self, path: str, value: Any) -> None: if ctx is not None and ctx.readonly: raise PermissionError("query methods cannot mutate store") - parsed = parse_path(path) - _set_at( - self._state, - parsed, - value, - default_factory=self._default_factory, + parsed = _normalize_path(path) + self._state = cast( + T, + _set_at( + self._state, + parsed, + value, + default_factory=self._default_factory, + ), ) pending = _batch_context.get() @@ -174,7 +203,7 @@ def _get_segment(obj: Any, segment: PathSegment, require: bool) -> Any: case Index(i): try: return obj[i] - except IndexError: + except (IndexError, KeyError, TypeError): if require: raise return None @@ -205,7 +234,10 @@ def _set_at( path: Path, value: Any, default_factory: DefaultFactory | None = None, -) -> None: +) -> Any: + if len(path) == 0: + return value + obj = root for i, segment in enumerate(path[:-1], start=1): try: @@ -234,6 +266,7 @@ def _set_at( if default_factory is None: raise _set_or_append_segment(obj, path[-1], value, require_appendable=True) + return root def _serialize(value: Any) -> Any: @@ -263,3 +296,35 @@ def __exit__(self, *_: Any) -> None: _batch_context: ContextVar[PendingUpdates | None] = ContextVar( "_batch_context", default=None ) + + +def _normalize_path(path: PathKey) -> Path: + if isinstance(path, str): + return parse_path(path) + if isinstance(path, int): + return (_normalize_index(path),) + if isinstance(path, tuple): + return tuple(_normalize_path_segment(segment) for segment in path) + raise TypeError( + "RemoteState path must be a string, integer, or tuple of path segments" + ) + + +def _normalize_path_segment(segment: PathKeySegment) -> PathSegment: + if isinstance(segment, Property): + return segment + if isinstance(segment, Index): + return _normalize_index(segment.i) + if isinstance(segment, bool): + raise ValueError("RemoteState path indices must be non-negative integers") + if isinstance(segment, int): + return _normalize_index(segment) + if isinstance(segment, str): + return Property(segment) + raise TypeError("RemoteState path tuple segments must be strings or integers") + + +def _normalize_index(index: int) -> Index: + if isinstance(index, bool) or index < 0: + raise ValueError("RemoteState path indices must be non-negative integers") + return Index(index) diff --git a/remotestate-py/tests/test_path.py b/remotestate-py/tests/test_path.py index 4163786..7695d2e 100644 --- a/remotestate-py/tests/test_path.py +++ b/remotestate-py/tests/test_path.py @@ -18,6 +18,10 @@ def test_simple_property(): assert parse_path("user") == (Property("user"),) +def test_empty_path_is_root(): + assert parse_path("") == () + + def test_nested_properties(): assert parse_path("user.name") == (Property("user"), Property("name")) @@ -26,6 +30,10 @@ def test_index(): assert parse_path("items[3]") == (Property("items"), Index(3)) +def test_root_index(): + assert parse_path("[3].name") == (Index(3), Property("name")) + + def test_string_key(): assert parse_path('user["display name"]') == ( Property("user"), @@ -44,6 +52,14 @@ def test_string_key(): ) +def test_root_string_key(): + assert parse_path('["root"]') == (Property("root"),) + assert parse_path('["display name"].value') == ( + Property("display name"), + Property("value"), + ) + + def test_nested_after_index(): assert parse_path("items[3].name") == ( Property("items"), @@ -75,19 +91,16 @@ def test_invalid_starts_with_dot(): parse_path(".user") -def test_invalid_starts_with_index(): - with pytest.raises(ValueError): - parse_path("[0].name") +def test_allows_root_index(): + assert parse_path("[0].name") == (Index(0), Property("name")) -def test_invalid_starts_with_string_key(): - with pytest.raises(ValueError): - parse_path('["root"]') +def test_allows_root_string_key(): + assert parse_path('["root"]') == (Property("root"),) -def test_invalid_empty(): - with pytest.raises(ValueError): - parse_path("") +def test_allows_empty_root(): + assert parse_path("") == () def test_invalid_trailing_dot(): @@ -136,6 +149,10 @@ def test_prefixes_single(): assert len(result) == 1 +def test_prefixes_root(): + assert prefixes(parse_path("")) == [] + + # --- format_path --- @@ -143,8 +160,13 @@ def test_roundtrip_simple(): assert format_path(parse_path("user.name")) == "user.name" +def test_roundtrip_root(): + assert format_path(parse_path("")) == "" + + def test_roundtrip_index(): assert format_path(parse_path("items[3].name")) == "items[3].name" + assert format_path(parse_path("[3].name")) == "[3].name" def test_roundtrip_string_key(): @@ -153,6 +175,9 @@ def test_roundtrip_string_key(): assert format_path(parse_path('user["0"]')) == 'user["0"]' assert format_path(parse_path("user['0']")) == 'user["0"]' assert format_path(parse_path('items[""].label')) == 'items[""].label' + assert format_path(parse_path('["display name"].value')) == ( + '["display name"].value' + ) def test_roundtrip_deep(): @@ -169,10 +194,14 @@ def test_roundtrip_empty_string_key(): def test_to_jsonpath(): assert to_jsonpath("user.name") == "$.user.name" + assert to_jsonpath("") == "$" + assert to_jsonpath("[0].name") == "$[0].name" def test_from_jsonpath(): assert from_jsonpath("$.user.name") == "user.name" + assert from_jsonpath("$") == "" + assert from_jsonpath("$[0].name") == "[0].name" def test_from_jsonpath_invalid(): diff --git a/remotestate-py/tests/test_service.py b/remotestate-py/tests/test_service.py index e608499..66cef70 100644 --- a/remotestate-py/tests/test_service.py +++ b/remotestate-py/tests/test_service.py @@ -127,6 +127,14 @@ async def test_builtin_get_query_works_on_base_service(store): assert result == 0 +@pytest.mark.asyncio +async def test_builtin_get_query_defaults_to_root(store): + service = Service(store) + coro, _ = invoke_query(service, "get") + result = await coro + assert result is store.state + + @pytest.mark.asyncio async def test_builtin_set_action_works_on_base_service(store): service = Service(store) diff --git a/remotestate-py/tests/test_store.py b/remotestate-py/tests/test_store.py index dc35aa4..c3cc226 100644 --- a/remotestate-py/tests/test_store.py +++ b/remotestate-py/tests/test_store.py @@ -3,7 +3,7 @@ import pytest from pydantic import BaseModel -from remotestate.path import Property +from remotestate.path import Index, Property # noinspection PyProtectedMember from remotestate.store import Store, _batch_pending_updates @@ -57,6 +57,26 @@ def test_get_simple(simple_store): assert simple_store.get("user.name") == "Norman" +def test_get_root_value(simple_store): + assert simple_store.get() is simple_store.state + assert simple_store.get("") is simple_store.state + assert simple_store[()] is simple_store.state + + +def test_get_root_array_by_index(): + store = Store([{"label": "foo"}]) + + assert store.get("[0].label") == "foo" + assert store[0, "label"] == "foo" + + +def test_get_tuple_path_treats_string_as_single_segment(): + store = Store({"items.with.dot": "value"}) + + assert store["items.with.dot",] == "value" + assert store["items.with.dot"] is None + + def test_get_index(simple_store): assert simple_store.get("items[0].label") == "foo" @@ -104,6 +124,34 @@ def test_set_simple(simple_store): assert simple_store.get("user.name") == "Klaus" +def test_set_root_value(simple_store): + simple_store.set("", ["replacement"]) + + assert simple_store.state == ["replacement"] + assert simple_store.get("") == ["replacement"] + + +def test_set_root_value_with_empty_tuple(simple_store): + simple_store[()] = {"count": 99} + + assert simple_store.state == {"count": 99} + assert simple_store["count"] == 99 + + +def test_set_tuple_path(simple_store): + simple_store["items", 0, "label"] = "x" + + assert simple_store["items[0].label"] == "x" + + +def test_set_root_array_by_index(): + store = Store([{"label": "foo"}]) + + store[0, "label"] = "x" + + assert store.state == [{"label": "x"}] + + def test_set_index(simple_store): simple_store.set("items[1].label", "baz") assert simple_store.get("items[1].label") == "baz" @@ -149,6 +197,21 @@ def factory(path): ] +def test_set_default_factory_receives_root_index_paths(): + calls = [] + + def factory(path): + calls.append(path) + return {} + + store = Store([], default_factory=factory) + + store.set("[0].label", "foo") + + assert calls == [(Index(0),)] + assert store.state == [{"label": "foo"}] + + def test_set_default_factory_can_create_pydantic_objects(): def factory(path): if path == (Property("user"),): @@ -238,6 +301,14 @@ def test_subscribe_updates_contain_exact_path_only(simple_store): assert updates == {"items[0].label": "new"} +def test_subscribe_root_update_uses_empty_path(simple_store): + cb = MagicMock() + simple_store.subscribe(cb) + simple_store.set("", {"count": 1}) + updates = cb.call_args[0][0] + assert updates == {"": {"count": 1}} + + def test_unsubscribe(simple_store): cb = MagicMock() unsubscribe = simple_store.subscribe(cb) diff --git a/remotestate-ts/CHANGES.md b/remotestate-ts/CHANGES.md index 727365a..1a2f957 100644 --- a/remotestate-ts/CHANGES.md +++ b/remotestate-ts/CHANGES.md @@ -1,5 +1,18 @@ ## Version 0.3.0 (in development) +- Relaxed the shared path grammar so the empty string addresses the root value + and paths may start with a bracketed array index or string key, such as + `[0].label` or `["display name"].value`. +- Changed `Path` from a non-empty tuple type to a general segment array so + `[]` represents the root state value. +- Removed the redundant `RelativePath` export; `Path` is now the single parsed + segment-array type. +- `Store.get()` and `useRemoteStateValue()` now default to the root state value + when no path is passed. +- Updated the transport-backed store cache so root subscriptions overlap all + descendant updates, root updates materialize cached descendants, and leaf + updates can patch cached root snapshots. + ## Version 0.2.0 diff --git a/remotestate-ts/README.md b/remotestate-ts/README.md index aeb9019..ae91890 100644 --- a/remotestate-ts/README.md +++ b/remotestate-ts/README.md @@ -147,7 +147,8 @@ Hook overview: - `useRemoteStateClient()` returns the nearest typed client - `useRemoteStore()` returns the reactive value store behind the hooks - `useRemoteTaskStore()` returns the task store behind the progress hooks -- `useRemoteStateValue(path)` subscribes to one path and returns the cached value or `undefined` +- `useRemoteStateValue(path?)` subscribes to one path and returns the cached value or `undefined`; + omit `path` to subscribe to the root state value - `useRemoteState(path, initialValue?)` behaves like React `useState` for one remote path - `useRemoteTask(taskId)` returns one tracked task snapshot - `useRemoteTasks()` returns all tracked task snapshots @@ -179,19 +180,23 @@ Find an example in the section **User Guide** below. The path helpers are useful when you need to work with nested state outside the React hooks. They use a simplified [JSONPath](https://www.rfc-editor.org/info/rfc9535/) -form without the `$.` prefix: a root identifier, followed by dotted -identifiers, bracketed integer indices, or bracketed JSON string keys. +form without the `$.` prefix. The empty string addresses the root state value. +Otherwise a path starts with an identifier, bracketed integer index, or +bracketed JSON string key, followed by dotted identifiers, bracketed integer +indices, or bracketed JSON string keys. Bracketed string keys may use either single or double quotes; canonical output always uses double quotes. -| Example | Valid? | Notes | -| ---------------------- | ------ | ----------------------------------------------------- | -| `user` | yes | root identifier only | -| `items[0].label` | yes | dotted identifier plus integer index | -| `user["display name"]` | yes | bracketed string key | -| `$.user` | no | `$.` prefix is not part of the syntax | -| `["root"]` | no | root must be an identifier | -| `items[01]` | no | indices are canonical integers without leading zeroes | +| Example | Valid? | Notes | +| ------------------------ | ------ | ----------------------------------------------------- | +| empty string | yes | root state value | +| `user` | yes | root property shorthand | +| `[0].label` | yes | array root plus child property | +| `items[0].label` | yes | dotted identifier plus integer index | +| `["display name"].value` | yes | bracketed string key at the root | +| `user["display name"]` | yes | bracketed string key | +| `$.user` | no | `$.` prefix is not part of the syntax | +| `items[01]` | no | indices are canonical integers without leading zeroes | - `normalizePath(path)` validates a path-like value and returns a `Path` - `parsePath(path)` turns a strict string path into a `Path` and throws `SyntaxError` on malformed input @@ -204,6 +209,8 @@ import { getPathAt, normalizePath, parsePath, setPathAt } from "remotestate"; const path = parsePath("items[0].label"); const labelPath = parsePath('user["display name"]'); +const rootPath = parsePath(""); +const arrayRootPath = normalizePath([0, "label"]); const safePath = normalizePath(["items", 0, "label"]); const current = getPathAt(state, path); const next = setPathAt(state, path, "updated"); @@ -244,9 +251,11 @@ store and task store used by the React hooks. The low-level store can be observed by path: ```typescript -const unsubscribe = client.store.subscribe("items[1].label", () => { - console.log(client.store.get("items[1].label")); +const path = parsePath("items[1].label"); +const unsubscribe = client.store.subscribe(path, () => { + console.log(client.store.get(path)); }); +console.log(client.store.get()); // root state value ``` Subscriptions also react to related parent or child updates. For example, a @@ -309,8 +318,8 @@ Fallback clients use the same `RemoteStateClient` shape as remote clients, so the standard hooks keep working and remain reactive. The example below adapts a [Zustand](https://zustand.docs.pmnd.rs/) store for local fallback mode. -When you implement a fallback store, the store methods receive parsed, -non-empty path segments. `getPathAt()` and `setPathAt()` are the shared path +When you implement a fallback store, the store methods receive parsed path +segments. An empty path addresses the root state value. `getPathAt()` and `setPathAt()` are the shared path helpers to use for nested reads and writes. `setPathAt()` preserves the original identity when the target value is unchanged, which makes it easy to skip unnecessary Zustand updates and avoid extra renders. @@ -343,7 +352,7 @@ function createLocalCounterClient(): RemoteStateClient { const store: Store = { // Read the current local value for one RemoteState path. - get: (path: Path): unknown => { + get: (path: Path = []): unknown => { if (isCountProperty(path)) { return getPathAt(useCounterStore.getState(), path); } @@ -353,7 +362,7 @@ function createLocalCounterClient(): RemoteStateClient { set: (path: Path, value: unknown): void => { if (isCountProperty(path)) { const currentState = useCounterStore.getState(); - const nextState = setPathAt(currentState, pathSegments, value); + const nextState = setPathAt(currentState, path, value); if (nextState !== currentState) { useCounterStore.setState(nextState); } diff --git a/remotestate-ts/src/lib/index.ts b/remotestate-ts/src/lib/index.ts index b6de351..d79539d 100644 --- a/remotestate-ts/src/lib/index.ts +++ b/remotestate-ts/src/lib/index.ts @@ -14,7 +14,7 @@ export type { LocalQueryHandlers, LocalStateClientOptions, } from "./local"; -export type { Path, PathLike, PathSegment, RelativePath } from "./path"; +export type { Path, PathLike, PathSegment } from "./path"; export type { IncomingMessage, OutgoingMessage, diff --git a/remotestate-ts/src/lib/path.ts b/remotestate-ts/src/lib/path.ts index 60e446d..f931021 100644 --- a/remotestate-ts/src/lib/path.ts +++ b/remotestate-ts/src/lib/path.ts @@ -4,39 +4,45 @@ export type PathSegment = string | number; /** - * A relative RemoteState path comprising property names, string keys, or - * integer array indices. - */ -export type RelativePath = readonly PathSegment[]; - -/** - * A non-empty parsed path. + * A parsed RemoteState path. * - * The first segment is always a string identifier; later segments may be + * An empty path addresses the root state value. Otherwise segments may be * strings or numeric array indices. This is the form used by store * implementations and other low-level helpers that already operate on * segmented paths. */ -export type Path = readonly [string, ...RelativePath]; +export type Path = readonly PathSegment[]; /** * A value of type ``PathLike`` can be normalized into a value of type `Path`. */ -export type PathLike = string | RelativePath | Path; +export type PathLike = string | Path; const PATH_SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const INVALID_PATH_MESSAGE = "RemoteState paths must be valid simplified JSONPath paths"; +const STRING_ESCAPES: Readonly>> = { + '"': '"', + "'": "'", + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", +}; /** * Normalizes a path-like value into a validated RemoteState path. * - * A valid path starts with a root identifier and may continue with dotted + * A valid path may be empty to address the root value. Otherwise it starts + * with an identifier or bracketed segment and may continue with dotted * identifiers, bracketed integer indices, or bracketed JSON string keys. * * @param path A path-like value. * @returns The validated RemoteState path. - * @throws A `SyntaxError` if the path is empty or malformed. + * @throws A `SyntaxError` if the path is malformed. */ export function normalizePath(path: PathLike): Path { let rawPath: readonly PathSegment[]; @@ -58,7 +64,9 @@ export function normalizePath(path: PathLike): Path { * * RemoteState paths use a strict subset of JSONPath without the `$.` prefix: * - * - the root segment must be an identifier + * - an empty path addresses the root state value + * - the first segment may be an identifier, bracketed integer index, or + * bracketed JSON string key * - later segments may be dotted identifiers, bracketed integer indices, * or bracketed JSON string keys * - identifiers must match ``[a-zA-Z_][a-zA-Z0-9_]*`` @@ -68,8 +76,11 @@ export function normalizePath(path: PathLike): Path { * * Examples: * + * - empty string (root) * - ``user`` + * - ``[0].label`` * - ``items[0].label`` + * - ``["display name"]`` * - ``user["display name"]`` * * @param path The path string to parse. @@ -77,14 +88,13 @@ export function normalizePath(path: PathLike): Path { * @throws A `SyntaxError` if the input is not a strict dotted/bracket path. */ export function parsePath(path: string): Path { - const segments: PathSegment[] = []; - const first = readIdentifier(path, 0); - if (!first) { - throw new SyntaxError(INVALID_PATH_MESSAGE); + if (path === "") { + return []; } - segments.push(first.value); - let position = first.nextIndex; + const segments: PathSegment[] = []; + let position = 0; + while (position < path.length) { const next = path[position]; if (next === ".") { @@ -106,6 +116,14 @@ export function parsePath(path: string): Path { position = bracketSegment.nextIndex; continue; } + if (position === 0) { + const identifier = readIdentifier(path, position); + if (identifier) { + segments.push(identifier.value); + position = identifier.nextIndex; + continue; + } + } throw new SyntaxError(INVALID_PATH_MESSAGE); } @@ -121,11 +139,13 @@ export function parsePath(path: string): Path { */ export function formatPath(path: Path): string { validatePathSegments(path); - let result = path[0]; - for (let index = 1; index < path.length; index += 1) { + let result = ""; + for (let index = 0; index < path.length; index += 1) { const segment = path[index]; if (typeof segment === "number") { result += "[" + String(segment) + "]"; + } else if (index === 0 && PATH_SEGMENT_PATTERN.test(segment)) { + result += segment; } else if (PATH_SEGMENT_PATTERN.test(segment)) { result += "." + segment; } else { @@ -142,7 +162,7 @@ export function formatPath(path: Path): string { * @param path The path segments to follow. * @returns The nested value, or `undefined` if any segment is missing. */ -export function getPathAt(value: unknown, path: RelativePath): unknown { +export function getPathAt(value: unknown, path: Path): unknown { let current = value; for (const segment of path) { current = getChild(current, segment); @@ -166,7 +186,7 @@ export function getPathAt(value: unknown, path: RelativePath): unknown { */ export function setPathAt( value: unknown, - path: RelativePath, + path: Path, childValue: unknown, ): unknown { if (path.length === 0) { @@ -203,8 +223,8 @@ export function setPathAt( * @returns Whether `prefix` is the same path or an ancestor of `path`. */ export function isPathPrefixSegments( - prefix: RelativePath, - path: RelativePath, + prefix: Path, + path: Path, ): boolean { if (prefix.length > path.length) { return false; @@ -228,12 +248,12 @@ export function pathsOverlap(left: string, right: string): boolean { * * @param prefix The prefix to remove. * @param path The full parsed path. - * @returns The remaining relative path after the prefix. + * @returns The remaining path segments after the prefix. */ export function pathSegmentsAfter( - prefix: RelativePath, - path: RelativePath, -): RelativePath { + prefix: Path, + path: Path, +): Path { return path.slice(prefix.length); } @@ -283,6 +303,9 @@ function isUnknownArray(value: unknown): value is unknown[] { } function isPathPrefix(prefix: string, path: string): boolean { + if (prefix === "") { + return true; + } if (prefix === path) { return true; } @@ -293,14 +316,7 @@ function isPathPrefix(prefix: string, path: string): boolean { function validatePathSegments( path: readonly PathSegment[], ): asserts path is Path { - if (path.length === 0) { - throw new SyntaxError(INVALID_PATH_MESSAGE); - } - if (typeof path[0] !== "string" || !PATH_SEGMENT_PATTERN.test(path[0])) { - throw new SyntaxError(INVALID_PATH_MESSAGE); - } - for (let index = 1; index < path.length; index += 1) { - const segment = path[index]; + for (const segment of path) { if (typeof segment === "number") { if (!Number.isInteger(segment) || segment < 0) { throw new SyntaxError(INVALID_PATH_MESSAGE); @@ -408,69 +424,44 @@ function readQuotedStringLiteral( index += 1; continue; } - switch (escape) { - case "\\": - value += "\\"; - index += 1; - continue; - case "/": - value += "/"; - index += 1; - continue; - case "b": - value += "\b"; - index += 1; - continue; - case "f": - value += "\f"; - index += 1; - continue; - case "n": - value += "\n"; - index += 1; - continue; - case "r": - value += "\r"; - index += 1; - continue; - case "t": - value += "\t"; - index += 1; - continue; - case "u": { - const unicode = readUnicodeCodeUnit(path, index + 1); - if (!unicode) { + const escaped = STRING_ESCAPES[escape]; + if (escaped !== undefined) { + value += escaped; + index += 1; + continue; + } + if (escape === "u") { + const unicode = readUnicodeCodeUnit(path, index + 1); + if (!unicode) { + return null; + } + if (unicode.codeUnit >= 0xd800 && unicode.codeUnit <= 0xdbff) { + if ( + path[unicode.nextIndex] !== "\\" || + path[unicode.nextIndex + 1] !== "u" + ) { return null; } - if (unicode.codeUnit >= 0xd800 && unicode.codeUnit <= 0xdbff) { - if ( - path[unicode.nextIndex] !== "\\" || - path[unicode.nextIndex + 1] !== "u" - ) { - return null; - } - const low = readUnicodeCodeUnit(path, unicode.nextIndex + 2); - if (!low || low.codeUnit < 0xdc00 || low.codeUnit > 0xdfff) { - return null; - } - value += String.fromCodePoint( - ((unicode.codeUnit - 0xd800) << 10) + - (low.codeUnit - 0xdc00) + - 0x10000, - ); - index = low.nextIndex; - continue; - } - if (unicode.codeUnit >= 0xdc00 && unicode.codeUnit <= 0xdfff) { + const low = readUnicodeCodeUnit(path, unicode.nextIndex + 2); + if (!low || low.codeUnit < 0xdc00 || low.codeUnit > 0xdfff) { return null; } - value += String.fromCharCode(unicode.codeUnit); - index = unicode.nextIndex; + value += String.fromCodePoint( + ((unicode.codeUnit - 0xd800) << 10) + + (low.codeUnit - 0xdc00) + + 0x10000, + ); + index = low.nextIndex; continue; } - default: + if (unicode.codeUnit >= 0xdc00 && unicode.codeUnit <= 0xdfff) { return null; + } + value += String.fromCharCode(unicode.codeUnit); + index = unicode.nextIndex; + continue; } + return null; } return null; } diff --git a/remotestate-ts/src/lib/react/hooks.ts b/remotestate-ts/src/lib/react/hooks.ts index 70d3847..a870206 100644 --- a/remotestate-ts/src/lib/react/hooks.ts +++ b/remotestate-ts/src/lib/react/hooks.ts @@ -12,6 +12,8 @@ import { RemoteStateContext } from "./context"; import type { Store } from "../types"; import type { TaskState, TaskStore } from "../tasks"; +const ROOT_PATH: Path = []; + /** * Functional-update shape accepted by `useRemoteState`. * @@ -58,12 +60,13 @@ export function useRemoteTaskStore(): TaskStore { * Subscribe to one store path and return its current cached value. * * @typeParam T The expected value type at the path. - * @param path The state path to read and subscribe to. + * @param path The state path to read and subscribe to. If omitted, subscribes + * to the root state value. * @returns The cached value, or `undefined` until it is available. */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters export function useRemoteStateValue( - path: PathLike, + path: PathLike = ROOT_PATH, ): T | undefined { const parsedPath = useNormalizedPath(path); const store = useRemoteStore(); @@ -185,6 +188,6 @@ export function useRemoteTasks(): readonly TaskState[] { // --- Helper hooks -export function useNormalizedPath(path: PathLike): Path { +export function useNormalizedPath(path: PathLike = ROOT_PATH): Path { return useMemo(() => normalizePath(path), [path]); } diff --git a/remotestate-ts/src/lib/store.ts b/remotestate-ts/src/lib/store.ts index b46d9f4..453162b 100644 --- a/remotestate-ts/src/lib/store.ts +++ b/remotestate-ts/src/lib/store.ts @@ -12,6 +12,8 @@ import { import type { Store, Transport } from "./types"; import { DebugLog, getDebugLog } from "./debug"; +const ROOT_PATH: Path = []; + type StoreListener = () => void; type StoreSubscription = { path: string; @@ -58,17 +60,18 @@ export class StoreImpl implements Store { /** * Get the current cached value for a path. * - * @param path The parsed non-empty state path to read. + * @param path The parsed state path to read. If omitted or empty, reads the + * root state value. * @returns The cached value, or `undefined` if the path is not cached. */ - get(path: Path): unknown { + get(path: Path = ROOT_PATH): unknown { return this.cache.get(formatPath(path)); } /** * Set a state value through the backend's built-in `set` action. * - * @param path The parsed non-empty state path to write. + * @param path The parsed state path to write. * @param value The value to assign. * @returns A promise that resolves after the action result is applied. */ @@ -100,7 +103,7 @@ export class StoreImpl implements Store { /** * Ensure a path is fetched from Python if it is not already cached. * - * @param path The parsed non-empty state path to provide. + * @param path The parsed state path to provide. */ provide(path: Path): void { const pathKey = formatPath(path); @@ -121,7 +124,7 @@ export class StoreImpl implements Store { /** * Register a listener for changes related to one path. * - * @param path The parsed non-empty state path to subscribe to. + * @param path The parsed state path to subscribe to. * @param listener Listener called when the path or a related path changes. * @returns A function that unregisters the listener. */ @@ -162,9 +165,6 @@ export class StoreImpl implements Store { this.cache.set(path, value); this.authoritativePaths.add(path); const parsedPath = this._getParsedPath(path); - if (parsedPath.length === 0) { - return; - } for (const relatedPath of this._getRelatedPaths(path)) { if (relatedPath === path) { @@ -172,9 +172,6 @@ export class StoreImpl implements Store { } const relatedSegments = this._getParsedPath(relatedPath); - if (relatedSegments.length === 0) { - continue; - } if (isPathPrefixSegments(relatedSegments, parsedPath)) { // A subscribed/cached ancestor changed through a leaf update. diff --git a/remotestate-ts/src/lib/types.ts b/remotestate-ts/src/lib/types.ts index c88e5ed..232f871 100644 --- a/remotestate-ts/src/lib/types.ts +++ b/remotestate-ts/src/lib/types.ts @@ -89,19 +89,21 @@ export interface Store { /** * Get the current value snapshot for the given path segments. * - * @param path The parsed non-empty path into the state. + * @param path The parsed path into the state. If omitted or empty, the root + * state value is read. * @returns The cached value, or `undefined` if the value is not cached. */ - get(path: Path): unknown; + get(path?: Path): unknown; /** - * Set the value at a parsed non-empty state path. + * Set the value at a parsed state path. * * Remote tasks dispatch the built-in backend `set` action and resolve after * the resulting update is applied. Local tasks should update their backing * state container and notify subscribers. * - * @param path The parsed non-empty state path to write. + * @param path The parsed state path to write. An empty path replaces the + * root state value. * @param value The value to assign. */ set(path: Path, value: unknown): void | Promise; @@ -112,14 +114,16 @@ export interface Store { * fetch its current value (and cache it) so ``get()`` can return its * latest value. * - * @param path The parsed non-empty state path to provide. + * @param path The parsed state path to provide. An empty path fetches the + * root state value. */ provide(path: Path): void; /** * Subscribes to this store by registering a listener. * - * @param path The parsed non-empty state path to subscribe to. + * @param path The parsed state path to subscribe to. An empty path + * subscribes to the root state value and all descendants. * @param listener A listener that is informed about state changes. * @returns A function that unregisters the listener. */ diff --git a/remotestate-ts/src/test/path.test.ts b/remotestate-ts/src/test/path.test.ts index 16a28b1..6a6ba1e 100644 --- a/remotestate-ts/src/test/path.test.ts +++ b/remotestate-ts/src/test/path.test.ts @@ -8,6 +8,7 @@ import { type Path, type PathLike, } from "../lib"; +import { pathsOverlap } from "../lib/path"; describe("parsePath", () => { it("parses dotted and indexed paths", () => { @@ -33,14 +34,24 @@ describe("parsePath", () => { expect(parsePath("count")).toEqual(["count"]); }); + it("parses the empty root path", () => { + expect(parsePath("")).toEqual([]); + }); + + it("parses root bracket segments", () => { + expect(parsePath("[0].label")).toEqual([0, "label"]); + expect(parsePath('["display name"].value')).toEqual([ + "display name", + "value", + ]); + }); + it("throws on invalid trailing input", () => { expect(() => parsePath("items..label")).toThrow(SyntaxError); }); - it("throws on invalid roots", () => { + it("throws on invalid path starts", () => { expect(() => parsePath("1items")).toThrow(SyntaxError); - expect(() => parsePath('["root"]')).toThrow(SyntaxError); - expect(() => parsePath("[0]")).toThrow(SyntaxError); }); it("throws on non-canonical integer syntax", () => { @@ -66,6 +77,17 @@ describe("formatPath", () => { it("formats a single root segment", () => { expect(formatPath(["count"])).toBe("count"); }); + + it("formats the empty root path", () => { + expect(formatPath([])).toBe(""); + }); + + it("formats root bracket segments", () => { + expect(formatPath([0, "label"])).toBe("[0].label"); + expect(formatPath(["display name", "value"])).toBe( + '["display name"].value', + ); + }); }); describe("normalizePath", () => { @@ -96,14 +118,17 @@ describe("normalizePath", () => { expect(normalizePath(["items", ""])).toEqual(["items", ""]); }); - it("rejects empty paths", () => { - expect(() => normalizePath([])).toThrow(SyntaxError); - expect(() => normalizePath("")).toThrow(SyntaxError); + it("accepts empty root paths", () => { + expect(normalizePath([])).toEqual([]); + expect(normalizePath("")).toEqual([]); + }); + + it("accepts root index and string-key paths", () => { + expect(normalizePath([1, "label"])).toEqual([1, "label"]); + expect(normalizePath(["", "label"])).toEqual(["", "label"]); }); - it("rejects paths that do not start with a non-empty property name", () => { - expect(() => normalizePath([1, "label"])).toThrow(SyntaxError); - expect(() => normalizePath(["", "label"])).toThrow(SyntaxError); + it("rejects invalid array-form path segments", () => { expect(() => normalizePath(["items", 1.5])).toThrow(SyntaxError); expect(() => normalizePath(["items", -1, "label"])).toThrow(SyntaxError); }); @@ -173,3 +198,10 @@ describe("setPathAt", () => { expect(setPathAt(value, [], value)).toBe(value); }); }); + +describe("pathsOverlap", () => { + it("treats the root path as overlapping every path", () => { + expect(pathsOverlap("", "items[0].label")).toBe(true); + expect(pathsOverlap("items[0].label", "")).toBe(true); + }); +}); diff --git a/remotestate-ts/src/test/provider.test.tsx b/remotestate-ts/src/test/provider.test.tsx index cb359e4..e119650 100644 --- a/remotestate-ts/src/test/provider.test.tsx +++ b/remotestate-ts/src/test/provider.test.tsx @@ -4,6 +4,8 @@ import { createLocalStateClient, RemoteStateProvider, useRemoteStateClient, + useRemoteStateValue, + type PathLike, type RemoteStateClient, type Store, } from "../lib"; @@ -36,7 +38,7 @@ afterEach(() => { function createFallbackClient(): RemoteStateClient { const store: Store = { - get: (path) => + get: (path = []) => path.length === 1 && path[0] === "source" ? "fallback" : undefined, set: vi.fn(), provide: vi.fn(), @@ -56,6 +58,12 @@ function RequiredClientStatus() { } describe("RemoteStateProvider", () => { + it("allows useRemoteStateValue to omit the path", () => { + const readRoot: (path?: PathLike) => unknown = useRemoteStateValue; + + expect(readRoot).toBe(useRemoteStateValue); + }); + it("creates a remote client when URL is provided", () => { const html = renderToString( diff --git a/remotestate-ts/src/test/store.test.ts b/remotestate-ts/src/test/store.test.ts index e7b9a8a..927c6c6 100644 --- a/remotestate-ts/src/test/store.test.ts +++ b/remotestate-ts/src/test/store.test.ts @@ -22,6 +22,22 @@ describe("StoreImpl", () => { expect(store.get(["count"])).toBe(42); }); + it("caches the root value from GetResultMessage", () => { + const transport = mockTransportWithHandler(); + const store = new StoreImpl(asTransport(transport)); + const value = [{ label: "foo" }]; + + transport._triggerMessage({ + type: "get_result", + call_id: "1", + path: "", + value, + }); + + expect(store.get([])).toBe(value); + expect(store.get()).toBe(value); + }); + it("updates cache from ActionResultMessage", () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -67,6 +83,21 @@ describe("StoreImpl", () => { expect(listener).toHaveBeenCalledOnce(); }); + it("notifies root listeners on child updates", () => { + const transport = mockTransportWithHandler(); + const store = new StoreImpl(asTransport(transport)); + const listener = vi.fn(); + store.subscribe([], listener); + + transport._triggerMessage({ + type: "action_result", + call_id: "abc", + updates: { "items[1].label": "Test 2" }, + }); + + expect(listener).toHaveBeenCalledOnce(); + }); + it("notifies once when multiple action updates overlap a subscribed path", () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -173,6 +204,28 @@ describe("StoreImpl", () => { expect(store.get(["items"])).not.toBe(items); }); + it("patches cached root values from a leaf action update", () => { + const transport = mockTransportWithHandler(); + const store = new StoreImpl(asTransport(transport)); + const root = { items: [{ label: "foo" }] }; + + transport._triggerMessage({ + type: "get_result", + call_id: "1", + path: "", + value: root, + }); + + transport._triggerMessage({ + type: "action_result", + call_id: "abc", + updates: { "items[0].label": "x" }, + }); + + expect(store.get([])).toEqual({ items: [{ label: "x" }] }); + expect(store.get([])).not.toBe(root); + }); + it("materializes a subscribed parent snapshot from a leaf action update", () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -232,6 +285,22 @@ describe("StoreImpl", () => { ).toEqual({ duration: 123 }); }); + it("materializes a subscribed child snapshot from a root action update", () => { + const transport = mockTransportWithHandler(); + const store = new StoreImpl(asTransport(transport)); + const listener = vi.fn(); + store.subscribe(["items", 0, "label"], listener); + + transport._triggerMessage({ + type: "action_result", + call_id: "abc", + updates: { "": { items: [{ label: "x" }] } }, + }); + + expect(listener).toHaveBeenCalledOnce(); + expect(store.get(["items", 0, "label"])).toBe("x"); + }); + it("materializes a subscribed child snapshot from a parent fetch result", () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -301,6 +370,17 @@ describe("StoreImpl", () => { ); }); + it("fetches the root path if not cached", () => { + const transport = mockTransport(); + const store = new StoreImpl(asTransport(transport)); + + store.provide([]); + + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ type: "get", path: "" }), + ); + }); + it("sets a path through the built-in set action", () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -317,6 +397,22 @@ describe("StoreImpl", () => { ); }); + it("sets the root path through the built-in set action", () => { + const transport = mockTransportWithHandler(); + const store = new StoreImpl(asTransport(transport)); + + void store.set([], { count: 3 }); + + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: "action", + method: "set", + args: ["", { count: 3 }], + kwargs: {}, + }), + ); + }); + it("resolves set after matching action result", async () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport));