nandi/frqpublic Fork 0
d333b6f7b4be024f494b613751b18edbeed856bc
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

The binding is Dart, and it works f7aea3b · on d333b6f7b4be024f494b613751b18edbeed856bc · nandi · 10h ago
CLAUDE.md · 141 lines · 6.6 KBmarkdown
Blame HistoryOpen raw

Working in this repo

Nix

STOP BUILDING LOCALLY. Build on Modal. This machine is for editing and for
evaluating — nix flake check, nix eval, nix build --dry-run, nix repl
— and not for realising a derivation. A local build of the whole graph gets
killed for memory long before it finishes, and the minutes spent finding that
out are minutes not spent on the change. So:

modal run .modal/flutter-web/container.py   # the web bundle, on Modal
modal run .modal/flutter-dev/container.py   # the incremental Flutter loop

The containers in .modal/ are not a third source tree: they are CI config
that happens to live here, the way .github/ would be.

--dry-run locally to see what would be built, then hand the build to Modal.
The one exception is a derivation you already know is trivial and already
substitutable; if you are unsure, it is not the exception.

modal app logs is no substitute for watching that command: it resolves
deployed apps by name, not the ephemeral one a modal run creates, and carries
nothing until the Sandbox starts — the image build streams to the client and
nowhere else.

You are already running inside the Arch distrobox, where nix lives, so run
the evaluating commands directly — do not wrap them in distrobox enter.

The containers run as Modal Sandboxes on a real VM, which is what makes a
build work out there at all: the ptyshim that used to stand in for a working
pty under gVisor is deprecated, and nothing here should reintroduce it. Neither
container carries nix: tools/toolchain.sh fetches Flutter, a JDK and the
Clojure CLI by sha256 onto the devshell Volume, and the build runs out of
those.

A remote builder (eu.nixbuild.net) is also configured here, for the case
where you want a derivation built somewhere other than Modal: --store ssh-ng://eu.nixbuild.net --eval-store auto rather than a builders entry, so
the whole graph stays there and only .drv files go up.

One thing this container is not representative of: /etc/localtime is a
regular file here rather than a symlink, so anything that reads the zone out
of its path sees nothing. That is a real deployment shape, not an artefact —
frq.clock handles it.

Never pipe a long task through tail

tail and head do not emit anything until their input ends, so a build, a
test run or a deploy piped through one shows nothing at all until it is over —
and if it is killed or times out first, its output is lost with it. That is the
opposite of what you want from the commands that take longest.

Let them write to the terminal, or tee them if you want a copy to grep
afterwards:

modal run .modal/flutter-web/container.py 2>&1 | tee /tmp/frq-build.log

Trim afterwards, on the file, where the whole run is still there to re-read.
The same goes for grep and awk in a live pipeline: they buffer when their
output is not a terminal, so pass --line-buffered / fflush() or watch the
file instead.

The Nim core

nim/ is the portable logic, moving out of common/ one module at a time as
a native library the Dart side calls through FFI. Read nim/README.md before
touching it — in particular the status section, which says what is actually
wired up (the Nim half and its ABI) and what is not (the Dart binding).

Two rules the ABI has, both of which cost a segfault to rediscover:

  • Every string the core returns is the caller's to free, with frq_free.
    Nim's allocator is not Dart's.
  • frq_init runs once before anything else.

dart/frq_core is the other half of that seam, and is plain Dart. New
code on the Dart side of the boundary is written in Dart rather than
ClojureDart — the core exists to have less Clojure in the tree, and adding
more of it to call the thing replacing it is the wrong direction. ClojureDart
shrinks from both ends.

just nim-test and just dart-test need no Flutter, which is most of the
point: the whole boundary is checkable in about a second.

A module is not deleted from common/ when its Nim version lands: the web
target cannot load a native library, so the ClojureDart original is the web's
implementation until there is a wasm build. Deleting one would take the web
build with it.

The two source trees

common/  .cljc  portable — every target compiles it
flutter/ .cljd  the Flutter half, and the host implementations

The extension is the boundary, not a convention: ClojureDart reads .cljd and
.cljc and never .clj. The rule for anything under common/ is that it may
not require a dart: library — if it needs the host, it asks frq.io, and the
implementation that installed itself answers. frq.io.dart is installed by
flutter/src/frq/main.cljd, which has to await the storage directory first;
frq.io.web by main_web.cljd.

Adding a host call means adding it to the seam in common/frq/io.cljc and to
every implementation. Name it for the result rather than the mechanism — the
seam has write-private-file! and not a chmod, because Dart has no chmod.

There used to be a third tree, src/, and a second runtime under it: jolt,
glimmer, and a libcosmic desktop window painting the same screens. It is gone,
along with just cosmic, just tui, the AV/MoQ media plane and the native
objects they loaded. Flutter is the only frontend now, and common/ is
compiled by one compiler rather than two — which is why the #?(:jolt ...)
reader conditionals that used to be scattered through it are not there any
more. tools/check-common.py still guards the seam, and CI still runs it on
every push.

flutter/ builds three things, from one clojure -M:cljd compile:

just apk, out of the flake's own .#flutter shell (clojure, jdk17, flutter)
and its .#android-sdk package. Impure on purpose: Gradle fetches its own
dependencies and writes into ANDROID_HOME, so the recipe copies the store SDK
to flutter/.home and lets it finish there.

just flutter-desktop, out of .#flutter-desktop (the same clojure and
flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are).
Impure for the network half of the same reasons and no writable-SDK dance,
since nothing writes into the store. nixGL off NixOS.

just flutter-web, out of no nix shell at all — tools/toolchain.sh fetches
the three pinned tarballs it needs, which is what lets .modal/flutter-web/
run the same script on a plain Debian image.

The consequence for common/ is that "the phone" is not a synonym for "the
ClojureDart side": three targets compile it. An implementation that branches on
the platform has to ask (Platform.isAndroid) rather than assume; see
frq.io.dart/write-private-file!, where assuming cost a token its file mode.
See flutter/README.md.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# Working in this repo

## Nix

**STOP BUILDING LOCALLY. Build on Modal.** This machine is for editing and for
evaluating — `nix flake check`, `nix eval`, `nix build --dry-run`, `nix repl`
— and not for realising a derivation. A local build of the whole graph gets
killed for memory long before it finishes, and the minutes spent finding that
out are minutes not spent on the change. So:

```bash
modal run .modal/flutter-web/container.py   # the web bundle, on Modal
modal run .modal/flutter-dev/container.py   # the incremental Flutter loop
```

The containers in `.modal/` are not a third source tree: they are CI config
that happens to live here, the way `.github/` would be.

`--dry-run` locally to see what *would* be built, then hand the build to Modal.
The one exception is a derivation you already know is trivial and already
substitutable; if you are unsure, it is not the exception.

`modal app logs` is no substitute for watching that command: it resolves
deployed apps by name, not the ephemeral one a `modal run` creates, and carries
nothing until the Sandbox starts — the image build streams to the client and
nowhere else.

You are already running inside the Arch distrobox, where `nix` lives, so run
the evaluating commands directly — do not wrap them in `distrobox enter`.

The containers run as Modal **Sandboxes on a real VM**, which is what makes a
build work out there at all: the ptyshim that used to stand in for a working
pty under gVisor is deprecated, and nothing here should reintroduce it. Neither
container carries nix: `tools/toolchain.sh` fetches Flutter, a JDK and the
Clojure CLI by sha256 onto the `devshell` Volume, and the build runs out of
those.

A remote builder (`eu.nixbuild.net`) is also configured here, for the case
where you want a derivation built somewhere other than Modal: `--store
ssh-ng://eu.nixbuild.net --eval-store auto` rather than a `builders` entry, so
the whole graph stays there and only .drv files go up.

One thing this container is *not* representative of: `/etc/localtime` is a
regular file here rather than a symlink, so anything that reads the zone out
of its path sees nothing. That is a real deployment shape, not an artefact —
frq.clock handles it.

## Never pipe a long task through `tail`

`tail` and `head` do not emit anything until their input ends, so a build, a
test run or a deploy piped through one shows nothing at all until it is over —
and if it is killed or times out first, its output is lost with it. That is the
opposite of what you want from the commands that take longest.

Let them write to the terminal, or `tee` them if you want a copy to grep
afterwards:

```bash
modal run .modal/flutter-web/container.py 2>&1 | tee /tmp/frq-build.log
```

Trim afterwards, on the file, where the whole run is still there to re-read.
The same goes for `grep` and `awk` in a live pipeline: they buffer when their
output is not a terminal, so pass `--line-buffered` / `fflush()` or watch the
file instead.

## The Nim core

`nim/` is the portable logic, moving out of `common/` one module at a time as
a native library the Dart side calls through FFI. Read `nim/README.md` before
touching it — in particular the status section, which says what is actually
wired up (the Nim half and its ABI) and what is not (the Dart binding).

Two rules the ABI has, both of which cost a segfault to rediscover:

* Every string the core returns is the **caller's** to free, with `frq_free`.
  Nim's allocator is not Dart's.
* `frq_init` runs once before anything else.

`dart/frq_core` is the other half of that seam, and is **plain Dart**. New
code on the Dart side of the boundary is written in Dart rather than
ClojureDart — the core exists to have less Clojure in the tree, and adding
more of it to call the thing replacing it is the wrong direction. ClojureDart
shrinks from both ends.

`just nim-test` and `just dart-test` need no Flutter, which is most of the
point: the whole boundary is checkable in about a second.

A module is not deleted from `common/` when its Nim version lands: the web
target cannot load a native library, so the ClojureDart original is the web's
implementation until there is a wasm build. Deleting one would take the web
build with it.

## The two source trees

```
common/  .cljc  portable — every target compiles it
flutter/ .cljd  the Flutter half, and the host implementations
```

The extension is the boundary, not a convention: ClojureDart reads `.cljd` and
`.cljc` and never `.clj`. The rule for anything under `common/` is that it may
not require a `dart:` library — if it needs the host, it asks `frq.io`, and the
implementation that installed itself answers. `frq.io.dart` is installed by
`flutter/src/frq/main.cljd`, which has to await the storage directory first;
`frq.io.web` by `main_web.cljd`.

Adding a host call means adding it to the seam in `common/frq/io.cljc` and to
every implementation. Name it for the result rather than the mechanism — the
seam has `write-private-file!` and not a chmod, because Dart has no chmod.

There used to be a third tree, `src/`, and a second runtime under it: jolt,
glimmer, and a libcosmic desktop window painting the same screens. It is gone,
along with `just cosmic`, `just tui`, the AV/MoQ media plane and the native
objects they loaded. Flutter is the only frontend now, and `common/` is
compiled by one compiler rather than two — which is why the `#?(:jolt ...)`
reader conditionals that used to be scattered through it are not there any
more. `tools/check-common.py` still guards the seam, and CI still runs it on
every push.

`flutter/` builds three things, from one `clojure -M:cljd compile`:

`just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter)
and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own
dependencies and writes into `ANDROID_HOME`, so the recipe copies the store SDK
to `flutter/.home` and lets it finish there.

`just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and
flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are).
Impure for the network half of the same reasons and no writable-SDK dance,
since nothing writes into the store. nixGL off NixOS.

`just flutter-web`, out of no nix shell at all — `tools/toolchain.sh` fetches
the three pinned tarballs it needs, which is what lets `.modal/flutter-web/`
run the same script on a plain Debian image.

The consequence for `common/` is that "the phone" is not a synonym for "the
ClojureDart side": three targets compile it. An implementation that branches on
the platform has to ask (`Platform.isAndroid`) rather than assume; see
`frq.io.dart/write-private-file!`, where assuming cost a token its file mode.
See flutter/README.md.