diff --git a/src/harel/cli.py b/src/harel/cli.py
index b9403ef..7e1c835 100644
--- a/src/harel/cli.py
+++ b/src/harel/cli.py
@@ -24,7 +24,7 @@
from .errors import HarelError
from .instance import Instance, Status
from .model import Machine
-from .store import Store, StoreState
+from .store import Store, StoreState, open_store
# Exit codes (SPEC §13.2).
EXIT_OK = 0
@@ -39,7 +39,7 @@ def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
store_dir = args.store or os.environ.get("HAREL_STORE", "./.harel")
try:
- return int(args.cmd(args, Store(store_dir)))
+ return int(args.cmd(args, open_store(store_dir)))
except HarelError as exc:
print(str(exc), file=sys.stderr)
return EXIT_OTHER
@@ -47,7 +47,11 @@ def main(argv: list[str] | None = None) -> int:
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="harel", description="harel statechart engine")
- p.add_argument("--store", default=None, help="store directory (default ./.harel)")
+ p.add_argument(
+ "--store",
+ default=None,
+ help="store spec: file:
| mem: | sqlite: (default ./.harel)",
+ )
p.add_argument(
"--version", action="version", version=f"harel {_pkg_version()}"
)
diff --git a/src/harel/store.py b/src/harel/store.py
index 3a8bdbc..b8cca45 100644
--- a/src/harel/store.py
+++ b/src/harel/store.py
@@ -1,19 +1,31 @@
-"""File-backed store for the CLI (SPEC §13.1).
+"""Store backends for the CLI (SPEC §8, §13.1).
-A store is a directory (``--store ``, default ``$HAREL_STORE`` or
-``./.harel``) holding the registered definitions, instance snapshots, and the
-virtual clock. The on-disk layout is an implementation detail; the normative
-contract is CLI behaviour + the JSON I/O (§13.4). State-changing commands load
-the affected instances, run all instances to quiescence, and persist atomically.
+A store holds the registered definitions, instance snapshots, the virtual clock,
+and the processing mode (§14). It is selected by a ``--store `` scheme:
+
+- ``file:`` (or a bare ````) — JSON snapshot files under a directory.
+ **Default** (``./.harel``).
+- ``mem:`` — in-memory, ephemeral; only meaningful within a single process
+ (e.g. one ``run`` batch/streaming session, §13.7, or a test).
+- ``sqlite:`` — a single-file SQLite database.
+
+All backends are behaviorally identical (same CLI results, same snapshot JSON, §8);
+the on-disk/in-memory layout is an implementation detail. ``open_store(spec)``
+parses the scheme.
"""
from __future__ import annotations
+import abc
+import copy
import json
+import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
+_SCHEMES = {"file", "mem", "sqlite"}
+
@dataclass
class StoreState:
@@ -24,7 +36,38 @@ class StoreState:
mode: str = "auto" # processing mode, auto|manual (SPEC §14)
-class Store:
+def _state_from_parts(
+ defs: dict[str, Any] | None,
+ instances: list[dict[str, Any]] | None,
+ meta: dict[str, Any] | None,
+) -> StoreState:
+ meta = meta or {}
+ return StoreState(
+ defs=defs or {},
+ instances=instances or [],
+ now=int(meta.get("now", 0)),
+ spawn_counters=dict(meta.get("spawn_counters") or {}),
+ mode=str(meta.get("mode", "auto")),
+ )
+
+
+def _meta_json(state: StoreState) -> dict[str, Any]:
+ return {"now": state.now, "spawn_counters": state.spawn_counters, "mode": state.mode}
+
+
+class Store(abc.ABC):
+ """The store adapter interface (SPEC §8): load/save a ``StoreState``."""
+
+ @abc.abstractmethod
+ def load(self) -> StoreState: ...
+
+ @abc.abstractmethod
+ def save(self, state: StoreState) -> None: ...
+
+
+class FileStore(Store):
+ """JSON snapshot files under a directory (``file:`` / bare ````)."""
+
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
@@ -38,15 +81,10 @@ def _read_json(self, name: str) -> Any:
return json.loads(p.read_text(encoding="utf-8"))
def load(self) -> StoreState:
- defs = self._read_json("defs.json") or {}
- instances = self._read_json("instances.json") or []
- meta = self._read_json("meta.json") or {}
- return StoreState(
- defs=defs,
- instances=instances,
- now=int(meta.get("now", 0)),
- spawn_counters=dict(meta.get("spawn_counters") or {}),
- mode=str(meta.get("mode", "auto")),
+ return _state_from_parts(
+ self._read_json("defs.json"),
+ self._read_json("instances.json"),
+ self._read_json("meta.json"),
)
def save(self, state: StoreState) -> None:
@@ -58,13 +96,80 @@ def save(self, state: StoreState) -> None:
json.dumps(state.instances, indent=2), encoding="utf-8"
)
(self.path / "meta.json").write_text(
- json.dumps(
- {
- "now": state.now,
- "spawn_counters": state.spawn_counters,
- "mode": state.mode,
- },
- indent=2,
- ),
- encoding="utf-8",
+ json.dumps(_meta_json(state), indent=2), encoding="utf-8"
)
+
+
+class MemoryStore(Store):
+ """In-process, ephemeral store (``mem:``); not shared across processes."""
+
+ def __init__(self) -> None:
+ self._state: StoreState = StoreState()
+
+ def load(self) -> StoreState:
+ return copy.deepcopy(self._state)
+
+ def save(self, state: StoreState) -> None:
+ self._state = copy.deepcopy(state)
+
+
+class SqliteStore(Store):
+ """A single-file SQLite database (``sqlite:``); ``sqlite3`` is stdlib."""
+
+ def __init__(self, path: str | Path) -> None:
+ self.path = Path(path)
+ if self.path.parent and str(self.path.parent) not in ("", "."):
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self._conn = sqlite3.connect(str(self.path))
+ self._conn.execute(
+ "CREATE TABLE IF NOT EXISTS harel_state ("
+ " key TEXT PRIMARY KEY,"
+ " value TEXT NOT NULL"
+ ")"
+ )
+ self._conn.commit()
+
+ def _get(self, key: str) -> str | None:
+ row = self._conn.execute(
+ "SELECT value FROM harel_state WHERE key = ?", (key,)
+ ).fetchone()
+ return row[0] if row is not None else None
+
+ def _set(self, key: str, value: str) -> None:
+ self._conn.execute(
+ "INSERT INTO harel_state (key, value) VALUES (?, ?) "
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ (key, value),
+ )
+
+ def load(self) -> StoreState:
+ defs = json.loads(self._get("defs") or "{}")
+ instances = json.loads(self._get("instances") or "[]")
+ meta = json.loads(self._get("meta") or "{}")
+ return _state_from_parts(defs, instances, meta)
+
+ def save(self, state: StoreState) -> None:
+ self._set("defs", json.dumps(state.defs))
+ self._set("instances", json.dumps(state.instances))
+ self._set("meta", json.dumps(_meta_json(state)))
+ self._conn.commit()
+
+ def close(self) -> None:
+ self._conn.close()
+
+
+def _split_scheme(spec: str) -> tuple[str, str]:
+ scheme, sep, rest = spec.partition(":")
+ if sep and scheme in _SCHEMES:
+ return scheme, rest
+ return "file", spec
+
+
+def open_store(spec: str) -> Store:
+ """Select a backend from a ``--store `` scheme (SPEC §13.1)."""
+ scheme, rest = _split_scheme(spec)
+ if scheme == "mem":
+ return MemoryStore()
+ if scheme == "sqlite":
+ return SqliteStore(rest)
+ return FileStore(rest) # "file:" or a bare ""
diff --git a/tests/test_stores.py b/tests/test_stores.py
new file mode 100644
index 0000000..d8d8ebf
--- /dev/null
+++ b/tests/test_stores.py
@@ -0,0 +1,193 @@
+"""Store backends + the --store scheme (SPEC §8, §13.1).
+
+All backends (``file``, ``mem``, ``sqlite``) round-trip an instance identically;
+``sqlite:`` persists across CLI invocations; ``mem:`` is isolated per process (one
+``run`` session). ``open_store`` parses the scheme.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+
+import pytest
+
+import harel
+import harel.cli as cli
+from harel.store import FileStore, MemoryStore, SqliteStore, StoreState, open_store
+
+TURNSTILE = """\
+id: turnstile
+events:
+ coin: { payload: { amount: { type: int, required: true } } }
+ push: {}
+top:
+ esvs:
+ fare: { type: int, init: 50 }
+ initial: { transition_to: locked }
+ states:
+ locked:
+ on_events:
+ coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" }
+ unlocked:
+ on_events:
+ push: { transition_to: locked }
+"""
+
+
+# --- open_store scheme parsing ----------------------------------------------
+def test_open_store_parses_each_scheme(tmp_path) -> None:
+ assert isinstance(open_store(str(tmp_path / "f")), FileStore)
+ assert isinstance(open_store(f"file:{tmp_path / 'f2'}"), FileStore)
+ assert isinstance(open_store("mem:"), MemoryStore)
+ assert isinstance(open_store(f"sqlite:{tmp_path / 's.db'}"), SqliteStore)
+
+
+def test_open_store_bare_path_is_file_for_backcompat(tmp_path) -> None:
+ store = open_store(str(tmp_path / "bare"))
+ assert isinstance(store, FileStore)
+
+
+# --- round-trip parity across backends --------------------------------------
+def _state() -> StoreState:
+ host = harel.Host()
+ host.register_all(harel.load_definitions(TURNSTILE))
+ host.create_root(host.machines["turnstile"], "t1")
+ host.run_to_quiescence()
+ return StoreState(
+ defs={"turnstile@1": TURNSTILE},
+ instances=host.snapshot_all(),
+ now=12_000,
+ spawn_counters={"t1": 3},
+ mode="manual",
+ )
+
+
+@pytest.mark.parametrize(
+ "factory",
+ [
+ pytest.param(lambda tmp: FileStore(tmp / "f"), id="file"),
+ pytest.param(lambda tmp: MemoryStore(), id="mem"),
+ pytest.param(lambda tmp: SqliteStore(tmp / "s.db"), id="sqlite"),
+ ],
+)
+def test_round_trip_state_identical(factory, tmp_path) -> None:
+ store = factory(tmp_path)
+ store.save(_state())
+ loaded = store.load()
+ # the snapshot JSON (§8) is identical across backends.
+ assert loaded.defs == {"turnstile@1": TURNSTILE}
+ assert loaded.instances == _state().instances
+ assert loaded.now == 12_000
+ assert loaded.spawn_counters == {"t1": 3}
+ assert loaded.mode == "manual"
+
+
+def test_file_store_writes_snapshot_json_files(tmp_path) -> None:
+ store = FileStore(tmp_path / "f")
+ store.save(_state())
+ files = sorted(p.name for p in (tmp_path / "f").iterdir())
+ assert files == ["defs.json", "instances.json", "meta.json"]
+ snap = json.loads((tmp_path / "f" / "instances.json").read_text())
+ assert snap[0]["def_id"] == "turnstile"
+
+
+def test_sqlite_store_persists_across_handles(tmp_path) -> None:
+ path = tmp_path / "s.db"
+ SqliteStore(path).save(_state())
+ # a fresh handle on the same file reads it back (a new CLI invocation).
+ loaded = SqliteStore(path).load()
+ assert loaded.instances == _state().instances
+ assert loaded.mode == "manual"
+
+
+def test_memory_store_is_ephemeral_per_instance() -> None:
+ a = MemoryStore()
+ a.save(_state())
+ b = MemoryStore() # a separate process has no state
+ assert b.load().instances == []
+
+
+# --- end-to-end through the CLI ---------------------------------------------
+def _run(
+ store_spec: str,
+ argv: list[str],
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> tuple[int, str]:
+ rc = cli.main(["--store", store_spec, *argv])
+ return rc, capsys.readouterr().out
+
+
+def _run_batch(
+ store_spec: str,
+ lines: list[list[str]],
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> tuple[int, list[dict]]:
+ stdin = "".join(json.dumps(line) + "\n" for line in lines)
+ monkeypatch.setattr("sys.stdin", io.StringIO(stdin))
+ rc = cli.main(["--store", store_spec, "run", "-"])
+ out = capsys.readouterr().out
+ return rc, [json.loads(x) for x in out.splitlines() if x.strip()]
+
+
+def test_mem_store_holds_state_within_one_run_session(tmp_path, monkeypatch, capsys):
+ machine = tmp_path / "m.yaml"
+ machine.write_text(TURNSTILE)
+ # one process: state persists across batch lines via the in-memory store.
+ rc, results = _run_batch(
+ "mem:",
+ [
+ ["new", "t1", str(machine)],
+ ["send", "t1", "coin", "--payload", "amount=100"],
+ ["state", "t1"],
+ ],
+ monkeypatch,
+ capsys,
+ )
+ assert rc == 0
+ assert results[0]["result"]["config"] == ["locked"]
+ assert results[1]["result"]["config"] == ["unlocked"]
+ assert results[2]["result"]["config"] == ["unlocked"]
+
+
+def test_mem_store_does_not_persist_across_invocations(tmp_path, monkeypatch, capsys):
+ machine = tmp_path / "m.yaml"
+ machine.write_text(TURNSTILE)
+ rc, _ = _run("mem:", ["new", "t1", str(machine)], monkeypatch, capsys)
+ assert rc == 0
+ # a separate process: the mem store is empty, so the instance is gone.
+ rc, _ = _run("mem:", ["state", "t1"], monkeypatch, capsys)
+ assert rc == 4 # not found
+
+
+def test_sqlite_store_persists_across_cli_invocations(tmp_path, monkeypatch, capsys):
+ machine = tmp_path / "m.yaml"
+ machine.write_text(TURNSTILE)
+ db = f"sqlite:{tmp_path / 's.db'}"
+ rc, out = _run(db, ["new", "t1", str(machine), "--json"], monkeypatch, capsys)
+ assert rc == 0 and json.loads(out)["config"] == ["locked"]
+ rc, out = _run(
+ db, ["send", "t1", "coin", "--payload", "amount=100", "--json"], monkeypatch, capsys
+ )
+ assert rc == 0 and json.loads(out)["config"] == ["unlocked"]
+ # a third invocation reads the persisted state.
+ rc, out = _run(db, ["state", "t1", "--json"], monkeypatch, capsys)
+ assert rc == 0 and json.loads(out)["config"] == ["unlocked"]
+
+
+def test_backends_produce_identical_cli_results(tmp_path, monkeypatch, capsys):
+ machine = tmp_path / "m.yaml"
+ machine.write_text(TURNSTILE)
+
+ def drive(spec: str) -> list[dict]:
+ _run(spec, ["new", "t1", str(machine)], monkeypatch, capsys)
+ _, out = _run(
+ spec, ["send", "t1", "coin", "--payload", "amount=100", "--json"], monkeypatch, capsys
+ )
+ return json.loads(out)
+
+ file_result = drive(str(tmp_path / "file"))
+ sqlite_result = drive(f"sqlite:{tmp_path / 's.db'}")
+ assert file_result == sqlite_result