~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain runs on a Rust server modelled on knotless, without hatk

Open chadtmiller.com wants to merge into main from grain.social/grain:feature/rust
.knotless/workflows/check.yml+7−1
@@ -1,11 +1,17 @@
# Formatting, lint and types, as `npm run check` runs them: vp check, then
# svelte-check. The packages are kept between runs and installed again only
# when the lockfile changes (the npm-ci shared step).
#
# The Node side's: at cutover rust.yml is renamed over it, and what of it
# checks web/ moves to a web.yml of its own, as knotless's does.
image: node:25-slim
runners: [runner.knotless.dev]
# A pre-flight: the merge queue takes a pull into a batch only once this
# has passed at its head, so one that does not build fails no batch.
queue: first
on:
push: { refs: [refs/heads/main] }
pull: { base: [refs/heads/main] }
pull: { base: [refs/heads/main, refs/heads/feature/*] }
cache: [node_modules]
steps:
- uses: chadtmiller.com/steps/npm-ci@1
.knotless/workflows/deploy.yml+25−5
@@ -1,6 +1,12 @@
# Deploys main to the Hetzner host once this commit's test and check have
# passed. The appview image is built from this checkout on the host, over
# SSH, as a candidate, grain:build-<commit>, while the checks run; once
# Deploys main to the Hetzner host once this commit's checks all pass:
# check and test, the Node app's, rust and harness, the Rust
# workspace's, and lexicons, so that no part can fail and still deploy.
# Until cutover the Node app is what deploys; at cutover rust.yml and
# harness.yml become check.yml and test.yml, and the list below is
# `check test lexicons`.
#
# The appview image is built from this checkout on the host, over SSH, as
# a candidate, grain:build-<commit>, while the checks run; once
# they pass it becomes grain:current and the grain service is recreated
# from it, leaving the rest of the stack as it is. grain-deploy on the
# host rebuilds the whole stack by hand.
@@ -38,12 +44,26 @@ steps:
timeout: 30
run: |
DOCKER_HOST="ssh://$GRAIN_HOST" docker build -t "grain:build-$KNOTLESS_RUN_SHA" .
- name: checks
- name: checks done
timeout: 30
run: |
# 25 minutes over all five, and never a failure: the next step
# judges. Its checks can wait for a job behind other runs, so it
# waits for them over two steps, each under the runner's 30 minutes
# a step, as knotless's deploy does.
repo="knotless://$KNOTLESS_RUN_REPO"
end=$(($(date +%s) + 1500))
for wf in check test rust harness lexicons; do
left=$((end - $(date +%s)))
[ "$left" -gt 0 ] || break
knotless checks "$repo" --ref "$KNOTLESS_RUN_REF" --commit "$KNOTLESS_RUN_SHA" --workflow "$wf" --wait "$left" || true
done
- name: checks pass
timeout: 30
run: |
repo="knotless://$KNOTLESS_RUN_REPO"
end=$(($(date +%s) + 1500))
for wf in test check; do
for wf in check test rust harness lexicons; do
left=$((end - $(date +%s)))
[ "$left" -gt 0 ] || left=1
out=$(knotless checks "$repo" --ref "$KNOTLESS_RUN_REF" --commit "$KNOTLESS_RUN_SHA" --workflow "$wf" --wait "$left")
.knotless/workflows/e2e.ymlnew+152−0
@@ -0,0 +1,152 @@
# The pages used as people use them, as `just e2e` runs them: grain-serve
# and the web's build together, in Chromium, logging in through the PDS's
# own approval page, then each write the pages make, each read back on the
# page and from the PDS (harness/e2e/flows.spec.mjs). Then, over the
# spaces alpha, what an account on a PDS with spaces finds: the Groups tab
# and the groups page (spaces.spec.mjs). rust.yml and harness.yml check grain-serve, check.yml
# and test.yml the web; this, that the two work together. Each flow that
# fails leaves the page as it was then among the check's outputs, as a
# picture, beside what the servers said.
#
# Node, the packages, Chromium, the libraries it needs, the alpha and the
# build are kept between runs as main's runs, or a feature/ branch's,
# leave them: nothing is fetched again until what it is made from changes.
image: rust:1.98
runners: [runner.knotless.dev]
# Every push to main or a feature/ branch, and pulls into either that
# change the pages, the server they log in to and write through, the lexicons
# between them, the harness they run over, or this file. A pull changing
# none passes at once, noted as skipped.
on:
push: { refs: [refs/heads/main, refs/heads/feature/*] }
pull:
base: [refs/heads/main, refs/heads/feature/*]
paths:
[
web/**,
lexicons/**,
crates/grain/**,
crates/grain-serve/**,
Cargo.toml,
Cargo.lock,
package.json,
package-lock.json,
svelte.config.js,
vite.config.ts,
hatk.generated.client.ts,
harness/e2e/**,
harness/pds.mjs,
harness/tls-proxy.mjs,
harness/package.json,
harness/package-lock.json,
.knotless/workflows/e2e.yml,
]
cache:
[
target,
/usr/local/cargo/registry,
/usr/local/rustup,
/opt/node,
/opt/browsers,
/opt/chromium-debs,
/opt/atproto,
node_modules,
harness/node_modules,
]
env:
PLAYWRIGHT_BROWSERS_PATH: /opt/browsers
# The spaces alpha the spaces flows run over, as harness.yml pins it:
# the two move together.
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf
ALPHA_BUILD: "1"
steps:
# Node into /opt/node, and the web's and the harness's packages when
# their lockfiles change, each kept in the cache.
- uses: chadtmiller.com/steps/node@1
with: { version: 24.22.1 }
- uses: chadtmiller.com/steps/npm-ci@1
name: web packages
- uses: chadtmiller.com/steps/npm-ci@1
name: harness
with: { dir: harness }
- name: chromium
run: |
export PATH=/opt/node/bin:$PATH
cd harness
# The browser, into /opt/browsers once for each Playwright version.
npx playwright install chromium
# The libraries it needs, which `install-deps` would fetch with apt on
# every run: their .debs are kept, fetched again only when this
# Playwright or this Debian asks for others, and installed offline.
pkgs=$(npx playwright install-deps --dry-run chromium | sed -n "s/.*--no-install-recommends \([^\"']*\).*/\1/p")
[ -n "$pkgs" ] || { echo "no packages in: $(npx playwright install-deps --dry-run chromium)"; exit 1; }
debs=/opt/chromium-debs
want="$(. /etc/os-release && echo "$VERSION_ID") $pkgs"
if [ "$(cat $debs/.want 2>/dev/null)" != "$want" ]; then
mkdir -p $debs && rm -rf "${debs:?}"/* && mkdir -p $debs/partial
apt-get update -qq
apt-get install -y -qq --no-install-recommends --download-only -o Dir::Cache::archives=$debs $pkgs
echo "$want" > $debs/.want
fi
dpkg -i $debs/*.deb > /dev/null
- name: spaces alpha
run: |
[ "$(cat /opt/atproto/.built 2>/dev/null)" = "$ATPROTO_COMMIT $ALPHA_BUILD" ] && exit 0
export PATH=/opt/node/bin:$PATH COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_HOME=/opt/atproto/.corepack
mkdir -p /opt/atproto && find /opt/atproto -mindepth 1 -delete
cd /opt/atproto
git init -q . && git fetch -q --depth 1 https://github.com/bluesky-social/atproto.git "$ATPROTO_COMMIT" && git checkout -q FETCH_HEAD
mkdir .bin && corepack enable --install-directory /opt/atproto/.bin
export PATH=/opt/atproto/.bin:$PATH
pnpm install --frozen-lockfile --ignore-scripts
pnpm run build:tooling --force
pnpm --filter '@atproto/dev-env^...' --filter @atproto/dev-env run --if-present prebuild
pnpm exec tsc --build packages/dev-env/tsconfig.build.json
# The PDS serves the OAuth sign-in pages from this bundle.
pnpm --filter @atproto/oauth-provider-ui run build:ui
echo "$ATPROTO_COMMIT $ALPHA_BUILD" > /opt/atproto/.built
- name: sqlite
run: |
# The one install script the alpha's PDS needs, which the install
# above skips: SQLite's native module, as better-sqlite3 builds it.
export PATH=/opt/node/bin:$PATH
cd "$(dirname "$(node -p "require.resolve('better-sqlite3/package.json', { paths: ['/opt/atproto/packages/pds'] })")")"
[ -f build/Release/better_sqlite3.node ] && exit 0
npm run install
- name: build
run: |
export PATH=/opt/node/bin:$PATH
cargo build --locked -p grain-serve --bin grain-serve
npm run build
- name: flows
run: |
export PATH=/opt/node/bin:$PATH
rm -rf target/e2e
status=0
(cd harness && npx playwright test -c e2e/playwright.config.mjs) || status=$?
# Then the spaces ones, over a world of their own on the alpha.
cp -r target/e2e/logs target/e2e/logs-public 2>/dev/null || true
(cd harness && GRAIN_DEV_ENV=/opt/atproto/packages/dev-env npx playwright test -c e2e/playwright.config.mjs) || status=$?
if [ $status -ne 0 ]; then
# What the servers said, the last of it, for the flows that failed.
# What each failed page held, and the browser said.
for context in target/e2e/*/*/error-context.md; do
[ -f "$context" ] && { echo "--- $context"; head -c 6000 "$context"; echo; }
done
for log in target/e2e/logs-public/*.log target/e2e/logs/*.log; do
[ -f "$log" ] && { echo "--- $log"; grep -v '"url.path":"/\(_app\|atmo\)/' "$log" | tail -n 80 | cut -c1-600; }
done
fi
# The page each failed flow ended on, named for the flow, and the logs.
if [ -n "${KNOTLESS_RUN_OUTPUTS:-}" ] && [ -d target/e2e ]; then
for shot in target/e2e/*/*/test-failed-*.png; do
[ -f "$shot" ] && cp "$shot" "$KNOTLESS_RUN_OUTPUTS/$(basename "$(dirname "$shot")").png"
done
for log in target/e2e/logs-public/*.log; do
[ -f "$log" ] && cp "$log" "$KNOTLESS_RUN_OUTPUTS/public-$(basename "$log")"
done
for log in target/e2e/logs/*.log; do
[ -f "$log" ] && cp "$log" "$KNOTLESS_RUN_OUTPUTS/spaces-$(basename "$log")"
done
fi
exit $status
.knotless/workflows/harness.ymlnew+107−0
@@ -0,0 +1,107 @@
# The Rust tests against the reference PDS, under the coverage gate, as
# `just cov` runs them (harness/cov.mjs): every test binary, instrumented,
# against @atproto/pds and a PLC directory from harness/pds.mjs, failing
# under harness/cov.json's lines or functions. Then that every XRPC answer
# grain-serve's tests received is as its lexicon says (harness/answers.mjs),
# and that the check would name a wrong one (harness/answers-test.sh); and
# that no query they ran scans the network at prod's size
# (harness/plans.mjs).
#
# The test binaries named `spaces` run against the reference PDS on the
# permissioned spaces alpha, built from an atproto commit, as knotless's
# tests are: npm's alpha builds name their dependencies by workspace, so
# they do not install.
#
# Node, the harness's packages, the alpha's build, cargo-llvm-cov and the
# build are kept between runs; each is made again only when what it is
# made from changes. So are the test runs that passed, in /opt/testcache:
# a test binary the same byte for byte as one that passed, against the
# same harness, is not run again, and its profile counts (harness/cov.mjs).
#
# At cutover this becomes test.yml, as knotless's is, over the Node one,
# and rust.yml check.yml (see there).
image: rust:1.98
runners: [runner.knotless.dev]
# A pre-flight, as check is: the merge queue takes a pull into a batch only
# once this has passed at its head, so one whose tests fail on their own
# costs no batch and no halving.
queue: first
# Pushes to main and to a feature/ branch, which keeps caches of its own
# for the pulls into it: a branch keeps them only where both push and
# pull name it.
on:
push: { refs: [refs/heads/main, refs/heads/feature/*] }
pull: { base: [refs/heads/main, refs/heads/feature/*] }
cache:
[
target,
/usr/local/cargo/registry,
/usr/local/rustup,
/opt/node,
/opt/tools,
/opt/atproto,
harness/node_modules,
/opt/testcache,
]
env:
# The spaces alpha the tests of spaces run against, as knotless's do.
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf
# Bumped when the steps that build the alpha change, to build it again.
ALPHA_BUILD: "1"
steps:
# Node into /opt/node, the harness's packages when their lockfile
# changes, and cargo-llvm-cov into /opt/tools, each kept in the cache.
- uses: chadtmiller.com/steps/node@1
with: { version: 24.22.1 }
- uses: chadtmiller.com/steps/npm-ci@1
name: harness
with: { dir: harness, omit: dev }
- uses: chadtmiller.com/steps/cargo-llvm-cov@1
with: { version: 0.9.1 }
- name: spaces alpha
run: |
[ "$(cat /opt/atproto/.built 2>/dev/null)" = "$ATPROTO_COMMIT $ALPHA_BUILD" ] && exit 0
export PATH=/opt/node/bin:$PATH COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_HOME=/opt/atproto/.corepack
mkdir -p /opt/atproto && find /opt/atproto -mindepth 1 -delete
cd /opt/atproto
git init -q . && git fetch -q --depth 1 https://github.com/bluesky-social/atproto.git "$ATPROTO_COMMIT" && git checkout -q FETCH_HEAD
mkdir .bin && corepack enable --install-directory /opt/atproto/.bin
export PATH=/opt/atproto/.bin:$PATH
pnpm install --frozen-lockfile --ignore-scripts
pnpm run build:tooling --force
pnpm --filter '@atproto/dev-env^...' --filter @atproto/dev-env run --if-present prebuild
pnpm exec tsc --build packages/dev-env/tsconfig.build.json
# The PDS serves the OAuth sign-in pages from this bundle.
pnpm --filter @atproto/oauth-provider-ui run build:ui
echo "$ATPROTO_COMMIT $ALPHA_BUILD" > /opt/atproto/.built
- name: sqlite
run: |
# The one install script the alpha's PDS needs, which the install
# above skips: SQLite's native module, as better-sqlite3 builds it.
export PATH=/opt/node/bin:$PATH
cd "$(dirname "$(node -p "require.resolve('better-sqlite3/package.json', { paths: ['/opt/atproto/packages/pds'] })")")"
[ -f build/Release/better_sqlite3.node ] && exit 0
npm run install
- name: test
run: |
export PATH=/opt/node/bin:$PATH GRAIN_DEV_ENV=/opt/atproto/packages/dev-env
# Half the host's cores, as two test runs go side by side, over two
# stock PDSes, as one PDS is one node process, and the alpha beside
# them, all at once: each binary against the stock PDS but those
# named `spaces`, which go against the alpha (harness/cov.mjs).
COV_STORE=/opt/testcache COV_JOBS=$(( $(nproc) / 2 )) COV_HARNESSES=2 COV_TOOLCHAIN=$(rustup show active-toolchain | cut -d' ' -f1) \
node harness/cov.mjs
- name: answers
run: |
# Every XRPC answer grain-serve's tests received, fresh or kept with
# a stored run, as its lexicon says.
export PATH=/opt/node/bin:$PATH
harness/answers-test.sh
node harness/answers.mjs target/llvm-cov-target/cov-report/answers
- name: plans
run: |
# No query the tests ran scans a table that grows with the network,
# or sorts many of its rows, as SQLite would plan it at prod's size,
# unless its SQL says so.
export PATH=/opt/node/bin:$PATH
node harness/plans.mjs target/llvm-cov-target/cov-report/plans
.knotless/workflows/lexicons.ymlnew+30−0
@@ -0,0 +1,30 @@
# The lexicons, as `just lex` checks them: goat's lint, what it does not
# see, and that they only grow from main's (harness/lexicons.mjs), over
# grain's own under lexicons/social/grain, less what the old server alone
# serves. goat is kept between runs.
#
# The XRPC answers are held to the lexicons by harness.yml, over what
# grain-serve's tests received.
image: node:24.22.1
runners: [runner.knotless.dev]
# Every push to main, and pulls into it or a feature/ branch, at their
# heads merged with their base, that change the lexicons, their check or
# this file. A pull changing none passes at once, noted as skipped.
on:
push: { refs: [refs/heads/main] }
pull:
base: [refs/heads/main, refs/heads/feature/*]
paths: [lexicons/**, harness/lexicons.mjs, .knotless/workflows/lexicons.yml]
cache: [/opt/goat]
steps:
- name: goat
run: |
mkdir -p /opt/goat
[ -x /opt/goat/goat ] || curl -fsSL https://github.com/bluesky-social/goat/releases/download/v0.2.6/goat_Linux_x86_64.tar.gz | tar -xz -C /opt/goat goat
/opt/goat/goat --version
- name: lexicons
run: |
# The checkout is another user's: git reads where it left main only
# once told it may.
git config --global --add safe.directory "$PWD"
PATH=/opt/goat:$PATH node harness/lexicons.mjs
.knotless/workflows/publish-lexicons.yml+4−3
@@ -1,5 +1,6 @@
# Publishes grain's lexicons as atproto resolves them, once main's test
# and check pass at a commit that changes them, with the publish-lexicons
# Publishes grain's lexicons as atproto resolves them, once main's checks
# all pass (check, test, rust, harness and lexicons, as deploy.yml waits on)
# at a commit that changes them, with the publish-lexicons
# shared step: a com.atproto.lexicon.schema record in grain.social's repo
# for each lexicon whose authority is in the grain.social zone, and the
# `_lexicon.<authority>` TXT records naming that account. The rest of
@@ -41,7 +42,7 @@ steps:
- name: checks
uses: chadtmiller.com/steps/wait-for-checks@2
on: { refs: [refs/heads/main] }
with: { workflows: test check }
with: { workflows: check test rust harness lexicons }
- name: publish
uses: chadtmiller.com/steps/publish-lexicons@1
on: { refs: [refs/heads/main] }
.knotless/workflows/rust.ymlnew+41−0
@@ -0,0 +1,41 @@
# The Rust workspace: formatting and clippy with warnings denied, as `just
# lint` runs them; harness.yml runs the tests. Kept apart from check.yml
# and test.yml, which run the Node side of the repository.
#
# At cutover this becomes check.yml, as knotless's is, and harness.yml
# test.yml: the Node check.yml and test.yml are deleted, this file is
# renamed over check.yml, and the lists of checks deploy.yml and
# publish-lexicons.yml wait on go back to `check test` (and lexicons).
#
# The build, the registry and the toolchain's components are kept between
# runs, so a push builds only what it changed.
image: rust:1.98
runners: [runner.knotless.dev]
# A pre-flight: the merge queue takes a pull into a batch only once this
# has passed at its head, so one that does not build fails no batch.
queue: first
# Pushes to main and to a feature/ branch, which keeps caches of its own
# for the pulls into it: a branch keeps them only where both push and
# pull name it.
on:
push: { refs: [refs/heads/main, refs/heads/feature/*] }
pull: { base: [refs/heads/main, refs/heads/feature/*] }
cache: [target, /usr/local/cargo/registry, /usr/local/rustup]
steps:
# grain-import, the one-shot cutover from the old server's database, and
# grain-serve's login carryover, which reads the cookies it set until a
# date after cutover, are the only Rust that may name it: the rest
# describes grain on its own terms. Both are deleted after cutover. The pattern is bracketed so that this file does not match it.
# git grep exits 1 when it finds nothing, and more when it cannot search.
- name: the old server is named only in grain-import
run: |
git config --global --add safe.directory "$PWD"
rc=0
git grep -in 'h[a]tk' -- crates harness .knotless/workflows/rust.yml ':(exclude)crates/grain-import' ':(exclude)crates/grain-serve/src/login/carryover.rs' || rc=$?
case $rc in
1) exit 0 ;;
0) echo "named outside crates/grain-import, above"; exit 1 ;;
*) echo "git grep could not search the checkout"; exit 2 ;;
esac
# cargo fmt --check, then clippy over the workspace, warnings denied.
- uses: chadtmiller.com/steps/rust-lint@1
.knotless/workflows/test.yml+7−1
@@ -2,11 +2,17 @@
# themselves without SPACES_LIVE=1, and the Playwright tests in test/browser
# are not run here. The packages are kept between runs and installed again
# only when the lockfile changes (the npm-ci shared step).
#
# The Node side's: at cutover harness.yml is renamed over it.
image: node:25-slim
runners: [runner.knotless.dev]
# A pre-flight, as check is: the merge queue takes a pull into a batch only
# once this has passed at its head, so one whose tests fail on their own
# costs no batch and no halving.
queue: first
on:
push: { refs: [refs/heads/main] }
pull: { base: [refs/heads/main] }
pull: { base: [refs/heads/main, refs/heads/feature/*] }
cache: [node_modules]
steps:
- uses: chadtmiller.com/steps/npm-ci@1
.knotless/uses.lock+12−0
@@ -1,4 +1,12 @@
# Written by `knotless uses update`. Commit it: runs read it.
chadtmiller.com/steps/cargo-llvm-cov@1:
version: 1.0.0
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.tbnmqntp7ufhx7yd6i3rdqewys.sjjb7q6l3fsl3spvqsuzdoe73w
chadtmiller.com/steps/node@1:
version: 1.0.0
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.qespbvwvof6xwjdaedpzq3koia.sjjb7q6l3fsl3spvqsuzdoe73w
chadtmiller.com/steps/npm-ci@1:
version: 1.0.0
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15
@@ -7,6 +15,10 @@ chadtmiller.com/steps/publish-lexicons@1:
version: 1.0.0
commit: cc436177fab7461a33e84ec8876a2d66656a5c09
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.ez5tgppxbpaxxrztjj3cnlyyhc.sjjb7q6l3fsl3spvqsuzdoe73w
chadtmiller.com/steps/rust-lint@1:
version: 1.0.0
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.zvxvlxa6fe7s4pnwsnmgmm63aj.sjjb7q6l3fsl3spvqsuzdoe73w
chadtmiller.com/steps/wait-for-checks@2:
version: 2.0.0
commit: d5b057a2314833fdee9f600c7872db2916b0d0aa
app/lib/utils/records.tsdeleted+0−120
@@ -1,120 +0,0 @@
import { callXrpc } from "$hatk/client";
import { nextTid } from "./tid.ts";
/** One `dev.hatk.applyWrites#create` entry with a client-minted rkey. */
export interface CreateWrite {
$type: "dev.hatk.applyWrites#create";
collection: string;
rkey: string;
value: Record<string, unknown>;
}
/** One `dev.hatk.applyWrites#update` entry. The record must already exist. */
export interface UpdateWrite {
$type: "dev.hatk.applyWrites#update";
collection: string;
rkey: string;
value: Record<string, unknown>;
}
export interface DeleteWrite {
$type: "dev.hatk.applyWrites#delete";
collection: string;
rkey: string;
}
export type Write = CreateWrite | UpdateWrite | DeleteWrite;
export function createWrite(
collection: string,
rkey: string,
value: Record<string, unknown>,
): CreateWrite {
return { $type: "dev.hatk.applyWrites#create", collection, rkey, value };
}
export function updateWrite(
collection: string,
rkey: string,
value: Record<string, unknown>,
): UpdateWrite {
return { $type: "dev.hatk.applyWrites#update", collection, rkey, value };
}
export function deleteWrite(collection: string, rkey: string): DeleteWrite {
return { $type: "dev.hatk.applyWrites#delete", collection, rkey };
}
export function atUri(did: string, collection: string, rkey: string): string {
return `at://${did}/${collection}/${rkey}`;
}
export function rkeyOf(uri: string): string {
return uri.split("/").pop()!;
}
/**
* Send every write in a single atomic PDS transaction.
*
* One request instead of one per record: a 10-photo gallery used to be ~31
* sequential `createRecord` calls, each its own signed repo commit and firehose
* event, which is where the minute-plus publish time came from. It also means a
* failure can no longer leave a half-written gallery behind.
*/
export async function applyWrites(writes: Write[]): Promise<void> {
if (writes.length === 0) return;
await callXrpc("dev.hatk.applyWrites", { writes } as never);
}
/** True if the gallery is already in the index. Any failure reads as "no". */
export async function galleryExists(galleryUri: string): Promise<boolean> {
try {
const res = await callXrpc("social.grain.unspecced.getGallery", { gallery: galleryUri });
return !!(res as { gallery?: unknown })?.gallery;
} catch {
return false;
}
}
/** Run `fn` over `items`, at most `limit` at a time, preserving input order. */
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = [];
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index], index);
}
});
await Promise.all(workers);
return results;
}
/**
* Blobs can't ride along in applyWrites — the PDS takes raw bytes — so they
* stay one request each. Overlapping a few keeps a full gallery from being a
* long chain of serial uploads, while staying gentle enough on a phone's
* connection and memory.
*/
const UPLOAD_CONCURRENCY = 3;
export function dataUrlToBlob(dataUrl: string): Blob {
const base64 = dataUrl.split(",")[1];
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new Blob([binary], { type: "image/jpeg" });
}
/** Upload each data URL as a blob and return the blob refs in input order. */
export async function uploadPhotoBlobs(dataUrls: string[]): Promise<unknown[]> {
return mapWithConcurrency(dataUrls, UPLOAD_CONCURRENCY, async (dataUrl) => {
const result = await callXrpc("dev.hatk.uploadBlob", dataUrlToBlob(dataUrl) as never);
return (result as { blob: unknown }).blob;
});
}
/** Mint a fresh record key. Re-exported so callers only import from one place. */
export { nextTid };
app/lib/auth.tsdeleted+0−31
app/lib/feed.tsdeleted+0−5
app/lib/labels.tsdeleted+0−95
app/routes/.well-known/did.json/+server.tsdeleted+0−22
app/routes/oauth/callback/+page.sveltedeleted+0−11
app/routes/private/[authority]/[skey]/+page.sveltedeleted+0−309
app/routes/private/create/+page.sveltedeleted+0−274
app/routes/+layout.server.tsdeleted+0−32
crates/grain/src/bin/grain.rsnew+8−0
crates/grain/src/repo/car.rsnew+168−0
crates/grain/src/repo/mod.rsnew+229−0
crates/grain/src/repo/mst.rsnew+264−0
crates/grain/src/repo/star.rsnew+118−0
crates/grain/src/repo/tests.rsnew+439−0
439 lines changed: not loaded until asked for.
crates/grain/src/auth.rsnew+284−0
crates/grain/src/base32.rsnew+81−0
crates/grain/src/cli.rsnew+347−0
crates/grain/src/crypto.rsnew+359−0
crates/grain/src/drisl.rsnew+173−0
crates/grain/src/env.rsnew+294−0
crates/grain/src/guard.rsnew+180−0
crates/grain/src/identity.rsnew+717−0
717 lines changed: not loaded until asked for.
crates/grain/src/jetstream.rsnew+428−0
428 lines changed: not loaded until asked for.
crates/grain/src/keys.rsnew+100−0
crates/grain/src/labels.rsnew+245−0
crates/grain/src/lexicon.rsnew+1319−0
1319 lines changed: not loaded until asked for.
crates/grain/src/lib.rsnew+26−0
crates/grain/src/moderation.rsnew+296−0
crates/grain/src/oauth.rsnew+1438−0
1438 lines changed: not loaded until asked for.
crates/grain/src/session.rsnew+370−0
crates/grain/src/space.rsnew+789−0
789 lines changed: not loaded until asked for.
crates/grain/src/store.rsnew+119−0
crates/grain/src/telemetry.rsnew+883−0
883 lines changed: not loaded until asked for.
crates/grain/src/xrpc.rsnew+479−0
479 lines changed: not loaded until asked for.
crates/grain/tests/common/mod.rsnew+260−0
crates/grain/tests/cli.rsnew+39−0
crates/grain/tests/oauth.rsnew+255−0
crates/grain/tests/pds.rsnew+237−0
crates/grain/tests/repo.rsnew+145−0
crates/grain/tests/spaces.rsnew+144−0
crates/grain/Cargo.tomlnew+32−0
crates/grain-import/src/lib.rsnew+994−0
994 lines changed: not loaded until asked for.
crates/grain-import/src/main.rsnew+9−0
crates/grain-import/src/tests.rsnew+1096−0
1096 lines changed: not loaded until asked for.
crates/grain-import/tests/main.rsnew+43−0
crates/grain-import/Cargo.tomlnew+20−0
crates/grain-mcp/src/lib.rsnew+498−0
498 lines changed: not loaded until asked for.
crates/grain-mcp/src/main.rsnew+18−0
crates/grain-mcp/tests/mcp.rsnew+352−0
crates/grain-mcp/Cargo.tomlnew+22−0
crates/grain-serve/fonts/Inter-Regular.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/fonts/NotoEmoji-Regular.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/fonts/NotoSansJP-Regular.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/fonts/OFL.txtnew+95−0
crates/grain-serve/fonts/Syne-ExtraBold.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/src/classify/classifiers.rsnew+471−0
471 lines changed: not loaded until asked for.
crates/grain-serve/src/classify/clef.rsnew+305−0
crates/grain-serve/src/classify/mod.rsnew+396−0
crates/grain-serve/src/index/backfill.rsnew+147−0
crates/grain-serve/src/index/classify.rsnew+158−0
crates/grain-serve/src/index/graph.rsnew+35−0
crates/grain-serve/src/index/mod.rsnew+609−0
609 lines changed: not loaded until asked for.
crates/grain-serve/src/index/moderation.rsnew+418−0
418 lines changed: not loaded until asked for.
crates/grain-serve/src/index/plan.rsnew+59−0
crates/grain-serve/src/index/plans.rsnew+595−0
595 lines changed: not loaded until asked for.
crates/grain-serve/src/index/preferences.rsnew+138−0
crates/grain-serve/src/index/push.rsnew+153−0
crates/grain-serve/src/index/records.rsnew+345−0
crates/grain-serve/src/index/schema.rsnew+531−0
531 lines changed: not loaded until asked for.
crates/grain-serve/src/index/sessions.rsnew+204−0
crates/grain-serve/src/index/spaces.rsnew+76−0
crates/grain-serve/src/index/stories.rsnew+75−0
crates/grain-serve/src/index/tests.rsnew+750−0
750 lines changed: not loaded until asked for.
crates/grain-serve/src/login/carryover.rsnew+250−0
crates/grain-serve/src/login/profile.rsnew+129−0
crates/grain-serve/src/og/collage.rsnew+404−0
404 lines changed: not loaded until asked for.
crates/grain-serve/src/og/draw.rsnew+420−0
420 lines changed: not loaded until asked for.
crates/grain-serve/src/og/mod.rsnew+580−0
580 lines changed: not loaded until asked for.
crates/grain-serve/src/push/apns.rsnew+185−0
crates/grain-serve/src/push/fcm.rsnew+223−0
crates/grain-serve/src/push/mod.rsnew+201−0
crates/grain-serve/src/push/send.rsnew+258−0
crates/grain-serve/src/push/test-rsa-key.pemnew+28−0
crates/grain-serve/src/push/tests.rsnew+1051−0
1051 lines changed: not loaded until asked for.
crates/grain-serve/src/read/actor.rsnew+273−0
crates/grain-serve/src/read/camera.rsnew+300−0
crates/grain-serve/src/read/comment.rsnew+113−0
crates/grain-serve/src/read/feed.rsnew+242−0
crates/grain-serve/src/read/foryou.rsnew+199−0
crates/grain-serve/src/read/gallery.rsnew+78−0
crates/grain-serve/src/read/graph.rsnew+231−0
crates/grain-serve/src/read/location.rsnew+580−0
580 lines changed: not loaded until asked for.
crates/grain-serve/src/read/mod.rsnew+335−0
crates/grain-serve/src/read/moderation.rsnew+63−0
crates/grain-serve/src/read/notification.rsnew+418−0
418 lines changed: not loaded until asked for.
crates/grain-serve/src/read/place.rsnew+394−0
crates/grain-serve/src/read/search.rsnew+848−0
848 lines changed: not loaded until asked for.
crates/grain-serve/src/read/sitemap.rsnew+134−0
crates/grain-serve/src/read/story.rsnew+130−0
crates/grain-serve/src/read/tests.rsnew+87−0
crates/grain-serve/src/read/views.rsnew+575−0
575 lines changed: not loaded until asked for.
crates/grain-serve/src/spaces/blob.rsnew+105−0
crates/grain-serve/src/spaces/group.rsnew+864−0
864 lines changed: not loaded until asked for.
crates/grain-serve/src/spaces/mod.rsnew+367−0
crates/grain-serve/src/spaces/pool.rsnew+724−0
724 lines changed: not loaded until asked for.
crates/grain-serve/src/spaces/support.rsnew+344−0
crates/grain-serve/src/accounts.rsnew+382−0
crates/grain-serve/src/backfill.rsnew+589−0
589 lines changed: not loaded until asked for.
crates/grain-serve/src/cdn.rsnew+161−0
crates/grain-serve/src/did.rsnew+69−0
crates/grain-serve/src/gallery.rsnew+120−0
crates/grain-serve/src/graph.rsnew+80−0
crates/grain-serve/src/jetstream.rsnew+269−0
crates/grain-serve/src/lib.rsnew+688−0
688 lines changed: not loaded until asked for.
crates/grain-serve/src/login.rsnew+824−0
824 lines changed: not loaded until asked for.
crates/grain-serve/src/main.rsnew+68−0
crates/grain-serve/src/moderation.rsnew+631−0
631 lines changed: not loaded until asked for.
crates/grain-serve/src/notification.rsnew+55−0
crates/grain-serve/src/pages.rsnew+97−0
crates/grain-serve/src/preferences.rsnew+286−0
crates/grain-serve/src/status.rsnew+393−0
crates/grain-serve/src/story.rsnew+59−0
crates/grain-serve/src/sync.rsnew+328−0
crates/grain-serve/src/telemetry.rsnew+214−0
crates/grain-serve/src/viewer.rsnew+122−0
crates/grain-serve/src/write.rsnew+661−0
661 lines changed: not loaded until asked for.
crates/grain-serve/src/xrpc.rsnew+79−0
crates/grain-serve/tests/common/mod.rsnew+178−0
crates/grain-serve/tests/host/mod.rsnew+565−0
565 lines changed: not loaded until asked for.
crates/grain-serve/tests/web/mod.rsnew+228−0
crates/grain-serve/tests/backfill.rsnew+582−0
582 lines changed: not loaded until asked for.
crates/grain-serve/tests/classify.rsnew+815−0
815 lines changed: not loaded until asked for.
crates/grain-serve/tests/cli.rsnew+309−0
crates/grain-serve/tests/cross_post.rsnew+138−0
crates/grain-serve/tests/deletes.rsnew+472−0
472 lines changed: not loaded until asked for.
crates/grain-serve/tests/gone.rsnew+211−0
crates/grain-serve/tests/graph.rsnew+251−0
crates/grain-serve/tests/health.rsnew+190−0
crates/grain-serve/tests/index_records.rsnew+407−0
407 lines changed: not loaded until asked for.
crates/grain-serve/tests/jetstream.rsnew+667−0
667 lines changed: not loaded until asked for.
crates/grain-serve/tests/login.rsnew+852−0
852 lines changed: not loaded until asked for.
crates/grain-serve/tests/main.rsnew+168−0
crates/grain-serve/tests/moderation.rsnew+760−0
760 lines changed: not loaded until asked for.
crates/grain-serve/tests/notifications.rsnew+786−0
786 lines changed: not loaded until asked for.
crates/grain-serve/tests/og.rsnew+348−0
crates/grain-serve/tests/pages.rsnew+163−0
crates/grain-serve/tests/push.rsnew+151−0
crates/grain-serve/tests/read.rsnew+1925−0
1925 lines changed: not loaded until asked for.
crates/grain-serve/tests/sitemap.rsnew+175−0
crates/grain-serve/tests/spaces.rsnew+1055−0
1055 lines changed: not loaded until asked for.
crates/grain-serve/tests/stories.rsnew+225−0
crates/grain-serve/tests/write.rsnew+475−0
475 lines changed: not loaded until asked for.
crates/grain-serve/Cargo.tomlnew+38−0
deploy/deploy.shnew+178−0
deploy/docker-compose.ymlnew+168−0
deploy/Dockerfilenew+55−0
deploy/Dockerfile.dockerignorenew+17−0
deploy/local.env.examplenew+17−0
deploy/logs.shnew+57−0
deploy/README.mdnew+119−0
deploy/rollout.shnew+118−0
docs/observability.mdnew+77−0
harness/answers-test/lexicons/getThing.jsonnew+21−0
harness/answers-test/right/answers.jsonlnew+4−0
harness/answers-test/wrong/answers.jsonlnew+5−0
harness/e2e/flows.spec.mjsnew+256−0
harness/e2e/pages.mjsnew+142−0
harness/e2e/playwright.config.mjsnew+39−0
harness/e2e/setup.mjsnew+184−0
harness/e2e/spaces.spec.mjsnew+51−0
harness/.node-versionnew+1−0
harness/answers-test.shnew+21−0
harness/answers.mjsnew+100−0
harness/authorize.mjsnew+159−0
harness/cov.jsonnew+1−0
harness/cov.mjsnew+673−0
673 lines changed: not loaded until asked for.
harness/lexicons.mjsnew+334−0
harness/package-lock.jsonnew+9488−0
A large diff, not shown.
harness/package.jsonnew+13−0
harness/pds.mjsnew+201−0
harness/plans.mjsnew+56−0
harness/quarantine.jsonnew+1−0
harness/tls-proxy.mjsnew+29−0
lexicons/com/atproto/admin/defs.jsonnew+47−0
lexicons/com/atproto/admin/getSubjectStatus.jsonnew+43−0
lexicons/com/atproto/admin/updateSubjectStatus.jsonnew+56−0
lexicons/com/atproto/label/queryLabels.jsonnew+47−0
lexicons/com/atproto/moderation/createReport.jsonnew+78−0
lexicons/com/atproto/moderation/defs.json+56−8
lexicons/social/grain/actor/defs.json+108−2
lexicons/social/grain/actor/getFavorites.jsonnew+33−0
lexicons/social/grain/actor/getPreferences.jsonnew+20−0
lexicons/social/grain/actor/getProfile.jsonnew+31−0
lexicons/social/grain/actor/getSuggestedFollows.jsonnew+30−0
lexicons/social/grain/actor/putPreferences.jsonnew+24−0
lexicons/social/grain/actor/searchActors.jsonnew+33−0
lexicons/social/grain/actor/searchActorsTypeahead.jsonnew+30−0
lexicons/social/grain/admin/actOnReview.jsonnew+33−0
lexicons/social/grain/admin/addRepos.jsonnew+33−0
lexicons/social/grain/admin/applyLabel.jsonnew+29−0
lexicons/social/grain/admin/defs.jsonnew+163−0
lexicons/social/grain/admin/getInfo.jsonnew+29−0
lexicons/social/grain/admin/getRepo.jsonnew+20−0
lexicons/social/grain/admin/getReport.jsonnew+24−0
lexicons/social/grain/admin/getReviewQueue.jsonnew+46−0
lexicons/social/grain/admin/getReviewStats.jsonnew+32−0
lexicons/social/grain/admin/getReviewSubject.jsonnew+50−0
lexicons/social/grain/admin/listLabelDefinitions.jsonnew+23−0
lexicons/social/grain/admin/listReports.jsonnew+38−0
lexicons/social/grain/admin/listRepos.jsonnew+34−0
lexicons/social/grain/admin/negateLabel.jsonnew+29−0
lexicons/social/grain/admin/removeRepos.jsonnew+33−0
lexicons/social/grain/admin/resolveReport.jsonnew+26−0
lexicons/social/grain/admin/resyncRepos.jsonnew+32−0
lexicons/social/grain/admin/searchRecords.jsonnew+32−0
lexicons/social/grain/admin/startScan.jsonnew+43−0
lexicons/social/grain/comment/comment.json+1−0
lexicons/social/grain/comment/getThread.jsonnew+38−0
lexicons/social/grain/favorite/favorite.json+1−0
lexicons/social/grain/feed/defs.jsonnew+22−0
lexicons/social/grain/feed/describeFeeds.jsonnew+28−0
lexicons/social/grain/feed/getFeed.jsonnew+47−0
lexicons/social/grain/gallery/defs.json+49−0
lexicons/social/grain/gallery/deleteGallery.jsonnew+33−0
lexicons/social/grain/gallery/gallery.json+1−0
lexicons/social/grain/gallery/getCameras.jsonnew+22−0
lexicons/social/grain/gallery/getFavorites.jsonnew+33−0
lexicons/social/grain/gallery/getGallery.jsonnew+33−0
lexicons/social/grain/gallery/getLocations.jsonnew+31−0
lexicons/social/grain/gallery/item.json+1−0
lexicons/social/grain/gallery/searchGalleries.jsonnew+33−0
lexicons/social/grain/graph/block.json+1−0
lexicons/social/grain/graph/defs.jsonnew+66−0
lexicons/social/grain/graph/follow.json+1−0
lexicons/social/grain/graph/getBlocks.jsonnew+30−0
lexicons/social/grain/graph/getFollowers.jsonnew+33−0
lexicons/social/grain/graph/getFollowing.jsonnew+33−0
lexicons/social/grain/graph/getKnownFollowers.jsonnew+30−0
lexicons/social/grain/graph/getMutes.jsonnew+30−0
lexicons/social/grain/group/createComment.jsonnew+41−0
lexicons/social/grain/group/createGallery.jsonnew+57−0
lexicons/social/grain/group/createGroup.jsonnew+54−0
lexicons/social/grain/group/defs.json+125−0
lexicons/social/grain/group/deleteComment.jsonnew+28−0
lexicons/social/grain/group/deleteGallery.jsonnew+38−0
lexicons/social/grain/group/getGallery.jsonnew+52−0
lexicons/social/grain/group/getGroup.jsonnew+19−0
lexicons/social/grain/group/getGroupHost.jsonnew+24−0
lexicons/social/grain/group/getSubmissions.jsonnew+33−0
lexicons/social/grain/group/joinGroup.jsonnew+31−0
lexicons/social/grain/group/leaveGroup.jsonnew+22−0
lexicons/social/grain/group/listFeed.jsonnew+37−0
lexicons/social/grain/group/listGalleries.jsonnew+35−0
lexicons/social/grain/group/listGroups.jsonnew+32−0
lexicons/social/grain/group/putFavorite.jsonnew+40−0
lexicons/social/grain/moderation/defs.jsonnew+14−0
lexicons/social/grain/moderation/describeLabels.jsonnew+28−0
lexicons/social/grain/notification/defs.jsonnew+45−0
lexicons/social/grain/notification/getUnreadCount.jsonnew+18−0
lexicons/social/grain/notification/listNotifications.jsonnew+36−0
lexicons/social/grain/notification/registerPush.jsonnew+35−0
lexicons/social/grain/notification/unregisterPush.jsonnew+35−0
lexicons/social/grain/notification/updateSeen.jsonnew+22−0
lexicons/social/grain/photo/photo.json+1−0
lexicons/social/grain/repo/applyWrites.jsonnew+102−0
lexicons/social/grain/repo/createRecord.jsonnew+43−0
lexicons/social/grain/repo/defs.jsonnew+15−0
lexicons/social/grain/repo/deleteRecord.jsonnew+39−0
lexicons/social/grain/repo/putRecord.jsonnew+44−0
lexicons/social/grain/repo/uploadBlob.jsonnew+25−0
lexicons/social/grain/server/defs.jsonnew+17−0
lexicons/social/grain/server/deleteAccount.jsonnew+64−0
lexicons/social/grain/server/getSitemap.jsonnew+35−0
lexicons/social/grain/server/getSpaceSupport.jsonnew+39−0
lexicons/social/grain/story/defs.json+18−0
lexicons/social/grain/story/getArchive.jsonnew+33−0
lexicons/social/grain/story/getAuthors.jsonnew+23−0
lexicons/social/grain/story/getStories.jsonnew+28−0
lexicons/social/grain/story/getStory.jsonnew+22−0
lexicons/social/grain/story/markViewed.jsonnew+30−0
lexicons/social/grain/story/story.json+1−0
lexicons/social/grain/sync/indexRecords.jsonnew+52−0
lexicons/social/grain/basePermissions.json+1−0
lexicons/README.mdnew+145−0
marketing/capture-og.mjs+2−2
server/helpers/cameraName.ts+1−1
test/web/auth.test.tsnew+144−0
test/web/grainServe.test.tsnew+97−0
test/web/preferences.test.tsnew+131−0
test/web/writes.test.tsnew+103−0
test/web/xrpc.test.tsnew+149−0
test/galleryDetail.test.ts+1−1
test/resolveRouteActor.test.ts+5−5
test/storyViews.test.ts+8−7
test/tid.test.ts+1−1
app/lib/actions/infinite-scroll.ts → web/src/lib/actions/infinite-scroll.tsrenamed+0−0
Moved without changes.
app/lib/components/atoms/Avatar.svelte → web/src/lib/components/atoms/Avatar.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/AvatarLightbox.svelte → web/src/lib/components/atoms/AvatarLightbox.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/BskyIcon.svelte → web/src/lib/components/atoms/BskyIcon.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Button.svelte → web/src/lib/components/atoms/Button.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Checkbox.svelte → web/src/lib/components/atoms/Checkbox.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/ContentWarningPicker.svelte → web/src/lib/components/atoms/ContentWarningPicker.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/ExifInfo.svelte → web/src/lib/components/atoms/ExifInfo.svelterenamed+1−1
app/lib/components/atoms/Facepile.svelte → web/src/lib/components/atoms/Facepile.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Field.svelte → web/src/lib/components/atoms/Field.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/FileDropZone.svelte → web/src/lib/components/atoms/FileDropZone.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Input.svelte → web/src/lib/components/atoms/Input.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/LocationInput.svelte → web/src/lib/components/atoms/LocationInput.svelterenamed+1−1
app/lib/components/atoms/LocationMapBanner.svelte → web/src/lib/components/atoms/LocationMapBanner.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Modal.svelte → web/src/lib/components/atoms/Modal.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/NotificationItem.svelte → web/src/lib/components/atoms/NotificationItem.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/OGMeta.svelte → web/src/lib/components/atoms/OGMeta.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/OverflowMenu.svelte → web/src/lib/components/atoms/OverflowMenu.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/PinButton.svelte → web/src/lib/components/atoms/PinButton.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/RichText.svelte → web/src/lib/components/atoms/RichText.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/RichTextarea.svelte → web/src/lib/components/atoms/RichTextarea.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/SelectCheck.svelte → web/src/lib/components/atoms/SelectCheck.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/SettingsGroup.svelte → web/src/lib/components/atoms/SettingsGroup.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/SettingsToggleRow.svelte → web/src/lib/components/atoms/SettingsToggleRow.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Skeleton.svelte → web/src/lib/components/atoms/Skeleton.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Spinner.svelte → web/src/lib/components/atoms/Spinner.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Textarea.svelte → web/src/lib/components/atoms/Textarea.svelterenamed+0−0
Moved without changes.
app/lib/components/atoms/Toast.svelte → web/src/lib/components/atoms/Toast.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/AvatarCrop.svelte → web/src/lib/components/molecules/AvatarCrop.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/BrowseTile.svelte → web/src/lib/components/molecules/BrowseTile.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/Comment.svelte → web/src/lib/components/molecules/Comment.svelterenamed+9−7
app/lib/components/molecules/DetailHeader.svelte → web/src/lib/components/molecules/DetailHeader.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/FavedByFollowing.svelte → web/src/lib/components/molecules/FavedByFollowing.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/FavoriteButton.svelte → web/src/lib/components/molecules/FavoriteButton.svelterenamed+15−12
app/lib/components/molecules/FeedTabs.svelte → web/src/lib/components/molecules/FeedTabs.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/FollowButton.svelte → web/src/lib/components/molecules/FollowButton.svelterenamed+4−5
app/lib/components/molecules/GalleryCard.svelte → web/src/lib/components/molecules/GalleryCard.svelterenamed+15−16
app/lib/components/molecules/GalleryCardSkeleton.svelte → web/src/lib/components/molecules/GalleryCardSkeleton.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/GalleryMedia.svelte → web/src/lib/components/molecules/GalleryMedia.svelterenamed+1−1
app/lib/components/molecules/GalleryMenu.svelte → web/src/lib/components/molecules/GalleryMenu.svelterenamed+4−3
app/lib/components/molecules/GallerySectionRow.svelte → web/src/lib/components/molecules/GallerySectionRow.svelterenamed+2−1
app/lib/components/molecules/IosAppBanner.svelte → web/src/lib/components/molecules/IosAppBanner.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/LocationsMap.svelte → web/src/lib/components/molecules/LocationsMap.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/MobileBottomBar.svelte → web/src/lib/components/molecules/MobileBottomBar.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/MobileTopBar.svelte → web/src/lib/components/molecules/MobileTopBar.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/PageHeading.svelte → web/src/lib/components/molecules/PageHeading.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/ProfileCard.svelte → web/src/lib/components/molecules/ProfileCard.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/ProfilePopover.svelte → web/src/lib/components/molecules/ProfilePopover.svelterenamed+2−1
app/lib/components/molecules/PublicTopBar.svelte → web/src/lib/components/molecules/PublicTopBar.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/PullToRefresh.svelte → web/src/lib/components/molecules/PullToRefresh.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/ReportButton.svelte → web/src/lib/components/molecules/ReportButton.svelterenamed+8−6
app/lib/components/molecules/SpacesRequired.svelte → web/src/lib/components/molecules/SpacesRequired.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/StoryArchive.svelte → web/src/lib/components/molecules/StoryArchive.svelterenamed+0−0
Moved without changes.
app/lib/components/molecules/StoryCreate.svelte → web/src/lib/components/molecules/StoryCreate.svelterenamed+3−3
app/lib/components/molecules/StoryStrip.svelte → web/src/lib/components/molecules/StoryStrip.svelterenamed+0−0
Moved without changes.
app/lib/components/organisms/AuthBar.svelte → web/src/lib/components/organisms/AuthBar.svelterenamed+0−0
Moved without changes.
app/lib/components/organisms/CommentSheet.svelte → web/src/lib/components/organisms/CommentSheet.svelterenamed+30−22
app/lib/components/organisms/FeedList.svelte → web/src/lib/components/organisms/FeedList.svelterenamed+1−1
app/lib/components/organisms/GalleryGrid.svelte → web/src/lib/components/organisms/GalleryGrid.svelterenamed+2−1
app/lib/components/organisms/LoginModal.svelte → web/src/lib/components/organisms/LoginModal.svelterenamed+12−3
app/lib/components/organisms/LoginWall.svelte → web/src/lib/components/organisms/LoginWall.svelterenamed+0−0
Moved without changes.
app/lib/components/organisms/MobileDrawer.svelte → web/src/lib/components/organisms/MobileDrawer.svelterenamed+0−0
Moved without changes.
app/lib/components/organisms/MobileSearch.svelte → web/src/lib/components/organisms/MobileSearch.svelterenamed+0−0
Moved without changes.
app/lib/components/organisms/SearchFlyout.svelte → web/src/lib/components/organisms/SearchFlyout.svelterenamed+3−2
app/lib/components/organisms/Sidebar.svelte → web/src/lib/components/organisms/Sidebar.svelterenamed+0−0
Moved without changes.
app/lib/components/organisms/StoryViewer.svelte → web/src/lib/components/organisms/StoryViewer.svelterenamed+4−4
app/lib/components/organisms/SuggestedFollows.svelte → web/src/lib/components/organisms/SuggestedFollows.svelterenamed+4−2
app/lib/components/templates/Shell.svelte → web/src/lib/components/templates/Shell.svelterenamed+0−0
Moved without changes.
web/src/lib/server/grainServe.tsnew+90−0
app/lib/utils/bsky-post.ts → web/src/lib/utils/bsky-post.tsrenamed+3−3
app/lib/utils/cameraName.ts → web/src/lib/utils/cameraName.tsrenamed+0−0
Moved without changes.
app/lib/utils/formatLocation.ts → web/src/lib/utils/formatLocation.tsrenamed+1−1
app/lib/utils/h3.ts → web/src/lib/utils/h3.tsrenamed+0−0
Moved without changes.
app/lib/utils/image-resize.ts → web/src/lib/utils/image-resize.tsrenamed+0−0
Moved without changes.
app/lib/utils/instagram-import.ts → web/src/lib/utils/instagram-import.tsrenamed+0−0
Moved without changes.
app/lib/utils/nominatim.ts → web/src/lib/utils/nominatim.tsrenamed+0−0
Moved without changes.
web/src/lib/utils/records.tsnew+171−0
app/lib/utils/rich-text.ts → web/src/lib/utils/rich-text.tsrenamed+0−0
Moved without changes.
app/lib/utils/share.ts → web/src/lib/utils/share.tsrenamed+0−0
Moved without changes.
app/lib/utils/tid.ts → web/src/lib/utils/tid.tsrenamed+0−0
Moved without changes.
web/src/lib/xrpc/index.tsnew+138−0
app/lib/accounts.ts → web/src/lib/accounts.tsrenamed+25−44
app/lib/actor.ts → web/src/lib/actor.tsrenamed+1−1
web/src/lib/auth.tsnew+120−0
app/lib/basemap.ts → web/src/lib/basemap.tsrenamed+0−0
Moved without changes.
web/src/lib/feed.tsnew+46−0
web/src/lib/labels.tsnew+186−0
app/lib/mutations.ts → web/src/lib/mutations.tsrenamed+33−14
app/lib/notifications.ts → web/src/lib/notifications.tsrenamed+0−0
Moved without changes.
web/src/lib/pds.tsnew+47−0
app/lib/preferences.ts → web/src/lib/preferences.tsrenamed+13−10
web/src/lib/preferenceSync.tsnew+149−0
app/lib/queries.ts → web/src/lib/queries.tsrenamed+173−136
app/lib/seo.ts → web/src/lib/seo.tsrenamed+4−2
app/lib/sitemap.ts → web/src/lib/sitemap.tsrenamed+0−0
Moved without changes.
app/lib/spaceBlob.ts → web/src/lib/spaceBlob.tsrenamed+0−0
Moved without changes.
app/lib/stores.ts → web/src/lib/stores.tsrenamed+0−0
Moved without changes.
app/lib/stories.ts → web/src/lib/stories.tsrenamed+6−3
app/lib/theme.ts → web/src/lib/theme.tsrenamed+2−2
app/lib/utils.ts → web/src/lib/utils.tsrenamed+0−0
Moved without changes.
app/routes/.well-known/apple-app-site-association/+server.ts → web/src/routes/.well-known/apple-app-site-association/+server.tsrenamed+0−0
Moved without changes.
app/routes/camera/[name]/+page.svelte → web/src/routes/camera/[name]/+page.svelterenamed+0−0
Moved without changes.
app/routes/camera/[name]/+page.ts → web/src/routes/camera/[name]/+page.tsrenamed+0−0
Moved without changes.
app/routes/cameras/+page.svelte → web/src/routes/cameras/+page.svelterenamed+0−0
Moved without changes.
app/routes/cameras/+page.ts → web/src/routes/cameras/+page.tsrenamed+0−0
Moved without changes.
app/routes/create/+page.server.ts → web/src/routes/create/+page.server.tsrenamed+0−0
Moved without changes.
app/routes/create/+page.svelte → web/src/routes/create/+page.svelterenamed+1−1
app/routes/embed/gallery/[actor]/[rkey]/+server.ts → web/src/routes/embed/gallery/[actor]/[rkey]/+server.tsrenamed+0−0
Moved without changes.
app/routes/explore/+page.svelte → web/src/routes/explore/+page.svelterenamed+0−0
Moved without changes.
app/routes/explore/+page.ts → web/src/routes/explore/+page.tsrenamed+0−0
Moved without changes.
app/routes/feeds/following/+page.svelte → web/src/routes/feeds/following/+page.svelterenamed+0−0
Moved without changes.
app/routes/feeds/following/+page.ts → web/src/routes/feeds/following/+page.tsrenamed+0−0
Moved without changes.
app/routes/feeds/for-you/+page.svelte → web/src/routes/feeds/for-you/+page.svelterenamed+0−0
Moved without changes.
app/routes/feeds/for-you/+page.ts → web/src/routes/feeds/for-you/+page.tsrenamed+0−0
Moved without changes.
app/routes/feeds/groups/+page.svelte → web/src/routes/feeds/groups/+page.svelterenamed+3−3
app/routes/feeds/recent/+page.svelte → web/src/routes/feeds/recent/+page.svelterenamed+0−0
Moved without changes.
app/routes/feeds/+page.svelte → web/src/routes/feeds/+page.svelterenamed+0−0
Moved without changes.
app/routes/group/[did]/create/+page.svelte → web/src/routes/group/[did]/create/+page.svelterenamed+7−5
app/routes/group/[did]/+page.svelte → web/src/routes/group/[did]/+page.svelterenamed+1−1
app/routes/group/[did]/+page.ts → web/src/routes/group/[did]/+page.tsrenamed+0−0
Moved without changes.
app/routes/groups/new/+page.svelte → web/src/routes/groups/new/+page.svelterenamed+0−0
Moved without changes.
app/routes/groups/+page.svelte → web/src/routes/groups/+page.svelterenamed+0−0
Moved without changes.
app/routes/groups/+page.ts → web/src/routes/groups/+page.tsrenamed+0−0
Moved without changes.
app/routes/hashtags/[tag]/+page.svelte → web/src/routes/hashtags/[tag]/+page.svelterenamed+0−0
Moved without changes.
app/routes/hashtags/[tag]/+page.ts → web/src/routes/hashtags/[tag]/+page.tsrenamed+0−0
Moved without changes.
app/routes/location/[h3]/+page.svelte → web/src/routes/location/[h3]/+page.svelterenamed+0−0
Moved without changes.
app/routes/location/[h3]/+page.ts → web/src/routes/location/[h3]/+page.tsrenamed+0−0
Moved without changes.
app/routes/locations/+page.svelte → web/src/routes/locations/+page.svelterenamed+0−0
Moved without changes.
app/routes/locations/+page.ts → web/src/routes/locations/+page.tsrenamed+0−0
Moved without changes.
app/routes/notifications/+page.svelte → web/src/routes/notifications/+page.svelterenamed+3−2
app/routes/profile/[actor]/followers/+page.svelte → web/src/routes/profile/[actor]/followers/+page.svelterenamed+0−0
Moved without changes.
app/routes/profile/[actor]/followers/+page.ts → web/src/routes/profile/[actor]/followers/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/following/+page.svelte → web/src/routes/profile/[actor]/following/+page.svelterenamed+0−0
Moved without changes.
app/routes/profile/[actor]/following/+page.ts → web/src/routes/profile/[actor]/following/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/gallery/[rkey]/edit/+page.svelte → web/src/routes/profile/[actor]/gallery/[rkey]/edit/+page.svelterenamed+5−4
app/routes/profile/[actor]/gallery/[rkey]/edit/+page.ts → web/src/routes/profile/[actor]/gallery/[rkey]/edit/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/gallery/[rkey]/favorited-by/+page.svelte → web/src/routes/profile/[actor]/gallery/[rkey]/favorited-by/+page.svelterenamed+0−0
Moved without changes.
app/routes/profile/[actor]/gallery/[rkey]/favorited-by/+page.ts → web/src/routes/profile/[actor]/gallery/[rkey]/favorited-by/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/gallery/[rkey]/+page.svelte → web/src/routes/profile/[actor]/gallery/[rkey]/+page.svelterenamed+3−3
app/routes/profile/[actor]/gallery/[rkey]/+page.ts → web/src/routes/profile/[actor]/gallery/[rkey]/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/known-followers/+page.svelte → web/src/routes/profile/[actor]/known-followers/+page.svelterenamed+0−0
Moved without changes.
app/routes/profile/[actor]/known-followers/+page.ts → web/src/routes/profile/[actor]/known-followers/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/story/[rkey]/+page.svelte → web/src/routes/profile/[actor]/story/[rkey]/+page.svelterenamed+1−1
app/routes/profile/[actor]/story/[rkey]/+page.ts → web/src/routes/profile/[actor]/story/[rkey]/+page.tsrenamed+0−0
Moved without changes.
app/routes/profile/[actor]/+page.svelte → web/src/routes/profile/[actor]/+page.svelterenamed+3−2
app/routes/profile/[actor]/+page.ts → web/src/routes/profile/[actor]/+page.tsrenamed+0−0
Moved without changes.
app/routes/search/+page.svelte → web/src/routes/search/+page.svelterenamed+0−0
Moved without changes.
app/routes/settings/account/+page.svelte → web/src/routes/settings/account/+page.svelterenamed+21−63
app/routes/settings/appearance/+page.svelte → web/src/routes/settings/appearance/+page.svelterenamed+0−0
Moved without changes.
app/routes/settings/import/+page.svelte → web/src/routes/settings/import/+page.svelterenamed+1−0
app/routes/settings/moderation/blocked/+page.svelte → web/src/routes/settings/moderation/blocked/+page.svelterenamed+0−0
Moved without changes.
app/routes/settings/moderation/muted/+page.svelte → web/src/routes/settings/moderation/muted/+page.svelterenamed+0−0
Moved without changes.
app/routes/settings/moderation/+page.svelte → web/src/routes/settings/moderation/+page.svelterenamed+0−0
Moved without changes.
app/routes/settings/notifications/+page.svelte → web/src/routes/settings/notifications/+page.svelterenamed+4−4
app/routes/settings/profile/+page.svelte → web/src/routes/settings/profile/+page.svelterenamed+6−7
app/routes/settings/profile/+page.ts → web/src/routes/settings/profile/+page.tsrenamed+0−0
Moved without changes.
app/routes/settings/upload-defaults/+page.svelte → web/src/routes/settings/upload-defaults/+page.svelterenamed+0−0
Moved without changes.
app/routes/settings/+layout.server.ts → web/src/routes/settings/+layout.server.tsrenamed+0−0
Moved without changes.
app/routes/settings/+layout.svelte → web/src/routes/settings/+layout.svelterenamed+0−0
Moved without changes.
app/routes/settings/+layout.ts → web/src/routes/settings/+layout.tsrenamed+0−0
Moved without changes.
app/routes/settings/+page.svelte → web/src/routes/settings/+page.svelterenamed+0−0
Moved without changes.
app/routes/sitemap/[kind]-[page].xml/+server.ts → web/src/routes/sitemap/[kind]-[page].xml/+server.tsrenamed+8−6
app/routes/sitemap.xml/+server.ts → web/src/routes/sitemap.xml/+server.tsrenamed+10−3
app/routes/support/child-safety/+page.svelte → web/src/routes/support/child-safety/+page.svelterenamed+0−0
Moved without changes.
app/routes/support/community-guidelines/+page.svelte → web/src/routes/support/community-guidelines/+page.svelterenamed+0−0
Moved without changes.
app/routes/support/copyright/+page.svelte → web/src/routes/support/copyright/+page.svelterenamed+0−0
Moved without changes.
app/routes/support/privacy/+page.svelte → web/src/routes/support/privacy/+page.svelterenamed+0−0
Moved without changes.
app/routes/support/terms/+page.svelte → web/src/routes/support/terms/+page.svelterenamed+0−0
Moved without changes.
app/routes/+error.svelte → web/src/routes/+error.svelterenamed+0−0
Moved without changes.
web/src/routes/+layout.server.tsnew+27−0
app/routes/+layout.svelte → web/src/routes/+layout.svelterenamed+16−31
app/routes/+layout.ts → web/src/routes/+layout.tsrenamed+0−0
Moved without changes.
app/routes/+page.svelte → web/src/routes/+page.svelterenamed+0−0
Moved without changes.
app/routes/+page.ts → web/src/routes/+page.tsrenamed+0−0
Moved without changes.
app/app.css → web/src/app.cssrenamed+0−0
Moved without changes.
app/app.html → web/src/app.htmlrenamed+0−0
Moved without changes.
app/error.html → web/src/error.htmlrenamed+0−0
Moved without changes.
web/src/hooks.server.tsnew+18−0
app/service-worker.js → web/src/service-worker.jsrenamed+0−0
Moved without changes.
static/atmo/atmo-anisota.jpg → web/static/atmo/atmo-anisota.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-beacon-bits.jpg → web/static/atmo/atmo-beacon-bits.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-blacksky.jpg → web/static/atmo/atmo-blacksky.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-blento.jpg → web/static/atmo/atmo-blento.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-bluesky.jpg → web/static/atmo/atmo-bluesky.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-eurosky.jpg → web/static/atmo/atmo-eurosky.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-flashes.jpg → web/static/atmo/atmo-flashes.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-gander.jpg → web/static/atmo/atmo-gander.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-germ.jpg → web/static/atmo/atmo-germ.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-leaflet.jpg → web/static/atmo/atmo-leaflet.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-northsky.jpg → web/static/atmo/atmo-northsky.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-offprint.jpg → web/static/atmo/atmo-offprint.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-pckt.jpg → web/static/atmo/atmo-pckt.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-plyr.jpg → web/static/atmo/atmo-plyr.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-popfeed.jpg → web/static/atmo/atmo-popfeed.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-semble.jpg → web/static/atmo/atmo-semble.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-skylight.jpg → web/static/atmo/atmo-skylight.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-slices.jpg → web/static/atmo/atmo-slices.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-spark.png → web/static/atmo/atmo-spark.pngrenamed+0−0
Moved without changes.
static/atmo/atmo-stream-place.jpg → web/static/atmo/atmo-stream-place.jpgrenamed+0−0
Moved without changes.
static/atmo/atmo-tangled.jpg → web/static/atmo/atmo-tangled.jpgrenamed+0−0
Moved without changes.
web/static/app-version.jsonnew+10−0
static/favicon.png → web/static/favicon.pngrenamed+0−0
Moved without changes.
static/germ-logo.png → web/static/germ-logo.pngrenamed+0−0
Moved without changes.
static/icon-192.png → web/static/icon-192.pngrenamed+0−0
Moved without changes.
static/icon-512.png → web/static/icon-512.pngrenamed+0−0
Moved without changes.
static/manifest.json → web/static/manifest.jsonrenamed+0−0
Moved without changes.
static/og-default.jpg → web/static/og-default.jpgrenamed+0−0
Moved without changes.
static/robots.txt → web/static/robots.txtrenamed+0−0
Moved without changes.
.gitignore+4−0
AGENTS.md+173−6
Cargo.locknew+3277−0
A large diff, not shown.
Cargo.tomlnew+87−0
hatk.config.ts+2−2
hatk.generated.client.ts+3−3
hatk.generated.ts+557−175
732 lines changed: not loaded until asked for.
Justfilenew+123−0
justfiledeleted+0−24
package-lock.json+1228−3
1231 lines changed: not loaded until asked for.
package.json+5−3
README.md+5−3
svelte.config.js+3−2
tsconfig.json+1−1
vite.config.ts+30−46
vitest.config.tsnew+15−0
vitest.coverage.config.ts+1−1