diff --git a/.github/workflows/flybot-site-deploy.yml b/.github/workflows/flybot-site-deploy.yml
index 183ab06..4b03344 100644
--- a/.github/workflows/flybot-site-deploy.yml
+++ b/.github/workflows/flybot-site-deploy.yml
@@ -6,7 +6,7 @@ on:
- 'flybot-site-v*'
env:
- AWS_REGION: ap-southeast-1
+ AWS_REGION: ${{ vars.AWS_REGION }}
ECR_REPOSITORY: flybot-site
jobs:
diff --git a/.github/workflows/pull-playground-deploy.yml b/.github/workflows/pull-playground-deploy.yml
new file mode 100644
index 0000000..6c2574a
--- /dev/null
+++ b/.github/workflows/pull-playground-deploy.yml
@@ -0,0 +1,76 @@
+name: Deploy Pull Playground
+
+on:
+ push:
+ tags:
+ - 'pull-playground-v*'
+
+env:
+ AWS_REGION: ${{ vars.AWS_REGION }}
+ S3_BUCKET: ${{ vars.PLAYGROUND_S3_BUCKET }}
+ CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.PLAYGROUND_CF_DISTRIBUTION_ID }}
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+
+ - name: Setup Clojure
+ uses: DeLaGuardo/setup-clojure@12.5
+ with:
+ cli: latest
+ bb: latest
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Cache deps
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.m2/repository
+ ~/.gitlibs
+ ~/.npm
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/deps.edn', '**/package-lock.json') }}
+
+ - name: Configure AWS credentials (OIDC)
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: Build SPA
+ working-directory: examples/pull-playground
+ run: |
+ npm install
+ clojure -M:cljs -m shadow.cljs.devtools.cli release app
+
+ - name: Deploy to S3
+ run: |
+ aws s3 sync examples/pull-playground/resources/public s3://$S3_BUCKET --delete
+
+ - name: Invalidate CloudFront cache
+ run: |
+ aws cloudfront create-invalidation \
+ --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
+ --paths "/*"
+
+ - name: Summary
+ run: |
+ echo "### Deployment Complete :rocket:" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "- **Tag:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **URL:** https://pattern.flybot.sg" >> $GITHUB_STEP_SUMMARY
diff --git a/examples/pull-playground/CLAUDE.md b/examples/pull-playground/CLAUDE.md
new file mode 100644
index 0000000..9603609
--- /dev/null
+++ b/examples/pull-playground/CLAUDE.md
@@ -0,0 +1,126 @@
+# Pull Playground
+
+Interactive SPA for learning pull patterns. Sandbox mode runs entirely in-browser (SCI); Remote mode talks to a demo server.
+
+## Quick Start
+
+```bash
+# Single terminal — nREPL with CLJ + CLJS support
+bb dev examples/pull-playground
+```
+
+```clojure
+;; In REPL: start shadow-cljs (same JVM)
+(shadow.cljs.devtools.server/start!)
+(shadow.cljs.devtools.api/watch :app) ; dev server on 3001
+
+;; Backend server (needed for Remote mode only)
+(start!) ; server on 8081
+(stop!)
+(restart!)
+```
+
+### Ports
+
+| Port | Purpose |
+|------|---------|
+| 3001 | shadow-cljs dev server (hot reload) |
+| 8081 | Backend API (Remote mode) |
+
+## Architecture
+
+### dispatch-of + pull-api (effects-as-data)
+
+The app uses a custom dispatch pattern — NOT Replicant's built-in action system. Components close over `dispatch!` and call it directly with effect maps:
+
+```clojure
+;; Views dispatch effect maps
+(dispatch! {:db state/set-loading ; pure state update
+ :pull (:pattern-text db)}) ; execute pattern
+
+(dispatch! {:db #(state/set-mode % :remote)
+ :nav :remote ; pushState URL
+ :pull :init}) ; fetch remote data
+```
+
+### Effect types
+
+| Effect | Value | What happens |
+|--------|-------|--------------|
+| `:db` | `(fn [db] db')` | `swap! app-db update root-key f` |
+| `:pull` | `"pattern string"` | Execute via current mode's `:execute` |
+| `:pull` | `:keyword` | Named action (`:init`, `:reset`, `:schema`) |
+| `:nav` | `:sandbox` / `:remote` | `pushState` URL navigation |
+| `:batch` | `(fn [db dispatcher] [...])` | Composed effects |
+
+### pull-api — noun-based API
+
+Data structure you can read to understand each mode's API surface:
+
+```clojure
+(def pull-api
+ {:sandbox
+ {:execute (fn [dispatch! _db pattern] ...)
+ :reset (fn [dispatch! _db] ...)}
+ :remote
+ {:execute (fn [dispatch! db pattern] ...)
+ :init (fn [dispatch! db] ...)
+ :schema (fn [dispatch! db] ...)}})
+```
+
+Leaf functions receive `dispatch!` (created by dispatch-of) and call it back with result effects. This enables recursive async flows without a separate callback system.
+
+### State layer
+
+Pure `db → db` updater functions in `state.cljc`. Testable on JVM (no browser needed). All state lives under `:app/playground` in the app-db atom.
+
+## Testing
+
+Uses Rich Comment Tests (RCT). State and views are `.cljc` — tests run on JVM:
+
+```bash
+bb test examples/pull-playground
+```
+
+### What to test
+
+| Layer | File | Testable on JVM? |
+|-------|------|-----------------|
+| State updaters | `state.cljc` | Yes — pure functions |
+| View structure | `views.cljc` | Yes — hiccup data |
+| pull-api / dispatch | `core.cljs` | No — requires browser (SCI, fetch) |
+| Sandbox eval | `sandbox.cljc` | Yes — SCI runs on JVM |
+
+### RCT conventions
+
+```clojure
+^:rct/test
+(comment
+ (set-mode {:mode :sandbox :result {:data 1}} :remote)
+ ;=> contains {:mode :remote :result nil}
+ )
+```
+
+Every new state updater or view helper needs an RCT test.
+
+## Key Files
+
+```
+src/sg/flybot/playground/
+├── common/data.cljc # Default sample data + schema
+├── server/main.clj # Demo backend (http-kit + remote handler)
+└── ui/core/
+ ├── core.cljs # Entry point, dispatch-of, pull-api, Transit client
+ ├── state.cljc # Pure state updaters (db → db)
+ ├── sandbox.cljc # SCI-based pattern execution
+ └── views.cljc # Replicant hiccup (dispatch! closures)
+```
+
+## deps.edn aliases
+
+| Alias | Purpose |
+|-------|---------|
+| `:dev` | nREPL + cider + RCT |
+| `:cljs` | shadow-cljs + SCI + replicant + transit-cljs |
+| `:rct` | RCT test runner |
+| `:run` | Start backend standalone |
diff --git a/examples/pull-playground/bb.edn b/examples/pull-playground/bb.edn
index 9ae4f88..3f22110 100644
--- a/examples/pull-playground/bb.edn
+++ b/examples/pull-playground/bb.edn
@@ -1,4 +1,25 @@
{:tasks
- {test
+ {dev
+ {:doc "Start nREPL with CLJS support. Then in REPL:
+ (shadow.cljs.devtools.server/start!)
+ (shadow.cljs.devtools.api/watch :app)
+ For backend server: (start!) for server on 8081."
+ :task (clojure "-M:dev:cljs -m nrepl.cmdline --middleware \"[cider.nrepl/cider-middleware]\"")}
+
+ watch
+ {:doc "Start shadow-cljs watch + dev server on port 3002 (standalone, no REPL)"
+ :task (shell "npx shadow-cljs -A:dev:cljs watch app")}
+
+ test
{:doc "Run RCT (no kaocha needed)"
- :task (clojure "-X:dev:rct")}}}
+ :task (clojure "-X:dev:rct")}
+
+ deploy
+ {:doc "Build SPA and deploy to S3. Set S3_BUCKET in .env or environment."
+ :task (let [bucket (System/getenv "S3_BUCKET")]
+ (when-not bucket
+ (println "Error: S3_BUCKET not set. Add it to .env or export it.")
+ (System/exit 1))
+ (shell "npm install")
+ (clojure "-M:cljs -m shadow.cljs.devtools.cli release app")
+ (shell "aws s3 sync resources/public" (str "s3://" bucket) "--delete"))}}}
diff --git a/examples/pull-playground/deps.edn b/examples/pull-playground/deps.edn
index 8254802..5d7dfef 100644
--- a/examples/pull-playground/deps.edn
+++ b/examples/pull-playground/deps.edn
@@ -1,24 +1,23 @@
{:paths ["src" "resources"]
- :deps {org.clojure/clojure {:mvn/version "1.12.0"}
+ :deps {org.clojure/clojure {:mvn/version "1.12.4"}
local/pattern {:local/root "../../pattern"}
local/collection {:local/root "../../collection"}
local/remote {:local/root "../../remote"}
- http-kit/http-kit {:mvn/version "2.8.0"}
- ring/ring-core {:mvn/version "1.12.2"}
- ring-cors/ring-cors {:mvn/version "0.1.13"}
- com.cognitect/transit-clj {:mvn/version "1.0.333"}
+ http-kit/http-kit {:mvn/version "2.8.1"}
+ ring/ring-core {:mvn/version "1.15.3"}
+ com.cognitect/transit-clj {:mvn/version "1.1.347"}
parinferish/parinferish {:mvn/version "0.8.0"}
- metosin/malli {:mvn/version "0.16.4"}}
+ metosin/malli {:mvn/version "0.20.0"}}
:aliases
{:dev {:extra-paths ["dev"]
:extra-deps {io.github.robertluo/rich-comment-tests {:mvn/version "1.1.78"}
- nrepl/nrepl {:mvn/version "1.3.0"}
- cider/cider-nrepl {:mvn/version "0.50.2"}}}
+ nrepl/nrepl {:mvn/version "1.5.2"}
+ cider/cider-nrepl {:mvn/version "0.58.0"}}}
:rct {:exec-fn com.mjdowney.rich-comment-tests.test-runner/run-tests-in-file-tree!
:exec-args {:dirs #{"src"}}}
- :run {:main-opts ["-m" "sg.flybot.playground.server"]}
- :cljs {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.22"}
- no.cjohansen/replicant {:mvn/version "2025.06.21"}
+ :run {:main-opts ["-m" "sg.flybot.playground.server.main"]}
+ :cljs {:extra-deps {thheller/shadow-cljs {:mvn/version "3.3.5"}
+ no.cjohansen/replicant {:mvn/version "2025.12.1"}
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
- org.babashka/sci {:mvn/version "0.8.43"}
+ org.babashka/sci {:mvn/version "0.12.51"}
parinferish/parinferish {:mvn/version "0.8.0"}}}}}
diff --git a/examples/pull-playground/dev/user.clj b/examples/pull-playground/dev/user.clj
index 9fa5c4f..dd60e26 100644
--- a/examples/pull-playground/dev/user.clj
+++ b/examples/pull-playground/dev/user.clj
@@ -1,47 +1,32 @@
-(ns user
- "REPL helpers for playground backend development.
-
- Start the demo server:
- (start!)
-
- Stop:
- (stop!)"
- (:require [sg.flybot.playground.server :as server]))
+;; SCI + shadow-cljs workaround (babashka/sci#832):
+;; Pre-load CLJS analyzer API so SCI's copy-var macro doesn't NPE.
+(try (requiring-resolve 'cljs.analyzer.api/ns-resolve) (catch Exception _ nil))
-(defn start!
- "Start the playground demo server on port 8081."
- []
- (server/start!))
+(ns user
+ "REPL helpers for playground development.
-(defn stop!
- "Stop the server."
- []
- (server/stop!))
+ Start: (start!)
+ Stop: (stop!)
+ Restart: (restart!)"
+ (:require [sg.flybot.playground.server.main :as server]))
-(defn restart!
- "Restart the server."
- []
- (stop!)
- (start!))
+(defn start! [] (server/start!))
+(defn stop! [] (server/stop!))
+(defn restart! [] (stop!) (start!))
(comment
- ;; Start the demo server
(start!)
-
- ;; Stop the server
(stop!)
- ;; Test pattern matching locally
+ ;; Test pattern matching against running server data
(require '[sg.flybot.pullable.impl :as impl])
(let [pattern '{:users ?users}
- matcher (impl/compile-pattern pattern)
- result (matcher (impl/vmr server/sample-data))]
- {:val (:val result)
- :vars (:vars result)})
+ data (:data @server/system)
+ result ((impl/compile-pattern pattern) (impl/vmr data))]
+ (:vars result))
- ;; Test with indexed lookup
(let [pattern '{:users {{:id 2} ?user}}
- matcher (impl/compile-pattern pattern)
- result (matcher (impl/vmr server/sample-data))]
+ data (:data @server/system)
+ result ((impl/compile-pattern pattern) (impl/vmr data))]
(:vars result)))
diff --git a/examples/pull-playground/resources/public/css/style.css b/examples/pull-playground/resources/public/css/style.css
index 41725a1..85f8215 100644
--- a/examples/pull-playground/resources/public/css/style.css
+++ b/examples/pull-playground/resources/public/css/style.css
@@ -6,12 +6,27 @@
--text: #1a1a2e;
--text-muted: #666;
--border: #e0e0e0;
- --accent: #0066cc;
- --accent-hover: #0052a3;
+ --accent: #2891b8;
+ --accent-hover: #237a9c;
+ --accent-gold: #b59135;
--success: #28a745;
--error: #dc3545;
--warning: #ffc107;
--code-bg: #f4f4f4;
+
+ /* Typography */
+ --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.2s ease;
+
+ /* Border radius */
+ --radius-sm: 3px;
+ --radius-md: 4px;
+ --radius-lg: 6px;
+ --radius-xl: 8px;
}
[data-theme="dark"] {
@@ -21,8 +36,9 @@
--text: #c9d1d9;
--text-muted: #8b949e;
--border: #30363d;
- --accent: #58a6ff;
- --accent-hover: #79b8ff;
+ --accent: #4db8db;
+ --accent-hover: #3da8cb;
+ --accent-gold: #d4a940;
--success: #3fb950;
--error: #f85149;
--warning: #d29922;
@@ -33,7 +49,7 @@
* { box-sizing: border-box; }
body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-family: var(--font-sans);
line-height: 1.6;
margin: 0;
background: var(--bg);
@@ -61,6 +77,9 @@ body {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
+ padding-bottom: 0.25rem;
+ border-bottom: 2px solid transparent;
+ border-image: linear-gradient(90deg, var(--accent), var(--accent-gold)) 1;
}
.header-right {
@@ -74,7 +93,7 @@ body {
display: flex;
gap: 0;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -85,7 +104,7 @@ body {
cursor: pointer;
color: var(--text-muted);
font-size: 0.875rem;
- transition: all 0.2s;
+ transition: all var(--transition-normal);
}
.mode-toggle button:hover {
@@ -101,7 +120,7 @@ body {
.theme-toggle {
background: var(--bg);
border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius-md);
padding: 0.25rem 0.5rem;
color: var(--text-muted);
cursor: pointer;
@@ -116,7 +135,7 @@ body {
.sidebar-toggle {
background: var(--bg);
border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius-md);
padding: 0.25rem 0.5rem;
color: var(--text-muted);
cursor: pointer;
@@ -201,6 +220,7 @@ body {
flex: 1;
display: flex;
flex-direction: column;
+ gap: 0.75rem;
}
.data-panel .editor-section {
@@ -210,10 +230,9 @@ body {
}
.data-panel .remote-sections {
- flex: 1;
display: flex;
flex-direction: column;
- gap: 1rem;
+ gap: 0.75rem;
}
.data-panel .url-section {
@@ -283,7 +302,7 @@ body {
.pattern-results-panel .results-content {
flex: 1;
overflow: auto;
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
}
@@ -322,10 +341,10 @@ body {
min-height: 120px;
padding: 0.75rem;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
background: var(--bg-editor);
color: var(--text);
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
resize: none;
@@ -339,10 +358,10 @@ body {
.editor-section input[type="text"] {
padding: 0.75rem;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
background: var(--bg-editor);
color: var(--text);
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
}
@@ -357,11 +376,11 @@ body {
color: white;
border: none;
padding: 0.75rem 1.5rem;
- border-radius: 6px;
+ border-radius: var(--radius-lg);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
- transition: background 0.2s;
+ transition: background var(--transition-normal);
margin-top: 0.5rem;
}
@@ -376,7 +395,7 @@ body {
/* Results Panel */
.results-panel .panel-content {
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
}
@@ -396,7 +415,7 @@ body {
.result-value {
background: var(--code-bg);
padding: 0.75rem;
- border-radius: 6px;
+ border-radius: var(--radius-lg);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
@@ -435,10 +454,10 @@ body {
.example-list li {
padding: 0.5rem 0.75rem;
- border-radius: 4px;
+ border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
- transition: background 0.2s;
+ transition: background var(--transition-normal);
}
.example-list li:hover {
@@ -467,7 +486,7 @@ body {
}
.syntax-list {
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.8;
}
@@ -490,9 +509,54 @@ body {
.remote-sections {
display: flex;
flex-direction: column;
- gap: 1rem;
- flex: 1;
- min-height: 0;
+ gap: 0.75rem;
+}
+
+/* Info hint icon with tooltip */
+.info-hint {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1rem;
+ margin-left: 0.375rem;
+ border-radius: 50%;
+ border: 1.5px solid var(--warning);
+ color: var(--warning);
+ font-size: 0.625rem;
+ font-weight: 600;
+ font-style: normal;
+ font-family: var(--font-sans);
+ text-transform: none;
+ cursor: help;
+ position: relative;
+ vertical-align: middle;
+}
+
+.info-hint:hover::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ left: 0;
+ width: 260px;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ color: var(--text);
+ font-size: 0.8125rem;
+ font-weight: 400;
+ font-style: normal;
+ line-height: 1.5;
+ text-transform: none;
+ letter-spacing: normal;
+ white-space: normal;
+ z-index: 100;
+}
+
+[data-theme="dark"] .info-hint:hover::after {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Schema Section (Remote mode) */
@@ -521,12 +585,12 @@ body {
.fetch-schema-btn {
background: var(--bg);
border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius-md);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
- transition: all 0.2s;
+ transition: all var(--transition-normal);
}
.fetch-schema-btn:hover {
@@ -545,7 +609,7 @@ body {
max-height: 200px;
overflow: auto;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
background: var(--bg-editor);
}
@@ -557,7 +621,7 @@ body {
.schema-value {
margin: 0;
padding: 0.75rem;
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
@@ -594,7 +658,7 @@ body {
flex: 1;
min-height: 120px;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
background: var(--bg-editor);
overflow: hidden;
}
@@ -618,7 +682,7 @@ body {
color: transparent;
-webkit-text-fill-color: transparent;
caret-color: var(--text);
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
resize: none;
@@ -654,7 +718,7 @@ body {
pointer-events: auto;
cursor: help;
border-radius: 2px;
- transition: background-color 0.15s;
+ transition: background-color var(--transition-fast);
}
.edn-editor-overlay.hoverable .token-keyword:hover {
@@ -664,7 +728,7 @@ body {
.edn-editor-code {
margin: 0;
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
@@ -769,7 +833,7 @@ body {
flex: 1;
min-height: 100px;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
background: var(--bg-editor);
overflow: auto;
}
@@ -777,7 +841,7 @@ body {
.schema-viewer .schema-code {
margin: 0;
padding: 0.75rem;
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
@@ -805,7 +869,7 @@ body {
.schema-viewer .token-keyword {
cursor: help;
border-radius: 2px;
- transition: background-color 0.15s;
+ transition: background-color var(--transition-fast);
}
.schema-viewer .token-keyword:hover {
@@ -828,7 +892,7 @@ body {
padding: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: var(--radius-xl);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 0.8125rem;
line-height: 1.5;
@@ -839,14 +903,14 @@ body {
}
.tooltip-header {
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-weight: 600;
color: var(--accent);
margin-bottom: 0.25rem;
}
.tooltip-type {
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
@@ -866,8 +930,8 @@ body {
.tooltip-example code {
background: var(--code-bg);
padding: 0.125rem 0.25rem;
- border-radius: 3px;
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
}
.tooltip-deprecated {
@@ -876,7 +940,7 @@ body {
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: rgba(255, 193, 7, 0.1);
- border-radius: 4px;
+ border-radius: var(--radius-md);
}
.tooltip-since {
@@ -901,9 +965,9 @@ body {
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.8125rem;
}
@@ -915,7 +979,7 @@ body {
padding: 0.5rem 0.75rem;
cursor: pointer;
color: var(--accent);
- transition: background-color 0.1s;
+ transition: background-color var(--transition-fast);
}
.autocomplete-item:hover,
@@ -934,7 +998,7 @@ body {
.edn-display {
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
background: var(--code-bg);
overflow: auto;
}
@@ -942,7 +1006,7 @@ body {
.edn-display .edn-code {
margin: 0;
padding: 0.75rem;
- font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
+ font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
@@ -957,7 +1021,7 @@ body {
display: flex;
gap: 0;
border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 0.5rem;
width: fit-content;
@@ -973,7 +1037,7 @@ body {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
- transition: all 0.15s;
+ transition: all var(--transition-fast);
}
.schema-view-toggle button:hover {
@@ -985,3 +1049,71 @@ body {
color: white;
}
+/*=============================================================================
+ * Sandbox Mode
+ *============================================================================*/
+
+.sandbox-data-section {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.sandbox-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ flex-shrink: 0;
+}
+
+.data-view-toggle {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.data-view-toggle button {
+ background: var(--bg);
+ border: none;
+ padding: 0.25rem 0.5rem;
+ cursor: pointer;
+ color: var(--text-muted);
+ font-size: 0.6875rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ transition: all var(--transition-fast);
+}
+
+.data-view-toggle button:hover {
+ background: var(--bg-card);
+}
+
+.data-view-toggle button.active {
+ background: var(--accent);
+ color: white;
+}
+
+.reset-btn {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all var(--transition-normal);
+}
+
+.reset-btn:hover {
+ border-color: var(--error);
+ color: var(--error);
+}
+
+.sandbox-content {
+ overflow: auto;
+}
+
diff --git a/examples/pull-playground/resources/public/index.html b/examples/pull-playground/resources/public/index.html
index fe2cf40..11521a0 100644
--- a/examples/pull-playground/resources/public/index.html
+++ b/examples/pull-playground/resources/public/index.html
@@ -5,6 +5,9 @@
Pull Pattern Playground
+
+
+
diff --git a/examples/pull-playground/shadow-cljs.edn b/examples/pull-playground/shadow-cljs.edn
index f47972b..82bbd16 100644
--- a/examples/pull-playground/shadow-cljs.edn
+++ b/examples/pull-playground/shadow-cljs.edn
@@ -1,11 +1,10 @@
{:deps true
:deps-aliases [:cljs]
- :dev-http {3002 {:root "resources/public"
- :proxy-url "http://localhost:8081"}}
+ :dev-http {3001 "resources/public"}
:nrepl {:port 0}
:builds
{:app {:target :browser
:output-dir "resources/public/js"
:asset-path "/js"
- :modules {:main {:entries [sg.flybot.playground.core]}}
- :devtools {:after-load sg.flybot.playground.core/render!}}}}
+ :modules {:main {:entries [sg.flybot.playground.ui.core]}}
+ :devtools {:after-load sg.flybot.playground.ui.core/render!}}}}
diff --git a/examples/pull-playground/src/sg/flybot/playground/api.cljs b/examples/pull-playground/src/sg/flybot/playground/api.cljs
deleted file mode 100644
index 9249e21..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/api.cljs
+++ /dev/null
@@ -1,75 +0,0 @@
-(ns sg.flybot.playground.api
- "Remote API client using Transit."
- (:require [cognitect.transit :as t]
- [cljs.reader :as reader]))
-
-(defn- transit-reader []
- (t/reader :json))
-
-(defn- transit-writer []
- (t/writer :json))
-
-(defn- encode [data]
- (t/write (transit-writer) data))
-
-(defn- decode [s]
- (t/read (transit-reader) s))
-
-(defn- format-error
- "Format an error response to a user-friendly string."
- [errors]
- (let [{:keys [code reason path]} (first errors)]
- (str (name code) ": " reason
- (when (seq path)
- (str " at " (pr-str path))))))
-
-(defn pull!
- "Execute a pull query against a remote server.
-
- url - Server API endpoint
- pattern-str - Pattern as EDN string
- on-success - fn of bindings map (symbol -> value)
- on-error - fn of error string"
- [url pattern-str on-success on-error]
- (try
- (let [pattern (reader/read-string pattern-str)]
- (-> (js/fetch url
- #js {:method "POST"
- :headers #js {"Content-Type" "application/transit+json"
- "Accept" "application/transit+json"}
- :body (encode {:pattern pattern})})
- (.then (fn [resp]
- (if (.-ok resp)
- (.text resp)
- (throw (js/Error. (str "HTTP " (.-status resp)))))))
- (.then (fn [text]
- (let [response (decode text)]
- ;; Check for error response
- (if (:errors response)
- (on-error (format-error (:errors response)))
- ;; Success: response is directly the bindings map
- (on-success response)))))
- (.catch (fn [err]
- (on-error (.-message err))))))
- (catch :default e
- (on-error (str "Parse error: " (.-message e))))))
-
-(defn fetch-schema!
- "Fetch schema from remote server.
-
- url - Server API endpoint (schema is at url/_schema)
- on-success - fn of {:schema ... :sample ...} map
- on-error - fn of error string"
- [url on-success on-error]
- (let [schema-url (str url "/_schema")]
- (-> (js/fetch schema-url
- #js {:method "GET"
- :headers #js {"Accept" "application/transit+json"}})
- (.then (fn [resp]
- (if (.-ok resp)
- (.text resp)
- (throw (js/Error. (str "HTTP " (.-status resp)))))))
- (.then (fn [text]
- (on-success (decode text))))
- (.catch (fn [err]
- (on-error (.-message err)))))))
diff --git a/examples/pull-playground/src/sg/flybot/playground/common/data.cljc b/examples/pull-playground/src/sg/flybot/playground/common/data.cljc
new file mode 100644
index 0000000..dab1a30
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/common/data.cljc
@@ -0,0 +1,39 @@
+(ns sg.flybot.playground.common.data
+ "Sample data and schema shared between sandbox and remote modes.")
+
+(def default-data
+ {:users [{:id 1 :name "Alice" :email "alice@example.com" :role :admin}
+ {:id 2 :name "Bob" :email "bob@example.com" :role :user}
+ {:id 3 :name "Carol" :email "carol@example.com" :role :user}]
+ :posts [{:id 1 :title "Hello World" :author "Alice" :tags ["intro" "welcome"]}
+ {:id 2 :title "Pattern Matching" :author "Bob" :tags ["tutorial" "patterns"]}
+ {:id 3 :title "Advanced Topics" :author "Alice" :tags ["advanced"]}]
+ :config {:version "1.0.0"
+ :features {:dark-mode true :notifications false}}})
+
+(def default-schema
+ "Malli hiccup form. Server wraps in m/schema, sandbox uses raw."
+ [:map {:version "1.0.0"
+ :doc "Sample API for Pull Pattern Playground"}
+ [:users {:doc "User accounts"}
+ [:vector {:ilookup true}
+ [:map
+ [:id {:doc "Unique identifier" :example 1} :int]
+ [:name {:doc "Display name" :example "Alice"} :string]
+ [:email {:doc "Email address" :example "alice@example.com"} :string]
+ [:role {:doc "User role" :example :admin} :keyword]]]]
+ [:posts {:doc "Blog posts"}
+ [:vector {:ilookup true}
+ [:map
+ [:id {:doc "Post identifier" :example 1} :int]
+ [:title {:doc "Post title" :example "Hello World"} :string]
+ [:author {:doc "Author name" :example "Alice"} :string]
+ [:tags {:doc "Post tags" :example ["intro" "welcome"]} [:vector :string]]]]]
+ [:config {:doc "Application configuration"}
+ [:map
+ [:version {:doc "App version"} :string]
+ [:features {:doc "Feature flags"}
+ [:map
+ [:dark-mode {:doc "Dark mode enabled"} :boolean]
+ [:notifications {:doc "Notifications enabled"} :boolean]]]
+ [:debug {:optional true :doc "Debug mode"} :boolean]]]])
diff --git a/examples/pull-playground/src/sg/flybot/playground/core.cljs b/examples/pull-playground/src/sg/flybot/playground/core.cljs
deleted file mode 100644
index e478110..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/core.cljs
+++ /dev/null
@@ -1,131 +0,0 @@
-(ns sg.flybot.playground.core
- "Playground SPA entry point - dispatch and effect execution."
- (:require [replicant.dom :as r]
- [sg.flybot.playground.state :as state]
- [sg.flybot.playground.views :as views]
- [sg.flybot.playground.local :as local]
- [sg.flybot.playground.api :as api]))
-
-(defonce app-state (atom state/initial-state))
-
-;;=============================================================================
-;; Event Dispatch
-;;=============================================================================
-
-(declare dispatch!)
-
-(def ^:private handlers
- "Maps event keywords to state functions."
- {:set-mode (fn [s mode] (state/set-mode s mode))
- :update-pattern (fn [s text] (state/update-pattern s text))
- :update-data (fn [s text] (state/update-data s text))
- :update-server-url (fn [s url] (state/update-server-url s url))
- :select-example (fn [s example] (state/select-example s example))
- :set-selected-example (fn [s idx] (state/set-selected-example s idx))
- :execute (fn [s _] (state/execute s))
- :execution-success (fn [s result] (state/execution-success s result))
- :execution-error (fn [s error] (state/execution-error s error))
- :clear-result (fn [s _] (state/clear-result s))
- :fetch-schema (fn [s _] (state/fetch-schema s))
- :fetch-schema-success (fn [s schema] (state/fetch-schema-success s schema))
- :fetch-schema-error (fn [s error] (state/fetch-schema-error s error))
- :set-sample-data (fn [s data] (state/set-sample-data s data))
- :set-schema-view-mode (fn [s mode] (state/set-schema-view-mode s mode))
- :toggle-sidebar (fn [s _] (state/toggle-sidebar s))
- ;; Autocomplete
- :show-autocomplete (fn [s data] (state/show-autocomplete s data))
- :hide-autocomplete (fn [s _] (state/hide-autocomplete s))
- :select-autocomplete (fn [s idx] (state/select-autocomplete s idx))
- :move-autocomplete (fn [s dir] (state/move-autocomplete-selection s dir))})
-
-(defn- apply-handler [state event]
- (let [[event-type & args] (if (vector? event) event [event])
- handler (get handlers event-type)]
- (if handler
- (apply handler state args)
- (do (js/console.warn "Unknown event:" event-type)
- {:state state}))))
-
-;;=============================================================================
-;; Effect Execution
-;;=============================================================================
-
-(defn- execute-local! [{:keys [pattern data]}]
- (let [result (local/execute pattern data)]
- (if (:error result)
- (dispatch! [:execution-error (:error result)])
- (dispatch! [:execution-success result]))))
-
-(defn- execute-remote! [{:keys [pattern url]}]
- (api/pull! url pattern
- (fn [result] (dispatch! [:execution-success result]))
- (fn [error] (dispatch! [:execution-error error]))))
-
-(defn- fetch-schema! [{:keys [url]}]
- (api/fetch-schema! url
- (fn [{:keys [schema sample]}]
- (dispatch! [:fetch-schema-success schema])
- ;; Use server-provided sample data (handles ILookup entities correctly)
- (js/setTimeout
- #(dispatch! [:set-sample-data sample])
- 0))
- (fn [error] (dispatch! [:fetch-schema-error error]))))
-
-(defn- execute-effects! [{:keys [local-exec remote-exec fetch-schema]}]
- (when local-exec (execute-local! local-exec))
- (when remote-exec (execute-remote! remote-exec))
- (when fetch-schema (fetch-schema! fetch-schema)))
-
-;;=============================================================================
-;; Dispatch
-;;=============================================================================
-
-(defn dispatch!
- "Dispatch an event: :keyword or [:keyword arg1 arg2 ...]"
- [event]
- (let [old-state @app-state
- {:keys [state fx]} (apply-handler old-state event)]
- (reset! app-state state)
- (when fx (execute-effects! fx))))
-
-;;=============================================================================
-;; Rendering
-;;=============================================================================
-
-(defonce root-el (atom nil))
-
-(defn render! []
- (when @root-el
- (r/render @root-el (views/app-view @app-state dispatch!))))
-
-(add-watch app-state :render (fn [_ _ _ _] (render!)))
-
-;;=============================================================================
-;; Theme
-;;=============================================================================
-
-(defn ^:export toggle-theme! []
- (let [body (.-body js/document)
- current (.getAttribute body "data-theme")
- new-theme (if (= current "dark") "light" "dark")]
- (.setAttribute body "data-theme" new-theme)
- (js/localStorage.setItem "theme" new-theme)
- (render!)))
-
-(defn- init-theme! []
- (let [saved (js/localStorage.getItem "theme")]
- (when (= saved "dark")
- (.setAttribute (.-body js/document) "data-theme" "dark"))))
-
-;;=============================================================================
-;; Initialization
-;;=============================================================================
-
-(defn ^:export init! []
- (js/console.log "Pull Pattern Playground initializing...")
- (reset! root-el (js/document.getElementById "app"))
- (init-theme!)
- (render!)
- (js/console.log "Pull Pattern Playground initialized"))
-
-(init!)
diff --git a/examples/pull-playground/src/sg/flybot/playground/examples.cljc b/examples/pull-playground/src/sg/flybot/playground/examples.cljc
deleted file mode 100644
index 711850d..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/examples.cljc
+++ /dev/null
@@ -1,54 +0,0 @@
-(ns sg.flybot.playground.examples
- "Pre-built example patterns for the playground.")
-
-(def examples
- [{:name "Basic Binding"
- :description "Extract a value into a variable"
- :pattern "{:name ?name}"
- :data "{:name \"Alice\" :age 30}"}
-
- {:name "Multiple Bindings"
- :description "Extract multiple values at once"
- :pattern "{:name ?name :age ?age}"
- :data "{:name \"Bob\" :age 25 :city \"NYC\"}"}
-
- {:name "Nested Maps"
- :description "Match patterns in nested structures"
- :pattern "{:user {:name ?name :email ?email}}"
- :data "{:user {:name \"Carol\" :email \"carol@example.com\" :id 123}}"}
-
- {:name "Wildcard"
- :description "Match any value without binding"
- :pattern "{:status ?_ :data ?data}"
- :data "{:status \"ok\" :data {:items [1 2 3]}}"}
-
- {:name "Sequence Binding"
- :description "Extract elements from sequences"
- :pattern "[?first ?second ?rest*]"
- :data "[1 2 3 4 5]"}
-
- {:name "Predicate Constraint"
- :description "Match only if predicate succeeds"
- :pattern "{:age (?age :when pos?)}"
- :data "{:name \"Dave\" :age 42}"}
-
- {:name "Default Value"
- :description "Provide fallback for missing keys"
- :pattern "{:name ?name :nickname (?nick :default \"N/A\")}"
- :data "{:name \"Eve\"}"}
-
- {:name "Indexed Lookup"
- :description "Look up by key pattern (ILookup)"
- :pattern "{:users {{:id 2} ?user}}"
- :data "{:users {{:id 1} {:name \"A\"} {:id 2} {:name \"B\"} {:id 3} {:name \"C\"}}}"}])
-
-(def syntax-reference
- [{:syntax "?x" :description "Bind value to x"}
- {:syntax "?_" :description "Wildcard (match any)"}
- {:syntax "?x?" :description "Optional (0-1)"}
- {:syntax "?x*" :description "Zero or more"}
- {:syntax "?x+" :description "One or more"}
- {:syntax "{}" :description "Map pattern"}
- {:syntax "[]" :description "Sequence pattern"}
- {:syntax ":when" :description "Predicate constraint"}
- {:syntax ":default" :description "Fallback value"}])
diff --git a/examples/pull-playground/src/sg/flybot/playground/local.cljs b/examples/pull-playground/src/sg/flybot/playground/local.cljs
deleted file mode 100644
index 4fe3d69..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/local.cljs
+++ /dev/null
@@ -1,80 +0,0 @@
-(ns sg.flybot.playground.local
- "Local pattern execution using the pattern library with SCI."
- (:require [cljs.reader :as reader]
- [sci.core :as sci]
- [sg.flybot.pullable.impl :as impl]))
-
-;; SCI context for sandboxed evaluation
-(def ^:private sci-ctx
- (sci/init {:namespaces {'clojure.core {'pos? pos?
- 'neg? neg?
- 'zero? zero?
- 'even? even?
- 'odd? odd?
- 'string? string?
- 'number? number?
- 'keyword? keyword?
- 'symbol? symbol?
- 'map? map?
- 'vector? vector?
- 'seq? seq?
- 'set? set?
- 'nil? nil?
- 'some? some?
- 'empty? empty?
- 'count count
- '= =
- 'not= not=
- '< <
- '> >
- '<= <=
- '>= >=
- 'identity identity}}}))
-
-(defn- sci-resolve [sym]
- (sci/eval-form sci-ctx sym))
-
-(defn- sci-eval [form]
- (sci/eval-form sci-ctx form))
-
-(defn execute
- "Execute a pattern against data locally.
-
- pattern-str - EDN string of the pattern
- data-str - EDN string of the data
-
- Returns bindings map (symbol -> value) on success,
- or {:error \"message\"} on failure."
- [pattern-str data-str]
- (try
- (let [pattern (reader/read-string pattern-str)
- data (reader/read-string data-str)
- matcher (impl/compile-pattern pattern {:resolve sci-resolve
- :eval sci-eval})
- result (matcher (impl/vmr data))]
- (if (impl/failure? result)
- {:error (str "Match failed: " (:reason result)
- (when (seq (:path result))
- (str " at path " (:path result))))}
- ;; Return vars bindings directly (spec-compliant format)
- (:vars result)))
- (catch :default e
- {:error (str "Error: " (.-message e))})))
-
-^:rct/test
-(comment
- ;; Basic binding works - returns vars directly
- (execute "{:name ?n}" "{:name \"Alice\"}")
- ;=>> {'n "Alice"}
-
- ;; Multiple bindings - returns vars directly
- (execute "{:a ?a :b ?b}" "{:a 1 :b 2 :c 3}")
- ;=>> {'a 1 'b 2}
-
- ;; Parse error returns error
- (:error (execute "{:name ?n" "{:name \"Alice\"}"))
- ;=>> string?
-
- ;; Match failure returns error
- (:error (execute "{:name ?n :age ?a}" "{:name \"Alice\"}")))
- ;=>> string?)
diff --git a/examples/pull-playground/src/sg/flybot/playground/server.clj b/examples/pull-playground/src/sg/flybot/playground/server.clj
deleted file mode 100644
index f1de1e2..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/server.clj
+++ /dev/null
@@ -1,124 +0,0 @@
-(ns sg.flybot.playground.server
- "Demo server with sample data for testing remote mode.
- Uses standard Remote Pull Protocol v0.2 handler."
- (:require [org.httpkit.server :as http]
- [ring.middleware.cors :refer [wrap-cors]]
- [ring.middleware.params :refer [wrap-params]]
- [sg.flybot.pullable.remote :as remote]
- [sg.flybot.pullable.collection :as coll]
- [sg.flybot.pullable.sample :as sample]
- [sg.flybot.pullable.malli]
- [malli.core :as m]))
-
-;;=============================================================================
-;; Sample Data
-;;=============================================================================
-
-(def users-source
- (coll/atom-source
- {:initial [{:id 1 :name "Alice" :email "alice@example.com" :role :admin}
- {:id 2 :name "Bob" :email "bob@example.com" :role :user}
- {:id 3 :name "Carol" :email "carol@example.com" :role :user}]}))
-
-(def posts-source
- (coll/atom-source
- {:initial [{:id 1 :title "Hello World" :author "Alice" :tags ["intro" "welcome"]}
- {:id 2 :title "Pattern Matching" :author "Bob" :tags ["tutorial" "patterns"]}
- {:id 3 :title "Advanced Topics" :author "Alice" :tags ["advanced"]}]}))
-
-(def sample-data
- {:users (coll/collection users-source)
- :posts (coll/collection posts-source)
- :config {:version "1.0.0"
- :features {:dark-mode true :notifications false}}})
-
-(def sample-schema
- "Schema describing the sample data structure with inline Malli documentation.
- Uses Malli's hiccup syntax with properties on each field entry."
- (m/schema
- [:map {:version "1.0.0"
- :doc "Sample API for Pull Pattern Playground"}
- [:users {:doc "User accounts"}
- [:vector {:ilookup true}
- [:map
- [:id {:doc "Unique identifier" :example 1} :int]
- [:name {:doc "Display name" :example "Alice"} :string]
- [:email {:doc "Email address" :example "alice@example.com"} :string]
- [:role {:doc "User role" :example :admin} :keyword]]]]
- [:posts {:doc "Blog posts"
- :operations {:list "Returns all posts"
- :get "Lookup by {:id n}"}}
- [:vector {:ilookup true}
- [:map
- [:id {:doc "Post identifier" :example 1} :int]
- [:title {:doc "Post title" :example "Hello World"} :string]
- [:author {:doc "Author name" :example "Alice"} :string]
- [:tags {:doc "Post tags" :example ["intro" "welcome"]} [:vector :string]]]]]
- [:config {:doc "Application configuration"}
- [:map
- [:version {:doc "App version"} :string]
- [:features {:doc "Feature flags"}
- [:map
- [:dark-mode {:doc "Dark mode enabled"} :boolean]
- [:notifications {:doc "Notifications enabled"} :boolean]]]]]]))
-
-;;=============================================================================
-;; API Function
-;;=============================================================================
-
-(defn api-fn
- "API function for Remote Pull Protocol.
- Returns data and schema for pattern matching."
- [_ring-request]
- {:data sample-data
- :schema sample-schema
- :sample (sample/generate sample-schema {:size 10 :seed 42 :min 5})})
-
-(defn- health-handler
- "Health check endpoint."
- [request]
- (when (and (= :get (:request-method request))
- (= "/health" (:uri request)))
- {:status 200
- :headers {"Content-Type" "text/plain"}
- :body "OK"}))
-
-;;=============================================================================
-;; Middleware
-;;=============================================================================
-
-(def app
- (let [pull-handler (remote/make-handler api-fn)]
- (-> (fn [request]
- (or (health-handler request)
- (pull-handler request)))
- wrap-params
- (wrap-cors :access-control-allow-origin [#".*"]
- :access-control-allow-methods [:get :post :options]
- :access-control-allow-headers ["Content-Type" "Accept"]))))
-
-;;=============================================================================
-;; Server
-;;=============================================================================
-
-(defonce server (atom nil))
-
-(defn start! [& [{:keys [port] :or {port 8081}}]]
- (println (str "Starting demo server on port " port "..."))
- (println "Sample data available:")
- (println " :users - List of users")
- (println " :posts - List of posts")
- (println " :config - App configuration")
- (reset! server (http/run-server app {:port port}))
- (println (str "Server running at http://localhost:" port)))
-
-(defn stop! []
- (when-let [s @server]
- (s :timeout 100)
- (reset! server nil)
- (println "Server stopped")))
-
-(defn -main [& args]
- (let [port (if (seq args) (Integer/parseInt (first args)) 8081)]
- (start! {:port port})
- @(promise))) ; Keep running
diff --git a/examples/pull-playground/src/sg/flybot/playground/server/main.clj b/examples/pull-playground/src/sg/flybot/playground/server/main.clj
new file mode 100644
index 0000000..29b9ae9
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/server/main.clj
@@ -0,0 +1,82 @@
+(ns sg.flybot.playground.server.main
+ "Demo server for testing remote mode.
+ Uses standard Remote Pull Protocol v0.2 handler."
+ (:require [org.httpkit.server :as http]
+ [ring.middleware.params :refer [wrap-params]]
+ [sg.flybot.playground.common.data :as data]
+ [sg.flybot.pullable.remote :as remote]
+ [sg.flybot.pullable.collection :as coll]
+ [sg.flybot.pullable.sample :as sample]
+ [sg.flybot.pullable.malli]
+ [malli.core :as m]))
+
+;;=============================================================================
+;; Middleware
+;;=============================================================================
+
+(defn- wrap-cors
+ "Add CORS headers for cross-origin requests."
+ [handler]
+ (fn [request]
+ (let [origin (get-in request [:headers "origin"] "*")]
+ (if (= :options (:request-method request))
+ {:status 204
+ :headers {"Access-Control-Allow-Origin" origin
+ "Access-Control-Allow-Methods" "GET, POST, OPTIONS"
+ "Access-Control-Allow-Headers" "Content-Type, Accept"
+ "Access-Control-Max-Age" "86400"}}
+ (-> (handler request)
+ (assoc-in [:headers "Access-Control-Allow-Origin"] origin))))))
+
+;;=============================================================================
+;; Handlers
+;;=============================================================================
+
+(defn- health-handler
+ "Health check endpoint."
+ [request]
+ (when (and (= :get (:request-method request))
+ (= "/health" (:uri request)))
+ {:status 200
+ :headers {"Content-Type" "text/plain"}
+ :body "OK"}))
+
+(defn- make-app [{:keys [data schema sample]}]
+ (let [api-fn (fn [_ring-request]
+ {:data data :schema schema :sample sample})
+ pull-handler (remote/make-handler api-fn)]
+ (-> (fn [request]
+ (or (health-handler request)
+ (pull-handler request)))
+ wrap-params
+ wrap-cors)))
+
+;;=============================================================================
+;; System Lifecycle
+;;=============================================================================
+
+(defonce system (atom nil))
+
+(defn start! [& [{:keys [port] :or {port 8081}}]]
+ (let [sources {:users (coll/atom-source {:initial (:users data/default-data)})
+ :posts (coll/atom-source {:initial (:posts data/default-data)})}
+ colls {:users (coll/collection (:users sources))
+ :posts (coll/collection (:posts sources))
+ :config (:config data/default-data)}
+ schema (m/schema data/default-schema)
+ sample (sample/generate schema {:size 10 :seed 42 :min 5})
+ app (make-app {:data colls :schema schema :sample sample})
+ stop-fn (http/run-server app {:port port})]
+ (reset! system {:stop-fn stop-fn :data colls :schema schema})
+ (println (str "Server running at http://localhost:" port))))
+
+(defn stop! []
+ (when-let [{:keys [stop-fn]} @system]
+ (stop-fn :timeout 100)
+ (reset! system nil)
+ (println "Server stopped")))
+
+(defn -main [& args]
+ (let [port (if (seq args) (Integer/parseInt (first args)) 8081)]
+ (start! {:port port})
+ @(promise)))
diff --git a/examples/pull-playground/src/sg/flybot/playground/state.cljc b/examples/pull-playground/src/sg/flybot/playground/state.cljc
deleted file mode 100644
index b780a43..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/state.cljc
+++ /dev/null
@@ -1,184 +0,0 @@
-(ns sg.flybot.playground.state
- "Application state - pure functions returning {:state ... :fx ...}.
-
- Each function returns a map with:
- - :state - the new state
- - :fx - optional effects map {:local-exec ... :remote-exec ...}")
-
-;;=============================================================================
-;; State Shape
-;;=============================================================================
-
-(def initial-state
- {:mode :local ; :local | :remote
- :pattern-text "" ; Pattern editor content
- :data-text "" ; Data editor content (local mode)
- :server-url "http://localhost:8081/api" ; Server URL (remote mode)
- :result nil ; bindings map (symbol -> value) or nil
- :error nil ; Error message string or nil
- :loading? false
- :selected-example nil ; Index of selected example
- :schema nil ; Remote server schema (remote mode only)
- :schema-loading? false
- :schema-error nil
- :sample-data nil ; Generated sample data from schema (remote mode)
- :schema-view-mode :schema ; :schema | :sample - toggle in schema viewer
- :sidebar-collapsed? false ; Hide examples panel (remote mode)
- ;; Autocomplete state
- :autocomplete nil}) ; {:completions [...] :selected 0 :prefix ":" :x :y}
-
-;;=============================================================================
-;; State Transitions (pure functions)
-;;=============================================================================
-
-(defn set-mode [state mode]
- {:state (-> state
- (assoc :mode mode)
- (assoc :result nil :error nil)
- (assoc :schema nil :schema-error nil :sample-data nil)
- (assoc :schema-view-mode :schema))})
-
-(defn update-pattern [state text]
- {:state (assoc state :pattern-text text)})
-
-(defn update-data [state text]
- {:state (assoc state :data-text text)})
-
-(defn update-server-url [state url]
- ;; Just update URL - debounced fetch happens via :fetch-schema event
- {:state (assoc state :server-url url)})
-
-(defn fetch-schema [state]
- ;; Trigger schema fetch for current URL (also sets loading state)
- {:state (assoc state :schema nil :schema-error nil :schema-loading? true)
- :fx {:fetch-schema {:url (:server-url state)}}})
-
-(defn select-example [state {:keys [pattern data]}]
- {:state (-> state
- (assoc :pattern-text pattern)
- (assoc :data-text data)
- (assoc :result nil :error nil))})
-
-(defn execute [state]
- {:state (assoc state :loading? true :error nil :result nil)
- :fx (if (= :local (:mode state))
- {:local-exec {:pattern (:pattern-text state)
- :data (:data-text state)}}
- {:remote-exec {:pattern (:pattern-text state)
- :url (:server-url state)}})})
-
-(defn execution-success [state result]
- {:state (assoc state :loading? false :result result :error nil)})
-
-(defn execution-error [state error]
- {:state (assoc state :loading? false :error error :result nil)})
-
-(defn clear-result [state]
- {:state (assoc state :result nil :error nil)})
-
-(defn set-selected-example [state idx]
- {:state (assoc state :selected-example idx)})
-
-(defn fetch-schema-success [state schema]
- {:state (assoc state :schema-loading? false :schema schema :schema-error nil :sample-data nil)})
-
-(defn fetch-schema-error [state error]
- {:state (assoc state :schema-loading? false :schema nil :schema-error error :sample-data nil)})
-
-(defn set-sample-data [state sample-data]
- {:state (assoc state :sample-data sample-data)})
-
-(defn set-schema-view-mode [state mode]
- {:state (assoc state :schema-view-mode mode)})
-
-(defn toggle-sidebar [state]
- {:state (update state :sidebar-collapsed? not)})
-
-;;=============================================================================
-;; Autocomplete
-;;=============================================================================
-
-(defn show-autocomplete [state autocomplete-data]
- {:state (assoc state :autocomplete autocomplete-data)})
-
-(defn hide-autocomplete [state]
- {:state (assoc state :autocomplete nil)})
-
-(defn select-autocomplete [state idx]
- {:state (assoc-in state [:autocomplete :selected] idx)})
-
-(defn move-autocomplete-selection [state direction]
- (let [{:keys [completions selected]} (:autocomplete state)
- n (count completions)
- new-idx (mod (+ selected direction) n)]
- {:state (assoc-in state [:autocomplete :selected] new-idx)}))
-
-;;=============================================================================
-;; Tests
-;;=============================================================================
-
-^:rct/test
-(comment
- ;; set-mode changes mode and clears results
- (let [{:keys [state]} (set-mode {:mode :local :result {:data 1}} :remote)]
- [(:mode state) (:result state)])
- ;=> [:remote nil]
-
- ;; update-pattern updates pattern text
- (let [{:keys [state]} (update-pattern initial-state "{:name ?n}")]
- (:pattern-text state))
- ;=> "{:name ?n}"
-
- ;; execute returns local-exec effect in local mode
- (let [{:keys [fx]} (execute {:mode :local :pattern-text "p" :data-text "d"})]
- (:local-exec fx))
- ;=> {:pattern "p" :data "d"}
-
- ;; execute returns remote-exec effect in remote mode
- (let [{:keys [fx]} (execute {:mode :remote :pattern-text "p" :server-url "http://x"})]
- (:remote-exec fx))
- ;=> {:pattern "p" :url "http://x"}
-
- ;; execution-success stores result (bindings map)
- (let [{:keys [state]} (execution-success {:loading? true} {'x 1})]
- [(:loading? state) (:result state)])
- ;=> [false {'x 1}]
-
- ;; execution-error stores error
- (let [{:keys [state]} (execution-error {:loading? true} "Parse error")]
- [(:loading? state) (:error state)])
- ;=> [false "Parse error"]
-
- ;; fetch-schema returns fetch-schema effect
- (let [{:keys [fx]} (fetch-schema {:server-url "http://test/api"})]
- (:fetch-schema fx))
- ;=> {:url "http://test/api"}
-
- ;; fetch-schema-success stores schema and clears sample-data
- (let [{:keys [state]} (fetch-schema-success {:schema-loading? true :sample-data {:old "data"}} {:users [:name :email]})]
- [(:schema-loading? state) (:schema state) (:sample-data state)])
- ;=> [false {:users [:name :email]} nil]
-
- ;; fetch-schema-error stores error and clears sample-data
- (let [{:keys [state]} (fetch-schema-error {:schema-loading? true :sample-data {:old "data"}} "Network error")]
- [(:schema-loading? state) (:schema-error state) (:sample-data state)])
- ;=> [false "Network error" nil]
-
- ;; set-sample-data stores generated data
- (let [{:keys [state]} (set-sample-data {} {:users [{:name "Alice"}]})]
- (:sample-data state))
- ;=> {:users [{:name "Alice"}]}
-
- ;; set-schema-view-mode changes view mode
- (let [{:keys [state]} (set-schema-view-mode {:schema-view-mode :schema} :sample)]
- (:schema-view-mode state))
- ;=> :sample
-
- ;; toggle-sidebar toggles collapsed state
- (let [{:keys [state]} (toggle-sidebar {:sidebar-collapsed? false})]
- (:sidebar-collapsed? state))
- ;=> true
-
- (let [{:keys [state]} (toggle-sidebar {:sidebar-collapsed? true})]
- (:sidebar-collapsed? state)))
- ;=> false)
diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core.cljs b/examples/pull-playground/src/sg/flybot/playground/ui/core.cljs
new file mode 100644
index 0000000..ee2b67a
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core.cljs
@@ -0,0 +1,225 @@
+(ns sg.flybot.playground.ui.core
+ "Playground SPA entry point.
+
+ Uses a dispatch-of effect pattern (like hibou):
+ - :db — pure state updater (swap! app-db update root-key f)
+ - :pull — pull pattern API (string or keyword, routed via pull-api)
+ - :nav — URL navigation (pushState)
+ - :batch — composed effects"
+ (:require [replicant.dom :as r]
+ [cognitect.transit :as t]
+ [cljs.reader :as reader]
+ [sg.flybot.playground.common.data :as data]
+ [sg.flybot.playground.ui.core.state :as state]
+ [sg.flybot.playground.ui.core.views :as views]
+ [sg.flybot.playground.ui.core.sandbox :as sandbox]
+ [sg.flybot.pullable.remote :as remote]))
+
+(defonce app-db (atom {:app/playground state/initial-state}))
+
+(def ^:private root-key :app/playground)
+
+(defonce ^:private popstate-listener (atom nil))
+
+;;=============================================================================
+;; Remote API Client (Transit)
+;;=============================================================================
+
+(defn- transit-reader []
+ (t/reader :json))
+
+(defn- transit-writer []
+ (t/writer :json))
+
+(defn- encode [data]
+ (t/write (transit-writer) data))
+
+(defn- decode [s]
+ (t/read (transit-reader) s))
+
+(defn- format-error
+ "Format an error response to a user-friendly string."
+ [errors]
+ (let [{:keys [code reason path]} (first errors)]
+ (str (name code) ": " reason
+ (when (seq path)
+ (str " at " (pr-str path))))))
+
+(defn- pull!
+ "Execute a pull query against a remote server.
+ Always reads body — errors are transit-encoded in the response."
+ [url pattern-str on-success on-error]
+ (try
+ (let [pattern (reader/read-string pattern-str)]
+ (-> (js/fetch url
+ #js {:method "POST"
+ :headers #js {"Content-Type" "application/transit+json"
+ "Accept" "application/transit+json"}
+ :body (encode {:pattern pattern})})
+ (.then (fn [resp] (.text resp)))
+ (.then (fn [text]
+ (let [response (decode text)]
+ (if (:errors response)
+ (on-error (format-error (:errors response)))
+ (on-success response)))))
+ (.catch (fn [err]
+ (on-error (.-message err))))))
+ (catch :default e
+ (on-error (str "Parse error: " (.-message e))))))
+
+(defn- fetch-schema!
+ "Fetch schema from remote server.
+ Checks resp.ok — schema endpoint returns plain HTTP errors, not transit."
+ [url on-success on-error]
+ (let [schema-url (str url "/_schema")]
+ (-> (js/fetch schema-url
+ #js {:method "GET"
+ :headers #js {"Accept" "application/transit+json"}})
+ (.then (fn [resp]
+ (if (.-ok resp)
+ (.text resp)
+ (throw (js/Error. (str "HTTP " (.-status resp)))))))
+ (.then (fn [text]
+ (on-success (decode text))))
+ (.catch (fn [err]
+ (on-error (.-message err)))))))
+
+;;=============================================================================
+;; Pull API
+;;=============================================================================
+
+(def ^:private read-all-pattern
+ (pr-str (into {} (map (fn [k] [k (symbol (str "?" (name k)))])) (keys data/default-data))))
+
+(defn- pull-result->data
+ "Convert pull bindings (transit symbol keys) to keyword-keyed data map."
+ [result]
+ (into {} (map (fn [[k v]] [(keyword (name k)) v])) result))
+
+(def ^:private pull-api
+ {:sandbox
+ {:execute
+ (fn [dispatch! _db pattern]
+ (let [{:keys [result error snapshot]} (sandbox/execute! pattern)]
+ (if error
+ (dispatch! {:db #(state/set-error % error)})
+ (dispatch! {:db #(-> % (state/set-result result)
+ (state/set-sandbox-data snapshot))}))))
+ :reset
+ (fn [dispatch! _db]
+ (let [snap (sandbox/reset-data!)]
+ (dispatch! {:db #(-> % (state/set-sandbox-data snap) state/clear-result)})))}
+
+ :remote
+ {:execute
+ (fn [dispatch! db pattern]
+ (let [url (:server-url db)
+ mutation? (try (some? (remote/parse-mutation (reader/read-string pattern)))
+ (catch :default _ false))]
+ (pull! url pattern
+ (fn [result]
+ (dispatch! {:db #(state/set-result % result)})
+ (when mutation?
+ (pull! url read-all-pattern
+ (fn [data]
+ (dispatch! {:db #(state/set-remote-data % (pull-result->data data))}))
+ (fn [_] nil))))
+ (fn [error]
+ (dispatch! {:db #(state/set-error % error)})))))
+ :init
+ (fn [dispatch! db]
+ (pull! (:server-url db) read-all-pattern
+ (fn [result]
+ (dispatch! {:db #(state/set-remote-data % (pull-result->data result))}))
+ (fn [_] nil)))
+ :schema
+ (fn [dispatch! db]
+ (fetch-schema! (:server-url db)
+ (fn [{:keys [schema sample]}]
+ (dispatch! {:db #(-> % (state/set-schema schema) (assoc :sample-data sample))}))
+ (fn [error]
+ (dispatch! {:db #(state/set-schema-error % error)}))))}})
+
+;;=============================================================================
+;; Dispatch
+;;=============================================================================
+
+(defn dispatch-of [app-db root-key]
+ (fn [effects]
+ (doseq [[type effect-def] effects]
+ (case type
+ :db (swap! app-db update root-key effect-def)
+ :pull (let [db (get @app-db root-key)
+ api (get pull-api (:mode db))
+ dispatch! ((partial dispatch-of app-db) root-key)]
+ (if (string? effect-def)
+ ((:execute api) dispatch! db effect-def)
+ (when-let [f (get api effect-def)]
+ (f dispatch! db))))
+ :nav (let [path (str "/" (name effect-def))]
+ (when-not (= path (.-pathname js/location))
+ (.pushState js/history nil "" path)))
+ :batch (doseq [[dispatch! eff] (effect-def @app-db (partial dispatch-of app-db))]
+ (dispatch! eff))
+ (js/console.warn "Unknown effect type:" type)))))
+
+;;=============================================================================
+;; Rendering
+;;=============================================================================
+
+(defonce root-el (atom nil))
+
+(defn render! []
+ (when-let [el @root-el]
+ (let [db (get @app-db root-key)
+ dispatch! (dispatch-of app-db root-key)]
+ (r/render el (views/app-view {::views/db db ::views/dispatch! dispatch!})))))
+
+(add-watch app-db :render (fn [_ _ _ _] (render!)))
+
+;;=============================================================================
+;; Theme
+;;=============================================================================
+
+(defn ^:export toggle-theme! []
+ (let [body (.-body js/document)
+ new-theme (if (= (.getAttribute body "data-theme") "dark") "light" "dark")]
+ (.setAttribute body "data-theme" new-theme)
+ (js/localStorage.setItem "theme" new-theme)))
+
+(defn- init-theme! []
+ (when (= (js/localStorage.getItem "theme") "dark")
+ (.setAttribute (.-body js/document) "data-theme" "dark")))
+
+;;=============================================================================
+;; Initialization
+;;=============================================================================
+
+(defn- path->mode []
+ (if (= (.-pathname js/location) "/remote")
+ :remote
+ :sandbox))
+
+(defn ^:export init! []
+ (reset! root-el (js/document.getElementById "app"))
+ (init-theme!)
+ (when (= (.-pathname js/location) "/")
+ (.replaceState js/history nil "" "/sandbox"))
+ (let [snap (sandbox/init!)
+ mode (path->mode)
+ dispatch! (dispatch-of app-db root-key)]
+ (dispatch! {:db (fn [db]
+ (-> db
+ (state/set-sandbox-data snap)
+ (state/set-sandbox-schema data/default-schema)
+ (state/set-mode mode)))})
+ (when (= mode :remote)
+ (dispatch! {:pull :init}))
+ (when-let [prev @popstate-listener]
+ (.removeEventListener js/window "popstate" prev))
+ (let [listener (fn [_] (dispatch! {:db #(state/set-mode % (path->mode))}))]
+ (.addEventListener js/window "popstate" listener)
+ (reset! popstate-listener listener)))
+ (render!))
+
+(init!)
diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/sandbox.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/sandbox.cljc
new file mode 100644
index 0000000..20c0de3
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/sandbox.cljc
@@ -0,0 +1,151 @@
+(ns sg.flybot.playground.ui.core.sandbox
+ "Sandbox mode — browser-side atom-source collections with CRUD.
+
+ Detects mutations from pattern structure (nil key = create,
+ nil value = delete) and executes patterns against atom-sources."
+ (:require #?(:clj [clojure.edn :as edn]
+ :cljs [cljs.reader :as reader])
+ [sg.flybot.playground.common.data :as data]
+ [sg.flybot.pullable.collection :as coll]
+ [sg.flybot.pullable.impl :as impl]
+ [sg.flybot.pullable.remote :as remote]
+ [sg.flybot.pullable.malli]
+ [malli.core :as m]
+ #?(:cljs [sci.core :as sci])))
+
+;;=============================================================================
+;; SCI Context (ClojureScript only)
+;;=============================================================================
+
+#?(:cljs
+ (def ^:private sci-ctx
+ (sci/init {:namespaces {'clojure.core {'pos? pos?
+ 'neg? neg?
+ 'zero? zero?
+ 'even? even?
+ 'odd? odd?
+ 'string? string?
+ 'number? number?
+ 'keyword? keyword?
+ 'symbol? symbol?
+ 'map? map?
+ 'vector? vector?
+ 'seq? seq?
+ 'set? set?
+ 'nil? nil?
+ 'some? some?
+ 'empty? empty?
+ 'count count
+ '= =
+ 'not= not=
+ '< <
+ '> >
+ '<= <=
+ '>= >=
+ 'identity identity}}})))
+
+#?(:cljs
+ (defn- sci-resolve [sym]
+ (sci/eval-form sci-ctx sym)))
+
+#?(:cljs
+ (defn- sci-eval [form]
+ (sci/eval-form sci-ctx form)))
+
+;;=============================================================================
+;; Sources
+;;=============================================================================
+
+(defonce sources (atom nil))
+(defonce compiled-schema (atom nil))
+
+(defn- make-sources [data]
+ {:users (coll/atom-source {:initial (:users data)})
+ :posts (coll/atom-source {:initial (:posts data)})})
+
+(defn- make-collections [srcs]
+ {:users (coll/collection (:users srcs))
+ :posts (coll/collection (:posts srcs))})
+
+(defn snapshot []
+ (when-let [srcs @sources]
+ (let [colls (make-collections srcs)]
+ {:users (vec (seq (:users colls)))
+ :posts (vec (seq (:posts colls)))
+ :config (:config data/default-data)})))
+
+(defn init! []
+ (reset! sources (make-sources data/default-data))
+ (reset! compiled-schema (m/schema data/default-schema))
+ (snapshot))
+
+(defn reset-data! []
+ (init!))
+
+;;=============================================================================
+;; Execution
+;;=============================================================================
+
+(defn- collection? [v]
+ (instance? #?(:clj sg.flybot.pullable.collection.Collection
+ :cljs coll/Collection) v))
+
+(defn- realize
+ "Convert Collection objects to plain vectors."
+ [v]
+ (cond
+ (collection? v) (vec (seq v))
+ (map? v) (into {} (map (fn [[k v]] [k (realize v)])) v)
+ (vector? v) (mapv realize v)
+ (seq? v) (map realize v)
+ :else v))
+
+(defn- read-pattern [s]
+ #?(:clj (edn/read-string s)
+ :cljs (reader/read-string s)))
+
+(defn execute!
+ "Execute a pattern string against sandbox collections.
+ Auto-detects read vs mutation from pattern structure.
+ Returns {:result bindings :snapshot data} or {:error message}."
+ [pattern-str]
+ (try
+ (let [pattern (read-pattern pattern-str)]
+ (if-let [{:keys [path query value]} (remote/parse-mutation pattern)]
+ (let [coll-key (first path)
+ coll (get (make-collections @sources) coll-key)]
+ (if (and coll (satisfies? coll/Mutable coll))
+ {:result {coll-key (coll/mutate! coll query value)}
+ :snapshot (snapshot)}
+ {:error (str "Collection " coll-key " not found or not mutable")}))
+ (let [colls (make-collections @sources)
+ data (assoc colls :config (:config data/default-data))
+ opts #?(:clj {:schema @compiled-schema}
+ :cljs {:schema @compiled-schema
+ :resolve sci-resolve :eval-fn sci-eval})
+ matcher (impl/compile-pattern pattern opts)
+ result (matcher (impl/vmr data))]
+ (if (impl/failure? result)
+ {:error (str "Match failed: " (:reason result)
+ (when (seq (:path result))
+ (str " at path " (:path result))))}
+ {:result (realize (:vars result)) :snapshot (snapshot)}))))
+ (catch #?(:clj Exception :cljs :default) e
+ {:error (str "Error: " #?(:clj (.getMessage e) :cljs (.-message e)))})))
+
+;;=============================================================================
+;; Tests
+;;=============================================================================
+
+^:rct/test
+(comment
+ (do (init!) (:result (execute! "{:config ?cfg}")))
+ ;=>> {'cfg map?}
+
+ (do (init!) (get-in (execute! "{:users ?all}") [:result 'all]))
+ ;=>> vector?
+
+ (do (init!)
+ (-> (execute! "{:users {nil {:id 99 :name \"Dave\" :email \"d@e\" :role :user}}}")
+ :snapshot :users count)))
+ ;=> 4)
diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/state.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/state.cljc
new file mode 100644
index 0000000..77e987a
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/state.cljc
@@ -0,0 +1,145 @@
+(ns sg.flybot.playground.ui.core.state
+ "Application state — pure db→db updater functions.
+
+ State lives under :app/playground in the app-db atom.
+ Each updater takes a state map and returns a new state map.")
+
+;;=============================================================================
+;; State Shape
+;;=============================================================================
+
+(def initial-state
+ {:mode :sandbox ; :sandbox | :remote (path-based: /sandbox, /remote)
+ :pattern-text "" ; Pattern editor content
+ :result nil ; Bindings map (symbol → value)
+ :error nil ; Error message string
+ :loading? false
+ :selected-example nil ; Index of selected example
+ ;; Sandbox mode
+ :sandbox-data nil ; Snapshot: {:users [...] :posts [...] :config {...}}
+ :sandbox-schema nil ; Malli hiccup form for autocomplete/tooltips
+ :data-view :data ; :data | :schema toggle
+ ;; Remote mode
+ :remote-data nil ; Server snapshot: {:users [...] :posts [...] :config {...}}
+ :server-url "http://localhost:8081/api"
+ :schema nil ; Remote server schema
+ :schema-loading? false
+ :schema-error nil
+ :sample-data nil ; Generated sample data from schema
+ :schema-view-mode :schema ; :schema | :sample toggle
+ ;; Autocomplete
+ :autocomplete nil}) ; {:completions [...] :selected 0 :prefix ":" :x :y}
+
+;;=============================================================================
+;; Pure Updaters (db → db)
+;;=============================================================================
+
+(defn clear-result [db]
+ (assoc db :result nil :error nil))
+
+(defn set-loading [db]
+ (assoc db :loading? true :error nil :result nil))
+
+(defn set-mode [db mode]
+ (-> db
+ (assoc :mode mode)
+ (assoc :result nil :error nil :selected-example nil)
+ (assoc :schema nil :schema-error nil :sample-data nil)
+ (assoc :schema-view-mode :schema)))
+
+(defn set-result [db result]
+ (assoc db :loading? false :result result :error nil))
+
+(defn set-error [db error]
+ (assoc db :loading? false :error error :result nil))
+
+(defn set-sandbox-data [db snapshot]
+ (assoc db :sandbox-data snapshot))
+
+(defn set-sandbox-schema [db schema]
+ (assoc db :sandbox-schema schema))
+
+(defn set-remote-data [db data]
+ (assoc db :remote-data data))
+
+;;=============================================================================
+;; Remote mode updaters
+;;=============================================================================
+
+(defn set-schema-loading [db]
+ (assoc db :schema nil :schema-error nil :schema-loading? true))
+
+(defn set-schema [db schema]
+ (assoc db :schema-loading? false :schema schema :schema-error nil :sample-data nil))
+
+(defn set-schema-error [db error]
+ (assoc db :schema-loading? false :schema nil :schema-error error :sample-data nil))
+
+;;=============================================================================
+;; Autocomplete updaters
+;;=============================================================================
+
+(defn show-autocomplete [db data]
+ (assoc db :autocomplete data))
+
+(defn hide-autocomplete [db]
+ (assoc db :autocomplete nil))
+
+(defn select-autocomplete [db idx]
+ (assoc-in db [:autocomplete :selected] idx))
+
+(defn move-autocomplete-selection [db direction]
+ (let [{:keys [completions selected]} (:autocomplete db)
+ n (count completions)]
+ (assoc-in db [:autocomplete :selected] (mod (+ selected direction) n))))
+
+;;=============================================================================
+;; Tests
+;;=============================================================================
+
+^:rct/test
+(comment
+ ;; set-mode changes mode and clears results
+ (let [db (set-mode {:mode :sandbox :result {:data 1}} :remote)]
+ [(:mode db) (:result db)])
+ ;=> [:remote nil]
+
+ ;; clear-result nils out result and error
+ (let [db (clear-result {:result {:a 1} :error "oops"})]
+ [(:result db) (:error db)])
+ ;=> [nil nil]
+
+ ;; set-loading sets loading and clears result/error
+ (let [db (set-loading {:loading? false :result {:a 1} :error "x"})]
+ [(:loading? db) (:result db) (:error db)])
+ ;=> [true nil nil]
+
+ ;; set-result stores bindings
+ (let [db (set-result {:loading? true} {'x 1})]
+ [(:loading? db) (:result db)])
+ ;=> [false {'x 1}]
+
+ ;; set-error stores error
+ (let [db (set-error {:loading? true} "Parse error")]
+ [(:loading? db) (:error db)])
+ ;=> [false "Parse error"]
+
+ ;; set-sandbox-data stores snapshot
+ (let [db (set-sandbox-data {} {:users [{:id 1}]})]
+ (:sandbox-data db))
+ ;=> {:users [{:id 1}]}
+
+ ;; set-remote-data stores remote server snapshot
+ (let [db (set-remote-data {} {:users [{:id 1}]})]
+ (:remote-data db))
+ ;=> {:users [{:id 1}]}
+
+ ;; set-schema stores schema and clears sample-data
+ (let [db (set-schema {:schema-loading? true :sample-data {:old "data"}} [:map [:name :string]])]
+ [(:schema-loading? db) (:schema db) (:sample-data db)])
+ ;=> [false [:map [:name :string]] nil]
+
+ ;; move-autocomplete-selection wraps around
+ (let [db {:autocomplete {:completions [:a :b :c] :selected 2}}]
+ (:selected (:autocomplete (move-autocomplete-selection db 1)))))
+ ;=> 0)
diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/views.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/views.cljc
new file mode 100644
index 0000000..9b69b01
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views.cljc
@@ -0,0 +1,287 @@
+(ns sg.flybot.playground.ui.core.views
+ "UI views — defalias components with namespaced props.
+
+ Components receive data via ::keys and dispatch effects directly."
+ (:require [sg.flybot.playground.ui.core.views.examples :as examples]
+ [sg.flybot.playground.ui.core.views.edn-editor-interactive.editor :as edn]
+ [sg.flybot.playground.ui.core.state :as state]
+ [clojure.string :as str]
+ #?(:cljs [sg.flybot.playground.ui.core.views.edn-editor-interactive :as edn-i])
+ #?(:cljs [replicant.alias :refer [defalias]])))
+
+;;=============================================================================
+;; Helpers
+;;=============================================================================
+
+(defn- format-result [v]
+ (if (nil? v) "nil" (pr-str v)))
+
+(defn- format-result-pretty [v]
+ (str/trim (edn/pretty-str v)))
+
+;;=============================================================================
+;; Icons
+;;=============================================================================
+
+(defn- sun-icon []
+ [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
+ [:circle {:cx "12" :cy "12" :r "5"}]
+ [:line {:x1 "12" :y1 "1" :x2 "12" :y2 "3"}]
+ [:line {:x1 "12" :y1 "21" :x2 "12" :y2 "23"}]
+ [:line {:x1 "4.22" :y1 "4.22" :x2 "5.64" :y2 "5.64"}]
+ [:line {:x1 "18.36" :y1 "18.36" :x2 "19.78" :y2 "19.78"}]
+ [:line {:x1 "1" :y1 "12" :x2 "3" :y2 "12"}]
+ [:line {:x1 "21" :y1 "12" :x2 "23" :y2 "12"}]
+ [:line {:x1 "4.22" :y1 "19.78" :x2 "5.64" :y2 "18.36"}]
+ [:line {:x1 "18.36" :y1 "5.64" :x2 "19.78" :y2 "4.22"}]])
+
+(defn- moon-icon []
+ [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
+ [:path {:d "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"}]])
+
+;;=============================================================================
+;; Components
+;;=============================================================================
+
+#?(:cljs
+ (defalias site-header
+ [{::keys [db dispatch!]}]
+ (let [{:keys [mode]} db]
+ [:header.site-header
+ [:h1 "Pull Pattern Playground"]
+ [:div.header-right
+ [:div.mode-toggle
+ [:button {:class (when (= mode :sandbox) "active")
+ :on {:click #(dispatch! {:db (fn [db] (state/set-mode db :sandbox))
+ :nav :sandbox})}}
+ "Sandbox"]
+ [:button {:class (when (= mode :remote) "active")
+ :on {:click #(dispatch! {:db (fn [db] (state/set-mode db :remote))
+ :nav :remote
+ :pull :init})}}
+ "Remote"]]
+ [:button.theme-toggle {:title "Toggle theme"
+ :on {:click #(js/sg.flybot.playground.ui.core.toggle_theme_BANG_)}}
+ [:span.show-light (moon-icon)]
+ [:span.show-dark (sun-icon)]]]])))
+
+#?(:clj
+ (defn site-header [{}]
+ [:header.site-header
+ [:h1 "Pull Pattern Playground"]]))
+
+;;-----------------------------------------------------------------------------
+;; Data Panel
+;;-----------------------------------------------------------------------------
+
+(defn- data-display
+ "Read-only display of sample data with :data/:schema toggle and optional reset."
+ [{::keys [displayed-data displayed-schema data-view mode dispatch!]}]
+ [:div.sandbox-data-section
+ [:div.sandbox-header
+ [:div.data-view-toggle
+ [:button {:class (when (= data-view :data) "active")
+ :on {:click #(dispatch! {:db (fn [db] (assoc db :data-view :data))})}}
+ "Data"]
+ [:button {:class (when (= data-view :schema) "active")
+ :on {:click #(dispatch! {:db (fn [db] (assoc db :data-view :schema))})}}
+ "Schema"]]
+ (when (= mode :sandbox)
+ [:button.reset-btn {:on {:click #(dispatch! {:pull :reset})}}
+ "Reset"])]
+ [:div.sandbox-content
+ (let [text (format-result-pretty (if (= data-view :schema) displayed-schema displayed-data))]
+ (edn/edn-display {:value text :placeholder "No data"}))]])
+
+#?(:cljs
+ (defalias data-panel
+ [{::keys [db dispatch!]}]
+ (let [{:keys [mode server-url sandbox-data sandbox-schema remote-data data-view]} db]
+ [:div.panel.data-panel
+ [:div.panel-header
+ [:h2 "Data"]]
+ [:div.panel-content
+ (when (= mode :remote)
+ [:div.remote-sections
+ [:div.editor-section.url-section
+ [:label "Server URL"
+ [:span.info-hint {:data-tooltip "Remote mode connects to a running Pull Pattern server. Clone the repository and run the demo server locally."} "i"]]
+ [:input {:type "text"
+ :value server-url
+ :placeholder "http://localhost:8081/api"
+ :on {:input #(dispatch! {:db (fn [db] (assoc db :server-url (.. % -target -value)))})}}]]])
+ (data-display
+ {::displayed-data (if (= mode :remote) remote-data sandbox-data)
+ ::displayed-schema sandbox-schema
+ ::data-view data-view
+ ::mode mode
+ ::dispatch! dispatch!})]]))
+
+ :clj
+ (defn data-panel [{}]
+ [:div.panel.data-panel
+ [:div.panel-header
+ [:h2 "Data"]]
+ [:div.panel-content
+ [:div "Sample data"]]]))
+
+;;-----------------------------------------------------------------------------
+;; Pattern + Results Panel
+;;-----------------------------------------------------------------------------
+
+(def ^:private pattern-editor-id "pattern-editor")
+
+#?(:cljs
+ (defalias pattern-results-panel
+ [{::keys [db dispatch!]}]
+ (let [{:keys [mode pattern-text loading? schema sandbox-schema autocomplete result error]} db
+ active-schema (case mode
+ :remote schema
+ :sandbox sandbox-schema
+ nil)]
+ [:div.panel.pattern-results-panel
+ ;; Pattern section
+ [:div.pattern-section
+ [:div.section-header
+ [:label "Pattern"]
+ [:button.execute-btn
+ {:on {:click #(dispatch! {:db state/set-loading
+ :pull (:pattern-text db)})}
+ :disabled loading?}
+ (if loading? "Executing..." "Execute")]]
+ [:div.pattern-editor
+ (edn-i/edn-editor
+ {:value pattern-text
+ :placeholder "Enter a pull pattern, e.g. {:name ?n}"
+ :on-change #(dispatch! {:db (fn [db] (assoc db :pattern-text %))})
+ :parinfer-mode :indent
+ :editor-id pattern-editor-id
+ :schema (when (#{:remote :sandbox} mode) active-schema)
+ :hover-tooltips? (and (#{:remote :sandbox} mode) (some? active-schema))
+ :autocomplete (when (#{:remote :sandbox} mode) autocomplete)
+ :on-autocomplete #(dispatch! {:db (fn [db] (state/show-autocomplete db %))})
+ :on-autocomplete-hide #(dispatch! {:db state/hide-autocomplete})
+ :on-autocomplete-select #(dispatch! {:db (fn [db] (state/select-autocomplete db %))})
+ :on-autocomplete-move #(dispatch! {:db (fn [db] (state/move-autocomplete-selection db %))})})]
+ (when (and (#{:remote :sandbox} mode) autocomplete)
+ (edn-i/editor-autocomplete
+ {:editor-id pattern-editor-id
+ :autocomplete autocomplete
+ :on-change #(dispatch! {:db (fn [db] (assoc db :pattern-text %))})
+ :on-autocomplete-hide #(dispatch! {:db state/hide-autocomplete})
+ :on-autocomplete-select #(dispatch! {:db (fn [db] (state/select-autocomplete db %))})}))]
+ ;; Results section
+ [:div.results-section
+ [:div.section-header
+ [:label "Results"]]
+ [:div.results-content
+ (cond
+ loading?
+ [:div.loading "Executing pattern..."]
+
+ error
+ [:div.result-value.error error]
+
+ result
+ [:div.result-value.success
+ (edn/highlight-edn (format-result-pretty result))]
+
+ :else
+ [:div.result-value.empty "Enter a pattern and data, then click Execute"])]]]))
+
+ :clj
+ (defn pattern-results-panel [{::keys [db dispatch!]}]
+ (let [{:keys [pattern-text result error]} db]
+ [:div.panel.pattern-results-panel
+ [:div.pattern-section
+ [:div.section-header
+ [:label "Pattern"]
+ [:button.execute-btn "Execute"]]
+ [:div.pattern-editor
+ [:textarea {:value pattern-text
+ :placeholder "Enter a pull pattern, e.g. {:name ?n}"}]]]
+ [:div.results-section
+ [:div.section-header [:label "Results"]]
+ [:div.results-content
+ (cond
+ error [:div.result-value.error error]
+ result [:div.result-value.success (format-result-pretty result)]
+ :else [:div.result-value.empty "Enter a pattern and data, then click Execute"])]]])))
+
+;;-----------------------------------------------------------------------------
+;; Examples Panel
+;;-----------------------------------------------------------------------------
+
+#?(:cljs
+ (defalias examples-panel
+ [{::keys [selected-example dispatch!]}]
+ [:div.panel.examples-panel
+ [:div.panel-header
+ [:h2 "Examples"]]
+ [:div.panel-content
+ [:ul.example-list
+ (for [[idx example] (map-indexed vector examples/examples)]
+ [:li {:replicant/key idx
+ :class (when (= idx selected-example) "active")
+ :title (:description example)
+ :on {:click #(dispatch!
+ {:db (fn [db]
+ (-> db
+ (assoc :pattern-text (:pattern example)
+ :selected-example idx)
+ state/clear-result))})}}
+ (:name example)])]
+ [:div.syntax-reference
+ [:h3 "Syntax Reference"]
+ [:div.syntax-list
+ (for [{:keys [syntax description]} examples/syntax-reference]
+ [:div.syntax-item {:replicant/key syntax}
+ [:code syntax]
+ [:span description]])]]]])
+
+ :clj
+ (defn examples-panel [{::keys [selected-example dispatch!]}]
+ [:div.panel.examples-panel
+ [:div.panel-header [:h2 "Examples"]]
+ [:div.panel-content
+ [:ul.example-list
+ (for [[idx example] (map-indexed vector examples/examples)]
+ [:li {:replicant/key idx
+ :class (when (= idx selected-example) "active")}
+ (:name example)])]]]))
+
+;;=============================================================================
+;; App Root
+;;=============================================================================
+
+(defn app-view [{::keys [db dispatch!]}]
+ (let [{:keys [selected-example]} db]
+ [:div.app-container
+ #?(:cljs [::site-header {::db db ::dispatch! dispatch!}]
+ :clj (site-header {::db db ::dispatch! dispatch!}))
+ [:div.main-content.with-sidebar
+ #?(:cljs [::data-panel {::db db ::dispatch! dispatch!}]
+ :clj (data-panel {::db db ::dispatch! dispatch!}))
+ #?(:cljs [::pattern-results-panel {::db db ::dispatch! dispatch!}]
+ :clj (pattern-results-panel {::db db ::dispatch! dispatch!}))
+ #?(:cljs [::examples-panel {::selected-example selected-example
+ ::dispatch! dispatch!}]
+ :clj (examples-panel {::selected-example selected-example
+ ::dispatch! dispatch!}))]]))
+
+;;=============================================================================
+;; Tests
+;;=============================================================================
+
+^:rct/test
+(comment
+ ;; format-result handles nil
+ (format-result nil) ;=> "nil"
+
+ ;; format-result handles maps
+ (format-result {:a 1}) ;=> "{:a 1}"
+
+ ;; app-view returns container
+ (first (app-view {::db {:mode :sandbox :pattern-text ""}
+ ::dispatch! identity})))
+ ;=> :div.app-container)
diff --git a/examples/pull-playground/src/sg/flybot/playground/edn_editor_interactive.cljs b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive.cljs
similarity index 99%
rename from examples/pull-playground/src/sg/flybot/playground/edn_editor_interactive.cljs
rename to examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive.cljs
index c59db15..b57491e 100644
--- a/examples/pull-playground/src/sg/flybot/playground/edn_editor_interactive.cljs
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive.cljs
@@ -1,11 +1,11 @@
-(ns sg.flybot.playground.edn-editor-interactive
+(ns sg.flybot.playground.ui.core.views.edn-editor-interactive
"Interactive EDN editor with parinfer support.
This ClojureScript-only module handles the interactive editing behavior:
- Textarea for input
- Overlay with syntax highlighting
- Parinfer integration for auto-balancing"
- (:require [sg.flybot.playground.edn-editor :as edn]
+ (:require [sg.flybot.playground.ui.core.views.edn-editor-interactive.editor :as edn]
[parinferish.core :as paren]
[clojure.string :as str]
[malli.core :as m]))
diff --git a/examples/pull-playground/src/sg/flybot/playground/edn_editor.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive/editor.cljc
similarity index 99%
rename from examples/pull-playground/src/sg/flybot/playground/edn_editor.cljc
rename to examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive/editor.cljc
index 22d62ac..53446b8 100644
--- a/examples/pull-playground/src/sg/flybot/playground/edn_editor.cljc
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive/editor.cljc
@@ -1,4 +1,4 @@
-(ns sg.flybot.playground.edn-editor
+(ns sg.flybot.playground.ui.core.views.edn-editor-interactive.editor
"EDN editor component with syntax highlighting and rainbow parens.
Uses parinferish to parse EDN/Clojure code and render it with
diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/views/examples.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/examples.cljc
new file mode 100644
index 0000000..4dfa40c
--- /dev/null
+++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/examples.cljc
@@ -0,0 +1,65 @@
+(ns sg.flybot.playground.ui.core.views.examples
+ "Pre-built example patterns for the playground.")
+
+(def examples
+ [{:name "List all users"
+ :description "Read all users from the collection"
+ :pattern "{:users ?all}"}
+
+ {:name "List all posts"
+ :description "Read all posts from the collection"
+ :pattern "{:posts ?all}"}
+
+ {:name "Multiple bindings"
+ :description "Extract multiple top-level keys at once"
+ :pattern "{:users ?u :posts ?p}"}
+
+ {:name "Lookup user by ID"
+ :description "Find a specific user using indexed lookup"
+ :pattern "{:users {{:id 2} ?user}}"}
+
+ {:name "Read config"
+ :description "Read the application configuration"
+ :pattern "{:config ?cfg}"}
+
+ {:name "Nested map"
+ :description "Match nested structure inside config"
+ :pattern "{:config {:features ?features}}"}
+
+ {:name "Default value"
+ :description "Provide fallback for missing keys"
+ :pattern "{:config {:debug (?d :default false)}}"}
+
+ {:name "Constrained lookup"
+ :description "Find a post and apply a predicate constraint"
+ :pattern "{:posts {{:id 1} {:title (?t :when string?)}}}"}
+
+ {:name "Create user"
+ :description "Create with nil key — auto-detected as mutation"
+ :pattern "{:users {nil {:name \"Dave\" :email \"dave@example.com\" :role :user}}}"}
+
+ {:name "Update user"
+ :description "Update with map value — auto-detected as mutation"
+ :pattern "{:users {{:id 1} {:name \"Alice Updated\"}}}"}
+
+ {:name "Delete user"
+ :description "Delete with nil value — auto-detected as mutation"
+ :pattern "{:users {{:id 2} nil}}"}])
+
+(def syntax-reference
+ [{:syntax "?x" :description "Bind value to x"}
+ {:syntax "?_" :description "Wildcard (match any)"}
+ {:syntax "?x?" :description "Optional (0-1)"}
+ {:syntax "?x*" :description "Zero or more"}
+ {:syntax "?x+" :description "One or more"}
+ {:syntax "{}" :description "Map pattern"}
+ {:syntax "[]" :description "Sequence pattern"}
+ {:syntax ":when" :description "Predicate constraint"}
+ {:syntax ":default" :description "Fallback value"}
+ {:syntax "{nil data}" :description "Create (sandbox)"}
+ {:syntax "{{:id 1} nil}" :description "Delete (sandbox)"}])
+
+^:rct/test
+(comment
+ (count examples) ;=> 11
+ (:name (first examples))) ;=> "List all users")
diff --git a/examples/pull-playground/src/sg/flybot/playground/views.cljc b/examples/pull-playground/src/sg/flybot/playground/views.cljc
deleted file mode 100644
index e190d41..0000000
--- a/examples/pull-playground/src/sg/flybot/playground/views.cljc
+++ /dev/null
@@ -1,218 +0,0 @@
-(ns sg.flybot.playground.views
- "UI views - pure functions returning hiccup.
-
- Views emit events by calling (dispatch! :event) or (dispatch! [:event arg])."
- (:require [sg.flybot.playground.examples :as examples]
- [sg.flybot.playground.edn-editor :as edn]
- [clojure.string :as str]
- #?(:cljs [sg.flybot.playground.edn-editor-interactive :as edn-i])))
-
-;;=============================================================================
-;; Helpers
-;;=============================================================================
-
-(defn- format-result
- "Format a Clojure value for display (single line)"
- [v]
- (if (nil? v)
- "nil"
- (pr-str v)))
-
-(defn- format-result-pretty
- "Format a Clojure value with pretty-printing"
- [v]
- (str/trim (edn/pretty-str v)))
-
-;;=============================================================================
-;; Components
-;;=============================================================================
-
-(defn- sun-icon []
- [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
- [:circle {:cx "12" :cy "12" :r "5"}]
- [:line {:x1 "12" :y1 "1" :x2 "12" :y2 "3"}]
- [:line {:x1 "12" :y1 "21" :x2 "12" :y2 "23"}]
- [:line {:x1 "4.22" :y1 "4.22" :x2 "5.64" :y2 "5.64"}]
- [:line {:x1 "18.36" :y1 "18.36" :x2 "19.78" :y2 "19.78"}]
- [:line {:x1 "1" :y1 "12" :x2 "3" :y2 "12"}]
- [:line {:x1 "21" :y1 "12" :x2 "23" :y2 "12"}]
- [:line {:x1 "4.22" :y1 "19.78" :x2 "5.64" :y2 "18.36"}]
- [:line {:x1 "18.36" :y1 "5.64" :x2 "19.78" :y2 "4.22"}]])
-
-(defn- moon-icon []
- [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
- [:path {:d "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"}]])
-
-(defn site-header [state dispatch!]
- (let [{:keys [mode]} state]
- [:header.site-header
- [:h1 "Pull Pattern Playground"]
- [:div.header-right
- [:div.mode-toggle
- [:button {:class (when (= mode :local) "active")
- :on {:click #(dispatch! [:set-mode :local])}}
- "Local"]
- [:button {:class (when (= mode :remote) "active")
- :on {:click #(dispatch! [:set-mode :remote])}}
- "Remote"]]
- [:button.theme-toggle {:title "Toggle theme"
- :on {:click #?(:clj identity
- :cljs #(js/sg.flybot.playground.core.toggle_theme_BANG_))}}
- [:span.show-light (moon-icon)]
- [:span.show-dark (sun-icon)]]]]))
-
-(defn- schema-display [state dispatch!]
- (let [{:keys [schema schema-loading? schema-error sample-data schema-view-mode]} state]
- [:div.schema-section
- [:div.schema-header
- [:label "Server Schema"]
- [:button.fetch-schema-btn {:on {:click #(dispatch! :fetch-schema)}
- :disabled schema-loading?}
- (if schema-loading? "Loading..." "Fetch")]]
- #?(:cljs (edn-i/schema-viewer
- {:schema schema
- :sample-data sample-data
- :view-mode schema-view-mode
- :on-mode-change #(dispatch! [:set-schema-view-mode %])
- :loading? schema-loading?
- :error schema-error
- :placeholder "Click Fetch to load schema"})
- :clj [:div.schema-content
- (cond
- schema-error [:div.schema-error schema-error]
- schema [:pre.schema-value (format-result-pretty schema)]
- :else [:div.schema-empty "Click Fetch to load schema"])])]))
-
-(defn data-panel
- "Left panel: Data (local) or Schema (remote)"
- [state dispatch!]
- (let [{:keys [mode data-text server-url]} state]
- [:div.panel.data-panel
- [:div.panel-header
- [:h2 (if (= mode :local) "Data" "Schema")]]
- [:div.panel-content
- (if (= mode :local)
- [:div.editor-section
- #?(:cljs (edn-i/edn-editor
- {:value data-text
- :placeholder "Enter EDN data to match against"
- :on-change #(dispatch! [:update-data %])
- :parinfer-mode :indent})
- :clj [:textarea {:value data-text
- :placeholder "Enter EDN data to match against"}])]
- [:div.remote-sections
- [:div.editor-section.url-section
- [:label "Server URL"]
- [:input {:type "text"
- :value server-url
- :placeholder "http://localhost:8081/api"
- :on {:input #(dispatch! [:update-server-url (.. % -target -value)])}}]]
- (schema-display state dispatch!)])]]))
-
-(def ^:private pattern-editor-id "pattern-editor")
-
-(defn pattern-results-panel
- "Right panel: Pattern editor + Results stacked vertically"
- [state dispatch!]
- (let [{:keys [mode pattern-text loading? schema autocomplete result error]} state]
- [:div.panel.pattern-results-panel
- ;; Pattern section
- [:div.pattern-section
- [:div.section-header
- [:label "Pattern"]
- [:button.execute-btn {:on {:click #(dispatch! :execute)}
- :disabled loading?}
- (if loading? "Executing..." "Execute")]]
- [:div.pattern-editor
- #?(:cljs (edn-i/edn-editor
- {:value pattern-text
- :placeholder "Enter a pull pattern, e.g. {:name ?n}"
- :on-change #(dispatch! [:update-pattern %])
- :parinfer-mode :indent
- :editor-id pattern-editor-id
- ;; Pass schema for autocomplete and hover tooltips in remote mode
- :schema (when (= mode :remote) schema)
- ;; Enable hover tooltips in remote mode
- :hover-tooltips? (and (= mode :remote) (some? schema))
- ;; Autocomplete state and callbacks (dropdown rendered separately below)
- :autocomplete (when (= mode :remote) autocomplete)
- :on-autocomplete #(dispatch! [:show-autocomplete %])
- :on-autocomplete-hide #(dispatch! :hide-autocomplete)
- :on-autocomplete-select #(dispatch! [:select-autocomplete %])
- :on-autocomplete-move #(dispatch! [:move-autocomplete %])})
- :clj [:textarea {:value pattern-text
- :placeholder "Enter a pull pattern, e.g. {:name ?n}"}])]
- ;; Autocomplete dropdown - rendered outside editor to avoid overflow clipping
- #?(:cljs (when (and (= mode :remote) autocomplete)
- (edn-i/editor-autocomplete
- {:editor-id pattern-editor-id
- :autocomplete autocomplete
- :on-change #(dispatch! [:update-pattern %])
- :on-autocomplete-hide #(dispatch! :hide-autocomplete)
- :on-autocomplete-select #(dispatch! [:select-autocomplete %])}))
- :clj nil)]
- ;; Results section
- [:div.results-section
- [:div.section-header
- [:label "Results"]]
- [:div.results-content
- (cond
- loading?
- [:div.loading "Executing pattern..."]
-
- error
- [:div.result-value.error error]
-
- result
- [:div.result-value.success
- (edn/highlight-edn (format-result-pretty result))]
-
- :else
- [:div.result-value.empty "Enter a pattern and data, then click Execute"])]]]))
-
-(defn examples-panel [state dispatch!]
- (let [selected (:selected-example state)]
- [:div.panel.examples-panel
- [:div.panel-header
- [:h2 "Examples"]]
- [:div.panel-content
- [:ul.example-list
- (for [[idx example] (map-indexed vector examples/examples)]
- [:li {:replicant/key idx
- :class (when (= idx selected) "active")
- :title (:description example)
- :on {:click #(do (dispatch! [:select-example example])
- (dispatch! [:set-selected-example idx]))}}
- (:name example)])]
- [:div.syntax-reference
- [:h3 "Syntax Reference"]
- [:div.syntax-list
- (for [{:keys [syntax description]} examples/syntax-reference]
- [:div.syntax-item {:replicant/key syntax}
- [:code syntax]
- [:span description]])]]]]))
-
-(defn app-view [state dispatch!]
- (let [{:keys [mode]} state]
- [:div.app-container
- (site-header state dispatch!)
- [:div.main-content {:class (when (= mode :local) "with-sidebar")}
- (data-panel state dispatch!)
- (pattern-results-panel state dispatch!)
- (when (= mode :local)
- (examples-panel state dispatch!))]]))
-
-;;=============================================================================
-;; Tests
-;;=============================================================================
-
-^:rct/test
-(comment
- ;; format-result handles nil
- (format-result nil) ;=> "nil"
-
- ;; format-result handles maps
- (format-result {:a 1}) ;=> "{:a 1}"
-
- ;; app-view returns container
- (first (app-view {:mode :local :pattern-text "" :data-text ""} identity))) ;=> :div.app-container)
diff --git a/pattern/src/sg/flybot/pullable/impl.cljc b/pattern/src/sg/flybot/pullable/impl.cljc
index c6a00f5..c7b5e3c 100644
--- a/pattern/src/sg/flybot/pullable/impl.cljc
+++ b/pattern/src/sg/flybot/pullable/impl.cljc
@@ -10,7 +10,7 @@
[clojure.walk :as walk]
[clojure.zip :as zip]
[sg.flybot.pullable.schema :as schema]
- [sg.flybot.pullable.util :refer [vars->]])
+ [sg.flybot.pullable.util :refer [vars-> variable?]])
#?(:cljs (:require-macros [sg.flybot.pullable.impl])))
;;=============================================================================
@@ -1203,7 +1203,7 @@
(and (seq? x)
(let [fst (first x)]
(or ;; Original form: (?x :opt ...)
- (and (symbol? fst) (= \? (first (name fst))))
+ (variable? fst)
;; After matching-var-rewrite: ((? :var ...) :opt ...)
(and (seq? fst) (= '? (first fst)) (= :var (second fst)))
;; Rewritten wildcard: ((? :any) :skip ...)
@@ -1385,9 +1385,7 @@
[fst]
(cond
;; Quantified var: ?x*, ?x+, ?x?, ?x*!, ?x+!
- (and (symbol? fst)
- (= \? (first (name fst)))
- (parse-matching-var fst))
+ (and (variable? fst) (parse-matching-var fst))
(let [{:keys [sym quantifier greedy?]} (parse-matching-var fst)
var-sym (when sym (symbol (subs (name sym) 1)))]
(case quantifier
@@ -1397,8 +1395,7 @@
nil))
;; Simple var: ?x, ?_ (bare ? is not a var - it's the core pattern marker)
- (and (symbol? fst)
- (= \? (first (name fst)))
+ (and (variable? fst)
(> (count (name fst)) 1)
(not (re-find forbidden-var-chars (subs (name fst) 1))))
(let [var-name (subs (name fst) 1)]
@@ -1818,7 +1815,7 @@
[template vars]
(walk/postwalk
(fn [x]
- (if (and (symbol? x) (= \? (first (name x))))
+ (if (variable? x)
(let [nm (name x)
;; Strip ? prefix and any quantifier suffix
base (if-let [[_ b] (re-matches #"\?([^\s\?\+\*\!]+)[\?\+\*]?\!?" nm)]
@@ -1922,7 +1919,7 @@
vars (atom #{})
_ (walk/postwalk
(fn [x]
- (when (and (symbol? x) (= \? (first (name x))))
+ (when (variable? x)
(let [nm (name x)
;; Strip prefix ? and any suffix quantifiers
base (if-let [[_ b] (re-matches #"\?([^\s\?\+\*\!]+)[\?\+\*]?\!?" nm)]
@@ -2110,6 +2107,17 @@
(compile-pattern '?x {:schema :map}) ;=>> fn?
(compile-pattern '?x {:schema :string}) ;=>> fn?
+ ;; Valid: :default and :when chain patterns on scalar schema
+ (compile-pattern '{:name (?n :default "anon")} {:schema {:name :string}}) ;=>> fn?
+ (compile-pattern '{:age (?a :when pos?)} {:schema {:age :number}}) ;=>> fn?
+
+ ;; Invalid: key not in schema throws even with :default
+ (try
+ (compile-pattern '{:debug (?d :default false)} {:schema {:version :string}})
+ (catch clojure.lang.ExceptionInfo e
+ (:key (ex-data e))))
+ ;=> :debug
+
;; Invalid: seq pattern on :map schema - throws
(try
(compile-pattern '[?x] {:schema :map})
diff --git a/pattern/src/sg/flybot/pullable/schema.cljc b/pattern/src/sg/flybot/pullable/schema.cljc
index e5f58c3..a8f5a68 100644
--- a/pattern/src/sg/flybot/pullable/schema.cljc
+++ b/pattern/src/sg/flybot/pullable/schema.cljc
@@ -62,7 +62,7 @@
:seq (or (#{:seq :var :any} ptn-type)
(and (= ptn-type :map) indexed-lookup?))
(:number :string :keyword :symbol :boolean)
- (#{:var :val :pred :any :regex} ptn-type)
+ (#{:var :val :pred :any :regex :-> :sub} ptn-type)
true))]
(when-not type-ok?
(throw (ex-info (str "Schema violation: pattern :" ptn-type " vs schema :" type
diff --git a/pattern/src/sg/flybot/pullable/util.cljc b/pattern/src/sg/flybot/pullable/util.cljc
index cf7c6b2..7d2c86c 100644
--- a/pattern/src/sg/flybot/pullable/util.cljc
+++ b/pattern/src/sg/flybot/pullable/util.cljc
@@ -2,6 +2,20 @@
"Pure utility functions and macros with no domain knowledge."
#?(:cljs (:require-macros [sg.flybot.pullable.util])))
+(defn variable?
+ "Check if x is a ?-prefixed pattern variable symbol."
+ [x]
+ (and (symbol? x) (= \? (first (name x)))))
+
+^:rct/test
+(comment
+ (variable? '?x) ;=> true
+ (variable? '?_) ;=> true
+ (variable? '?x*) ;=> true
+ (variable? 'x) ;=> false
+ (variable? :foo) ;=> false
+ (variable? "?x")) ;=> false)
+
(defmacro vars->
"Create a fn that destructures vars map.
(vars-> [sym child] (mvar sym child))
diff --git a/remote/src/sg/flybot/pullable/remote.cljc b/remote/src/sg/flybot/pullable/remote.cljc
index 8cc1a48..15d0e59 100644
--- a/remote/src/sg/flybot/pullable/remote.cljc
+++ b/remote/src/sg/flybot/pullable/remote.cljc
@@ -102,6 +102,13 @@
(pull-handler request)
(next-handler request))))))
+(def parse-mutation
+ "Detect if pattern is a mutation. Returns {:path :query :value} or nil.
+
+ Mutations use nil (create) or map (update/delete) as query keys.
+ Read patterns (keyword query keys or ?-variable values) return nil."
+ http/parse-mutation)
+
;; For client implementations
(def encode
"Encode Clojure data to bytes. Format: :transit-json, :transit-msgpack, :edn."
@@ -115,6 +122,7 @@
(comment
make-handler ;=>> fn?
wrap-api ;=>> fn?
+ parse-mutation ;=>> fn?
encode ;=>> fn?
decode ;=>> fn?
)
diff --git a/remote/src/sg/flybot/pullable/remote/http.cljc b/remote/src/sg/flybot/pullable/remote/http.cljc
index 073a9d6..6fd8868 100644
--- a/remote/src/sg/flybot/pullable/remote/http.cljc
+++ b/remote/src/sg/flybot/pullable/remote/http.cljc
@@ -6,6 +6,7 @@
[clojure.string :as str]
[clojure.walk]
[sg.flybot.pullable.impl :as pattern]
+ [sg.flybot.pullable.util :refer [variable?]]
[sg.flybot.pullable.collection :as coll]
#?(:clj [cognitect.transit :as transit])
#?(:clj [clojure.edn :as edn])
@@ -282,9 +283,19 @@
(parse-mutation '{:posts ?all})
;=> nil
- (parse-mutation '{:member {:me ?user}}))
+ (parse-mutation '{:member {:me ?user}})
;=> nil
+ ;; parse-mutation: keyword query keys are reads, not mutations
+ (parse-mutation '{:config {:debug (?d :default false)}})
+ ;=> nil
+
+ (parse-mutation '{:users {:name "Alice"}})
+ ;=> nil
+
+ (parse-mutation '{:role {:config {:debug true}}}))
+ ;=> nil)
+
;;=============================================================================
;; Response Helpers
;;=============================================================================
@@ -374,12 +385,7 @@
{:body (encode (prepare-for-wire response) format)
:content-type (get content-types format)})
-(defn- variable?
- "Check if value is a ?-prefixed symbol (pattern variable)."
- [v]
- (and (symbol? v) (= \? (first (name v)))))
-
-(defn- parse-mutation
+(defn parse-mutation
"Detect if pattern is a mutation. Returns {:path :query :value} or nil.
Flat mutation patterns:
@@ -405,13 +411,15 @@
(= 1 (count (val (first v1)))))
(let [[k2 v2] (first v1)
[query value] (first v2)]
- (when-not (variable? value)
+ (when (and (not (variable? value))
+ (or (nil? query) (map? query)))
{:path [k1 k2] :query query :value value}))
;; Flat: {:posts {query value}}
(and (map? v1) (= 1 (count v1)))
(let [[query value] (first v1)]
- (when-not (variable? value)
+ (when (and (not (variable? value))
+ (or (nil? query) (map? query)))
{:path [k1] :query query :value value}))
:else nil)))))