diff --git a/remotestate-demo/README.md b/remotestate-demo/README.md index 7dbf7eb..db579d2 100644 --- a/remotestate-demo/README.md +++ b/remotestate-demo/README.md @@ -17,9 +17,9 @@ If you are developing a production application, we recommend updating the config ```js export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... @@ -34,40 +34,40 @@ export default defineConfig([ ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) +]); ``` You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +import reactX from "eslint-plugin-react-x"; +import reactDom from "eslint-plugin-react-dom"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... // Enable lint rules for React - reactX.configs['recommended-typescript'], + reactX.configs["recommended-typescript"], // Enable lint rules for React DOM reactDom.configs.recommended, ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) +]); ``` diff --git a/remotestate-demo/eslint.config.js b/remotestate-demo/eslint.config.js index ef614d2..da6f026 100644 --- a/remotestate-demo/eslint.config.js +++ b/remotestate-demo/eslint.config.js @@ -1,14 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -19,4 +19,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/remotestate-demo/package-lock.json b/remotestate-demo/package-lock.json index 70bb4cf..cf39ccb 100644 --- a/remotestate-demo/package-lock.json +++ b/remotestate-demo/package-lock.json @@ -30,7 +30,7 @@ }, "../remotestate-ts": { "name": "remotestate", - "version": "0.2.0", + "version": "0.3.0-dev.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.0.0", @@ -698,9 +698,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -718,9 +715,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -738,9 +732,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +749,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +766,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +783,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/remotestate-demo/src/App.css b/remotestate-demo/src/App.css index f90339d..f460279 100644 --- a/remotestate-demo/src/App.css +++ b/remotestate-demo/src/App.css @@ -167,7 +167,7 @@ &::before, &::after { - content: ''; + content: ""; position: absolute; top: -4.5px; border: 5px solid transparent; diff --git a/remotestate-demo/src/index.css b/remotestate-demo/src/index.css index 5fb3313..aad5cb1 100644 --- a/remotestate-demo/src/index.css +++ b/remotestate-demo/src/index.css @@ -11,8 +11,8 @@ --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --sans: system-ui, "Segoe UI", Roboto, sans-serif; + --heading: system-ui, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; font: 18px/145% var(--sans); diff --git a/remotestate-demo/vite.config.ts b/remotestate-demo/vite.config.ts index 7174aea..44dcdb1 100644 --- a/remotestate-demo/vite.config.ts +++ b/remotestate-demo/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ @@ -8,6 +8,6 @@ export default defineConfig({ // linked via `file:` paths (which create symlinks with separate node_modules). // Can be removed once all dependencies are installed from npm. resolve: { - dedupe: ['react', 'react-dom'] - } -}) + dedupe: ["react", "react-dom"], + }, +}); diff --git a/remotestate-py/CHANGES.md b/remotestate-py/CHANGES.md index 18ffd36..7f7ce54 100644 --- a/remotestate-py/CHANGES.md +++ b/remotestate-py/CHANGES.md @@ -1,3 +1,33 @@ +## 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()` now defaults to the root state value when no path is passed. +- Removed the built-in `Service.get()` query and `Service.set()` action. + Store reads and writes now use dedicated `get`/`set` protocol messages, while + service actions and queries are reserved for domain methods. +- `Service` is now generic over the root state type, so `service.store` + preserves the `Store[T]` type. +- 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]`. +- Added public `PathInput` and `PathSegmentInput` aliases plus + `normalize_path()` and `normalize_path_segment()` helpers under + `remotestate.path`. +- Removed the Python-only `Property` and `Index` parsed path segment classes. + Parsed paths now use primitive tuple segments, such as `("items", 0, + "label")`, matching the TypeScript path model. +- Aligned `PathInput` with TypeScript: pass a string path or a sequence of + path segments. Root array entries can be addressed as `"[0]"` or `(0,)`. + + ## Version 0.2.0 - Tightened the shared path grammar to a strict JSONPath subset without the diff --git a/remotestate-py/README.md b/remotestate-py/README.md index e4d0646..f15b37f 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 @@ -96,9 +101,9 @@ class User: def defaults(path: rs.path.Path): - if path == (rs.path.Property("user"),): + if path == ("user",): return User() - if path == (rs.path.Property("items"),): + if path == ("items",): return [] return {} @@ -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 @@ -139,15 +147,14 @@ class Counter(rs.Service): ## Service Helpers -`Service` also provides built-in methods that power the generic TypeScript bridge: +`Service` exposes the store and progress helper used by actions and queries: -- `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 -The reserved service method names are `get`, `set`, and `notify`. Do not reuse those names -for custom actions or queries. +The reserved service method name is `notify`. Store reads and writes use +`service.store.get(...)` and `service.store.set(...)`; `get` and `set` remain +available for custom actions or queries. `Service._init_app(app)` can be overridden to customize the FastAPI app when `serve()` creates one. @@ -180,30 +187,37 @@ print("UI Base URL: ", result.ui_base_url) ## Paths `remotestate.path` exposes the parsed path types used by `Store.default_factory` and other -advanced integrations: +advanced integrations. Parsed paths are tuples of string property names and integer array +indices, matching the TypeScript package's `string | number` path segments: - `Path` -- `Property` -- `Index` +- `PathSegment` +- `PathInput` +- `PathSegmentInput` 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 | - -Use `parse_path()` and `format_path()` when you need to inspect, validate, or construct paths. +| 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 `normalize_path()` when accepting raw path inputs, and use `parse_path()` and +`format_path()` when you need to inspect, validate, or construct string paths. ## More Docs diff --git a/remotestate-py/pyproject.toml b/remotestate-py/pyproject.toml index 7790fbe..cb2a89a 100644 --- a/remotestate-py/pyproject.toml +++ b/remotestate-py/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remotestate" -version = "0.2.0" +version = "0.3.0.dev0" authors = [{name = "forman"}] description = "Python state, React UI." readme = "README.md" diff --git a/remotestate-py/src/remotestate/__init__.py b/remotestate-py/src/remotestate/__init__.py index 6914bf2..9af3ce0 100644 --- a/remotestate-py/src/remotestate/__init__.py +++ b/remotestate-py/src/remotestate/__init__.py @@ -3,8 +3,8 @@ from importlib.metadata import version from . import path -from .service import Service, action, query from .serve import ServeResult, serve +from .service import Service, action, query from .store import Store __version__ = version("remotestate") diff --git a/remotestate-py/src/remotestate/context.py b/remotestate-py/src/remotestate/context.py index 8c84eff..0bde24c 100644 --- a/remotestate-py/src/remotestate/context.py +++ b/remotestate-py/src/remotestate/context.py @@ -29,3 +29,8 @@ class _CallContext: "_call_context", default=None ) """Task-local context for the currently executing action or query.""" + +_suppress_store_broadcast: ContextVar[bool] = ContextVar( + "_suppress_store_broadcast", default=False +) +"""Whether store subscribers should skip external transport broadcasts.""" diff --git a/remotestate-py/src/remotestate/log.py b/remotestate-py/src/remotestate/log.py index 5c8a1ff..625f29e 100644 --- a/remotestate-py/src/remotestate/log.py +++ b/remotestate-py/src/remotestate/log.py @@ -1,4 +1,4 @@ -from typing import Final import logging +from typing import Final LOG: Final = logging.getLogger("remotestate") diff --git a/remotestate-py/src/remotestate/path.py b/remotestate-py/src/remotestate/path.py index 6ea5c74..b751473 100644 --- a/remotestate-py/src/remotestate/path.py +++ b/remotestate-py/src/remotestate/path.py @@ -1,40 +1,78 @@ import functools import json import re -from dataclasses import dataclass +from collections.abc import Sequence -@dataclass(frozen=True) -class Property: - """A named property segment in a RemoteState path. +# A single segment in a RemoteState path. +type PathSegment = str | int - Args: - key: Property name. - """ +# A parsed RemoteState path. +type Path = tuple[PathSegment, ...] - key: str +# A raw value accepted as one path segment. +type PathSegmentInput = str | int +# A raw value accepted anywhere a RemoteState path is needed. +type PathInput = str | Sequence[PathSegmentInput] -@dataclass(frozen=True) -class Index: - """A list index segment in a RemoteState path. +_IDENTIFIER_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") +_INVALID_PATH_MESSAGE = "RemoteState paths must be valid simplified JSONPath paths" +_STRING_ESCAPES = { + '"': '"', + "'": "'", + "\\": "\\", + "/": "/", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", +} + + +def normalize_path(path: PathInput) -> Path: + """Normalize a path input value into a validated RemoteState path. Args: - i: Zero-based list index. + path: RemoteState path string or a sequence of path segment inputs + such as ``("items", 0, "label")``. + + Returns: + Parsed path. + + Raises: + TypeError: If ``path`` is not a supported path input value. + ValueError: If ``path`` contains an invalid segment. """ + if isinstance(path, str): + return parse_path(path) + if isinstance(path, Sequence): + return tuple(normalize_path_segment(segment) for segment in path) + raise TypeError("RemoteState path must be a string or sequence of path segments") - i: int +def normalize_path_segment(segment: PathSegmentInput) -> PathSegment: + """Normalize one path segment input value into a validated path segment. -# One parsed path segment. -type PathSegment = Property | Index + Args: + segment: A string property name or integer index. -# A parsed RemoteState path. -type Path = tuple[PathSegment, ...] + Returns: + Parsed path segment. -_IDENTIFIER_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") -_INTEGER_RE = re.compile(r"0|[1-9][0-9]*") -_INVALID_PATH_MESSAGE = "RemoteState paths must be valid simplified JSONPath paths" + Raises: + TypeError: If ``segment`` is not a supported path segment input value. + ValueError: If an integer index is negative. + """ + if isinstance(segment, bool): + raise ValueError("RemoteState path indices must be non-negative integers") + if isinstance(segment, int): + _validate_index(segment) + return segment + if isinstance(segment, str): + return segment + raise TypeError("RemoteState path segments must be strings or integers") @functools.cache @@ -44,7 +82,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 +95,11 @@ def parse_path(path: str) -> Path: Examples: + - ``""`` - ``"user"`` + - ``"[0].label"`` - ``"items[0].label"`` + - ``"[\"display name\"]"`` - ``"user[\"display name\"]"`` Args: @@ -69,13 +112,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] = [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 ".": @@ -83,7 +135,7 @@ def parse_path(path: str) -> Path: identifier = _read_identifier(path, pos) if identifier is None: raise _invalid_path(path, pos) - segments.append(Property(identifier[0])) + segments.append(identifier[0]) pos = identifier[1] case "[": bracket = _read_bracket_segment(path, pos) @@ -97,42 +149,40 @@ def parse_path(path: str) -> Path: return tuple(segments) -def prefixes(path: Path) -> list[Path]: - """Return all non-empty prefixes of a parsed path. +def format_path(path: Path) -> str: + """Convert parsed RemoteState path segments back to dotted/bracket syntax. Args: path: Parsed path. Returns: - Prefix paths ordered from shortest to longest. + The canonical string form used by the transport and cache keys. """ - return [path[:i] for i in range(1, len(path) + 1)] + _validate_path(path) + parts: list[str] = [] + for index, segment in enumerate(path): + if isinstance(segment, int): + parts.append(f"[{segment}]") + elif index == 0 and _IDENTIFIER_RE.fullmatch(segment): + parts.append(segment) + elif _IDENTIFIER_RE.fullmatch(segment): + parts.append(f".{segment}") + else: + parts.append(f"[{json.dumps(segment, ensure_ascii=False)}]") + return "".join(parts) -def format_path(path: Path) -> str: - """Convert a parsed path back to a RemoteState path string. +def prefixes(path: Path) -> list[Path]: + """Return all non-root prefixes of a parsed path. Args: path: Parsed path. Returns: - String representation of ``path``. + Prefix paths ordered from shortest to longest. The root path ``()`` has + no non-root prefixes. """ - _validate_path(path) - - parts: list[str] = [] - for index, seg in enumerate(path): - match seg: - case Property(key): - if index == 0: - parts.append(key) - elif _IDENTIFIER_RE.fullmatch(key): - parts.append(f".{key}") - else: - parts.append(f"[{json.dumps(key, ensure_ascii=False)}]") - case Index(i): - parts.append(f"[{i}]") - return "".join(parts) + return [path[:i] for i in range(1, len(path) + 1)] def to_jsonpath(path: str) -> str: @@ -144,6 +194,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,32 +205,37 @@ 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:]: - match segment: - case Property(key): - if not isinstance(key, str): - raise ValueError(_INVALID_PATH_MESSAGE) - case Index(i): - if not isinstance(i, int) or i < 0: - raise ValueError(_INVALID_PATH_MESSAGE) + for segment in path: + if isinstance(segment, bool): + raise ValueError(_INVALID_PATH_MESSAGE) + if isinstance(segment, int): + _validate_index(segment) + elif not isinstance(segment, str): + raise ValueError(_INVALID_PATH_MESSAGE) + + +def _validate_index(index: int) -> None: + if index < 0: + raise ValueError("RemoteState path indices must be non-negative integers") def _read_identifier(path: str, start: int) -> tuple[str, int] | None: @@ -198,31 +257,29 @@ def _read_bracket_segment(path: str, start: int) -> tuple[PathSegment, int] | No next_char = path[start + 1] if next_char in {'"', "'"}: - parsed = _read_quoted_string(path, start + 1) + parsed = _read_quoted_string_literal(path, start + 1) if parsed is None: return None key, pos = parsed if pos >= len(path) or path[pos] != "]": return None - return Property(key), pos + 1 + return key, pos + 1 - if not next_char.isdigit(): + if not _is_digit(next_char): return None pos = start + 1 - while pos < len(path) and path[pos].isdigit(): + while pos < len(path) and _is_digit(path[pos]): pos += 1 digits = path[start + 1 : pos] if len(digits) > 1 and digits.startswith("0"): return None - if not _INTEGER_RE.fullmatch(digits): - return None if pos >= len(path) or path[pos] != "]": return None - return Index(int(digits)), pos + 1 + return int(digits), pos + 1 -def _read_quoted_string(path: str, start: int) -> tuple[str, int] | None: +def _read_quoted_string_literal(path: str, start: int) -> tuple[str, int] | None: if start >= len(path) or path[start] not in {'"', "'"}: return None @@ -243,64 +300,35 @@ def _read_quoted_string(path: str, start: int) -> tuple[str, int] | None: if pos >= len(path): return None escape = path[pos] - if escape == quote: - value.append(quote) + escaped = _STRING_ESCAPES.get(escape) + if escaped is not None: + value.append(escaped) pos += 1 continue - match escape: - case "\\": - value.append("\\") - pos += 1 - continue - case "/": - value.append("/") - pos += 1 - continue - case "b": - value.append("\b") - pos += 1 - continue - case "f": - value.append("\f") - pos += 1 - continue - case "n": - value.append("\n") - pos += 1 - continue - case "r": - value.append("\r") - pos += 1 - continue - case "t": - value.append("\t") - pos += 1 - continue - case "u": - parsed = _read_unicode_code_unit(path, pos + 1) - if parsed is None: + if escape == "u": + parsed = _read_unicode_code_unit(path, pos + 1) + if parsed is None: + return None + code_unit, pos = parsed + if 0xD800 <= code_unit <= 0xDBFF: + if pos + 6 > len(path) or path[pos] != "\\" or path[pos + 1] != "u": + return None + low = _read_unicode_code_unit(path, pos + 2) + if low is None: return None - code_unit, pos = parsed - if 0xD800 <= code_unit <= 0xDBFF: - if pos + 6 > len(path) or path[pos] != "\\" or path[pos + 1] != "u": - return None - low = _read_unicode_code_unit(path, pos + 2) - if low is None: - return None - low_unit, pos = low - if not 0xDC00 <= low_unit <= 0xDFFF: - return None - code_point = ( - 0x10000 + ((code_unit - 0xD800) << 10) + (low_unit - 0xDC00) - ) - value.append(chr(code_point)) - continue - if 0xDC00 <= code_unit <= 0xDFFF: + low_unit, pos = low + if not 0xDC00 <= low_unit <= 0xDFFF: return None - value.append(chr(code_unit)) + code_point = ( + 0x10000 + ((code_unit - 0xD800) << 10) + (low_unit - 0xDC00) + ) + value.append(chr(code_point)) continue - case _: + if 0xDC00 <= code_unit <= 0xDFFF: return None + value.append(chr(code_unit)) + continue + return None return None @@ -308,17 +336,27 @@ def _read_unicode_code_unit(path: str, start: int) -> tuple[int, int] | None: if start + 4 > len(path): return None hex_digits = path[start : start + 4] - if not re.fullmatch(r"[0-9a-fA-F]{4}", hex_digits): + if not all(_is_hex_digit(char) for char in hex_digits): return None return int(hex_digits, 16), start + 4 def _is_identifier_start(char: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z_]", char)) + code = ord(char) + return char == "_" or 0x41 <= code <= 0x5A or 0x61 <= code <= 0x7A def _is_identifier_part(char: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z0-9_]", char)) + return _is_identifier_start(char) or _is_digit(char) + + +def _is_digit(char: str) -> bool: + return "0" <= char <= "9" + + +def _is_hex_digit(char: str) -> bool: + code = ord(char) + return 0x30 <= code <= 0x39 or 0x41 <= code <= 0x46 or 0x61 <= code <= 0x66 def _invalid_path(path: str, pos: int | None = None) -> ValueError: diff --git a/remotestate-py/src/remotestate/protocol.py b/remotestate-py/src/remotestate/protocol.py index 837cbe7..f58d27e 100644 --- a/remotestate-py/src/remotestate/protocol.py +++ b/remotestate-py/src/remotestate/protocol.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, Annotated +from typing import Annotated, Any, Literal from pydantic import BaseModel, Field @@ -17,7 +17,23 @@ class GetMessage(BaseModel): """An internal get-ID.""" path: str - """The modification path using a simplified JSON-Path format.""" + """Path into store's state using a simplified JSON-Path format.""" + + +class SetMessage(BaseModel): + """Set one store value by path.""" + + type: Literal["set"] = "set" + """Message type.""" + + call_id: str + """An internal set-ID.""" + + path: str + """Path into store's state using a simplified JSON-Path format.""" + + value: Any + """New value to assign at ``path``.""" class ActionMessage(BaseModel): @@ -99,12 +115,25 @@ class ActionResultMessage(BaseModel): """Message type.""" call_id: str - """An internal action- or query-ID.""" + """An internal action-ID.""" updates: dict[str, Any] """Mapping from state paths to changed state values. May be empty.""" +class SetResultMessage(BaseModel): + """Return the batched store updates produced by a previous ``SetMessage``.""" + + type: Literal["set_result"] = "set_result" + """Message type.""" + + call_id: str + """An internal set-ID.""" + + updates: dict[str, Any] + """Mapping from state paths to changed state values.""" + + class QueryResultMessage(BaseModel): """Return the computed result for a previous ``QueryMessage``.""" @@ -153,13 +182,13 @@ class TaskUpdateMessage(BaseModel): class ErrorMessage(BaseModel): - """Return an error for a previous action, query, or parse failure.""" + """Return an error for a previous request or parse failure.""" type: Literal["error"] = "error" """Message type.""" call_id: str - """An internal action- or query-ID.""" + """An internal request ID.""" message: str """Error message text.""" @@ -170,13 +199,14 @@ class ErrorMessage(BaseModel): # ---------------------------------------------------- IncomingMessage = Annotated[ - GetMessage | ActionMessage | QueryMessage, + GetMessage | SetMessage | ActionMessage | QueryMessage, Field(discriminator="type"), ] """Any message that can be sent from JavaScript to Python.""" OutgoingMessage = Annotated[ GetResultMessage + | SetResultMessage | QueryResultMessage | TaskUpdateMessage | ActionResultMessage diff --git a/remotestate-py/src/remotestate/serve.py b/remotestate-py/src/remotestate/serve.py index d409148..7203cf0 100644 --- a/remotestate-py/src/remotestate/serve.py +++ b/remotestate-py/src/remotestate/serve.py @@ -11,14 +11,12 @@ import uvicorn from fastapi import FastAPI - from fastapi.staticfiles import StaticFiles from starlette.staticfiles import PathLike +from .log import LOG from .server import Server from .service import Service -from .log import LOG - # Imported at module level so tests can patch remotestate.serve._get_ipython. try: @@ -206,7 +204,8 @@ def _display_result( display_mode = _get_display_mode(display) if display_mode == "notebook": - from IPython.display import IFrame, display as ipython_display + from IPython.display import IFrame + from IPython.display import display as ipython_display ipython_display(IFrame(src=result.ui_url, width=width, height=height)) elif display_mode == "browser": diff --git a/remotestate-py/src/remotestate/server.py b/remotestate-py/src/remotestate/server.py index c53ac70..906ddae 100644 --- a/remotestate-py/src/remotestate/server.py +++ b/remotestate-py/src/remotestate/server.py @@ -6,27 +6,28 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles -from starlette.staticfiles import PathLike from pydantic import TypeAdapter +from starlette.staticfiles import PathLike +from .context import _suppress_store_broadcast +from .log import LOG from .protocol import ( ActionMessage, + ActionResultMessage, ErrorMessage, GetMessage, GetResultMessage, IncomingMessage, - ActionResultMessage, OutgoingMessage, QueryMessage, QueryResultMessage, + SetMessage, + SetResultMessage, TaskUpdateMessage, ) -from .context import _call_context from .service import Service -from .store import PendingUpdates +from .store import PendingUpdates, _batch_pending_updates from .transport import Transport -from .log import LOG - _IncomingAdapter: TypeAdapter[IncomingMessage] = TypeAdapter(IncomingMessage) @@ -83,13 +84,13 @@ async def sender(msg: TaskUpdateMessage) -> None: return sender def _broadcast_store_update(self, updates: PendingUpdates) -> None: - # Action dispatch already returns the batched updates as an - # ActionResultMessage. This subscription is for Python-side store.set() - # calls that happen outside a dispatched service action. - if _call_context.get() is not None: + # Dispatched actions and store set messages return their updates in the + # matching result message. This subscription is for Python-side + # store.set() calls that happen outside a dispatched request. + if _suppress_store_broadcast.get(): return self._transport.send_nowait( - ActionResultMessage(call_id="store_update", updates=updates) + SetResultMessage(call_id="store_update", updates=updates) ) # noinspection PyProtectedMember @@ -112,6 +113,18 @@ async def __dispatch(self, msg: IncomingMessage) -> None: GetResultMessage(call_id=call_id, path=path, value=value) ) + case SetMessage(call_id=call_id, path=path, value=value): + token = _suppress_store_broadcast.set(True) + try: + with _batch_pending_updates() as pending: + self._store.set(path, value) + self._store._flush(pending) + await self._transport.send( + SetResultMessage(call_id=call_id, updates=pending) + ) + finally: + _suppress_store_broadcast.reset(token) + case ActionMessage( call_id=call_id, task_id=task_id, diff --git a/remotestate-py/src/remotestate/service.py b/remotestate-py/src/remotestate/service.py index 626fcf7..2a3ef70 100644 --- a/remotestate-py/src/remotestate/service.py +++ b/remotestate-py/src/remotestate/service.py @@ -4,14 +4,16 @@ import functools import inspect from collections.abc import Callable, Coroutine -from typing import Any +from typing import Any, Generic, TypeVar from fastapi import FastAPI -from .context import _call_context, _CallContext +from .context import _call_context, _CallContext, _suppress_store_broadcast from .protocol import TaskUpdateMessage from .store import PendingUpdates, Store, _batch_pending_updates +T = TypeVar("T") + class _ActionMarker: """Marker object used while collecting ``@action`` methods.""" @@ -66,19 +68,16 @@ def query(fn: Callable) -> _QueryMarker: return _QueryMarker(_ensure_async(fn)) -_BUILTIN_SERVICE_METHODS = {"get", "set", "notify"} +_RESERVED_SERVICE_METHODS = {"notify"} -class Service: +class Service(Generic[T]): """Implements the Python queries and actions exposed over the websocket bridge. Subclasses define ``@action`` and ``@query`` methods. Dispatch helpers take care of call scoping, read-only enforcement for queries, and batched store invalidation after actions complete. - The base class also provides the built-in ``get`` query and ``set`` - action used by the generic React bridge. - ``Service`` may serve as a base class for store-specific queries and actions, but it can also be instantiated as-is, if no queries and actions are required for a given store. @@ -88,15 +87,13 @@ class Service: - ``_init_app`` - FastAPI instance initialization - ``store`` - property that provides reactive state container - - ``get`` - built-in query to get a state value - - ``set`` - built-in action to set a state value - ``notify`` - report task updates Args: store: The reactive state container. """ - _store: Store + _store: Store[T] _actions: dict[str, Callable] _queries: dict[str, Callable] @@ -110,9 +107,9 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls._queries.update(getattr(base, "_queries", {})) for name, value in list(cls.__dict__.items()): - if name in _BUILTIN_SERVICE_METHODS: + if name in _RESERVED_SERVICE_METHODS: raise TypeError( - f"{cls.__name__}.{name} conflicts with a built-in " + f"{cls.__name__}.{name} conflicts with a reserved " "RemoteState service method" ) if isinstance(value, _ActionMarker): @@ -122,7 +119,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls._queries[name] = value.fn setattr(cls, name, value.fn) - def __init__(self, store: Store) -> None: + def __init__(self, store: Store[T]) -> None: """Create a service bound to a reactive store. Args: @@ -149,42 +146,10 @@ def _init_app(self, app: FastAPI): """ @property - def store(self) -> Store: + def store(self) -> Store[T]: """Store: The reactive state container.""" return self._store - @query - 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. - - Returns: - The value at ``path``, or ``None`` when the path is missing. - """ - return self.store.get(path) - - @action - def set(self, path: str, value: Any) -> None: - """Built-in action that sets a store value by path. - - This is the write-side of the generic bridge used by the TypeScript - ``useRemoteState()`` hook and related helpers, so a simple UI state does - not require a custom action on every user service. - - Args: - path: RemoteState path to write. - value: New value to assign at ``path``. - - Returns: - None. - """ - self.store.set(path, value) - # noinspection PyMethodMayBeStatic def notify( self, @@ -263,6 +228,7 @@ async def _rs_invoke_action( readonly=False, ) ) + broadcast_token = _suppress_store_broadcast.set(True) try: with _batch_pending_updates() as pending: await fn(self, *args, **kwargs) @@ -270,6 +236,7 @@ async def _rs_invoke_action( self.store._flush(pending) return pending finally: + _suppress_store_broadcast.reset(broadcast_token) _call_context.reset(token) async def _rs_invoke_query( diff --git a/remotestate-py/src/remotestate/store.py b/remotestate-py/src/remotestate/store.py index 2a8ef79..227650e 100644 --- a/remotestate-py/src/remotestate/store.py +++ b/remotestate-py/src/remotestate/store.py @@ -3,23 +3,23 @@ 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 ( - Index, Path, + PathInput, PathSegment, - Property, format_path, - parse_path, + normalize_path, ) type PendingUpdates = dict[str, Any] type DefaultFactory = Callable[[Path], Any] +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,18 +28,19 @@ 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 - containing ``Property("user")`` or ``Index(0)`` segments. + containing ``"user"`` or ``0`` segments. If omitted, missing parents raise the same ``KeyError``, ``IndexError``, or ``AttributeError`` as before. """ @@ -47,7 +48,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: PathInput) -> Any: + """Return the value at ``path``. + + ``path`` may be a RemoteState path string 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: PathInput, value: Any) -> None: + """Set ``value`` at ``path``. + + ``path`` follows the same rules as ``__getitem__``. + """ + self.set(path, value) + + def get(self, path: PathInput = (), *, require: bool = False) -> Any: """Return the value at ``path``. Missing values return ``None`` by default. Pass ``require=True`` to @@ -55,8 +77,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 +93,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: PathInput, value: Any) -> None: """Set ``value`` at ``path`` and notify subscribers. If this store has a default factory, missing intermediate path @@ -82,8 +105,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 +121,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() @@ -149,46 +175,41 @@ def _flush(self, pending: PendingUpdates) -> None: def _set_or_append_segment( obj: Any, segment: PathSegment, value: Any, *, require_appendable: bool ) -> None: - if isinstance(segment, Index) and isinstance(obj, list) and segment.i == len(obj): + if isinstance(segment, int) and isinstance(obj, list) and segment == len(obj): obj.append(value) return if require_appendable: - if isinstance(segment, Index): - raise IndexError(segment.i) - else: - raise KeyError(segment.key) + if isinstance(segment, int): + raise IndexError(segment) + raise KeyError(segment) _set_segment(obj, segment, value) def _get_segment(obj: Any, segment: PathSegment, require: bool) -> Any: - match segment: - case Property(key): - if isinstance(obj, dict): - if require: - return obj[key] - return obj.get(key) - else: - if require: - return getattr(obj, key) - return getattr(obj, key, None) - case Index(i): - try: - return obj[i] - except IndexError: - if require: - raise - return None + if isinstance(segment, str): + if isinstance(obj, dict): + if require: + return obj[segment] + return obj.get(segment) + if require: + return getattr(obj, segment) + return getattr(obj, segment, None) + try: + return obj[segment] + except (IndexError, KeyError, TypeError): + if require: + raise + return None def _set_segment(obj: Any, segment: PathSegment, value: Any) -> None: - match segment: - case Property(key): - if isinstance(obj, dict): - obj[key] = value - else: - setattr(obj, key, value) - case Index(i): - obj[i] = value + if isinstance(segment, str): + if isinstance(obj, dict): + obj[segment] = value + else: + setattr(obj, segment, value) + return + obj[segment] = value def _get_at(root: Any, path: Path, require: bool) -> Any: @@ -205,7 +226,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: @@ -214,9 +238,9 @@ def _set_at( if default_factory is None: raise if ( - isinstance(segment, Index) + isinstance(segment, int) and isinstance(obj, list) - and segment.i > len(obj) + and segment > len(obj) ): raise default_value = default_factory(path[:i]) @@ -224,7 +248,7 @@ def _set_at( obj, segment, default_value, - require_appendable=isinstance(segment, Index), + require_appendable=isinstance(segment, int), ) obj = default_value @@ -234,6 +258,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: diff --git a/remotestate-py/tests/test_path.py b/remotestate-py/tests/test_path.py index 4163786..5b9f845 100644 --- a/remotestate-py/tests/test_path.py +++ b/remotestate-py/tests/test_path.py @@ -2,177 +2,183 @@ import pytest from remotestate.path import ( - Index, - Property, from_jsonpath, format_path, + normalize_path, + normalize_path_segment, parse_path, prefixes, to_jsonpath, ) -# --- parse_path --- +def test_parse_path_parses_dotted_and_indexed_paths(): + assert parse_path("items[1].label") == ("items", 1, "label") -def test_simple_property(): - assert parse_path("user") == (Property("user"),) +def test_parse_path_parses_bracketed_string_keys(): + assert parse_path('user["display name"]') == ("user", "display name") + assert parse_path("user['display name']") == ("user", "display name") + assert parse_path('user["0"]') == ("user", "0") + assert parse_path("user['0']") == ("user", "0") + assert parse_path('items[""].label') == ("items", "", "label") + assert parse_path('user["weird.key"].value') == ("user", "weird.key", "value") + assert parse_path('user[""]') == ("user", "") -def test_nested_properties(): - assert parse_path("user.name") == (Property("user"), Property("name")) - -def test_index(): - assert parse_path("items[3]") == (Property("items"), Index(3)) - - -def test_string_key(): - assert parse_path('user["display name"]') == ( - Property("user"), - Property("display name"), - ) - assert parse_path("user['display name']") == ( - Property("user"), - Property("display name"), - ) - assert parse_path('user["0"]') == (Property("user"), Property("0")) - assert parse_path("user['0']") == (Property("user"), Property("0")) - assert parse_path('items[""].label') == ( - Property("items"), - Property(""), - Property("label"), +def test_parse_path_parses_bracketed_string_key_escapes(): + assert parse_path('user["line\\nbreak"]') == ("user", "line\nbreak") + assert parse_path('user["tab\\tseparated"]') == ("user", "tab\tseparated") + assert parse_path('user["quote\\"slash\\\\"]') == ("user", 'quote"slash\\') + assert parse_path("user['double\\\"quote']") == ("user", 'double"quote') + assert parse_path('user["emoji \\uD83D\\uDE00"]') == ( + "user", + "emoji " + chr(0x1F600), ) -def test_nested_after_index(): - assert parse_path("items[3].name") == ( - Property("items"), - Index(3), - Property("name"), - ) +def test_parse_path_parses_a_single_root_segment(): + assert parse_path("count") == ("count",) -def test_deep(): - assert parse_path("a.b[0].c[1].d") == ( - Property("a"), - Property("b"), - Index(0), - Property("c"), - Index(1), - Property("d"), - ) +def test_parse_path_parses_the_empty_root_path(): + assert parse_path("") == () -def test_underscore_in_key(): - assert parse_path("my_field.sub_field") == ( - Property("my_field"), - Property("sub_field"), - ) +def test_parse_path_parses_root_bracket_segments(): + assert parse_path("[0].label") == (0, "label") + assert parse_path('["display name"].value') == ("display name", "value") -def test_invalid_starts_with_dot(): +def test_parse_path_throws_on_invalid_trailing_input(): with pytest.raises(ValueError): - parse_path(".user") + parse_path("items..label") + with pytest.raises(ValueError): + parse_path("user.") + with pytest.raises(ValueError): + parse_path("items[*]") -def test_invalid_starts_with_index(): +def test_parse_path_throws_on_invalid_path_starts(): with pytest.raises(ValueError): - parse_path("[0].name") + parse_path("1items") + with pytest.raises(ValueError): + parse_path(".items") -def test_invalid_starts_with_string_key(): +def test_parse_path_throws_on_non_canonical_integer_syntax(): + with pytest.raises(ValueError): + parse_path("items[01]") with pytest.raises(ValueError): - parse_path('["root"]') + parse_path("items[foo]") -def test_invalid_empty(): - with pytest.raises(ValueError): - parse_path("") +def test_format_path_formats_dotted_and_indexed_paths(): + assert format_path(("items", 1, "label")) == "items[1].label" -def test_invalid_trailing_dot(): - with pytest.raises(ValueError): - parse_path("user.") +def test_format_path_formats_bracketed_string_keys_canonically(): + assert format_path(("user", "display name")) == 'user["display name"]' + assert format_path(("user", "0")) == 'user["0"]' + assert format_path(("items", "", "label")) == 'items[""].label' + assert format_path(("user", "weird.key", "value")) == 'user["weird.key"].value' + assert format_path(("items", "")) == 'items[""]' -def test_invalid_double_dot(): - with pytest.raises(ValueError): - parse_path("user..name") +def test_format_path_formats_a_single_root_segment(): + assert format_path(("count",)) == "count" -def test_invalid_non_integer_index(): - with pytest.raises(ValueError): - parse_path("items[foo]") +def test_format_path_formats_the_empty_root_path(): + assert format_path(()) == "" -def test_invalid_leading_zero_index(): - with pytest.raises(ValueError): - parse_path("items[01]") +def test_format_path_formats_root_bracket_segments(): + assert format_path((0, "label")) == "[0].label" + assert format_path(("display name", "value")) == '["display name"].value' -def test_invalid_jsonpath_wildcard(): - with pytest.raises(ValueError): - parse_path("items[*]") +def test_normalize_path_normalizes_dotted_strings_into_parsed_paths(): + assert normalize_path("items[1].label") == ("items", 1, "label") -# --- prefixes --- +def test_normalize_path_accepts_an_already_parsed_path_input_value(): + path = ("items", 1, "label") + assert normalize_path(path) == path -def test_prefixes_simple(): - path = parse_path("user.name") - result = [format_path(p) for p in prefixes(path)] - assert result == ["user", "user.name"] +def test_normalize_path_accepts_parsed_relative_paths(): + parsed = parse_path("items[1].label") -def test_prefixes_with_index(): - path = parse_path("items[3].name") - result = [format_path(p) for p in prefixes(path)] - assert result == ["items", "items[3]", "items[3].name"] + assert normalize_path(parsed) == ("items", 1, "label") -def test_prefixes_single(): - path = parse_path("user") - result = prefixes(path) - assert len(result) == 1 +def test_normalize_path_accepts_string_keys_in_array_form(): + assert normalize_path(("items", "display name")) == ("items", "display name") + assert normalize_path(("items", "")) == ("items", "") -# --- format_path --- +def test_normalize_path_accepts_empty_root_paths(): + assert normalize_path(()) == () + assert normalize_path("") == () -def test_roundtrip_simple(): - assert format_path(parse_path("user.name")) == "user.name" +def test_normalize_path_accepts_root_index_and_string_key_paths(): + assert normalize_path((1, "label")) == (1, "label") + assert normalize_path(("", "label")) == ("", "label") -def test_roundtrip_index(): - assert format_path(parse_path("items[3].name")) == "items[3].name" +def test_normalize_path_segment_accepts_raw_segment_values(): + assert normalize_path_segment("items") == "items" + assert normalize_path_segment(1) == 1 -def test_roundtrip_string_key(): - assert format_path(parse_path('user["display name"]')) == 'user["display name"]' - assert format_path(parse_path("user['display name']")) == 'user["display name"]' - 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' +def test_normalize_path_rejects_invalid_array_form_path_segments(): + with pytest.raises(TypeError): + normalize_path(("items", 1.5)) + with pytest.raises(ValueError): + normalize_path(("items", -1, "label")) + with pytest.raises(ValueError): + normalize_path(("items", True, "label")) -def test_roundtrip_deep(): - s = "a.b[0].c[1].d" - assert format_path(parse_path(s)) == s +def test_normalize_path_rejects_invalid_string_syntax(): + with pytest.raises(ValueError): + normalize_path("items..label") + with pytest.raises(ValueError): + normalize_path("items[01]") -def test_roundtrip_empty_string_key(): - assert format_path(parse_path('user[""]')) == 'user[""]' +def test_normalize_path_rejects_bare_root_index_input(): + with pytest.raises(TypeError): + normalize_path(0) # type: ignore[arg-type] -# --- jsonpath --- +def test_prefixes_returns_non_root_prefixes(): + assert [format_path(p) for p in prefixes(parse_path("user.name"))] == [ + "user", + "user.name", + ] + assert [format_path(p) for p in prefixes(parse_path("items[3].name"))] == [ + "items", + "items[3]", + "items[3].name", + ] + assert prefixes(parse_path("user")) == [("user",)] + assert prefixes(parse_path("")) == [] 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(): @@ -181,7 +187,8 @@ def test_from_jsonpath_invalid(): def test_path_namespace_is_exported_from_package_root(): - assert rs.path.Property("user") == Property("user") - assert rs.path.Index(3) == Index(3) + assert rs.path.parse_path("user") == ("user",) + assert not hasattr(rs.path, "Property") + assert not hasattr(rs.path, "Index") assert not hasattr(rs, "Property") assert not hasattr(rs, "Index") diff --git a/remotestate-py/tests/test_protocol.py b/remotestate-py/tests/test_protocol.py index c1dda99..42c469a 100644 --- a/remotestate-py/tests/test_protocol.py +++ b/remotestate-py/tests/test_protocol.py @@ -5,6 +5,8 @@ from remotestate.protocol import ( IncomingMessage, GetMessage, + SetMessage, + SetResultMessage, OutgoingMessage, ActionMessage, ) @@ -23,6 +25,18 @@ def test_get(): ) == GetMessage(call_id="x", path="y") +def test_set(): + assert _incoming_adapter.validate_json( + to_json(type="set", call_id="x", path="count", value=7) + ) == SetMessage(call_id="x", path="count", value=7) + + +def test_set_result(): + assert _outgoing_adapter.validate_python( + {"type": "set_result", "call_id": "x", "updates": {"count": 7}} + ) == SetResultMessage(call_id="x", updates={"count": 7}) + + def test_action(): assert _incoming_adapter.validate_json( to_json( diff --git a/remotestate-py/tests/test_server.py b/remotestate-py/tests/test_server.py index 813b89b..2b6317e 100644 --- a/remotestate-py/tests/test_server.py +++ b/remotestate-py/tests/test_server.py @@ -8,6 +8,8 @@ ErrorMessage, GetMessage, GetResultMessage, + SetMessage, + SetResultMessage, ActionResultMessage, QueryMessage, QueryResultMessage, @@ -36,6 +38,11 @@ def _init_app(self, app: FastAPI): async def increment(self): self.store.set("count", self.store.get("count") + 1) + @action + async def multi_set(self): + self.store.set("count", 7) + self.store.set("user.name", "Klaus") + @query async def get_count(self) -> int: return self.store.get("count") @@ -64,7 +71,7 @@ def test_external_store_set_broadcasts_update(server): server._transport.send_nowait.assert_called_once() sent = server._transport.send_nowait.call_args[0][0] - assert isinstance(sent, ActionResultMessage) + assert isinstance(sent, SetResultMessage) assert sent.call_id == "store_update" assert sent.updates == {"count": 7} @@ -136,9 +143,10 @@ async def test_transport_close_clears_connections(): # --- Dispatch --- # The dispatcher routes incoming protocol messages to the correct handler: -# GetMessage → store.get → ValueMessage -# CallMessage → service action → (store mutation, no return value) -# InvokeMessage → service query → InvokeResultMessage +# GetMessage → store.get → GetResultMessage +# SetMessage → store.set → SetResultMessage +# ActionMessage → service action → ActionResultMessage +# QueryMessage → service query → QueryResultMessage @pytest.mark.asyncio @@ -172,44 +180,51 @@ async def test_dispatch_call_action(server): @pytest.mark.asyncio -async def test_dispatch_builtin_get_query(server): +async def test_dispatch_action_batches_store_updates(server): sent = [] server._transport.send = AsyncMock(side_effect=lambda m: sent.append(m)) + server._transport.send_nowait = MagicMock() await server._dispatch( - QueryMessage( - call_id="abc", - task_id="abc", - method="get", - args=["count"], - kwargs={}, + ActionMessage( + call_id="abc", task_id="abc", method="multi_set", args=[], kwargs={} ) ) + server._transport.send_nowait.assert_not_called() assert len(sent) == 1 - assert isinstance(sent[0], QueryResultMessage) + assert isinstance(sent[0], ActionResultMessage) assert sent[0].call_id == "abc" - assert sent[0].value == 0 + assert sent[0].updates == {"count": 7, "user.name": "Klaus"} @pytest.mark.asyncio -async def test_dispatch_builtin_set_action(server): +async def test_dispatch_set(server): sent = [] server._transport.send = AsyncMock(side_effect=lambda m: sent.append(m)) + server._transport.send_nowait = MagicMock() - await server._dispatch( - ActionMessage( - call_id="abc", - task_id="abc", - method="set", - args=["count", 7], - kwargs={}, - ) - ) + await server._dispatch(SetMessage(call_id="abc", path="count", value=7)) + assert len(sent) == 1 assert server._store.get("count") == 7 - assert isinstance(sent[0], ActionResultMessage) - assert sent[0].updates["count"] == 7 + assert isinstance(sent[0], SetResultMessage) + assert sent[0].call_id == "abc" + assert sent[0].updates == {"count": 7} + server._transport.send_nowait.assert_not_called() + + +@pytest.mark.asyncio +async def test_dispatch_set_root(server): + sent = [] + server._transport.send = AsyncMock(side_effect=lambda m: sent.append(m)) + + await server._dispatch(SetMessage(call_id="abc", path="", value={"count": 7})) + + assert len(sent) == 1 + assert server._store.get("count") == 7 + assert isinstance(sent[0], SetResultMessage) + assert sent[0].updates[""] == {"count": 7} @pytest.mark.asyncio diff --git a/remotestate-py/tests/test_service.py b/remotestate-py/tests/test_service.py index e608499..60b3e41 100644 --- a/remotestate-py/tests/test_service.py +++ b/remotestate-py/tests/test_service.py @@ -116,33 +116,31 @@ def invoke_query(service, method, args=None, kwargs=None): ) -# --- built-ins --- +# --- reserved names --- @pytest.mark.asyncio -async def test_builtin_get_query_works_on_base_service(store): - service = Service(store) - coro, _ = invoke_query(service, "get", args=["count"]) - result = await coro - assert result == 0 +async def test_get_and_set_are_available_for_custom_service_methods(store): + class MyService(Service): + @query + async def get(self): + return self.store.get("count") + @action + async def set(self, count: int): + self.store.set("count", count) -@pytest.mark.asyncio -async def test_builtin_set_action_works_on_base_service(store): - service = Service(store) - coro, _ = invoke_action(service, "set", args=["count", 7]) - await coro - assert store.get("count") == 7 + service = MyService(store) + query_coro, _ = invoke_query(service, "get") + assert await query_coro == 0 + action_coro, _ = invoke_action(service, "set", args=[7]) + await action_coro + assert store.get("count") == 7 -def test_builtin_method_names_are_reserved(): - with pytest.raises(TypeError, match="conflicts with a built-in"): - class BadService(Service): - @query - async def get(self): - return self.store.get("count") - with pytest.raises(TypeError, match="conflicts with a built-in"): +def test_notify_method_name_is_reserved(): + with pytest.raises(TypeError, match="conflicts with a reserved"): class BadNotifyService(Service): @action async def notify(self): @@ -178,6 +176,29 @@ async def test_action_batch_single_notify(store): assert cb.call_count == 1 +@pytest.mark.asyncio +async def test_action_does_not_notify_between_store_sets(store): + cb = MagicMock() + + class MyService(Service): + @action + async def multi_set(self): + self.store.set("count", 99) + cb.assert_not_called() + + self.store.set("user.name", "Klaus") + cb.assert_not_called() + + service = MyService(store) + store.subscribe(cb) + + coro, _ = invoke_action(service, "multi_set") + updates = await coro + + cb.assert_called_once_with({"count": 99, "user.name": "Klaus"}) + assert updates == {"count": 99, "user.name": "Klaus"} + + @pytest.mark.asyncio async def test_action_returns_updates(store): service = make_service(store) diff --git a/remotestate-py/tests/test_store.py b/remotestate-py/tests/test_store.py index dc35aa4..9ff0fbb 100644 --- a/remotestate-py/tests/test_store.py +++ b/remotestate-py/tests/test_store.py @@ -3,8 +3,6 @@ import pytest from pydantic import BaseModel -from remotestate.path import Property - # noinspection PyProtectedMember from remotestate.store import Store, _batch_pending_updates @@ -57,6 +55,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 +122,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" @@ -144,14 +190,29 @@ def factory(path): store.set("user.address.city", "Hamburg") assert calls == [ - (Property("user"),), - (Property("user"), Property("address")), + ("user",), + ("user", "address"), ] +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 == [(0,)] + assert store.state == [{"label": "foo"}] + + def test_set_default_factory_can_create_pydantic_objects(): def factory(path): - if path == (Property("user"),): + if path == ("user",): return User( name="", age=0, @@ -169,7 +230,7 @@ def factory(path): def test_set_default_factory_can_create_list_items(): def factory(path): - if path == (Property("items"),): + if path == ("items",): return [] return {} @@ -200,7 +261,7 @@ def test_set_sparse_list_index_raises_with_default_factory(): def factory(path): calls.append(path) - if path == (Property("items"),): + if path == ("items",): return [] return {} @@ -209,7 +270,7 @@ def factory(path): with pytest.raises(IndexError): store.set("items[1].label", "foo") - assert calls == [(Property("items"),)] + assert calls == [("items",)] def test_get_never_calls_default_factory(): @@ -238,6 +299,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 5ae8982..425f9f6 100644 --- a/remotestate-ts/CHANGES.md +++ b/remotestate-ts/CHANGES.md @@ -1,3 +1,22 @@ +## 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. +- `Store.set()` now uses a dedicated store `set` protocol message and receives + `set_result` updates instead of dispatching a service action named `set`. +- 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. +- Renamed the public path input alias from `PathLike` to `PathInput`, and added + `PathSegmentInput` for raw segment values. + ## Version 0.2.0 - Tightened the shared path grammar to a strict JSONPath subset without the diff --git a/remotestate-ts/README.md b/remotestate-ts/README.md index aeb9019..1f0491e 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,21 +180,25 @@ 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 | - -- `normalizePath(path)` validates a path-like value and returns a `Path` +| 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 `PathInput` value and returns a `Path` - `parsePath(path)` turns a strict string path into a `Path` and throws `SyntaxError` on malformed input - `formatPath(path)` turns parsed segments back into canonical path syntax - `getPathAt(value, path)` reads a nested value @@ -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/TODO.md b/remotestate-ts/TODO.md index bc42876..42141f2 100644 --- a/remotestate-ts/TODO.md +++ b/remotestate-ts/TODO.md @@ -3,9 +3,9 @@ ## New Features - [ ] Ease implementing the `RemoteStateClient` interface for zustand-users. - Create a new subpackage `zustand` for this and make zustand a peer dependency. + Create a new subpackage `zustand` for this and make zustand a peer dependency. - [ ] Make `react` a truly optional submodule. - No longer export it from maon module. + No longer export it from main module. - [x] Ease implementing the `RemoteStateClient` interface in general. See example in `README.md`, which is quite complex. - [x] Require an explicit WebSocket URL or local fallback client. diff --git a/remotestate-ts/package-lock.json b/remotestate-ts/package-lock.json index f15869d..ab1c6a6 100644 --- a/remotestate-ts/package-lock.json +++ b/remotestate-ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "remotestate", - "version": "0.2.0", + "version": "0.3.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remotestate", - "version": "0.2.0", + "version": "0.3.0-dev.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/remotestate-ts/package.json b/remotestate-ts/package.json index fdd7d98..41c845b 100644 --- a/remotestate-ts/package.json +++ b/remotestate-ts/package.json @@ -1,6 +1,6 @@ { "name": "remotestate", - "version": "0.2.0", + "version": "0.3.0-dev.0", "description": "Python state, React UI.", "type": "module", "main": "./dist/remotestate.js", diff --git a/remotestate-ts/src/lib/index.ts b/remotestate-ts/src/lib/index.ts index b6de351..e1e30d3 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, PathInput, PathSegment, PathSegmentInput } from "./path"; export type { IncomingMessage, OutgoingMessage, diff --git a/remotestate-ts/src/lib/local.ts b/remotestate-ts/src/lib/local.ts index 51c1d5f..6c6f096 100644 --- a/remotestate-ts/src/lib/local.ts +++ b/remotestate-ts/src/lib/local.ts @@ -1,5 +1,4 @@ import type { RemoteStateClient } from "./client"; -import { normalizePath, PathLike } from "./path"; import { createRemoteTaskStore, type WritableTaskStore } from "./tasks"; import { type ActionMethod, @@ -90,10 +89,7 @@ export function createLocalStateClient( const ownsTaskStore = options.tasks === undefined; const actionHandlers: Partial< Record Awaitable> - > = { - ...actions, - set: createLocalSetAction(store), - }; + > = actions; const queryHandlers: Partial< Record Awaitable> > = queries; @@ -130,10 +126,3 @@ export function createLocalStateClient( }, }; } - -function createLocalSetAction(store: Store) { - const set = async (path: PathLike, value: unknown) => { - await store.set(normalizePath(path), value); - }; - return set as (...args: unknown[]) => Awaitable; -} diff --git a/remotestate-ts/src/lib/path.ts b/remotestate-ts/src/lib/path.ts index 60e446d..489803f 100644 --- a/remotestate-ts/src/lib/path.ts +++ b/remotestate-ts/src/lib/path.ts @@ -4,42 +4,53 @@ 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 raw value accepted as one path segment. + */ +export type PathSegmentInput = PathSegment; /** - * A value of type ``PathLike`` can be normalized into a value of type `Path`. + * A raw value accepted anywhere a RemoteState path is needed. */ -export type PathLike = string | RelativePath | Path; +export type PathInput = string | readonly PathSegmentInput[]; -const PATH_SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const IDENTIFIER_RE = /^[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. + * Normalizes a path input 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. + * @param path A path input 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[]; +export function normalizePath(path: PathInput): Path { + let rawPath: readonly PathSegmentInput[]; if (typeof path === "string") { rawPath = parsePath(path); } else if (Array.isArray(path)) { @@ -58,7 +69,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 +81,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 +93,28 @@ 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[] = []; + if (path === "") { + return []; + } + const first = readIdentifier(path, 0); - if (!first) { + let segments: PathSegment[]; + let position: number; + + if (first) { + segments = [first.value]; + position = first.nextIndex; + } else if (path[0] === "[") { + const bracketSegment = readBracketSegment(path, 0); + if (!bracketSegment) { + throw new SyntaxError(INVALID_PATH_MESSAGE); + } + segments = [bracketSegment.value]; + position = bracketSegment.nextIndex; + } else { throw new SyntaxError(INVALID_PATH_MESSAGE); } - segments.push(first.value); - let position = first.nextIndex; while (position < path.length) { const next = path[position]; if (next === ".") { @@ -121,12 +151,14 @@ 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 (PATH_SEGMENT_PATTERN.test(segment)) { + } else if (index === 0 && IDENTIFIER_RE.test(segment)) { + result += segment; + } else if (IDENTIFIER_RE.test(segment)) { result += "." + segment; } else { result += "[" + JSON.stringify(segment) + "]"; @@ -142,7 +174,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 +198,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) { @@ -202,10 +234,7 @@ export function setPathAt( * @param path The full path to compare against. * @returns Whether `prefix` is the same path or an ancestor of `path`. */ -export function isPathPrefixSegments( - prefix: RelativePath, - path: RelativePath, -): boolean { +export function isPathPrefixSegments(prefix: Path, path: Path): boolean { if (prefix.length > path.length) { return false; } @@ -228,12 +257,9 @@ 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 { +export function pathSegmentsAfter(prefix: Path, path: Path): Path { return path.slice(prefix.length); } @@ -283,6 +309,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 +322,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); @@ -330,11 +352,16 @@ function readIdentifier( } function isIdentifierStart(char: string): boolean { - return /[a-zA-Z_]/.test(char); + const code = char.charCodeAt(0); + return ( + char === "_" || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) + ); } function isIdentifierPart(char: string): boolean { - return /[a-zA-Z0-9_]/.test(char); + return isIdentifierStart(char) || isDigit(char); } function readBracketSegment( @@ -408,69 +435,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; } @@ -483,12 +485,23 @@ function readUnicodeCodeUnit( return null; } const hex = path.slice(start, start + 4); - if (!/^[0-9a-fA-F]{4}$/.test(hex)) { - return null; + for (let index = 0; index < hex.length; index += 1) { + if (!isHexDigit(hex[index])) { + return null; + } } return { codeUnit: Number.parseInt(hex, 16), nextIndex: start + 4 }; } -function isDigit(char: string): boolean { - return char >= "0" && char <= "9"; +function isDigit(char: string | undefined): boolean { + return char !== undefined && char >= "0" && char <= "9"; +} + +function isHexDigit(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x46) || + (code >= 0x61 && code <= 0x66) + ); } diff --git a/remotestate-ts/src/lib/protocol.ts b/remotestate-ts/src/lib/protocol.ts index 6a514d4..b388f9d 100644 --- a/remotestate-ts/src/lib/protocol.ts +++ b/remotestate-ts/src/lib/protocol.ts @@ -22,6 +22,31 @@ export interface GetMessage { path: string; } +/** + * Write one value at a store path. + */ +export interface SetMessage { + /** + * Protocol discriminator for store set requests. + */ + type: "set"; + + /** + * Internal call ID used to correlate the response. + */ + call_id: string; + + /** + * State path to write. + */ + path: string; + + /** + * Value to assign at the path. + */ + value: unknown; +} + /** * Invoke a state-mutating service method. */ @@ -141,6 +166,26 @@ export interface ActionResultMessage { updates: Record; // path --> value mapping } +/** + * Return the batched store updates produced by a store set request. + */ +export interface SetResultMessage { + /** + * Protocol discriminator for store set results. + */ + type: "set_result"; + + /** + * Internal call ID from the request. + */ + call_id: string; + + /** + * Mapping from changed state paths to their latest values. + */ + updates: Record; +} + /** * Return the result of a previous `QueryMessage`. */ @@ -238,13 +283,18 @@ export interface ErrorMessage { /** * Any message the Remote State bridge can send to Python. */ -export type IncomingMessage = GetMessage | ActionMessage | QueryMessage; +export type IncomingMessage = + | GetMessage + | SetMessage + | ActionMessage + | QueryMessage; /** * Any message Python can send back to the Remote State bridge. */ export type OutgoingMessage = | GetResultMessage + | SetResultMessage | ActionResultMessage | QueryResultMessage | TaskUpdateMessage diff --git a/remotestate-ts/src/lib/react/hooks.ts b/remotestate-ts/src/lib/react/hooks.ts index 70d3847..324b77c 100644 --- a/remotestate-ts/src/lib/react/hooks.ts +++ b/remotestate-ts/src/lib/react/hooks.ts @@ -7,11 +7,13 @@ import { useSyncExternalStore, } from "react"; import { type RemoteStateClient } from "../client"; -import { normalizePath, type Path, PathLike } from "../path"; +import { normalizePath, type Path, type PathInput } from "../path"; 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: PathInput = ROOT_PATH, ): T | undefined { const parsedPath = useNormalizedPath(path); const store = useRemoteStore(); @@ -96,14 +99,14 @@ export function useRemoteStateValue( * @returns A tuple containing the current value and an async setter. */ export function useRemoteState( - path: PathLike, + path: PathInput, ): [T | undefined, (next: SetStateValue) => Promise]; export function useRemoteState( - path: PathLike, + path: PathInput, initialValue: T, ): [T, (next: SetStateValue) => Promise]; export function useRemoteState( - path: PathLike, + path: PathInput, initialValue?: T, ): [T | undefined, (next: SetStateValue) => Promise] { const parsedPath = useNormalizedPath(path); @@ -185,6 +188,6 @@ export function useRemoteTasks(): readonly TaskState[] { // --- Helper hooks -export function useNormalizedPath(path: PathLike): Path { +export function useNormalizedPath(path: PathInput = ROOT_PATH): Path { return useMemo(() => normalizePath(path), [path]); } diff --git a/remotestate-ts/src/lib/service.ts b/remotestate-ts/src/lib/service.ts index 8f88176..d028497 100644 --- a/remotestate-ts/src/lib/service.ts +++ b/remotestate-ts/src/lib/service.ts @@ -136,10 +136,11 @@ export class ServiceImpl implements Service { if (!("call_id" in msg) || msg.call_id !== callId) { return; } - unsubscribe(); if (msg.type === "query_result") { + unsubscribe(); resolve(msg.value); } else if (msg.type === "error") { + unsubscribe(); reject(new Error(msg.message)); } }); diff --git a/remotestate-ts/src/lib/store.ts b/remotestate-ts/src/lib/store.ts index b46d9f4..f59039e 100644 --- a/remotestate-ts/src/lib/store.ts +++ b/remotestate-ts/src/lib/store.ts @@ -1,4 +1,8 @@ -import type { ActionResultMessage, GetResultMessage } from "./protocol"; +import type { + ActionResultMessage, + GetResultMessage, + SetResultMessage, +} from "./protocol"; import { getPathAt, formatPath, @@ -12,6 +16,8 @@ import { import type { Store, Transport } from "./types"; import { DebugLog, getDebugLog } from "./debug"; +const ROOT_PATH: Path = []; + type StoreListener = () => void; type StoreSubscription = { path: string; @@ -49,8 +55,8 @@ export class StoreImpl implements Store { this.unsubscribeTransport = transport.subscribe((msg) => { if (msg.type === "get_result") { this._onGetResult(msg); - } else if (msg.type === "action_result") { - this._onActionResult(msg); + } else if (msg.type === "action_result" || msg.type === "set_result") { + this._onUpdateResult(msg); } }); } @@ -58,19 +64,20 @@ 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. + * Set a state value through the backend store protocol. * - * @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. + * @returns A promise that resolves after the set result is applied. */ set(path: Path, value: unknown): Promise { return new Promise((resolve, reject) => { @@ -80,7 +87,7 @@ export class StoreImpl implements Store { return; } unsubscribe(); - if (msg.type === "action_result") { + if (msg.type === "set_result") { resolve(); } else if (msg.type === "error") { reject(new Error(msg.message)); @@ -88,11 +95,10 @@ export class StoreImpl implements Store { }); this.transport.send({ - type: "action", + type: "set", call_id: callId, - method: "set", - args: [formatPath(path), value], - kwargs: {}, + path: formatPath(path), + value, }); }); } @@ -100,7 +106,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 +127,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. */ @@ -150,7 +156,7 @@ export class StoreImpl implements Store { this._notify([msg.path]); } - private _onActionResult(msg: ActionResultMessage): void { + private _onUpdateResult(msg: ActionResultMessage | SetResultMessage): void { const changedPaths = Object.keys(msg.updates); for (const [path, value] of Object.entries(msg.updates)) { this._applyUpdate(path, value); @@ -162,9 +168,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 +175,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..2d8a6df 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. + * Remote stores dispatch a store `set` message and resolve after the + * resulting update is applied. Local stores 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/local.test.ts b/remotestate-ts/src/test/local.test.ts index 89a73cb..7f2ad1c 100644 --- a/remotestate-ts/src/test/local.test.ts +++ b/remotestate-ts/src/test/local.test.ts @@ -35,30 +35,6 @@ describe("createLocalStateClient", () => { await expect(client.query("count")).resolves.toBe(2); }); - it("delegates the built-in set action to the store", async () => { - const set = vi.fn(); - const client = createLocalStateClient({ - store: { - ...createStore(), - set, - }, - }); - - await client.action("set", ["count", 7]); - - expect(set).toHaveBeenCalledWith(["count"], 7); - }); - - it("rejects built-in set action without a string/array path", async () => { - const client = createLocalStateClient({ - store: createStore(), - }); - - await expect(client.action("set", [7, "count"])).rejects.toThrow( - "RemoteState path must be a string or array, but got number", - ); - }); - it("throws for unsupported local methods", async () => { const client = createLocalStateClient({ store: createStore(), diff --git a/remotestate-ts/src/test/path.test.ts b/remotestate-ts/src/test/path.test.ts index 16a28b1..c7048b0 100644 --- a/remotestate-ts/src/test/path.test.ts +++ b/remotestate-ts/src/test/path.test.ts @@ -6,8 +6,10 @@ import { parsePath, setPathAt, type Path, - type PathLike, + type PathInput, + type PathSegmentInput, } from "../lib"; +import { pathsOverlap } from "../lib/path"; describe("parsePath", () => { it("parses dotted and indexed paths", () => { @@ -29,18 +31,49 @@ describe("parsePath", () => { expect(parsePath('user[""]')).toEqual(["user", ""]); }); + it("parses bracketed string key escapes", () => { + expect(parsePath('user["line\\nbreak"]')).toEqual(["user", "line\nbreak"]); + expect(parsePath('user["tab\\tseparated"]')).toEqual([ + "user", + "tab\tseparated", + ]); + expect(parsePath('user["quote\\"slash\\\\"]')).toEqual([ + "user", + 'quote"slash\\', + ]); + expect(parsePath("user['double\\\"quote']")).toEqual([ + "user", + 'double"quote', + ]); + expect(parsePath('user["emoji \\uD83D\\uDE00"]')).toEqual([ + "user", + "emoji " + String.fromCodePoint(0x1f600), + ]); + }); + it("parses a single root segment", () => { 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); + expect(() => parsePath(".items")).toThrow(SyntaxError); }); it("throws on non-canonical integer syntax", () => { @@ -66,6 +99,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", () => { @@ -76,8 +120,8 @@ describe("normalizePath", () => { expectTypeOf(normalized).toEqualTypeOf(); }); - it("accepts an already parsed PathLike value without cloning", () => { - const path = ["items", 1, "label"] as const satisfies PathLike; + it("accepts an already parsed PathInput value without cloning", () => { + const path = ["items", 1, "label"] as const satisfies PathInput; expect(normalizePath(path)).toBe(path); }); @@ -96,14 +140,23 @@ 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("rejects paths that do not start with a non-empty property name", () => { - expect(() => normalizePath([1, "label"])).toThrow(SyntaxError); - expect(() => normalizePath(["", "label"])).toThrow(SyntaxError); + it("accepts root index and string-key paths", () => { + expect(normalizePath([1, "label"])).toEqual([1, "label"]); + expect(normalizePath(["", "label"])).toEqual(["", "label"]); + }); + + it("exports a PathSegmentInput type for raw segment values", () => { + const segment = 1 satisfies PathSegmentInput; + + expect(segment).toBe(1); + }); + + it("rejects invalid array-form path segments", () => { expect(() => normalizePath(["items", 1.5])).toThrow(SyntaxError); expect(() => normalizePath(["items", -1, "label"])).toThrow(SyntaxError); }); @@ -173,3 +226,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..0344a8f 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 PathInput, 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?: PathInput) => 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/service.test.ts b/remotestate-ts/src/test/service.test.ts index 716542d..d4761af 100644 --- a/remotestate-ts/src/test/service.test.ts +++ b/remotestate-ts/src/test/service.test.ts @@ -166,6 +166,40 @@ describe("ServiceImpl", () => { await expect(promise).resolves.toBe(15.0); }); + it("ignores progress updates before query_result", async () => { + const transport = mockTransportWithHandler(); + const taskStore = new TaskStoreImpl(); + const taskController = new TaskController( + taskStore, + asTransport(transport), + ); + const service = new ServiceImpl(asTransport(transport), taskController); + + const promise = service.query( + "compute", + [5.0], + {}, + { taskId: "compute-task" }, + ); + const sentMsg = transport.send.mock.calls[0][0] as { call_id: string }; + + transport._triggerMessage({ + type: "update_task", + call_id: sentMsg.call_id, + task_id: "compute-task", + method: "compute", + status: "running", + progress: 50, + }); + transport._triggerMessage({ + type: "query_result", + call_id: sentMsg.call_id, + value: 15.0, + }); + + await expect(promise).resolves.toBe(15.0); + }); + it("rejects on error message", async () => { const transport = mockTransportWithHandler(); const service = new ServiceImpl(asTransport(transport)); diff --git a/remotestate-ts/src/test/store.test.ts b/remotestate-ts/src/test/store.test.ts index e7b9a8a..b730364 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)); @@ -227,9 +280,25 @@ describe("StoreImpl", () => { }); expect(listener).toHaveBeenCalledOnce(); - expect( - store.get(["processRequests", "sleep_a_while", "inputs"]), - ).toEqual({ duration: 123 }); + expect(store.get(["processRequests", "sleep_a_while", "inputs"])).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", () => { @@ -248,9 +317,9 @@ describe("StoreImpl", () => { }); expect(listener).toHaveBeenCalledOnce(); - expect( - store.get(["processRequests", "sleep_a_while", "inputs"]), - ).toEqual({ duration: 123 }); + expect(store.get(["processRequests", "sleep_a_while", "inputs"])).toEqual({ + duration: 123, + }); }); it("refreshes cached child values from a parent action update", () => { @@ -301,7 +370,18 @@ describe("StoreImpl", () => { ); }); - it("sets a path through the built-in set action", () => { + 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 store set message", () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -309,15 +389,29 @@ describe("StoreImpl", () => { expect(transport.send).toHaveBeenCalledWith( expect.objectContaining({ - type: "action", - method: "set", - args: ["count", 3], - kwargs: {}, + type: "set", + path: "count", + value: 3, }), ); }); - it("resolves set after matching action result", async () => { + it("sets the root path through the store set message", () => { + const transport = mockTransportWithHandler(); + const store = new StoreImpl(asTransport(transport)); + + void store.set([], { count: 3 }); + + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: "set", + path: "", + value: { count: 3 }, + }), + ); + }); + + it("resolves set after matching set result", async () => { const transport = mockTransportWithHandler(); const store = new StoreImpl(asTransport(transport)); @@ -325,7 +419,7 @@ describe("StoreImpl", () => { const sentMsg = transport.send.mock.calls[0][0] as { call_id: string }; transport._triggerMessage({ - type: "action_result", + type: "set_result", call_id: sentMsg.call_id, updates: { count: 3 }, });