diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml
index 93253a4..27912dc 100644
--- a/.github/workflows/npm.yml
+++ b/.github/workflows/npm.yml
@@ -60,7 +60,7 @@ jobs:
version: 10.11.0
- uses: actions/setup-node@v4
with:
- node-version: 22
+ node-version-file: .nvmrc
cache: pnpm
cache-dependency-path: crates/spock-runtime/studio/pnpm-lock.yaml
@@ -123,7 +123,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 22
+ node-version-file: .nvmrc
registry-url: "https://registry.npmjs.org"
- name: use latest npm (Trusted Publishing needs >= 11.5.1)
@@ -198,7 +198,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
- node-version: 22
+ node-version-file: .nvmrc
- name: install spock from npm
run: |
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..ca5c350
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24.18.0
diff --git a/Cargo.lock b/Cargo.lock
index 9140864..b1e315d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1946,6 +1946,7 @@ dependencies = [
"thiserror",
"time",
"tokio",
+ "tower-http",
"uuid",
]
diff --git a/README.md b/README.md
index 53f1652..a512b56 100644
--- a/README.md
+++ b/README.md
@@ -637,19 +637,39 @@ UI-session state and experience behavior, with Spock as its canonical
provider. No fact may be authoritative in both languages.
Uhura is a subsystem of the Spock project: its canonical source lives in its
-own repository and is included here as a git submodule at `uhura/`. Today this
-is topology only — Spock's workspace, build, and CI do not touch the
-submodule, and `cargo` builds work without it (clone with
-`--recurse-submodules` only if you want the Uhura tree). Once Uhura is
-minimally stable, its tooling ships through the unified `spock` toolchain,
-and the runtime integration lands as contract projection plus a provider
-adapter.
+own repository and is included here as a git submodule at `uhura/`. Spock's
+core workspace, default build, and CI remain independent of the submodule, so
+`cargo` builds work without it; clone with `--recurse-submodules` for the
+explicit composition runner below. Once Uhura is minimally stable, its tooling
+ships through the unified `spock` toolchain, and the runtime integration lands
+as contract projection plus a provider adapter.
+
+From an umbrella checkout, the general composition runner accepts any Spock
+program and Uhura project. For the Instagram example:
+
+```sh
+./scripts/spock-uhura.sh \
+ examples/instagram-poc/app.spock \
+ uhura/examples/instagram-uhura
+```
+
+It builds the two Rust launchers, starts the requested Spock authority on port
+4000, waits for it to become ready, and opens the requested Uhura project
+through its read-only Editor on . The Editor's Play
+button enters the live prototype at `/play` without starting another process.
+`--spock-port` and `--uhura-port` override those defaults; the project's
+provider configuration must address the same Spock port. `Ctrl-C` stops both
+runtimes. Contributor frontend tooling in Spock and Uhura uses the same Node 24
+LTS pin from their respective `.nvmrc` files and pnpm 10.11.0; Node is not
+required by this runtime command.
## Repository Layout
- `examples/` contains product requirements and current-valid Spock examples.
- `docs/rfd/` contains discussion drafts and proposal-only language ideas.
- `npm/` contains package metadata for npm name reservation.
+- `scripts/` contains umbrella composition tooling that keeps Spock and Uhura
+ independently buildable.
- `uhura/` is the Uhura client language (git submodule of
[gridaco/uhura](https://github.com/gridaco/uhura); not yet wired into the
build).
diff --git a/crates/spock-runtime/Cargo.toml b/crates/spock-runtime/Cargo.toml
index 8dd23d8..b347b8e 100644
--- a/crates/spock-runtime/Cargo.toml
+++ b/crates/spock-runtime/Cargo.toml
@@ -26,6 +26,9 @@ hex = "0.4"
getrandom = "0.2"
# content-type inference for `file("...")` seed assets (RFD 0018).
mime_guess = "2"
+# Permissive CORS on the whole surface: v0 is the open dev tier bound to
+# 127.0.0.1, and browser clients on another local origin must reach it.
+tower-http = { version = "0.6", features = ["cors"] }
# Embeds the compiled studio SPA (studio/dist) into the binary. `debug-embed`
# forces embedding in debug builds too, so `spock run` serves it from any cwd
# (matching the old include_str! behavior) — the console stays fully offline.
diff --git a/crates/spock-runtime/src/http.rs b/crates/spock-runtime/src/http.rs
index 271fed8..575873c 100644
--- a/crates/spock-runtime/src/http.rs
+++ b/crates/spock-runtime/src/http.rs
@@ -20,6 +20,7 @@ use rusqlite::types::{Value as SqlValue, ValueRef};
use rust_embed::RustEmbed;
use serde_json::{json, Map, Value as JsonValue};
use spock_lang::ir::{FnArity, Table, Type};
+use tower_http::cors::CorsLayer;
use crate::error::ApiError;
use crate::filter;
@@ -110,7 +111,16 @@ pub fn router(app: Arc) -> Result {
);
}
- Ok(base.fallback(not_found).with_state(app).merge(gql))
+ // Permissive CORS across the whole surface, preflight included: v0 is the
+ // open dev tier on 127.0.0.1 (RFD 0014 — the actor header is deliberately
+ // forgeable), so a browser client on another local origin (e.g. a Uhura
+ // shell) may call `/graphql/v1` and `/rest/v1/rpc/{fn}` with `content-type`
+ // and `x-spock-actor` headers. Unconditional by decision, like the reads.
+ Ok(base
+ .fallback(not_found)
+ .with_state(app)
+ .merge(gql)
+ .layer(CorsLayer::permissive()))
}
/// Serve the app on an already-bound listener until the task is stopped.
diff --git a/crates/spock-runtime/studio/.npmrc b/crates/spock-runtime/studio/.npmrc
new file mode 100644
index 0000000..fed155d
--- /dev/null
+++ b/crates/spock-runtime/studio/.npmrc
@@ -0,0 +1,3 @@
+engine-strict=true
+manage-package-manager-versions=true
+package-manager-strict-version=true
diff --git a/crates/spock-runtime/studio/README.md b/crates/spock-runtime/studio/README.md
index 72996e4..e620d92 100644
--- a/crates/spock-runtime/studio/README.md
+++ b/crates/spock-runtime/studio/README.md
@@ -21,10 +21,14 @@ editing the console needs Node.
## Develop
+Use the repository's Node 24 LTS `.nvmrc` and the pnpm version declared in
+`package.json`:
+
```sh
-pnpm install
+nvm use
+corepack pnpm install --frozen-lockfile
# in another terminal: spock run .spock --port 4000
-pnpm dev # Vite dev server on :5173, proxies /~contract, /rest,
+corepack pnpm dev # Vite dev server on :5173, proxies /~contract, /rest,
# /graphql, /~personas, /~whoami to :4000 (HMR)
```
@@ -33,7 +37,7 @@ pnpm dev # Vite dev server on :5173, proxies /~contract, /rest,
After changing anything under `src/` or `index.html`:
```sh
-pnpm build # tsc -b && vite build -> dist/
+corepack pnpm build # tsc -b && vite build -> dist/
cargo build # re-embeds dist/ into the binary (run from the repo root)
```
diff --git a/crates/spock-runtime/studio/package.json b/crates/spock-runtime/studio/package.json
index 12d9eb6..e03b2b9 100644
--- a/crates/spock-runtime/studio/package.json
+++ b/crates/spock-runtime/studio/package.json
@@ -3,6 +3,11 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "packageManager": "pnpm@10.11.0",
+ "engines": {
+ "node": "24.x",
+ "pnpm": "10.11.0"
+ },
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build && node -e \"require('fs').writeFileSync('dist/.gitkeep','')\"",
@@ -25,7 +30,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
- "@types/node": "^24.13.2",
+ "@types/node": "24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
diff --git a/crates/spock-runtime/studio/pnpm-lock.yaml b/crates/spock-runtime/studio/pnpm-lock.yaml
index 62afcaf..8dd5c55 100644
--- a/crates/spock-runtime/studio/pnpm-lock.yaml
+++ b/crates/spock-runtime/studio/pnpm-lock.yaml
@@ -49,7 +49,7 @@ importers:
version: 1.4.0
devDependencies:
'@types/node':
- specifier: ^24.13.2
+ specifier: 24.13.3
version: 24.13.3
'@types/react':
specifier: ^19.2.17
diff --git a/crates/spock-runtime/tests/http.rs b/crates/spock-runtime/tests/http.rs
index 9c41ec6..ff3d787 100644
--- a/crates/spock-runtime/tests/http.rs
+++ b/crates/spock-runtime/tests/http.rs
@@ -168,6 +168,27 @@ async fn the_whole_protocol() {
assert_eq!(resp.status().as_u16(), 404); // table writes live on /graphql/v1
}
+#[tokio::test]
+async fn local_browser_clients_can_preflight_the_protocol() {
+ let base = start().await;
+ let response = reqwest::Client::new()
+ .request(reqwest::Method::OPTIONS, format!("{base}/graphql/v1"))
+ .header("Origin", "http://127.0.0.1:8787")
+ .header("Access-Control-Request-Method", "POST")
+ .header(
+ "Access-Control-Request-Headers",
+ "content-type,x-spock-actor",
+ )
+ .send()
+ .await
+ .expect("OPTIONS preflight");
+
+ assert_eq!(response.status(), reqwest::StatusCode::OK);
+ assert_eq!(response.headers()["access-control-allow-origin"], "*");
+ assert_eq!(response.headers()["access-control-allow-methods"], "*");
+ assert_eq!(response.headers()["access-control-allow-headers"], "*");
+}
+
async fn rpc(base: &str, name: &str, body: Option) -> (u16, Value) {
let mut req = reqwest::Client::new().post(format!("{base}/rest/v1/rpc/{name}"));
if let Some(body) = body {
diff --git a/docs/rfd/0020-distribution.md b/docs/rfd/0020-distribution.md
index a8a33f2..599cd77 100644
--- a/docs/rfd/0020-distribution.md
+++ b/docs/rfd/0020-distribution.md
@@ -151,7 +151,8 @@ this has a single, simple answer: **every build job runs `pnpm build` before
`cargo build`, guarded.** Per job:
1. `actions/checkout`
-2. `pnpm/action-setup@v4`; `actions/setup-node@v4` with `node-version: 22`,
+2. `pnpm/action-setup@v4`; `actions/setup-node@v4` with the repository's
+ Node 24 LTS `.nvmrc`,
`cache: pnpm`,
`cache-dependency-path: crates/spock-runtime/studio/pnpm-lock.yaml`
3. `pnpm -C crates/spock-runtime/studio install --frozen-lockfile`
diff --git a/examples/instagram-poc/app.spock b/examples/instagram-poc/app.spock
new file mode 100644
index 0000000..94b6c14
--- /dev/null
+++ b/examples/instagram-poc/app.spock
@@ -0,0 +1,800 @@
+//! instagram-poc — the Spock half of the Uhura↔Spock wiring proof-of-concept.
+//!
+//! This program is the live authority behind the Uhura demo app
+//! (uhura/examples/instagram-uhura): its tables and seed mirror the demo's
+//! standard fixture (fixtures/standard.toml), with the fixture's generated
+//! images materialized as Spock storage objects. Its fns satisfy the feed,
+//! comments, profile, people, story, saved-library, and create port contracts.
+//! The app-owned Play provider assembles projection shapes from Spock GraphQL,
+//! signs storage downloads, and maps port commands onto `POST /rest/v1/rpc/*`.
+//!
+//! Identity is the v0 dev seam (RFD 0014): the driver acts as Mira by sending
+//! `X-Spock-Actor: 10000000-0000-4000-8000-000000000001` — user keys are
+//! literal uuids in the seed, so ids are stable across restarts.
+//!
+//! Image and video file columns are `storage_object` references. Existing demo
+//! JPEGs and MP4s enter through `file("./seed/…")`; uploaded bytes enter through
+//! Spock's signed storage protocol and can be attached with `create_image_post`.
+//!
+//! Counts are intentionally absent from the stored model. Posts, likes,
+//! comments, follows, saves, and story views are source rows; the Play provider
+//! derives every number and label shown by Uhura from a GraphQL snapshot. Saves
+//! remain private viewer state and are never presented as public engagement.
+//! The 162-row demo seed exercises 9 actors, 23 posts, 12 story frames, and the
+//! three stored-media shapes: image, carousel, and playable video plus poster.
+
+// ---------------------------------------------------------------------------
+// rules
+// ---------------------------------------------------------------------------
+
+/// A comment body must contain something other than spaces, tabs, or line
+/// breaks (RFD 0013 validator; derives `comment_body_invalid` on the whole
+/// floor, fn escapes included).
+fn nonempty(s: text) -> bool {
+ unchecked sql("SELECT length(trim(:s, ' ' || char(9, 10, 13))) > 0")
+}
+
+// ---------------------------------------------------------------------------
+// people
+// ---------------------------------------------------------------------------
+
+/// A demo person. The identity anchor: `user.id` is what `X-Spock-Actor`
+/// carries and `spock_actor()` returns. Profile counts are derived from post
+/// and follow rows instead of being stored here as decorative labels.
+auth table user {
+ key id: uuid = auto
+ /// The unique handle, e.g. "mira.santos".
+ username: text unique
+ /// Full display name, e.g. "Mira Santos".
+ display_name: text
+ /// The avatar image stored in Spock's byte plane.
+ avatar: storage_object
+ /// Alt text for the avatar.
+ avatar_alt: text
+ /// Profile bio.
+ bio: text
+}
+
+// ---------------------------------------------------------------------------
+// social graph and stories
+// ---------------------------------------------------------------------------
+
+/// A directed follower edge. Profile follower/following counts and people
+/// lists are computed from these rows.
+table follow {
+ key (follower, followed)
+ /// The person doing the following.
+ follower: user on delete cascade
+ /// The person being followed.
+ followed: user on delete cascade
+ /// When the relationship began.
+ at: timestamp = now
+}
+
+/// One active story frame. Authors can own a sequence of frames; position is
+/// unique within that active sequence so playback order is deterministic.
+table story {
+ key id: uuid = auto
+ /// Who published the frame.
+ author: user on delete cascade
+ /// Position within the author's current story, 1-based.
+ position: int
+ /// Story image stored in Spock's byte plane.
+ media_file: storage_object
+ /// Accessible alternative text.
+ media_alt: text
+ /// Optional overlay caption.
+ caption: text?
+ /// Real publication instant; the provider formats relative time.
+ published_at: timestamp = now
+ unique (author, position)
+}
+
+/// A viewer has seen a specific story frame. Ring state is derived: an
+/// author's ring is unseen when at least one current frame lacks this edge.
+table story_view {
+ key (viewer, story)
+ /// Who viewed the frame.
+ viewer: user on delete cascade
+ /// Which frame was viewed.
+ story: story on delete cascade
+ /// When it was viewed.
+ at: timestamp = now
+}
+
+// ---------------------------------------------------------------------------
+// posts and engagement
+// ---------------------------------------------------------------------------
+
+/// A published post. `media_kind` selects the port's media union variant: an
+/// `image` row carries `media_file`/`media_alt`; a `video` row additionally
+/// carries playable `video_file` bytes while retaining `media_file` as its
+/// poster; a `carousel` row leaves those fields absent and owns
+/// `carousel_slide` rows instead.
+table post {
+ key id: uuid = auto
+ /// The author.
+ author: user
+ /// Caption text. An empty string means the author published without one.
+ caption: text
+ /// Real publication instant; the provider derives age labels and ordering.
+ published_at: timestamp = now
+ /// Whether this post participates in the home feed. Older profile posts
+ /// remain first-class posts but do not flood the short demo feed.
+ show_in_feed: bool = true
+ /// Which media union variant this post renders as.
+ media_kind: "image" | "carousel" | "video"
+ /// Image or video-poster object; absent for carousels.
+ media_file: storage_object?
+ /// Playable video bytes; present only for video posts.
+ video_file: storage_object?
+ /// Alt text for `media_file`; absent for carousels.
+ media_alt: text?
+}
+
+/// A person tagged in a published post. The composite key prevents duplicate
+/// tags while keeping the post itself as the single media/caption authority
+/// used by feed, profile-grid, tagged-grid, and post-detail projections. A tag
+/// disappears with either endpoint: deleting a post cannot leave a dangling
+/// profile tile, and deleting a person removes references to that identity.
+table post_tag {
+ key (post, person)
+ /// The post carrying the tag.
+ post: post on delete cascade
+ /// The person tagged in that post.
+ person: user on delete cascade
+}
+
+/// One slide of a carousel post, in slide order.
+table carousel_slide {
+ key id: uuid = auto
+ /// The carousel post this slide belongs to.
+ post: post on delete cascade
+ /// Slide order, 1-based.
+ position: int
+ /// The slide image stored in Spock's byte plane.
+ file: storage_object
+ /// Alt text for the slide.
+ alt: text
+}
+
+/// A comment on a post. Thread order and age labels derive from `created_at`.
+table comment {
+ key id: uuid = auto
+ /// The post commented on.
+ post: post on delete cascade
+ /// Who wrote it.
+ author: user
+ /// The comment text (non-empty).
+ body: text check nonempty
+ /// Real creation instant.
+ created_at: timestamp = now
+}
+
+/// A like edge. The composite key makes a like at-most-once per (user, post)
+/// by construction — no separate `unique` needed.
+table like {
+ key (user, post)
+ /// Who liked.
+ user: user
+ /// The liked post.
+ post: post on delete cascade
+ /// When the like landed.
+ at: timestamp = now
+}
+
+/// A post saved to one user's private library. The composite key makes saving
+/// idempotent. Unlike a like, this edge is viewer-private state: it produces no
+/// notification and has no public count.
+table save {
+ key (user, post)
+ /// The user who saved the post. RPCs always stamp the current actor.
+ user: user on delete cascade
+ /// The saved post.
+ post: post on delete cascade
+ /// When the post was saved.
+ at: timestamp = now
+}
+
+// ---------------------------------------------------------------------------
+// commands — the port write surface
+// ---------------------------------------------------------------------------
+
+/// Like a post as the current actor (`X-Spock-Actor`). Idempotent: liking a
+/// post you already like returns the existing like row and does not touch
+/// any counter because the displayed count is derived from like rows. Refuses
+/// `not_authorized` when anonymous and answers `not_found` when the post does
+/// not exist (the returning SELECT finds no row, so the transaction rolls back).
+mut fn like_post(
+ /// The post to like.
+ post: post,
+) -> like ! not_authorized | not_found {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ INSERT INTO "like" ("user", post)
+ SELECT spock_actor(), p.id FROM post p WHERE p.id = :post
+ ON CONFLICT ("user", post) DO NOTHING
+ """)
+ unchecked sql("""
+ SELECT * FROM "like" WHERE post = :post AND "user" = spock_actor()
+ """)
+}
+
+/// Remove the actor's like. Idempotent: unliking a post you don't like (or
+/// a post that doesn't exist) returns null and changes nothing.
+mut fn unlike_post(
+ /// The post to unlike.
+ post: post,
+) -> like? ! not_authorized {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ DELETE FROM "like" WHERE post = :post AND "user" = spock_actor()
+ RETURNING *
+ """)
+}
+
+/// Save a post to the current actor's private library. Idempotent: saving an
+/// already-saved post returns the existing edge without refreshing its time.
+mut fn save_post(
+ /// The post to save.
+ post: post,
+) -> save ! not_authorized | not_found {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ INSERT INTO save ("user", post)
+ SELECT spock_actor(), p.id FROM post p WHERE p.id = :post
+ ON CONFLICT ("user", post) DO NOTHING
+ """)
+ unchecked sql("""
+ SELECT * FROM save WHERE post = :post AND "user" = spock_actor()
+ """)
+}
+
+/// Remove a post from the current actor's private library. Idempotent: an
+/// absent save edge returns null and changes nothing.
+mut fn unsave_post(
+ /// The post to remove from saved posts.
+ post: post,
+) -> save? ! not_authorized {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ DELETE FROM save WHERE post = :post AND "user" = spock_actor()
+ RETURNING *
+ """)
+}
+
+/// Comment on a post as the current actor, returning the authority's echo of
+/// the new row — including its minted UUIDv7 `id` and real `created_at`.
+/// An empty or whitespace-only body trips the `comment_body_invalid` check; a
+/// missing post is `not_found` (the answering INSERT..SELECT inserts nothing).
+mut fn add_comment(
+ /// The post to comment on.
+ post: post,
+ /// The comment text (non-empty).
+ body: text,
+) -> comment ! not_authorized | comment_body_invalid | not_found {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ INSERT INTO comment (post, author, body)
+ SELECT p.id, spock_actor(), :body
+ FROM post p WHERE p.id = :post
+ RETURNING *
+ """)
+}
+
+/// Follow another person. Idempotent; a repeated follow returns the existing
+/// edge. Counts and lists update automatically because they derive from rows.
+mut fn follow_user(
+ /// The person to follow.
+ target: user,
+) -> follow ! not_authorized | cannot_follow_self | not_found {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ SELECT spock_refuse('cannot_follow_self') WHERE spock_actor() = :target
+ """)
+ unchecked sql("""
+ INSERT INTO follow (follower, followed)
+ SELECT spock_actor(), u.id FROM "user" u WHERE u.id = :target
+ ON CONFLICT (follower, followed) DO NOTHING
+ """)
+ unchecked sql("""
+ SELECT * FROM follow
+ WHERE follower = spock_actor() AND followed = :target
+ """)
+}
+
+/// Stop following another person. Idempotent; an absent edge returns null.
+mut fn unfollow_user(
+ /// The person to stop following.
+ target: user,
+) -> follow? ! not_authorized {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ DELETE FROM follow
+ WHERE follower = spock_actor() AND followed = :target
+ RETURNING *
+ """)
+}
+
+/// Mark one story frame as viewed. Idempotent; the resulting ring state is
+/// computed by comparing all story rows with the viewer's view edges.
+mut fn mark_story_viewed(
+ /// The story frame that became visible.
+ story: story,
+) -> story_view ! not_authorized | not_found {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ INSERT INTO story_view (viewer, story)
+ SELECT spock_actor(), s.id FROM story s WHERE s.id = :story
+ ON CONFLICT (viewer, story) DO NOTHING
+ """)
+ unchecked sql("""
+ SELECT * FROM story_view
+ WHERE viewer = spock_actor() AND story = :story
+ """)
+}
+
+/// Publish one uploaded image as a new feed post. The caller first mints a
+/// signed upload URL, PUTs the bytes, then passes the returned storage-object
+/// id here. Only a committed JPEG, PNG, or WebP object owned by the current
+/// actor may be attached; seed objects deliberately have no owner and cannot
+/// be claimed. Storage v0 records the upload's Content-Type header; byte-level
+/// image decoding/sniffing is deliberately beyond this local-dev PoC.
+/// Retrying the same object is idempotent: the first published post is returned.
+mut fn create_image_post(
+ /// A committed image object minted by the current actor.
+ image: storage_object,
+ /// The new post's caption; empty publishes without a caption.
+ caption: text,
+ /// Accessible alternative text for the image; empty is accepted when the
+ /// author does not provide one.
+ alt: text,
+) -> post ! not_authorized | image_not_ready | unsupported_media_type {
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized') WHERE spock_actor() IS NULL
+ """)
+ unchecked sql("""
+ SELECT spock_refuse('image_not_ready')
+ WHERE NOT EXISTS (
+ SELECT 1 FROM storage_object
+ WHERE id = :image AND state = 'committed'
+ )
+ """)
+ unchecked sql("""
+ SELECT spock_refuse('not_authorized')
+ WHERE NOT EXISTS (
+ SELECT 1 FROM storage_object
+ WHERE id = :image AND owner = spock_actor()
+ )
+ """)
+ unchecked sql("""
+ SELECT spock_refuse('unsupported_media_type')
+ WHERE NOT EXISTS (
+ SELECT 1 FROM storage_object
+ WHERE id = :image
+ AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
+ )
+ """)
+ unchecked sql("""
+ INSERT INTO post (
+ author, caption, media_kind, media_file, media_alt
+ )
+ SELECT spock_actor(), :caption, 'image', :image, :alt
+ WHERE NOT EXISTS (
+ SELECT 1 FROM post AS existing WHERE existing.media_file = :image
+ )
+ """)
+ unchecked sql("""
+ SELECT * FROM post
+ WHERE author = spock_actor() AND media_file = :image
+ ORDER BY published_at
+ LIMIT 1
+ """)
+}
+
+// ---------------------------------------------------------------------------
+// seed — coherent social rows backed by stored JPEG and MP4 bytes
+// ---------------------------------------------------------------------------
+
+seed {
+ // People use stable ids because the Play provider's actor header is Mira's
+ // UUID. Every person has a complete profile; no profile count is seeded.
+ mira = user { id: "10000000-0000-4000-8000-000000000001",
+ username: "mira.santos", display_name: "Mira Santos",
+ avatar: file("./seed/avatar-mira.jpg"), avatar_alt: "Mira Santos",
+ bio: "Food and travel photographer in Lisbon. Usually awake before the trams." }
+ lena = user { id: "10000000-0000-4000-8000-000000000002",
+ username: "lena.holt", display_name: "Lena Holt",
+ avatar: file("./seed/avatar-lena.jpg"), avatar_alt: "Lena Holt",
+ bio: "Ceramics and slow mornings. Small-batch studio work from Portland." }
+ marco = user { id: "10000000-0000-4000-8000-000000000003",
+ username: "marco.reyes", display_name: "Marco Reyes",
+ avatar: file("./seed/avatar-marco.jpg"), avatar_alt: "Marco Reyes",
+ bio: "Surf photographer, road-trip cook, and reluctant morning person." }
+ nils = user { id: "10000000-0000-4000-8000-000000000004",
+ username: "nils.bergman", display_name: "Nils Bergman",
+ avatar: file("./seed/avatar-nils.jpg"), avatar_alt: "Nils Bergman",
+ bio: "Night skies and northern water, filmed around Tromsø." }
+ priya = user { id: "10000000-0000-4000-8000-000000000005",
+ username: "priya.raman", display_name: "Priya Raman",
+ avatar: file("./seed/avatar-priya.jpg"), avatar_alt: "Priya Raman",
+ bio: "Bread notebook, tiny kitchen, stubborn sourdough starter." }
+ ayla = user { id: "10000000-0000-4000-8000-000000000006",
+ username: "ayla.demir", display_name: "Ayla Demir",
+ avatar: file("./seed/avatar-ayla.jpg"), avatar_alt: "Ayla Demir",
+ bio: "Istanbul by ferry. Architecture, tea, and ordinary light." }
+ june = user { id: "10000000-0000-4000-8000-000000000007",
+ username: "june.park", display_name: "June Park",
+ avatar: file("./seed/avatar-june.jpg"), avatar_alt: "June Park",
+ bio: "Natural-fiber clothes made in a very crowded studio." }
+ theo = user { id: "10000000-0000-4000-8000-000000000008",
+ username: "theo.okafor", display_name: "Theo Okafor",
+ avatar: file("./seed/avatar-theo.jpg"), avatar_alt: "Theo Okafor",
+ bio: "Murals, community courts, and an unreliable jump shot." }
+ kenji = user { id: "10000000-0000-4000-8000-000000000009",
+ username: "kenji.rides", display_name: "Kenji Tanaka",
+ avatar: file("./seed/avatar-kenji.jpg"), avatar_alt: "Kenji Tanaka",
+ bio: "Long climbs, quiet roads, and coffee at the turnaround." }
+
+ // Directed relationships: every Followers/Following count and list comes
+ // from these edges. Mira follows six people and has four followers.
+ follow { follower: mira, followed: lena }
+ follow { follower: mira, followed: marco }
+ follow { follower: mira, followed: priya }
+ follow { follower: mira, followed: ayla }
+ follow { follower: mira, followed: june }
+ follow { follower: mira, followed: kenji }
+ follow { follower: lena, followed: mira }
+ follow { follower: lena, followed: marco }
+ follow { follower: lena, followed: priya }
+ follow { follower: lena, followed: june }
+ follow { follower: lena, followed: theo }
+ follow { follower: marco, followed: lena }
+ follow { follower: marco, followed: nils }
+ follow { follower: marco, followed: ayla }
+ follow { follower: marco, followed: kenji }
+ follow { follower: nils, followed: lena }
+ follow { follower: nils, followed: marco }
+ follow { follower: nils, followed: theo }
+ follow { follower: priya, followed: mira }
+ follow { follower: priya, followed: lena }
+ follow { follower: priya, followed: ayla }
+ follow { follower: priya, followed: june }
+ follow { follower: ayla, followed: lena }
+ follow { follower: ayla, followed: priya }
+ follow { follower: ayla, followed: june }
+ follow { follower: june, followed: mira }
+ follow { follower: june, followed: lena }
+ follow { follower: june, followed: ayla }
+ follow { follower: june, followed: theo }
+ follow { follower: theo, followed: mira }
+ follow { follower: theo, followed: lena }
+ follow { follower: theo, followed: june }
+ follow { follower: kenji, followed: lena }
+ follow { follower: kenji, followed: marco }
+ follow { follower: kenji, followed: nils }
+ follow { follower: kenji, followed: priya }
+
+ // Twelve active frames across six tray authors. Mira, Lena, and Marco each
+ // carry a three-frame sequence so the story player exercises progress and
+ // next/previous navigation. Story-view rows below make Lena, Marco, and June
+ // unseen to Mira while Priya and Kenji are already seen.
+ mira_story = story {
+ id: "30000000-0000-4000-8000-000000000001", author: mira, position: 1,
+ media_file: file("./seed/thumb-mira-1.jpg"),
+ media_alt: "Pastéis de nata cooling on a marble counter",
+ caption: "Breakfast before the first tram",
+ published_at: "2026-07-13T15:10:00Z" }
+ mira_story_tram = story {
+ id: "30000000-0000-4000-8000-000000000008", author: mira, position: 2,
+ media_file: file("./seed/thumb-mira-2.jpg"),
+ media_alt: "Tram rails catching the first light in Lisbon",
+ caption: "Then the city wakes",
+ published_at: "2026-07-13T15:22:00Z" }
+ mira_story_market = story {
+ id: "30000000-0000-4000-8000-000000000009", author: mira, position: 3,
+ media_file: file("./seed/thumb-mira-3.jpg"),
+ media_alt: "Crates of citrus stacked at the morning market",
+ caption: "Saturday palette",
+ published_at: "2026-07-13T15:38:00Z" }
+ lena_story = story {
+ id: "30000000-0000-4000-8000-000000000002", author: lena, position: 1,
+ media_file: file("./seed/thumb-lena-7.jpg"),
+ media_alt: "Lena throwing a tall clay cylinder",
+ caption: "One pull, no edits",
+ published_at: "2026-07-13T14:55:00Z" }
+ lena_story_glazes = story {
+ id: "30000000-0000-4000-8000-000000000010", author: lena, position: 2,
+ media_file: file("./seed/thumb-lena-8.jpg"),
+ media_alt: "Rows of glaze buckets labelled by firing cone",
+ caption: "The unglamorous half of studio day",
+ published_at: "2026-07-13T15:08:00Z" }
+ lena_story_studio = story {
+ id: "30000000-0000-4000-8000-000000000011", author: lena, position: 3,
+ media_file: file("./seed/thumb-lena-9.jpg"),
+ media_alt: "Morning light crossing a clean ceramics workbench",
+ caption: "Reset for tomorrow",
+ published_at: "2026-07-13T15:24:00Z" }
+ marco_story = story {
+ id: "30000000-0000-4000-8000-000000000004", author: marco, position: 1,
+ media_file: file("./seed/media-marco-baja-2.jpg"),
+ media_alt: "Campfire on a bluff above the break",
+ caption: "Last night at camp",
+ published_at: "2026-07-13T14:40:00Z" }
+ marco_story_swell = story {
+ id: "30000000-0000-4000-8000-000000000012", author: marco, position: 2,
+ media_file: file("./seed/media-marco-baja-1.jpg"),
+ media_alt: "Long left-hand wave peeling along a desert point",
+ caption: "It finally arrived",
+ published_at: "2026-07-13T14:52:00Z" }
+ marco_story_dawn = story {
+ id: "30000000-0000-4000-8000-000000000013", author: marco, position: 3,
+ media_file: file("./seed/media-marco-baja-3.jpg"),
+ media_alt: "Surfboard fins silhouetted against a Baja sunrise",
+ caption: "Pack up before the wind",
+ published_at: "2026-07-13T15:04:00Z" }
+ priya_story = story {
+ id: "30000000-0000-4000-8000-000000000005", author: priya, position: 1,
+ media_file: file("./seed/media-priya-starter.jpg"),
+ media_alt: "Freshly sliced sourdough loaf",
+ caption: "Still warm",
+ published_at: "2026-07-13T14:20:00Z" }
+ june_story = story {
+ id: "30000000-0000-4000-8000-000000000006", author: june, position: 1,
+ media_file: file("./seed/media-june-lookbook.jpg"),
+ media_alt: "Linen garments arranged by shade",
+ caption: "Fitting day",
+ published_at: "2026-07-13T13:50:00Z" }
+ kenji_story = story {
+ id: "30000000-0000-4000-8000-000000000007", author: kenji, position: 1,
+ media_file: file("./seed/media-kenji-copper.jpg"),
+ media_alt: "Road bike at Copper Pass",
+ caption: "Worth the climb",
+ published_at: "2026-07-13T13:15:00Z" }
+ story_view { viewer: mira, story: mira_story }
+ story_view { viewer: mira, story: mira_story_tram }
+ story_view { viewer: mira, story: mira_story_market }
+ story_view { viewer: mira, story: priya_story }
+ story_view { viewer: mira, story: kenji_story }
+
+ // Eight current home-feed posts. Their engagement totals below are exactly
+ // the number of real like/comment rows, not seeded headline numbers.
+ lena_glaze = post {
+ id: "20000000-0000-4000-8000-000000000001", author: lena,
+ caption: "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.",
+ published_at: "2026-07-13T13:00:00Z",
+ media_kind: "image", media_file: file("./seed/media-lena-glaze.jpg"),
+ media_alt: "Grid of copper-red glaze test tiles on a maple bench" }
+ marco_baja = post {
+ id: "20000000-0000-4000-8000-000000000002", author: marco,
+ caption: "Three days down the Baja coast. Swell arrived on the last morning, as it always does.",
+ published_at: "2026-07-13T10:00:00Z", media_kind: "carousel" }
+ nils_aurora = post {
+ id: "20000000-0000-4000-8000-000000000003", author: nils,
+ caption: "Aurora over the fjord last night — the whole sky was breathing.",
+ published_at: "2026-07-13T06:00:00Z", media_kind: "video",
+ media_file: file("./seed/media-nils-aurora-poster.jpg"),
+ video_file: file("./seed/media-nils-aurora.mp4"),
+ media_alt: "Green aurora curtains over a dark fjord" }
+ priya_starter = post {
+ id: "20000000-0000-4000-8000-000000000004", author: priya,
+ caption: "Day 400 of the starter. She's earned a name: Clint Yeastwood.",
+ published_at: "2026-07-12T23:00:00Z", media_kind: "image",
+ media_file: file("./seed/media-priya-starter.jpg"),
+ media_alt: "Open crumb of a sourdough loaf, sliced on a flour-dusted board" }
+ ayla_ferry = post {
+ id: "20000000-0000-4000-8000-000000000005", author: ayla,
+ caption: "Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon.",
+ published_at: "2026-07-12T16:00:00Z", media_kind: "image",
+ media_file: file("./seed/media-ayla-ferry.jpg"),
+ media_alt: "Ferry deck railing over blue water, city skyline behind" }
+ june_lookbook = post {
+ id: "20000000-0000-4000-8000-000000000006", author: june,
+ caption: "Studio lookbook, page one. Linen in every weight we could mill.",
+ published_at: "2026-07-12T10:00:00Z", media_kind: "image",
+ media_file: file("./seed/media-june-lookbook.jpg"),
+ media_alt: "Folded linen garments stacked by shade on a workbench" }
+ theo_court = post {
+ id: "20000000-0000-4000-8000-000000000007", author: theo,
+ caption: "Finished the mural at the 9th street court. Paint holds up better than my jumper.",
+ published_at: "2026-07-11T17:00:00Z", media_kind: "video",
+ media_file: file("./seed/media-theo-court.jpg"),
+ video_file: file("./seed/media-theo-court.mp4"),
+ media_alt: "Basketball court painted with bold geometric shapes" }
+ kenji_copper = post {
+ id: "20000000-0000-4000-8000-000000000008", author: kenji,
+ caption: "120km of switchbacks and one very smug goat. Copper Pass, you were worth it.",
+ published_at: "2026-07-11T11:00:00Z", media_kind: "image",
+ media_file: file("./seed/media-kenji-copper.jpg"),
+ media_alt: "Road bike leaning on a stone wall at a mountain pass" }
+
+ // Lena's and Mira's former decorative thumbnails are now genuine posts.
+ // They appear in profile grids and can open the same post-detail design as
+ // feed posts; show_in_feed keeps this history out of the compact home feed.
+ lena_bowls = post { id: "21000000-0000-4000-8000-000000000001", author: lena,
+ caption: "Copper glaze in close-up, before the wax cooled.",
+ published_at: "2026-07-10T14:00:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-1.jpg"), media_alt: "Copper-red glaze tiles" }
+ lena_greenware = post { id: "21000000-0000-4000-8000-000000000002", author: lena,
+ caption: "A quiet stack of bowls waiting for bisque firing.",
+ published_at: "2026-07-08T16:00:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-2.jpg"), media_alt: "Stack of unglazed bowls" }
+ lena_kiln = post { id: "21000000-0000-4000-8000-000000000003", author: lena,
+ caption: "Kiln Tetris, level thirty-seven.",
+ published_at: "2026-07-05T11:30:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-3.jpg"), media_alt: "Kiln shelf mid-load" }
+ lena_celadon = post { id: "21000000-0000-4000-8000-000000000004", author: lena,
+ caption: "Celadon tests after a slower cool-down.",
+ published_at: "2026-07-02T09:15:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-4.jpg"), media_alt: "Celadon test cups" }
+ lena_clay = post { id: "21000000-0000-4000-8000-000000000005", author: lena,
+ caption: "Fresh reclaim, wedged and ready for tomorrow.",
+ published_at: "2026-06-28T18:40:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-5.jpg"), media_alt: "Wedging table with fresh clay" }
+ lena_plates = post { id: "21000000-0000-4000-8000-000000000006", author: lena,
+ caption: "Dinner plates with just enough iron speckle.",
+ published_at: "2026-06-24T13:00:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-6.jpg"), media_alt: "Iron-speckled dinner plates" }
+ lena_throwing = post { id: "21000000-0000-4000-8000-000000000007", author: lena,
+ caption: "Pulling one tall cylinder before lunch.",
+ published_at: "2026-06-19T16:20:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-7.jpg"), media_alt: "Throwing a tall cylinder" }
+ lena_buckets = post { id: "21000000-0000-4000-8000-000000000008", author: lena,
+ caption: "Labelling day. Future me will be grateful.",
+ published_at: "2026-06-12T10:10:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-8.jpg"), media_alt: "Glaze buckets labelled by cone" }
+ lena_morning = post { id: "21000000-0000-4000-8000-000000000009", author: lena,
+ caption: "Seven o'clock light across the clean bench.",
+ published_at: "2026-06-03T14:05:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-lena-9.jpg"), media_alt: "Morning light across the studio bench" }
+
+ mira_pasteis = post { id: "22000000-0000-4000-8000-000000000001", author: mira,
+ caption: "The batch that vanished before I finished the coffee.",
+ published_at: "2026-07-09T07:30:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-mira-1.jpg"), media_alt: "Pastéis de nata on a marble counter" }
+ mira_tram = post { id: "22000000-0000-4000-8000-000000000002", author: mira,
+ caption: "Rails holding the first light on Rua da Conceição.",
+ published_at: "2026-07-04T05:45:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-mira-2.jpg"), media_alt: "Tram rails catching dawn light" }
+ mira_citrus = post { id: "22000000-0000-4000-8000-000000000003", author: mira,
+ caption: "Saturday citrus, arranged better than any still life.",
+ published_at: "2026-06-29T08:20:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-mira-3.jpg"), media_alt: "Market citrus stacked in crates" }
+ mira_tiles = post { id: "22000000-0000-4000-8000-000000000004", author: mira,
+ caption: "Blue after blue after blue.",
+ published_at: "2026-06-21T15:10:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-mira-4.jpg"), media_alt: "Tiled facade in alternating blues" }
+ mira_sardines = post { id: "22000000-0000-4000-8000-000000000005", author: mira,
+ caption: "Sardines, smoke, lemon. Nothing else needed.",
+ published_at: "2026-06-13T20:15:00Z", show_in_feed: false,
+ media_kind: "image", media_file: file("./seed/thumb-mira-5.jpg"), media_alt: "Grilled sardines over coals" }
+ mira_ferry = post { id: "22000000-0000-4000-8000-000000000006", author: mira,
+ caption: "The last ferry left a gold line all the way home.",
+ published_at: "2026-06-01T19:50:00Z", show_in_feed: false,
+ media_kind: "video", media_file: file("./seed/thumb-mira-6.jpg"),
+ video_file: file("./seed/media-mira-ferry.mp4"), media_alt: "Ferry wake at golden hour" }
+
+ // Real tag edges. Tagged grids reuse these posts and their media rather than
+ // carrying separate decorative thumbnails. Mira's tagged post is Marco's
+ // Baja carousel; its first slide is therefore her tagged-grid thumbnail.
+ post_tag { post: marco_baja, person: mira }
+ post_tag { post: june_lookbook, person: ayla }
+ post_tag { post: theo_court, person: june }
+ post_tag { post: priya_starter, person: lena }
+ post_tag { post: kenji_copper, person: marco }
+ post_tag { post: lena_glaze, person: theo }
+
+ // Marco's carousel, in slide order.
+ carousel_slide { post: marco_baja, position: 1, file: file("./seed/media-marco-baja-1.jpg"),
+ alt: "Long left-hand wave peeling along a desert point" }
+ carousel_slide { post: marco_baja, position: 2, file: file("./seed/media-marco-baja-2.jpg"),
+ alt: "Campfire on the bluff above the break at dusk" }
+ carousel_slide { post: marco_baja, position: 3, file: file("./seed/media-marco-baja-3.jpg"),
+ alt: "Board fins silhouetted against the sunrise" }
+
+ // Real comment rows. Feed cards and details count these rows.
+ comment { post: lena_glaze, author: kenji, created_at: "2026-07-13T13:28:00Z",
+ body: "That copper red is unreal. What cone are you firing to?" }
+ comment { post: lena_glaze, author: priya, created_at: "2026-07-13T13:35:00Z",
+ body: "The third tile down — that speckle! Saving this for glaze inspiration." }
+ comment { post: lena_glaze, author: june, created_at: "2026-07-13T13:42:00Z",
+ body: "Would buy the whole batch honestly. Seconds sale when?" }
+ comment { post: lena_glaze, author: theo, created_at: "2026-07-13T13:51:00Z",
+ body: "These would look wild as a court-side mosaic. Collab?" }
+ comment { post: marco_baja, author: kenji, created_at: "2026-07-13T10:18:00Z",
+ body: "That road looks nearly as good as the wave." }
+ comment { post: marco_baja, author: ayla, created_at: "2026-07-13T10:26:00Z",
+ body: "Frame two belongs on a wall." }
+ comment { post: nils_aurora, author: mira, created_at: "2026-07-13T06:22:00Z",
+ body: "The reflection makes this. Beautiful." }
+ comment { post: priya_starter, author: lena, created_at: "2026-07-13T12:40:00Z",
+ body: "Clint looks extremely healthy." }
+ comment { post: priya_starter, author: theo, created_at: "2026-07-13T13:05:00Z",
+ body: "I volunteer for quality control." }
+ comment { post: ayla_ferry, author: mira, created_at: "2026-07-13T06:00:00Z",
+ body: "That blue is perfect." }
+ comment { post: june_lookbook, author: ayla, created_at: "2026-07-12T22:35:00Z",
+ body: "The warm grey set, please." }
+ comment { post: theo_court, author: june, created_at: "2026-07-12T06:10:00Z",
+ body: "This completely changes the block." }
+ comment { post: kenji_copper, author: marco, created_at: "2026-07-12T00:20:00Z",
+ body: "Goat photo or it did not happen." }
+
+ // Real like edges. Mira has already liked Kenji's post; like toggles change
+ // the row set and therefore the displayed count with no synchronization job.
+ like { user: marco, post: lena_glaze }
+ like { user: nils, post: lena_glaze }
+ like { user: priya, post: lena_glaze }
+ like { user: ayla, post: lena_glaze }
+ like { user: june, post: lena_glaze }
+ like { user: theo, post: lena_glaze }
+ like { user: kenji, post: lena_glaze }
+ like { user: lena, post: marco_baja }
+ like { user: nils, post: marco_baja }
+ like { user: priya, post: marco_baja }
+ like { user: ayla, post: marco_baja }
+ like { user: kenji, post: marco_baja }
+ like { user: lena, post: nils_aurora }
+ like { user: marco, post: nils_aurora }
+ like { user: priya, post: nils_aurora }
+ like { user: ayla, post: nils_aurora }
+ like { user: june, post: nils_aurora }
+ like { user: lena, post: priya_starter }
+ like { user: marco, post: priya_starter }
+ like { user: nils, post: priya_starter }
+ like { user: ayla, post: priya_starter }
+ like { user: june, post: priya_starter }
+ like { user: theo, post: priya_starter }
+ like { user: kenji, post: priya_starter }
+ like { user: lena, post: ayla_ferry }
+ like { user: marco, post: ayla_ferry }
+ like { user: priya, post: ayla_ferry }
+ like { user: june, post: ayla_ferry }
+ like { user: lena, post: june_lookbook }
+ like { user: priya, post: june_lookbook }
+ like { user: ayla, post: june_lookbook }
+ like { user: theo, post: june_lookbook }
+ like { user: marco, post: theo_court }
+ like { user: ayla, post: theo_court }
+ like { user: june, post: theo_court }
+ like { user: kenji, post: theo_court }
+ like { user: mira, post: kenji_copper }
+ like { user: lena, post: kenji_copper }
+ like { user: marco, post: kenji_copper }
+ like { user: nils, post: kenji_copper }
+ like { user: priya, post: kenji_copper }
+ like { user: ayla, post: kenji_copper }
+ like { user: june, post: kenji_copper }
+ like { user: theo, post: kenji_copper }
+ like { user: mira, post: lena_bowls }
+ like { user: priya, post: lena_bowls }
+ like { user: june, post: lena_greenware }
+ like { user: theo, post: lena_kiln }
+ like { user: lena, post: mira_pasteis }
+ like { user: priya, post: mira_pasteis }
+ like { user: june, post: mira_tram }
+ like { user: kenji, post: mira_ferry }
+
+ // Private saved-library state. Mira's two rows make the default actor's
+ // Saved view useful immediately; Priya's row proves actor switching does not
+ // leak one person's library into another's. No public count derives from it.
+ save { user: mira, post: marco_baja }
+ save { user: mira, post: nils_aurora }
+ save { user: priya, post: lena_glaze }
+}
diff --git a/examples/instagram-poc/seed/avatar-ayla.jpg b/examples/instagram-poc/seed/avatar-ayla.jpg
new file mode 100644
index 0000000..ced0025
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-ayla.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-june.jpg b/examples/instagram-poc/seed/avatar-june.jpg
new file mode 100644
index 0000000..c415d61
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-june.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-kenji.jpg b/examples/instagram-poc/seed/avatar-kenji.jpg
new file mode 100644
index 0000000..f6ec02c
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-kenji.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-lena.jpg b/examples/instagram-poc/seed/avatar-lena.jpg
new file mode 100644
index 0000000..9d66ca5
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-lena.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-marco.jpg b/examples/instagram-poc/seed/avatar-marco.jpg
new file mode 100644
index 0000000..2696823
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-marco.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-mira.jpg b/examples/instagram-poc/seed/avatar-mira.jpg
new file mode 100644
index 0000000..363e12e
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-mira.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-nils.jpg b/examples/instagram-poc/seed/avatar-nils.jpg
new file mode 100644
index 0000000..68742ab
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-nils.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-priya.jpg b/examples/instagram-poc/seed/avatar-priya.jpg
new file mode 100644
index 0000000..feecd98
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-priya.jpg differ
diff --git a/examples/instagram-poc/seed/avatar-theo.jpg b/examples/instagram-poc/seed/avatar-theo.jpg
new file mode 100644
index 0000000..d4a300c
Binary files /dev/null and b/examples/instagram-poc/seed/avatar-theo.jpg differ
diff --git a/examples/instagram-poc/seed/media-ayla-ferry.jpg b/examples/instagram-poc/seed/media-ayla-ferry.jpg
new file mode 100644
index 0000000..0ab982d
Binary files /dev/null and b/examples/instagram-poc/seed/media-ayla-ferry.jpg differ
diff --git a/examples/instagram-poc/seed/media-june-lookbook.jpg b/examples/instagram-poc/seed/media-june-lookbook.jpg
new file mode 100644
index 0000000..c09d0e0
Binary files /dev/null and b/examples/instagram-poc/seed/media-june-lookbook.jpg differ
diff --git a/examples/instagram-poc/seed/media-kenji-copper.jpg b/examples/instagram-poc/seed/media-kenji-copper.jpg
new file mode 100644
index 0000000..fb75d9e
Binary files /dev/null and b/examples/instagram-poc/seed/media-kenji-copper.jpg differ
diff --git a/examples/instagram-poc/seed/media-lena-glaze.jpg b/examples/instagram-poc/seed/media-lena-glaze.jpg
new file mode 100644
index 0000000..6055270
Binary files /dev/null and b/examples/instagram-poc/seed/media-lena-glaze.jpg differ
diff --git a/examples/instagram-poc/seed/media-marco-baja-1.jpg b/examples/instagram-poc/seed/media-marco-baja-1.jpg
new file mode 100644
index 0000000..fd98e95
Binary files /dev/null and b/examples/instagram-poc/seed/media-marco-baja-1.jpg differ
diff --git a/examples/instagram-poc/seed/media-marco-baja-2.jpg b/examples/instagram-poc/seed/media-marco-baja-2.jpg
new file mode 100644
index 0000000..1b6f52c
Binary files /dev/null and b/examples/instagram-poc/seed/media-marco-baja-2.jpg differ
diff --git a/examples/instagram-poc/seed/media-marco-baja-3.jpg b/examples/instagram-poc/seed/media-marco-baja-3.jpg
new file mode 100644
index 0000000..a97bc22
Binary files /dev/null and b/examples/instagram-poc/seed/media-marco-baja-3.jpg differ
diff --git a/examples/instagram-poc/seed/media-mira-ferry.mp4 b/examples/instagram-poc/seed/media-mira-ferry.mp4
new file mode 100644
index 0000000..053c7bc
Binary files /dev/null and b/examples/instagram-poc/seed/media-mira-ferry.mp4 differ
diff --git a/examples/instagram-poc/seed/media-nils-aurora-poster.jpg b/examples/instagram-poc/seed/media-nils-aurora-poster.jpg
new file mode 100644
index 0000000..2fcc8ab
Binary files /dev/null and b/examples/instagram-poc/seed/media-nils-aurora-poster.jpg differ
diff --git a/examples/instagram-poc/seed/media-nils-aurora.mp4 b/examples/instagram-poc/seed/media-nils-aurora.mp4
new file mode 100644
index 0000000..7927e79
Binary files /dev/null and b/examples/instagram-poc/seed/media-nils-aurora.mp4 differ
diff --git a/examples/instagram-poc/seed/media-priya-starter.jpg b/examples/instagram-poc/seed/media-priya-starter.jpg
new file mode 100644
index 0000000..f34b7a6
Binary files /dev/null and b/examples/instagram-poc/seed/media-priya-starter.jpg differ
diff --git a/examples/instagram-poc/seed/media-theo-court.jpg b/examples/instagram-poc/seed/media-theo-court.jpg
new file mode 100644
index 0000000..597c14c
Binary files /dev/null and b/examples/instagram-poc/seed/media-theo-court.jpg differ
diff --git a/examples/instagram-poc/seed/media-theo-court.mp4 b/examples/instagram-poc/seed/media-theo-court.mp4
new file mode 100644
index 0000000..0903ed3
Binary files /dev/null and b/examples/instagram-poc/seed/media-theo-court.mp4 differ
diff --git a/examples/instagram-poc/seed/thumb-lena-1.jpg b/examples/instagram-poc/seed/thumb-lena-1.jpg
new file mode 100644
index 0000000..453601a
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-1.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-2.jpg b/examples/instagram-poc/seed/thumb-lena-2.jpg
new file mode 100644
index 0000000..e7a807c
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-2.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-3.jpg b/examples/instagram-poc/seed/thumb-lena-3.jpg
new file mode 100644
index 0000000..5122ed0
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-3.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-4.jpg b/examples/instagram-poc/seed/thumb-lena-4.jpg
new file mode 100644
index 0000000..5450c59
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-4.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-5.jpg b/examples/instagram-poc/seed/thumb-lena-5.jpg
new file mode 100644
index 0000000..1ab12ce
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-5.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-6.jpg b/examples/instagram-poc/seed/thumb-lena-6.jpg
new file mode 100644
index 0000000..ceb732e
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-6.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-7.jpg b/examples/instagram-poc/seed/thumb-lena-7.jpg
new file mode 100644
index 0000000..0783509
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-7.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-8.jpg b/examples/instagram-poc/seed/thumb-lena-8.jpg
new file mode 100644
index 0000000..f4ae586
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-8.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-lena-9.jpg b/examples/instagram-poc/seed/thumb-lena-9.jpg
new file mode 100644
index 0000000..8987ac1
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-lena-9.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-mira-1.jpg b/examples/instagram-poc/seed/thumb-mira-1.jpg
new file mode 100644
index 0000000..b620672
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-mira-1.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-mira-2.jpg b/examples/instagram-poc/seed/thumb-mira-2.jpg
new file mode 100644
index 0000000..15dc794
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-mira-2.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-mira-3.jpg b/examples/instagram-poc/seed/thumb-mira-3.jpg
new file mode 100644
index 0000000..230a26c
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-mira-3.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-mira-4.jpg b/examples/instagram-poc/seed/thumb-mira-4.jpg
new file mode 100644
index 0000000..e4ae4f9
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-mira-4.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-mira-5.jpg b/examples/instagram-poc/seed/thumb-mira-5.jpg
new file mode 100644
index 0000000..2161a0e
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-mira-5.jpg differ
diff --git a/examples/instagram-poc/seed/thumb-mira-6.jpg b/examples/instagram-poc/seed/thumb-mira-6.jpg
new file mode 100644
index 0000000..cc3b2f7
Binary files /dev/null and b/examples/instagram-poc/seed/thumb-mira-6.jpg differ
diff --git a/examples/instagram/client/.npmrc b/examples/instagram/client/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/examples/instagram/client/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/examples/instagram/client/README.md b/examples/instagram/client/README.md
index c594c03..7aca859 100644
--- a/examples/instagram/client/README.md
+++ b/examples/instagram/client/README.md
@@ -18,9 +18,11 @@ Start the server (from the repository root):
cargo run -p spock-cli -- run examples/instagram/v0.spock
```
-Then, in this directory:
+Then, in this directory, use the repository's Node 24 LTS pin and install the
+client tooling:
```sh
+nvm use
npm install
npm run generate # introspects http://127.0.0.1:4000/graphql/v1 → src/gql/
npm run check # tsc --noEmit: the queries are statically typed
diff --git a/examples/instagram/client/package-lock.json b/examples/instagram/client/package-lock.json
index 1a24504..43ceea4 100644
--- a/examples/instagram/client/package-lock.json
+++ b/examples/instagram/client/package-lock.json
@@ -8,10 +8,13 @@
"devDependencies": {
"@graphql-codegen/cli": "^7.2.0",
"@graphql-codegen/client-preset": "^6.1.0",
- "@types/node": "^26.1.1",
+ "@types/node": "24.13.3",
"graphql": "^16.14.2",
"tsx": "^4.23.0",
"typescript": "^7.0.2"
+ },
+ "engines": {
+ "node": "24.x"
}
},
"node_modules/@ardatan/relay-compiler": {
@@ -2018,13 +2021,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.1.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
- "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~8.3.0"
+ "undici-types": "~7.18.0"
}
},
"node_modules/@types/ws": {
@@ -4421,9 +4424,9 @@
}
},
"node_modules/undici-types": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
- "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
diff --git a/examples/instagram/client/package.json b/examples/instagram/client/package.json
index 7a7dc14..6251f32 100644
--- a/examples/instagram/client/package.json
+++ b/examples/instagram/client/package.json
@@ -2,6 +2,10 @@
"name": "spock-instagram-client",
"private": true,
"type": "module",
+ "packageManager": "npm@11.16.0",
+ "engines": {
+ "node": "24.x"
+ },
"scripts": {
"generate": "graphql-codegen --config codegen.ts",
"check": "tsc --noEmit",
@@ -10,7 +14,7 @@
"devDependencies": {
"@graphql-codegen/cli": "^7.2.0",
"@graphql-codegen/client-preset": "^6.1.0",
- "@types/node": "^26.1.1",
+ "@types/node": "24.13.3",
"graphql": "^16.14.2",
"tsx": "^4.23.0",
"typescript": "^7.0.2"
diff --git a/scripts/spock-uhura.sh b/scripts/spock-uhura.sh
new file mode 100755
index 0000000..297e8b6
--- /dev/null
+++ b/scripts/spock-uhura.sh
@@ -0,0 +1,242 @@
+#!/usr/bin/env bash
+# Run one Spock authority and one Uhura project through the default Editor.
+# The Editor hosts Play at /play, so both modes share one supervised runtime.
+# Node/pnpm are build-time tools only; this command runs checked-in web assets
+# and the locally built Wasm bundle.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SPOCK_PORT="${SPOCK_PORT:-4000}"
+UHURA_PORT="${UHURA_PORT:-8787}"
+
+usage() {
+ echo "Usage: $0 [--spock-port PORT] [--uhura-port PORT] " >&2
+ echo >&2
+ echo "Starts Spock and the Uhura Editor; the Editor hosts Play at /play." >&2
+ echo "The Uhura project's provider configuration must address the selected Spock port." >&2
+}
+
+while (($# > 0)); do
+ case "$1" in
+ --spock-port)
+ [[ $# -ge 2 ]] || { usage; exit 2; }
+ SPOCK_PORT="$2"
+ shift 2
+ ;;
+ --uhura-port)
+ [[ $# -ge 2 ]] || { usage; exit 2; }
+ UHURA_PORT="$2"
+ shift 2
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ echo "spock-uhura: unknown option: $1" >&2
+ usage
+ exit 2
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+if [[ $# -ne 2 ]]; then
+ usage
+ exit 2
+fi
+
+for tool in cargo curl; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ echo "spock-uhura: required command not found: $tool" >&2
+ exit 2
+ fi
+done
+
+SPOCK_PROGRAM="$1"
+UHURA_PROJECT="$2"
+[[ "$SPOCK_PROGRAM" = /* ]] || SPOCK_PROGRAM="$ROOT/$SPOCK_PROGRAM"
+[[ "$UHURA_PROJECT" = /* ]] || UHURA_PROJECT="$ROOT/$UHURA_PROJECT"
+
+if [[ ! -f "$SPOCK_PROGRAM" ]]; then
+ echo "spock-uhura: no Spock program: $SPOCK_PROGRAM" >&2
+ exit 2
+fi
+if [[ ! -f "$UHURA_PROJECT/uhura.toml" ]]; then
+ echo "spock-uhura: no Uhura project manifest: $UHURA_PROJECT/uhura.toml" >&2
+ exit 2
+fi
+
+for port in "$SPOCK_PORT" "$UHURA_PORT"; do
+ if [[ ! "$port" =~ ^[0-9]+$ ]]; then
+ echo "spock-uhura: invalid port: $port" >&2
+ exit 2
+ fi
+ if ((10#$port < 1 || 10#$port > 65535)); then
+ echo "spock-uhura: invalid port: $port" >&2
+ exit 2
+ fi
+done
+
+SPOCK_HEALTH="http://127.0.0.1:${SPOCK_PORT}/~health"
+UHURA_URL="http://127.0.0.1:${UHURA_PORT}/"
+UHURA_HEALTH="$UHURA_URL"
+
+http_ready() {
+ curl --connect-timeout 1 --max-time 1 --silent --fail "$1" >/dev/null 2>&1
+}
+
+if ((10#$SPOCK_PORT == 10#$UHURA_PORT)); then
+ echo "spock-uhura: Spock and Uhura ports must differ" >&2
+ exit 2
+fi
+
+required_artifacts=(
+ "$ROOT/uhura/web/dist/play/index.html"
+ "$ROOT/uhura/crates/uhura-wasm/pkg/web/uhura_wasm.js"
+ "$ROOT/uhura/crates/uhura-wasm/pkg/web/uhura_wasm_bg.wasm"
+)
+
+for artifact in "${required_artifacts[@]}"; do
+ if [[ ! -s "$artifact" ]]; then
+ echo "spock-uhura: missing shared build artifact: ${artifact#"$ROOT/"}" >&2
+ echo "Run the Uhura frontend check and uhura/scripts/build-wasm.sh once, then retry." >&2
+ exit 2
+ fi
+done
+
+if ! compgen -G "$ROOT/uhura/web/dist/play/assets/*.js" >/dev/null \
+ || ! compgen -G "$ROOT/uhura/web/dist/play/assets/*.css" >/dev/null; then
+ echo "spock-uhura: the shared Uhura Play asset bundle is incomplete" >&2
+ echo "Run the Uhura frontend check once, then retry." >&2
+ exit 2
+fi
+
+if http_ready "$SPOCK_HEALTH"; then
+ echo "spock-uhura: port ${SPOCK_PORT} already has a Spock server" >&2
+ echo "Stop it first so this command can own the authority process." >&2
+ exit 2
+fi
+
+cd "$ROOT"
+echo "Building Spock and Uhura launchers..."
+cargo build --locked -p spock-cli
+cargo build --locked --manifest-path uhura/Cargo.toml -p uhura-cli
+
+spock_pid=""
+uhura_pid=""
+canvas_out=""
+cleanup() {
+ trap - EXIT INT TERM
+ if [[ -n "$uhura_pid" ]] && kill -0 "$uhura_pid" 2>/dev/null; then
+ kill -TERM "$uhura_pid" 2>/dev/null || true
+ wait "$uhura_pid" 2>/dev/null || true
+ fi
+ if [[ -n "$spock_pid" ]] && kill -0 "$spock_pid" 2>/dev/null; then
+ kill -TERM "$spock_pid" 2>/dev/null || true
+ wait "$spock_pid" 2>/dev/null || true
+ fi
+ if [[ -n "$canvas_out" ]]; then
+ rm -rf "$canvas_out"
+ fi
+}
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+canvas_out="$(mktemp -d "${TMPDIR:-/tmp}/spock-uhura.XXXXXX")"
+
+echo "Starting Spock authority from ${SPOCK_PROGRAM#"$ROOT/"} on port ${SPOCK_PORT}..."
+"$ROOT/target/debug/spock" run "$SPOCK_PROGRAM" --port "$SPOCK_PORT" &
+spock_pid=$!
+
+ready=false
+deadline=$((SECONDS + 60))
+while ((SECONDS < deadline)); do
+ if http_ready "$SPOCK_HEALTH"; then
+ ready=true
+ break
+ fi
+ if ! kill -0 "$spock_pid" 2>/dev/null; then
+ wait "$spock_pid" || true
+ echo "spock-uhura: Spock stopped before becoming ready" >&2
+ exit 1
+ fi
+ sleep 0.25
+done
+
+if [[ "$ready" != true ]]; then
+ echo "spock-uhura: Spock did not become ready within 60 seconds" >&2
+ exit 1
+fi
+
+echo "Starting Uhura Editor for ${UHURA_PROJECT#"$ROOT/"} on port ${UHURA_PORT}..."
+"$ROOT/uhura/target/debug/uhura" editor "$UHURA_PROJECT" \
+ --port "$UHURA_PORT" --out "$canvas_out" &
+uhura_pid=$!
+
+ready=false
+deadline=$((SECONDS + 60))
+while ((SECONDS < deadline)); do
+ if http_ready "$UHURA_HEALTH"; then
+ ready=true
+ break
+ fi
+ if ! kill -0 "$uhura_pid" 2>/dev/null; then
+ set +e
+ wait "$uhura_pid"
+ rc=$?
+ set -e
+ echo "spock-uhura: Uhura stopped before becoming ready" >&2
+ if ((rc == 0)); then
+ rc=1
+ fi
+ exit "$rc"
+ fi
+ if ! kill -0 "$spock_pid" 2>/dev/null; then
+ set +e
+ wait "$spock_pid"
+ rc=$?
+ set -e
+ echo "spock-uhura: Spock stopped while Uhura was starting" >&2
+ if ((rc == 0)); then
+ rc=1
+ fi
+ exit "$rc"
+ fi
+ sleep 0.25
+done
+
+if [[ "$ready" != true ]]; then
+ echo "spock-uhura: Uhura did not become ready within 60 seconds" >&2
+ exit 1
+fi
+
+echo "Spock and Uhura Editor are ready at $UHURA_URL"
+echo "Use the Editor's Play button to open the live prototype."
+echo "Press Ctrl-C to stop both runtimes."
+
+while kill -0 "$spock_pid" 2>/dev/null && kill -0 "$uhura_pid" 2>/dev/null; do
+ sleep 0.5
+done
+
+if ! kill -0 "$spock_pid" 2>/dev/null; then
+ set +e
+ wait "$spock_pid"
+ rc=$?
+ set -e
+ echo "spock-uhura: Spock stopped; stopping Uhura" >&2
+else
+ set +e
+ wait "$uhura_pid"
+ rc=$?
+ set -e
+ echo "spock-uhura: Uhura stopped; stopping Spock" >&2
+fi
+exit "$rc"
diff --git a/uhura b/uhura
index 201e847..5b45eaa 160000
--- a/uhura
+++ b/uhura
@@ -1 +1 @@
-Subproject commit 201e847f709738fb75d14a2b6808502cab8f6ebb
+Subproject commit 5b45eaa68779bc29a126b3410b5a7ad1f9114deb