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