Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/flybot-site-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
- 'flybot-site-v*'

env:
AWS_REGION: ap-southeast-1
AWS_REGION: ${{ vars.AWS_REGION }}
ECR_REPOSITORY: flybot-site

jobs:
Expand Down
76 changes: 76 additions & 0 deletions .github/workflows/pull-playground-deploy.yml
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions examples/pull-playground/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 |
25 changes: 23 additions & 2 deletions examples/pull-playground/bb.edn
Original file line number Diff line number Diff line change
@@ -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"))}}}
23 changes: 11 additions & 12 deletions examples/pull-playground/deps.edn
Original file line number Diff line number Diff line change
@@ -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"}}}}}
51 changes: 18 additions & 33 deletions examples/pull-playground/dev/user.clj
Original file line number Diff line number Diff line change
@@ -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)))
Loading