noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
import { Note, NoteBoard, CreateNoteRequest, SignInRequest, Session } from "../types/notes.nox"

// The board is a `live` resource: the compiler publishes an invalidate-only
// notice over one multiplexed SSE controller after any validated mutation,
// and the acquisition re-runs the declared GET.
resource Board(boardId: String): NoteBoard {
    get {
        GET "/api/notes/{boardId}"
    }
    live
    cache 30s
    reload stale_while_revalidate
    retry 2
}

component AppPage {
    route {
        title: "Noxid App"
        description: "A typed form, a typed endpoint, and a live resource"
        render: client
    }

    state {
        displayName: String = ""
        signedInAs: String = ""
        sessionMessage: String = "Sign in to open your board."
        draftTitle: String = ""
        formMessage: String = "Type a note title, then submit it."
        createdNotes: Array<Note> = []
    }

    resources {
        board = Board(boardId: "welcome")
    }

    computed {
        pendingCount = createdNotes.countWhere(done, false)
    }

    actions {
        // The sign-in door. server/middleware/session.ts mints the signed
        // session cookie for the validated display name; this action reports
        // the principal the runtime bound. It is a starter placeholder —
        // replace it with a real identity provider.
        server signIn(request: SignInRequest): Session invalidates [Board] {
        }

        server signOut(): Boolean {
        }

        server createNote(request: CreateNoteRequest): Note {
        }

        validateDraft() {
            if len(trim(draftTitle)) == 0 {
                formMessage = "A note title is required."
            } else {
                formMessage = "Draft is valid and ready to submit."
            }
        }

        submitSignIn() {
            let request = SignInRequest(displayName = trim(displayName))
            sessionMessage = "Signing in…"
            let outcome = await signIn(request: request)
            #match outcome {
                Ok(session) {
                    signedInAs = session.userId
                    displayName = ""
                    sessionMessage = `Signed in as {session.userId}.`
                }
                Err(error) {
                    sessionMessage = `Sign-in failed: {error.message}`
                }
            }
        }

        submitSignOut() {
            let outcome = await signOut()
            #match outcome {
                Ok(cleared) {
                    signedInAs = ""
                    createdNotes = []
                    sessionMessage = "Signed out. Your notes stay with your principal."
                }
                Err(error) {
                    sessionMessage = `Sign-out failed: {error.message}`
                }
            }
        }

        // The view only offers this once `signedInAs` is set, and the server
        // refuses it outright without a session: the `Err` arm is where the
        // structured SESSION_PRINCIPAL_REQUIRED refusal becomes something the
        // person reading the page can act on.
        submit() {
            let request = CreateNoteRequest(boardId = "welcome", title = trim(draftTitle))
            formMessage = "Saving…"
            let outcome = await createNote(request: request)
            #match outcome {
                Ok(note) {
                    createdNotes.push(note)
                    draftTitle = ""
                    formMessage = "Saved."
                }
                Err(error) {
                    formMessage = `Save failed: {error.message}`
                }
            }
        }
    }

    intent {
        purpose: "Let one signed-in person add notes to a board and see the board stay current"
        success: "A submitted note is persisted through the typed boundary and appears in the list"
        constraints: ["Never show a note the server has not validated", "Never write without a session"]
    }

    invariant SavedNotesAreValidated {
        assert: createdNotes.countWhere(id, "") == 0
        depends: [createdNotes]
    }

    requirement APP_SIGN_IN {
        description: "Signing in binds the page to one principal and opens the board"
        verify: ["run the executable scenario"]
        depends: [displayName, signedInAs, submitSignIn, signIn]
    }

    requirement APP_SESSION_REQUIRED {
        description: "A submit with no session is refused, stores nothing, and shows the refusal"
        verify: ["run the executable scenario"]
        depends: [createdNotes, formMessage, submit, createNote]
    }

    requirement APP_NOTE_CREATE {
        description: "Submitting a non-empty draft stores the validated note and clears the draft"
        verify: ["run the executable scenario"]
        depends: [draftTitle, createdNotes, submit, createNote]
    }

    scenario SignInOpensTheBoard {
        description: "signing in binds the page to the principal the server returned"
        given: displayName = "ada", signedInAs = "", board = Ready(NoteBoard(boardId = "welcome", notes = [])), signIn = Ok(Session(userId = "ada"))
        when: submitSignIn()
        expect: signedInAs == "ada", displayName == "", sessionMessage == "Signed in as ada."
        covers: [APP_SIGN_IN]
    }

    scenario RefusesWriteWithoutSession {
        description: "the server's signed-out refusal stores nothing and reaches the page"
        given: signedInAs = "", draftTitle = "Write the launch note", createdNotes = [], board = Ready(NoteBoard(boardId = "welcome", notes = [])), createNote = Err(RemoteError(code = "SESSION_PRINCIPAL_REQUIRED", message = "this request carries no session"))
        when: submit()
        expect: createdNotes == [], draftTitle == "Write the launch note", formMessage == "Save failed: this request carries no session"
        covers: [APP_SESSION_REQUIRED]
    }

    scenario CreateNote {
        description: "a valid draft becomes a validated note under the signed-in principal"
        given: signedInAs = "ada", draftTitle = "Write the launch note", createdNotes = [], board = Ready(NoteBoard(boardId = "welcome", notes = [])), createNote = Ok(Note(id = "note-1", title = "Write the launch note", done = false))
        when: submit()
        expect: createdNotes == [Note(id = "note-1", title = "Write the launch note", done = false)], draftTitle == "", formMessage == "Saved."
        covers: [APP_NOTE_CREATE]
    }

    view {
        <main>
            <h1>Noxid App</h1>

            #if len(signedInAs) == 0 {
                <section class="card">
                    <h2>Sign in</h2>
                    <p>Every endpoint and action here needs a session. This form is a starter placeholder: it signs a cookie for the name you type. Replace it with your identity provider.</p>
                    <label for="display-name">Display name</label>
                    <input id="display-name" value:bind={displayName} />
                    <p>{sessionMessage}</p>
                    <button +click={submitSignIn}>Sign in</button>
                </section>
            } #else {
                <section class="card">
                    <h2>Session</h2>
                    <p>Signed in as {signedInAs}. {sessionMessage}</p>
                    <button +click={submitSignOut}>Sign out</button>
                </section>

                <section class="card">
                    <h2>Board</h2>
                    #match board {
                        Idle {
                            <p>Waiting to acquire the board.</p>
                        }
                        Loading {
                            <p>Loading the board…</p>
                        }
                        Ready(data) {
                            <div>
                                <p>Board {data.boardId} · {data.notes.count()} notes.</p>
                                #for note in data.notes key note.id {
                                    <article class="note">{note.title}</article>
                                }
                            </div>
                        }
                        Refreshing(data) {
                            <div>
                                <p>Refreshing board {data.boardId}…</p>
                                #for note in data.notes key note.id {
                                    <article class="note">{note.title}</article>
                                }
                            </div>
                        }
                        Failed(error) {
                            <p class="error">{error.code}: {error.message}</p>
                        }
                    }
                </section>

                <section class="card">
                    <h2>Add a note</h2>
                    <label for="note-title">Title</label>
                    <input id="note-title" value:bind={draftTitle} />
                    <p>{formMessage}</p>
                    <button +click={validateDraft}>Validate draft</button>
                    <button +click={submit}>Save note</button>
                    <p>{pendingCount} unfinished note(s) saved this session.</p>
                    #for note in createdNotes key note.id {
                        <article class="note">{note.title}</article>
                    }
                </section>
            }
        </main>
    }

    style {
        main { display: grid; gap: 1rem; padding: 2rem; }
        .card { padding: 1rem; border: 1px solid #d7dee8; border-radius: 0.75rem; }
        .note { padding: 0.5rem 0; border-top: 1px solid #e4e9ef; }
        input { width: 100%; box-sizing: border-box; padding: 0.6rem; font: inherit; }
        button { padding: 0.6rem 0.9rem; border: 0; border-radius: 0.5rem; background: #154f8b; color: #ffffff; cursor: pointer; }
        .error { color: #a32121; }
    }
}