Compare commits

..
5 Commits
Author SHA1 Message Date
Worph cef9dba59e migrate
Build and Push Docker Image / build (push) Failing after 2m28s
2026-11-04 11:55:00 +01:00
Worph 9ccaff90d8 CI: publish a Gitea release naming the IPCR image and digest on v* tags
Build and Push Docker Image / build (push) Successful in 1m7s
2026-10-25 19:02:36 +00:00
Worph 84af298792 v2.0.0: claims model - signed MMUR2 votes in a truth DB, merged view in a rebuildable cache DB; POST /api/claims, claims and merge routes, unsigned writes answer 410
Build and Push Docker Image / build (push) Successful in 5m1s
2026-10-25 17:36:00 +00:00
Manielle b0adefb97b CI accepts four-part tags (v1.2.3.1) for fixes
Build and Push Docker Image / build (push) Successful in 4m20s
2026-10-25 14:22:01 +01:00
Worph 5369dc4b9c docs: search plan + counters for v1.2.0; tools/search-parity (parity + latency vs a brute-force reference)
Build and Push Docker Image / build (push) Successful in 2m50s
2026-10-19 21:59:36 +00:00
81 changed files with 7585 additions and 5608 deletions
+43 -1
View File
@@ -36,7 +36,7 @@ jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
contents: write # the release step below
packages: write
steps:
@@ -73,15 +73,57 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Gitea's Packages tab only lists images in Gitea's own registry, so a v* tag gets a
# release instead, naming the image it shipped. The digest line is the one to trust:
# ipcr 1.7.1 can drop some of the tags pushed together (see the tag list).
- name: Publish the Gitea release
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
SHA: ${{ github.sha }}
DIGEST: ${{ steps.build.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
PULL: ipcr-watch.nsl.sh/ipns/metadec.eth/${{ env.IMAGE_NAME }}
run: |
node -e '
const e = process.env, ver = e.TAG.replace(/^v/, "");
const tags = e.TAGS.split("\n").filter(Boolean).map(t => "`" + t.split(":").pop() + "`");
const body = [
"**Image** (anonymous pull):",
"```", `docker pull ${e.PULL}:${ver}`, "```",
"Pinned by digest:",
"```", `${e.PULL}@${e.DIGEST}`, "```",
`Tags pushed: ${tags.join(", ")} · commit ${e.SHA.slice(0, 7)}`,
].join("\n");
console.log(JSON.stringify({ tag_name: e.TAG, name: e.TAG, body }));
' > /tmp/release.json
auth="Authorization: token $TOKEN"
code=$(curl -s -o /tmp/existing.json -w '%{http_code}' -H "$auth" "$API/releases/tags/$TAG")
if [ "$code" = 200 ]; then
id=$(node -p 'require("/tmp/existing.json").id')
method=PATCH url="$API/releases/$id"
else
method=POST url="$API/releases"
fi
code=$(curl -s -o /tmp/out.json -w '%{http_code}' -X "$method" -H "$auth" \
-H 'Content-Type: application/json' --data @/tmp/release.json "$url")
case "$code" in 2*) echo "release $TAG: $method $code" ;; *) cat /tmp/out.json; exit 1 ;; esac
+3
View File
@@ -45,6 +45,9 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
+3 -1
View File
@@ -6,7 +6,8 @@
# - Bash-based leader election script (flock on shared volume)
# - meta-core Go binary (HTTP API, Mount API, Watcher)
# - nginx (dashboard, WebDAV, API proxy)
# - SQLite metadata store, embedded in the Go binary (/meta-core/db/meta-core.db)
# - SQLite metadata store, embedded in the Go binary: truth /meta-core/db/meta-core.db
# (signed claims, backed up) + cache /meta-core/db-cache/cache.db (rebuildable)
# - rclone (single mount executor — SMB goes through rclone's :smb: backend)
# - supervisord (process management - runs on leader only)
#
@@ -105,6 +106,7 @@ RUN chmod +x /usr/local/bin/meta-core
RUN mkdir -p \
/meta-core/locks \
/meta-core/db \
/meta-core/db-cache \
/meta-core/services \
/meta-core/mounts/errors \
/files \
+1 -1
View File
@@ -2,7 +2,7 @@
# Variables
BINARY := meta-core
VERSION ?= 1.0.0
VERSION ?= 2.0.0
BUILD_DIR := bin
DOCKER_IMAGE := meta-core
+2 -2
View File
@@ -22,7 +22,7 @@ in the Go binary (no database port at all) — all metadata I/O is mediated by t
| **DB-file mutex** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner, so two containers sharing one `/meta-core` volume can never run two writers on the database file. It is a mutex, not discovery — nothing reads the lock. |
| **Embedded SQLite store** | One schema-free `kv(k, v)` table in `/meta-core/db/meta-core.db` (pure-Go `modernc.org/sqlite`, WAL, `synchronous=NORMAL`). One writer goroutine with group commit; a record write (fields + root index + CID aliases + FTS row + events) is atomic. Replaced Redis in 2026-10 (Redis + the in-memory search copy needed ~6.6 GB on watch.nsl.sh's 169k records; the SQLite file is ~2.5 GB on disk with a bounded page cache). |
| **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, neighbours, identity, UDL, and file access by CID. |
| **Indexed search** | `POST /api/metadata/search` builds a superset of candidates in SQL — a partial expression index `kv_field` over (field name, value) of every record key, plus an FTS5 trigram table — then verifies each with the unchanged Go matchers. Fresh immediately, no in-memory copy (`internal/storage/search.go`, `internal/api/search_index.go`). |
| **Indexed search** | `POST /api/metadata/search` builds a superset of candidates in SQL — a partial expression index `kv_field_v` over (field name, value) of every record key, plus an FTS5 trigram table; the smallest part drives, the other filters are probed per candidate — then verifies each with the unchanged Go matchers. Fresh immediately, no in-memory copy (`internal/storage/search.go`, `internal/api/search_index.go`). |
| **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the store's `file:events` / `meta:events` streams so external services never touch the store. `meta:events` entries are written in the same transaction as the change. |
| **WebDAV server** | `/webdav/*` exposes the `/files` volume (read+write) for cross-service file access. nginx in front of meta-core handles caching. |
| **Service discovery** | beacon v2: UDP multicast advertise/listen on `239.255.99.1:9099`, neighbours served at `/neighbors`. The file-based registry under `/meta-core/services/*.json` it replaced is gone. |
@@ -425,7 +425,7 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
| `internal/leader` | `LeaderInfoProvider` — builds the `/urls` payload from hostname/IP/config; no election logic. |
| `internal/meshdisco` | beacon v2 node (advertise, probe, neighbour map) — the Go port of the spec. |
| `internal/kv` | Storage engine: SQLite `kv` table, single writer + group commit, reader pool, in-transaction change events (`kv.go`), event streams as rows (`stream.go`). |
| `internal/storage` | Flat-key records (`client.go`), UUIDv7 minting, CID reverse index (`cid_resolution.go`), schema sentinel, search over `kv_field` + FTS (`search.go`), UDL store (`udl.go`, `udl_admin.go`), stats. |
| `internal/storage` | Flat-key records (`client.go`), UUIDv7 minting, CID reverse index (`cid_resolution.go`), schema sentinel, search over `kv_field_v` + FTS (`search.go`), derived counters (`onChange`), UDL store (`udl.go`, `udl_admin.go`), stats. |
| `internal/cid` | CID parsing + rank ladder for canonical-CID selection (`cid-rank-vectors.json`, guarded by the meta-root `scripts/check-cid-vectors.sh`). |
| `internal/identity` | Multi-account secp256k1 keystore, challenge/proof-of-possession, signature verification. |
| `internal/api` | HTTP server + all handlers + SSE event endpoints + search matchers + `/api/stats`. |
+20 -9
View File
@@ -6,6 +6,7 @@ import (
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -17,7 +18,7 @@ import (
)
// Version is set at build time
var Version = "1.0.0"
var Version = "2.0.0"
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -30,26 +31,36 @@ func main() {
// Create leader info provider (Go binary only runs as leader - followers loop in bash)
leaderProvider := leader.NewLeaderInfoProvider(cfg)
// Open the metadata store: an embedded SQLite file (internal/kv). There
// is no separate database process to wait for.
// Open the two embedded SQLite files: truth (signed claims, backed up)
// and the cache (everything derived, rebuildable). No database process.
if err := os.MkdirAll(filepath.Dir(cfg.CachePath()), 0o755); err != nil {
log.Fatalf("[meta-core] Failed to create the cache folder: %v", err)
}
storageClient := storage.NewClient()
if err := storageClient.Connect(cfg.DBPath()); err != nil {
if err := storageClient.Connect(cfg.DBPath(), cfg.CachePath()); err != nil {
log.Fatalf("[meta-core] Failed to open the metadata store: %v", err)
}
// Schema-version sentinel: alpha clean-wipe gate. Refuses to boot when
// the existing store predates the UUID-rooted schema, so legacy data
// can't silently leak into the new code paths. See
// docs/uuid-rooted-metadata.md.
// Schema-version gate: refuses a v1.x store (which the default truth path
// still points at) and any unknown layout. No migration code here — see
// tools/claims-migrate.
if err := storageClient.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
log.Fatalf("[meta-core] %v", err)
}
log.Printf("[meta-core] Store schema version OK (v%d)", storage.SchemaVersion)
// Bring the cache level with truth (replay after a crash, full rebuild
// when the cache folder is empty), then start the apply loop.
started := time.Now()
if err := storageClient.Start(); err != nil {
log.Fatalf("[meta-core] %v", err)
}
log.Printf("[meta-core] Cache ready in %s", time.Since(started).Round(time.Millisecond))
// Derived counters (record count, per-field schema) are kept exact by the
// write path. A store that predates them gets a one-time build here,
// before anything else writes.
started := time.Now()
started = time.Now()
if built, err := storageClient.EnsureCounters(); err != nil {
log.Fatalf("[meta-core] Failed to build the store's derived counters: %v", err)
} else if built {
+8 -10
View File
@@ -180,10 +180,9 @@ async function fetchAccounts(): Promise<Account[]> {
//
// Never throws and never reports an error upward: a name is decoration on top
// of the uid, and the account list must render identically whether the store is up
// (identity itself is file-backed, the UDL is not). The name is also *unsigned*
// — meta-core stores the plaintext beside the signed record and verifies
// nothing — so the uid stays on screen as the real identity, and the length cap
// bounds what a hostile writer can do to the layout.
// (identity itself is file-backed, the UDL is not). The name is the user's
// signed MMUR2 claim (meta-core verified it on write); the uid stays on screen
// as the real identity, and the length cap bounds the layout.
async function fetchDisplayName(uid: string): Promise<string | null> {
const url =
`/api/udl/record?uid=${encodeURIComponent(uid)}` +
@@ -192,12 +191,11 @@ async function fetchDisplayName(uid: string): Promise<string | null> {
try {
const res = await fetch(url);
if (!res.ok) return null; // 503 when storage is down, 500, …
const data = (await res.json()) as { exists?: boolean; value?: unknown };
// Key off `value`, not `exists`: clearing a name writes a tombstone, which
// leaves the cell existing with no plaintext. Private-tier cells look the
// same, which is exactly right — there is nothing to show for either.
if (typeof data.value !== 'string') return null;
const name = data.value.trim();
const data = (await res.json()) as { exists?: boolean; claim?: { value?: unknown } | null };
// A retraction (null) or ciphertext ({b64}) has nothing to show.
const value = data.claim?.value;
if (typeof value !== 'string') return null;
const name = value.trim();
return name ? name.slice(0, 64) : null;
} catch {
return null;
+1
View File
@@ -10,6 +10,7 @@ set -e
# Create required directories
mkdir -p /meta-core/locks
mkdir -p /meta-core/db
mkdir -p /meta-core/db-cache
mkdir -p /meta-core/mounts/errors
mkdir -p /files
mkdir -p /var/log/supervisor
+220 -31
View File
@@ -1,4 +1,14 @@
# API-Mediated Access (Redis Lockdown)
# API-Mediated Access (Redis Lockdown → Signed Claims)
> **meta-core v2.0.0 — the claims model.** The implementation basis is
> [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
> (meta-root). Where this file and that one disagree, that one wins. The
> *access* contract below still holds unchanged — HTTP only, events over
> SSE, no database reachable from outside `metacore-app`. The *write*
> contract changed: every metadata write is a **signed claim** posted to
> `POST /api/claims`, and the legacy unsigned write endpoints answer
> `410 Gone`. See ["Write contract — signed claims"](#write-contract--signed-claims-v200)
> below.
## Status
@@ -69,20 +79,23 @@ we don't fully control.
## The architecture in one picture
```
meta-core (only Redis writer + reader)
meta-core v2.0.0 (only store writer + reader)
┌──────────────────────────────────────┐
│ HTTP API surface: │
│ POST/PUT/PATCH /api/metadata/* │ ◄── writes
│ GET /api/meta/{cid} │ ◄── reads (by CID)
│ GET /api/metadata/* │ ◄── reads (by id)
│ GET /api/file/{cid} │ ◄── content
│ GET /api/events/files │ ◄── SSE stream
│ GET /api/events/meta │ ◄── SSE stream
│ POST /api/claims │ ◄── writes (signed claims only)
│ POST /api/merge │ ◄── stateless merge, no write
│ GET /api/meta/{cid} │ ◄── reads (by CID, merged)
│ GET /api/meta/{cid}/claims │ ◄── reads (raw signed claims)
│ GET /api/metadata/* │ ◄── reads (by rid, merged)
│ GET /api/file/{cid} │ ◄── content
│ GET /api/events/files │ ◄── SSE stream
│ GET /api/events/meta │ ◄── SSE stream (merged changes)
│ │ │
│ ▼ │
│ Redis (file:*, cid:*, streams) │
│ ◄── container-local, no exposed │
│ port, no docker-network alias │
│ ▼ verify sig → truth → apply │
│ truth /meta-core/db/meta-core.db │
│ cache /meta-core/db-cache/cache.db │
│ ◄── embedded SQLite, no port, │
│ no docker-network alias │
└──────────────────────────────────────┘
▲ ▲
│ HTTP │ HTTP SSE
@@ -95,9 +108,18 @@ we don't fully control.
```
**The single-writer rule.** meta-core has exclusive write authority over
the `file:*`, `cid:*`, `file:__index__` (now `idx:*`), and `meta-core:*` keyspaces. No
other service can write — not because of an honoured convention, but
because they can't reach Redis at all.
its store — in v2.0.0 the two SQLite databases (truth and cache); in the
Redis era the `file:*`, `cid:*`, `file:__index__` (later `idx:*`), and
`meta-core:*` keyspaces. No other service can write the store — not because
of an honoured convention, but because they can't reach it at all.
**What changed in v2.0.0 is who may *author* metadata.** meta-core is still
the only process that touches the database, but it no longer accepts
unsigned values: a writer submits signed votes (claims), meta-core verifies
each signature and stores the vote. meta-core **never signs a claim and
holds no writer key** — a store that could sign for everyone would prove
nothing. The signature is the write authentication; there are no
per-writer tokens.
**Stream reads also mediated.** Events go out through SSE endpoints on
meta-core's HTTP surface. Consumers don't see the underlying Redis
@@ -108,6 +130,147 @@ without breaking every consumer.
**Redis is private.** No host port mapping, no docker-network alias, no
URL in `kv-leader.info`. Discovery returns only meta-core's HTTP base.
(Since 2026-10 there is no Redis at all; the SQLite files are reachable only
from inside `metacore-app`.)
## Write contract — signed claims (v2.0.0)
Every metadata value is a **signed vote**: *signer `uid` says `key = value`
about content `cid`* — `claim(uid, cid, key) → value + ts + sig`. The
record a reader sees is the **merged view** that meta-core precomputes from
the votes; a reader's own votes always win in their view. Full model,
schema and merge rules: the
[implementation basis](../../../docs/implementation/metacore-claims-model.md)
§1–§7.
### Endpoint table
| | Endpoint | Behaviour |
|---|---|---|
| **Write** | `POST /api/claims` | A batch of signed claims (a JSON array, or `{"claims": [...]}`). **The only metadata write path.** |
| Removed | `PUT/PATCH/DELETE /meta/{hash}`, `PUT/DELETE /meta/{hash}/{key...}`, `POST /meta/{hash}/_add/{key...}`, `PUT/DELETE /api/metadata/{hashId}`, `PUT /api/metadata/{hashId}/property`, `POST /api/metadata/batch` | `410 Gone`, with a pointer to `POST /api/claims` |
| Kept | `PUT/GET/DELETE /api/kv/value` | Service scratch KV (`btmagnet:*`, `searchcov:*`), plain rows of the cache database's `kv` table (any key outside the reserved prefixes `file:`, `cid:`, `idx:`, `dup:`, `ev:`, `evhwm:`, `meta:`, `meta-core:`) — losing it only costs a refetch |
| Kept | `POST /api/files/register`, the file watcher | Fill `local_file` (box-local `filePath`, size, mtime, midhash — unsigned) and `cid_group` |
| UDL facade | `/api/udl/*` (same paths) | Writes go through the claims write path; reads come from the claims (per-signer index `claim_by_pid`). Records are MMUR2 claims. Shapes: see "UDL facade shapes" below |
| Read | `GET /api/meta/{cid}`, `GET /api/metadata/{rid}`, `GET /file/{cid}`, `GET /meta/{hash}`, `GET /api/metadata/hash-ids`, `GET /api/metadata/list`, `POST /api/metadata/search`, `GET /api/files/tuples`, `GET /api/schema`, `GET /api/stats` | Unchanged paths and response shapes, served from the merged view (+ `local_file`); optional `?as=<uid>`. They carry no `nsrc` |
| **New** | `GET /api/meta/{cid}/claims` | `{rid, cids, claims}`: the record's rid, the cids of its group and every live signed claim on them (all signers, tombstones excluded). `?key=` keeps one exact key, `?uid=` one signer. `404 unknown_cid` when no record holds the cid |
| **New** | `GET /api/metadata/{rid}/claims` | Same body and filters, addressed by rid (a cid is also accepted); `404` for an unknown record. What meta-search ingest and the gateway's corpus search use |
| **New** | `POST /api/merge` | Stateless: body `{claims, as?}`; verifies each claim (invalid ones dropped and counted, never a batch error), merges with the store's merge rule, overlays `as`'s own votes (in the request and held by this core for the same cids). Returns `{fields, nsrc, rejected}` — merged `key → value`, `key → agreeing signers`, rejected count. `413` above 10 000 claims. Writes nothing |
| Events | `GET /api/events/meta` | Unchanged `{type: set\|del, key, ts}` shape, now emitted for changes of the **merged** view, `key` still the literal `file:<rid>/<field>`; `GET /api/events/files` unchanged. Both streams are rows of the cache `kv` table (`ev:meta:events/…`, `ev:file:events/…`), ~100k entries each |
| Admin | `POST /api/metadata/clear` | Wipes both databases (truth and cache) |
| Removed | `/api/snapshot/export`, `/api/snapshot/import`, `/api/snapshot/wipe` | No snapshot format in v2.0.0 |
Endpoints the claims model does not touch keep their behaviour: discovery
(`/health`, `/status`, `/leader`, `/urls`, `/neighbors`), the account-key
identity service (`/api/identity/*` — its removal is a separate ticket;
v2.0.0 adds the `format` field to `sign` / `sign-batch` — `"compact"` or
`"der"`, default compact for `"MMUR2\n"` payloads and DER otherwise — and
makes `DELETE /api/identity` sign and store a retraction of every live
claim of the account before removing it),
`/api/file/{cid}/info`, `/file/cid`, `/data/{hash}*`, mounts, watchers and
WebDAV.
### The claim on the wire (JSON form)
```json
{ "cid": "bafy…", "uid": "zQ3s…", "key": "title",
"value": "The Matrix", // or {"b64": "…"} for bytes, or null
"ts": 1793001234567,
"sig": "base64(64 bytes)" }
```
| Field | Meaning |
|---|---|
| `cid` | The subject: a content CID as the writer knows it (bare CID string, as in `cids/*`). Never a rid — rids are local and never signed |
| `uid` | The signer: `"z" + base58btc(33-byte compressed secp256k1 pubkey)` |
| `key` | The field (`METADATA_KEYS.md`); a key-set member is inside the key (`genres/Drama`) |
| `value` | Text = public plaintext; `{"b64": …}` = bytes, a private (ciphertext) envelope; `null` = retraction |
| `ts` | Milliseconds since epoch. It **is** the version: only increases per `(uid, cid, key)` |
| `sig` | MMUR2: secp256k1 ECDSA, compact 64 bytes, over SHA-256 of `"MMUR2\n" ‖ canonical CBOR [cid, uid, key, value, ts]` |
A batch (the `POST /api/claims` body) is a JSON array of these (or
`{"claims": [...]}`). Kamilata and
the gateway wire carry the same five fields plus `sig`; the signed bytes
never change on any hop, and relays (meta-search, a client persisting a
gateway hit) forward claims verbatim — they never re-sign.
### Per-claim statuses
`POST /api/claims` answers with one status per claim:
| Status | Meaning |
|---|---|
| `accepted` | Verified and stored (or a retraction stored as a tombstone) |
| `unchanged` | The stored row for `(uid, cid, key)` already has this `ts` and `sig` — **or the same value under any `ts`** (a signer re-asserting what it holds): no verification, no write, and the stored row keeps its older `ts` |
| `stale` | The stored `ts` is ≥ the incoming `ts`; ignored (blocks replay of old votes) |
| `bad_signature` | The MMUR2 signature does not verify against the `uid`'s public key |
| `bad_request` | The claim is malformed |
The response is `200` with a JSON array of `{status, reason?}`, one entry
per claim, in input order, even when some are refused. A batch above 10 000
claims answers `413 too_many_claims`; a body that is not claims JSON
`400 invalid_json`. The
removed write routes answer `410 {"error":"gone","use":"POST /api/claims"}`.
`POST /api/claims`, `POST /api/merge` and the two claims read routes are
container-to-container routes, not routed through Caddy.
Rules: every claim is signed — ciphertext and retractions included; an
unsigned or badly signed claim is refused. A retraction (`value: null`) is
kept as a tombstone, so a peer cannot replay the vote it retracted. Values
are stored exactly as signed; normalisation (trim + case-fold, no NFC)
happens only inside the merge.
Accepted claims commit to the truth database in one group-committed
transaction; an apply loop then folds them, in commit order, into the
cache (the merged `file:<rid>/<field>` rows of its v1-layout `kv` table,
FTS, counters, events). `POST /api/claims` answers only once the cache has
applied the batch, so the writer reads its own write; another reader may
briefly see the previous merged value — the cache is behind, never wrong.
### `?as=<uid>` — the viewer's overlay
Every merged read accepts an optional `?as=<uid>`. The merge then puts that
signer's own live text vote first for each key (read-time overlay found
through the per-signer index, never stored in the shared merged view). Without
`?as=` the read returns the shared merge: most distinct signers → oldest
`ts` → byte-wise smallest value; ciphertext never merges.
### Raw claims and client-side merging
The claims read routes (`GET /api/metadata/{rid}/claims` for meta-search
ingest and the gateway's corpus hits, `GET /api/meta/{cid}/claims` by cid)
return the raw signed votes of every signer, not a merged map. A client that receives
claims from the swarm or a gateway displays them by calling its **local**
meta-core's `POST /api/merge` (one merge implementation, in Go), and
persists a hit by posting the claims **as received** to `POST /api/claims`.
### UDL facade shapes
Claims in the JSON form above (`internal/api/udl.go`):
| Route | Request | Response |
|---|---|---|
| `PUT /api/udl/record` | one claim | `200 {accepted: true, status}` for `accepted` / `unchanged`; `409 stale_version` for `stale`; `400` (status as slug) for `bad_signature` / `bad_request` |
| `PUT /api/udl/records` | `{records: [claim, …]}` | `200 {results: [{key, accepted, status, error?}, …]}`, input order; `accepted` = `accepted` or `unchanged` |
| `GET /api/udl/record?uid=&cid=&key=` | — | `{exists, claim}` — the stored claim, tombstone included; `exists` false when absent or a tombstone |
| `GET /api/udl/user/{uid}/key/{key}`, `/user/{uid}/cid/{cid}` | — | `{entries: [claim, …]}` |
| `GET /api/udl/user/{uid}/all?after=&limit=` | — | `{entries: [claim, …], nextCid}` |
| `GET /api/udl/user/{uid}/count` | — | `{cids}` |
| `GET /api/udl/cid/{cid}/users` | — | `{users: [uid, …]}` |
| `GET /api/udl/cid/{cid}/aggregate?keys=` | — | `{aggregates: [{key, count, avg, trues, sample_size}]}` |
| `GET /api/udl/users/stats` | — | `{users: {uid: {records, cids, keys}}}` for **every signer** with live claims — plugin, feeder, gateway and service keys included. `/api/stats` `udlUsers` is the size of this map |
### What a writer needs
Each writing container (every meta-sort plugin, every self-publishing
feeder, gateway core, meta-sort, meta-share, meta-search) signs with its
**own** key; users sign in the browser. meta-core holds none of these keys.
Key configuration per signer: implementation basis §9.
Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are not
claims: the watcher and `POST /api/files/register` write them into the
cache's `local_file` table, they are joined into the record view unsigned,
and they never appear in `/api/meta/{cid}/claims` or on the swarm.
## Eventing — HTTP Server-Sent Events
@@ -263,12 +426,19 @@ seen). The contract above holds unchanged:
- Retention is ~100k entries per stream, trimmed every 10k appends.
- IDs keep the Redis `<ms>-<seq>` form, so cursors persisted by meta-sort /
meta-share / meta-stremio before the switch still compare.
- `meta:events` entries (`{type: set|del, key, ts}`) are appended by the
storage engine **in the same transaction** as the `file:` key change —
`del` only when the key existed, `set` only when the value actually
changed (since v1.2.0; an unchanged write is a no-op). The Redis
keyspace-notification hop (PSUBSCRIBE in `MetaPublisher`), which could
drop events when it lagged, is gone.
- `meta:events` entries (`{type: set|del, key, ts}`) describe changes of
the **merged** view (v2.0.0): the apply loop emits `set`/`del` (keyed by
rid + key) only when a claim actually changed a merged value, in the same
cache transaction as that change. A group merge (two cid groups linked by
a `cids/*` vote) emits `del` for every key of the losing rid and `set` for
the survivor. *History:* v1.x appended them in the same transaction as the
`file:` key change; before 2026-10 a Redis keyspace-notification hop
(PSUBSCRIBE in `MetaPublisher`) could drop events when it lagged.
- **Cache rebuild (v2.0.0).** Events live in the cache database. A cache
rebuild (missing cache folder, or catch-up after an unclean shutdown)
emits no per-key events; SSE consumers get `event: gap` and take a
snapshot, as they do for a trimmed cursor. Event IDs stay time-based, so
they keep rising across a rebuild.
- **`Last-Event-ID: 0` / `0-0` on `/api/events/meta` returns a snapshot**
(since v1.2.0): one `set` per existing record key, then the live tail from
the head captured before the snapshot. Only the last snapshot event carries
@@ -522,6 +692,10 @@ durability across a meta-core restart).
### PR B — meta-sort writes via HTTP
> *History.* PR B moved writes onto the unsigned `/meta/{hash}` family.
> In v2.0.0 those routes answer `410 Gone`; writers sign claims and post
> them to `POST /api/claims` (see "Write contract — signed claims").
Swap `RedisClient` for an `HttpKVClient` implementation of the existing
`IKVClient` interface in `packages/meta-sort-core`. All writes
(`setMetadataFlat`, `setMetadataProperty`, batch updates) become HTTP
@@ -603,7 +777,7 @@ metacore-app produces a connection refused. The lock is real.
| Backing store for streams? | Redis Streams at the time; SQLite kv rows since 2026-10 | SSE wraps it; the 2026-10 swap proved it — no consumer changed |
| Consumer groups / horizontal scale? | Not supported via SSE | Not needed today; revisit if scale ever requires it |
| Storage portability? | Not a goal but a free side-effect | Will not be exercised in alpha |
| **Canonical write API path?** | **`/meta/{hash}` family** | Has GET/PUT/**PATCH**/DELETE plus `/_add/{key}` and per-property `{key:.*}` operations. `PATCH` calls `MergeMetadataFlat` — matches the partial-update semantics PR B needs. `/api/metadata/{hashId}` stays as the editor-internal surface (no PATCH, no per-property routes) but is not the migration target. |
| **Canonical write API path?** | **`POST /api/claims` (v2.0.0)** — signed claims only; every unsigned write route answers `410 Gone` | *History (v1.x):* the **`/meta/{hash}` family** was the canonical write surface. It had GET/PUT/**PATCH**/DELETE plus `/_add/{key}` and per-property `{key:.*}` operations. `PATCH` calls `MergeMetadataFlat` — matches the partial-update semantics PR B needs. `/api/metadata/{hashId}` stays as the editor-internal surface (no PATCH, no per-property routes) but is not the migration target. |
| **SSE event-id contract?** | **Opaque to consumers** | It is `<ms>-<seq>` (the Redis Stream entry form, kept after the 2026-10 SQLite switch); consumers MUST treat as opaque so the backing store can be swapped without breaking the wire contract. Documented in the SSE endpoint comment and in this doc. |
| **Typed error envelope?** | **`{error, message, retryable}` with stable `error` slug** | See "Error envelope" subsection below. |
| **Plugin direct-Redis risk?** | **Confirmed: no plugin uses Redis directly** | Grep across `packages/plugins/` for `ioredis` / `redis.createClient` / `new Redis()` returned empty. Plugins POST results back to meta-sort via `/api/plugins/callback`; meta-sort then writes. PR B picks up the plugin write path transparently. |
@@ -611,6 +785,11 @@ metacore-app produces a connection refused. The lock is real.
## Error envelope
(`410 Gone` from the removed v1.x write routes carries this envelope with a
message pointing to `POST /api/claims`. Per-claim outcomes of
`POST /api/claims` are statuses inside the response, not envelopes — see
"Per-claim statuses" above.)
Standard shape for all 4xx / 5xx responses from the metadata API:
```json
@@ -629,6 +808,10 @@ Stable `error` slugs the client should switch on:
| `unknown_root` | 404 | no | The hashId / UUID doesn't exist. Caller probably has a stale reference. |
| `unknown_cid` | 404 | no | The CID isn't in the reverse index. No retry helps. |
| `schema_violation` | 400 | no | Body shape rejected (missing required field, invalid value). Caller bug. |
| `invalid_json` | 400 | no | `POST /api/claims` / `POST /api/merge` body is not claims JSON. Caller bug. |
| `too_many_claims` | 413 | no | More than 10 000 claims in one `POST /api/claims` or `POST /api/merge`. Split the batch. |
| `stale_version` | 409 | yes | `PUT /api/udl/record`: an equal or newer `ts` is stored for this signer, cid and key. Re-read the record, sign with a newer `ts`, retry. |
| `gone` | 410 | no | A removed unsigned write route. Body is `{"error":"gone","use":"POST /api/claims"}` (no `message` / `retryable`). |
| `storage_unavailable` | 503 | yes | Store transient (Redis at the time; the embedded SQLite store since 2026-10). Caller should retry with backoff. |
| `internal` | 500 | yes (cautiously) | Unhandled server-side error. Retry once; escalate if persistent. |
@@ -643,10 +826,9 @@ import cycle on the api package; consolidating it into a shared
Mostly answered post-implementation:
- **Deprecation timeline for `/api/metadata/{hashId}`.** Still open. The
editor's React UI continues to call it; the surface itself was untouched
by this work. Worth a follow-up to consolidate the editor onto
`/meta/{hash}` and delete the parallel routes.
- **Deprecation timeline for `/api/metadata/{hashId}`.** Answered by
v2.0.0: its `PUT`/`DELETE` (and `PUT …/property`) answer `410 Gone`
like every unsigned write; `GET` stays as a merged read.
- **Initial bootstrap flood on `/api/events/files`.** Resolved in
practice: meta-fuse uses `initialCursor: '0-0'` to deliberately
@@ -666,8 +848,9 @@ Mostly answered post-implementation:
- **Authentication between internal services.** The hash-lock OIDC
perimeter handles external clients; internal service-to-service
trust is by network isolation (docker network for dev, equivalent
isolation in prod deployments). Adding service-to-service auth is a
separate concern.
isolation in prod deployments). Since v2.0.0 metadata *writes* carry
their own authentication — the claim signature — so meta-core needs no
per-writer tokens; transport-level service auth remains out of scope.
- **Replacing the public HTTP API surface.** This proposal exercises
what already exists; the only new endpoints are the two SSE streams.
- **Introducing new transport, broker, proxy, or sidecar.** Everything
@@ -716,6 +899,11 @@ What actually shipped vs. what this doc planned.
### Write-path CID resolution (`storage.ResolveRoot`)
> *History (v1.x).* In v2.0.0 a claim names a cid, never a rid, and the
> hidden rid comes from `cid_group` (a `cids/<other>` vote links two
> groups; the older rid survives). The dual-root failure below cannot
> recur: there is no write that addresses a root.
Not in the original plan but load-bearing.
**The problem.** meta-sort's PATCH-via-HTTP migration (PR B) routed
@@ -766,9 +954,10 @@ duplicated in each to avoid an import cycle on the `api` package.
- **meta-dup SSE + read migration.** Not deployed, no test harness.
When revived, copy meta-fuse's pattern.
- **Editor `/api/metadata/{hashId}` consolidation.** The duplicate
editor-internal API surface is untouched; could be retired in a
follow-up by porting the editor UI to `/meta/{hash}`.
- **Editor writes (resolved in v2.0.0).** `PUT /api/metadata/{hashId}`
answers `410`. meta-sort-editor's manual edits go through meta-sort's
backend, which signs them as meta-sort (its own key) and posts them to
`POST /api/claims`. The editor's reads keep using `/api/metadata/{hashId}`.
- **Consolidated `internal/httperr` package** for the typed envelope
helpers (currently duplicated 4× to avoid the import cycle). Code
hygiene; functionally a no-op.
+315 -103
View File
@@ -1,15 +1,22 @@
# Meta-Core Architecture
> **meta-core v2.0.0 — the claims model.** Implementation basis:
> [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
> (meta-root). Where this file and that one disagree, that one wins.
## Document Purpose
This document describes the architectural design of **meta-core**, the
standalone container that owns the metadata store (an embedded SQLite
file; Redis until 2026-10), the leader-info file, the HTTP+SSE
standalone container that owns the metadata store (two embedded SQLite
databases since v2.0.0 — *truth* and *cache*; a single SQLite file in
v1.x; Redis until 2026-10), the leader-info file, the HTTP+SSE
metadata surface, and a WebDAV file server. It covers the leader gate,
storage data model, API design, the SSE event surface, and the integration
contract with other MetaMesh services.
the claims data model and its write/apply pipeline, API design, the SSE
event surface, and the integration contract with other MetaMesh services.
For the on-disk key layout, see
For the engine design (SQLite, group commit, measurements), see
[metacore-storage-engine.md](../../../docs/project-architecture/metacore-storage-engine.md);
for the on-disk layout (tables of both databases), see
[metadata-storage-structure.md](metadata-storage-structure.md). For the
external-access design (store lockdown, SSE wire contract, auth rules),
see [api-mediated-access.md](api-mediated-access.md). For the UUID-rooted
@@ -24,7 +31,7 @@ view of file metadata:
| Service | Language | Role |
|---|---|---|
| **meta-sort** | TypeScript | Watches folders, runs plugins, writes metadata via meta-core's HTTP API. |
| **meta-sort** | TypeScript | Watches folders, runs plugins; each plugin signs its own claims and posts them to meta-core. |
| **meta-fuse** | TypeScript / Rust | Serves the virtual filesystem from metadata. |
| **meta-stremio** | Python | Stremio addon for HLS streaming. |
| **meta-dup** | TypeScript | Duplicate detection. |
@@ -32,8 +39,14 @@ view of file metadata:
meta-core sits below all of these as the single owner of:
1. **The metadata store** — an embedded SQLite file
(`/meta-core/db/meta-core.db`) holding metadata and the event streams.
1. **The metadata store** — two embedded SQLite databases: **truth**
(`/meta-core/db/meta-core.db`, backed up) holding the signed claims, and
**cache** (`/meta-core/db-cache/cache.db`, never backed up) holding every
index and derived row — a v1-layout `kv` table (the merged view as
`file:<rid>/<field>` rows, lookup, counters, event streams, service
scratch KV) with its field index and FTS, plus the `claim_by_pid` and
`local_file` tables. (v2.0.0 is implemented, uncommitted and not yet
deployed as of 2026-10-25.)
2. **The leader-info file** — the one place services look to discover meta-core's URLs.
3. **The HTTP+SSE surface** — the only way external services touch the data plane.
4. **A WebDAV server** — file access for plugins and other services.
@@ -44,7 +57,12 @@ meta-core sits below all of these as the single owner of:
> through meta-core's HTTP+SSE surface.
This is the contract that PR D of [api-mediated-access.md](api-mediated-access.md)
landed and that the rest of this document assumes.
landed and that the rest of this document assumes. v2.0.0 adds a second:
> Every metadata value is a **signed vote** — `claim(uid, cid, key) → value`
> + `ts` + `sig`. The only write is `POST /api/claims`. meta-core verifies
> every claim and **never signs one**: it holds no writer key, and the
> signature is the write authentication.
---
@@ -68,13 +86,15 @@ database file and the writable state under `/meta-core`.
│ ├─ rclone rcd (mount daemon) │
│ ├─ mount-watcher.sh (rclone mount lifecycle) │
│ └─ meta-core (Go) ──► HTTP + SSE + WebDAV on :9000 │
│ + embedded SQLite (db/meta-core.db) │
│ + embedded SQLite: truth (db/meta-core.db) │
│ and cache (cache/cache.db) │
└────────────────────────────────────────────────────────────────────────┘
│
┌────────────────────────────┴────────────────────────────┐
│ /meta-core (shared volume) │
│ locks/kv-leader.{lock,info} │
│ db/meta-core.db (+ -wal, -shm) │
│ db/meta-core.db (+ -wal, -shm) truth, backed up │
│ cache/cache.db (+ -wal, -shm) cache, never backed │
│ services/*.json │
│ mounts/{mounts.json, errors/} │
│ watchers.json │
@@ -203,67 +223,166 @@ Other services read this file, then call `GET /urls` for full discovery
## Schema-Version Gate
Before serving any traffic, `cmd/meta-core/main.go:54-61` calls
`storage.EnsureSchemaVersion`. The sentinel lives in the store at
`meta-core:schema-version` (`internal/storage/schema_version.go`). The
gate refuses to boot when the existing store layout predates the
UUID-rooted schema, so legacy `file:midhash256:*` data can't leak into
the current code paths. In alpha there is no automated migration —
operators wipe with `pnpm run clean:all` and restart.
Before serving any traffic, `cmd/meta-core/main.go` opens both databases
and calls `storage.EnsureSchemaVersion` (before the cache catch-up). In
v2.0.0 the sentinel is the `schema-version = "3"` row of the truth
database's `meta` table. The gate:
- **explicitly refuses a v1.x store** — a truth file holding the v1 `kv`
table (`meta-core:schema-version = "2"`), which the default path still
points at — with a message naming `tools/claims-migrate`;
- refuses a store with claims but no sentinel, or any other version;
- stamps a fresh store with `"3"`.
So no pre-claims data leaks into the current code paths. The meta-core
**service binary contains no migration code**: watch.nsl.sh is converted
once by the separate one-off tool `packages/meta-core/tools/claims-migrate`
(not built into the image; implementation basis §11); every other box and
dev stack starts empty (operators wipe with `pnpm run clean:all` and
restart).
---
## Data Model
### Two concepts: metadata vs. file bytes
### The one concept: signed votes
```
┌────────────────────────────────────────────────────────────────────┐
│ METADATA (embedded SQLite, kv table) │
│ │
│ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │
│ Storage: flat string keys │
│ file:<uuid>/<property> → string value │
│ file:<uuid>/cids/<bareCid> → "true" (key-set) │
│ dup:<uuid>/<path> → "" (one per path) │
│ │
│ No stored canonical_cid — derived by rank on read. │
│ │
│ Aliases (reverse index, one key each): │
│ cid:<bareCid> → <uuid> │
│ │
│ Index: idx:roots/<uuid> → "" (one per root) │
│ │
│ Mintable without a local file (remote / deleted entries OK). │
│ Resolvable by any registered CID in O(1). │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ FILE BYTES │
│ │
│ Location: /files volume (shared across containers). │
│ Access: path resolution via metadata, served via /webdav/*, │
│ /file/{cid} (reverse-lookup), or /data/{hash}. │
│ Path stored as filePath in the metadata, relative to /files. │
└────────────────────────────────────────────────────────────────────┘
claim(uid, cid, key) → value + ts + sig
```
### Why UUID-rooted?
- `uid` — the signer (a secp256k1 public key, `z…` base58btc). One key per
writing container (each meta-sort plugin, each self-publishing feeder,
gateway core, meta-share, meta-search) and one per user.
- `cid` — the subject, a content CID; never a rid.
- `key` — the field (`METADATA_KEYS.md`); key-set members live inside the
key (`genres/Drama`, `cids/<cid>`).
- `value` — text (public), bytes (signed + encrypted envelope) or null
(retraction, kept as a tombstone).
- `ts` — milliseconds; it **is** the version and only increases per
`(uid, cid, key)`.
- `sig` — MMUR2: secp256k1 ECDSA (compact 64 bytes) over SHA-256 of
`"MMUR2\n" ‖ canonical CBOR [cid, uid, key, value, ts]`.
Roots were previously content-hash tokens (`file:midhash256:abc/...`),
which privileged one hash algorithm. UUID-rooted storage treats every CID
symmetrically as an alias, makes `GET /api/meta/<cid>` O(1) for any
registered CID, and lets new digest types be added without rewriting root
keys. See [uuid-rooted-metadata.md](uuid-rooted-metadata.md) for the
design rationale and [metadata-storage-structure.md](metadata-storage-structure.md)
for the on-disk layout.
The old shared record (`file:<rid>/<field>`) and the old User Data Layer
store (`udl:rec:*`) both collapse into this shape. The store distinguishes
only plaintext (signed) from ciphertext (signed + encrypted); "personal vs
shared" is app logic.
### Two databases
```
┌──────────────────────────────────────────────────────────────────────┐
│ TRUTH /meta-core/db/meta-core.db (backed up; PKs only) │
│ │
│ principal(pid, uid) uid ↔ local surrogate pid │
│ claim(cid, key, pid → value, ts, sig, seq) │
│ the votes; PK (cid, key, pid) │
│ cid_group(cid → rid) hidden local record identity │
│ meta(name → value) schema-version, last_seq │
└──────────────────────────────────────────────────────────────────────┘
│ apply loop (seq order)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ CACHE /meta-core/db-cache/cache.db (never backed up; rebuildable)│
│ │
│ kv(k → v) the v1 layout, every row derived: │
│ file:<rid>/<field> the merged view every read uses │
│ cid:<cid> cid → rid lookup │
│ idx:roots/ idx:fts/ idx:count/ idx:schema/ roots, FTS rowid, │
│ counters (search totals, stats) │
│ ev:<stream>/…, evhwm:… /api/events/meta + /files backlog │
│ meta:applied_seq, meta:rebuilt_at apply-loop bookkeeping │
│ anything else /api/kv/value scratch KV │
│ kv_field_v (index), fts filter + free-text search │
│ claim_by_pid(pid, cid, key) "everything uid X wrote" (?as=, │
│ UDL facade, identity retraction) │
│ local_file(path → midhash, size, mtime) watcher facts, unsigned │
└──────────────────────────────────────────────────────────────────────┘
```
Disaster recovery is "delete the cache, replay truth". `META_CORE_DB_PATH`
names the truth file, `META_CORE_CACHE_PATH` the cache file. The cache keeps
the v1 `kv` layout so every v1 reader (record reads, field index, FTS,
counters, SSE + snapshot) runs unchanged on derived rows; `nsrc` is computed
by the merge (`POST /api/merge`), not stored. Full schema: implementation
basis §5.
### Records: cids, groups and the hidden rid
A vote never mentions a rid. A **rid** (UUIDv7, 26-char Crockford, as in
v1.x) is a local name for a group of cids known to be the same content,
held in `cid_group`:
- A new cid with no link gets its own group and a fresh rid.
- A `cids/<other>` vote on cid X links X's group and `<other>`'s group; the
group with the older rid survives, the losing rid gets a `del` event per
key and the survivor `set` events (no alias table). Retracting the link
does **not** split the group (phase 1); the record view synthesises
`cids/<cid> = "true"` for every cid of the group from `cid_group`.
- Reading record R = every vote on every cid of R's group, merged per key;
within a group one signer counts once per key.
- rids are never signed and never travel; consumers that hold them
(meta-sort, meta-fuse, meta-stremio) keep working because `cid_group`
lives in truth.
### Merge
Per `(rid, key)`, over the group's votes, each signer's newest vote counted
once (`internal/storage/merge.go`, shared by the apply loop, `?as=` and
`POST /api/merge`):
1. the viewer's own live text vote (`?as=<uid>`, applied at read time,
never stored);
2. retractions and ciphertext drop out — only text merges;
3. most distinct signers for the normalised value (**trim + case-fold
only**, no NFC) — `nsrc` is that count (computed, not stored);
4. tie → oldest `ts`;
5. tie → byte-wise smallest value.
The stored value is the winning vote's exact text. Key-sets need no special
code (each member is a key, present while one vote supports it). A
different kind of fact gets a different key, so a guesser (e.g.
filename-parser) never competes with a lookup on the same key.
### Box-local file facts
`filePath`, size, `mtimeNano` and midhash are **not claims** — meta-core
cannot sign, and the disk can always be rescanned. The file watcher and
`POST /api/files/register` write them into the cache's `local_file` table
(and the midhash into `cid_group`); they are joined into the record view
unsigned and never reach the claims API or the swarm. When several local
paths hold the same content, `filePath` is the lexicographically first one
(size and mtime from the same row); a signed `sizeByte` claim wins over the
on-disk size.
### File bytes
```
Location: /files volume (shared across containers).
Access: path resolution via local_file, served via /webdav/*,
/file/{cid} (reverse-lookup), or /data/{hash}.
Path: filePath (from local_file) in the record view, relative to /files.
```
### History: UUID-rooted flat keys (v1.x)
Until v2.0.0 a record was a set of flat keys under an opaque UUID root —
`file:<uuid>/<property>`, `file:<uuid>/cids/<bareCid>` key-sets,
`dup:<uuid>/<path>`, a reverse index `cid:<bareCid> → <uuid>` and a root
index `idx:roots/<uuid>`, all in one kv table. Roots had previously been
content-hash tokens (`file:midhash256:abc/...`); UUID roots treated every
CID symmetrically as an alias. The rid of v2.0.0 is that UUID, kept hidden
and local; migrated records kept their UUIDs. See
[uuid-rooted-metadata.md](uuid-rooted-metadata.md) (the *why*) and the
History note of [metadata-storage-structure.md](metadata-storage-structure.md).
### Path resolution
`filePath` values stored in metadata are absolute paths under the `/files`
volume (e.g. `/files/watch/movies/Movie.2024.mkv`). The `/files` prefix is
itself a per-host detail — moving the volume only requires re-mounting it
at the same path inside the container.
`filePath` values are absolute paths under the `/files` volume (e.g.
`/files/watch/movies/Movie.2024.mkv`). The `/files` prefix is itself a
per-host detail — moving the volume only requires re-mounting it at the
same path inside the container.
---
@@ -282,16 +401,16 @@ Bootstrap / discovery
GET /neighbors (alias: /api/neighbors)
GET /api/stats
Metadata (primary surface for other services)
Claims (the only metadata write path)
POST /api/claims signed claim batch → per-claim status
POST /api/merge {claims, as?} → {fields, nsrc, rejected}; no write
GET /api/meta/{cid}/claims {rid, cids, claims} of the cid's group (?key=, ?uid=; 404 unknown)
GET /api/metadata/{rid}/claims same, addressed by rid
Metadata reads (merged view + local_file; optional ?as=<uid>)
GET /meta
GET /meta/{hash}
PUT /meta/{hash}
PATCH /meta/{hash}
DELETE /meta/{hash}
GET /meta/{hash}/{key...}
PUT /meta/{hash}/{key...}
DELETE /meta/{hash}/{key...}
POST /meta/{hash}/_add/{key...}
CID-addressed (public, auth-bypassed at the perimeter)
GET /api/meta/{cid}
@@ -301,24 +420,45 @@ CID-addressed (public, auth-bypassed at the perimeter)
HEAD /data/{hash}
GET /data/{hash}/path
Editor / KV / schema / snapshot
Editor / KV / schema
GET /api/metadata/hash-ids
GET /api/files/tuples
POST /api/files/register (fills local_file + cid_group)
GET /api/metadata/list
POST /api/metadata/search
POST /api/metadata/batch
POST /api/metadata/clear
{GET,PUT,DELETE} /api/metadata/{hashId}
{GET,PUT} /api/metadata/{hashId}/property
GET /api/kv/{info,keys,tree,search,find,value}
PUT /api/kv/value
DELETE /api/kv/value
GET /api/kv/key/{key...}
POST /api/metadata/clear (wipes both databases)
GET /api/metadata/{hashId} (hashId = rid)
GET /api/metadata/{hashId}/property
GET /api/kv/{info,keys,tree,search,find} (read-only browser over the cache kv table)
{GET,PUT,DELETE} /api/kv/value (service scratch keys only; reserved prefixes refused)
GET /api/kv/key/{key...} (read-only)
GET /api/schema
POST /api/schema/rescan
GET /api/snapshot/export
POST /api/snapshot/import
POST /api/snapshot/wipe
User Data Layer (facade over claims; records are MMUR2)
{GET,PUT} /api/udl/record
PUT /api/udl/records
GET /api/udl/user/{uid}/{key/{key},cid/{cid},all,count}
GET /api/udl/cid/{cid}/{users,aggregate}
GET /api/udl/users/stats (every signer with live claims, plugin keys included)
Account-key identity service (kept in v2.0.0; removal is a separate ticket)
{GET,DELETE} /api/identity (DELETE signs + stores a retraction of every live claim first)
GET /api/identity/{accounts,aead-key}
POST /api/identity/{challenge,generate,import,reveal,sign,sign-batch}
(sign/sign-batch `format`: compact | der; default
compact for "MMUR2\n" payloads, DER otherwise)
Removed in v2.0.0 — answer 410 Gone, pointing to POST /api/claims
PUT/PATCH/DELETE /meta/{hash}
PUT/DELETE /meta/{hash}/{key...}
POST /meta/{hash}/_add/{key...}
PUT/DELETE /api/metadata/{hashId}
PUT /api/metadata/{hashId}/property
POST /api/metadata/batch
Removed in v2.0.0 — no snapshot format
GET /api/snapshot/export, POST /api/snapshot/{import,wipe}
SSE event streams (HTTP-mirror of the store's event streams)
GET /api/events/files
@@ -366,6 +506,13 @@ peers and unauthenticated tooling can reach them):
- (`/api/events/files`, `/api/events/meta` are also auth-bypassed but
**should not** be exposed publicly via Caddy; they're inside-only.)
Since v2.0.0 `/meta/*` serves reads only — its write verbs answer `410
Gone` — so the bypass no longer exposes an unsigned write. Writes go to
`POST /api/claims`, where the claim signature (not the perimeter) is the
write authentication. `POST /api/claims`, `POST /api/merge` and
`GET /api/meta/{cid}/claims` are container-to-container routes, not routed
through Caddy, like the v1.x write routes were.
See `docs/api-mediated-access.md` "Auth" for the rationale.
### SSE event streams — wire contract
@@ -389,9 +536,46 @@ the kv table (`internal/kv/stream.go`). The contract
`event: gap` payload before resuming from the oldest available entry.
External services use SSE with `Last-Event-ID` as the resume primitive.
`meta:events` entries are appended by the storage engine in the same
transaction as the change they describe; the Redis-era keyspace-notification
hop through `MetaPublisher` is gone.
Since v2.0.0 `meta:events` describes changes of the **merged** view, same
`{type: set|del, key, ts}` shape: the cache engine appends an
`ev:meta:events/…` row for every change of a merged `file:` row, in the same
cache transaction as the change (both streams are v1 stream rows of the
cache `kv` table). A cache rebuild
emits no per-key events — consumers get `event: gap` and take a snapshot;
event IDs stay time-based and keep rising. *History:* v1.x appended them in
the same transaction as the `file:` key change; the Redis-era
keyspace-notification hop through `MetaPublisher` is long gone.
### Write path and apply loop
```
POST /api/claims ──► per claim, before the writer:
0. validate (required fields, reserved keys, ts range) → else bad_request
1. same ts + sig as stored? → unchanged (no verify)
2. same value as stored (any ts)? → unchanged; stored row + older ts kept
3. stored ts ≥ incoming ts? → stale
4. verify MMUR2 signature → else bad_signature
──► accepted claims, one group-committed truth transaction:
5. re-check 1–3 under the writer
6. uid → pid (principal; insert on first sight)
7. upsert claim (tombstone if null), seq = ++last_seq
8. cid_group: mint rid for unknown cid; text cids/* links groups
(a retraction never splits)
9. commit (last_seq in truth meta); answer once the cache applied it
──► apply loop, in seq order, into the cache:
sync cid:/cids/<cid>/idx:roots rows → claim_by_pid
→ re-merge (rid, key) from truth PK range reads
→ set/delete file:<rid>/<key> (+ local_file for filePath/sizeByte/mtimeNano)
⇒ meta:events row + counter delta in the same tx
→ FTS row on a haystack change → meta:applied_seq
```
The verifier lives in the write path (secp256k1 from `internal/identity`);
meta-core holds **no** writer key and never signs a claim. Response: one
status per claim — `accepted` · `unchanged` · `stale` · `bad_signature` ·
`bad_request`. Request/response shapes and the 410 contract for removed
routes: [api-mediated-access.md](api-mediated-access.md) "Write contract —
signed claims".
### WebDAV URLs
@@ -421,7 +605,8 @@ WebDAV caching is handled by nginx's `proxy_cache` (see
| Single binary | Static compilation |
| Low resource | ~10-20 MB resident |
| HTTP server | stdlib `net/http` + gorilla/mux |
| Metadata store | embedded SQLite via `modernc.org/sqlite` (pure Go, CGO off) |
| Metadata store | embedded SQLite via `modernc.org/sqlite` (pure Go, CGO off) — truth + cache databases |
| Claim verification | secp256k1 ECDSA (`internal/identity`) |
| WebDAV | `golang.org/x/net/webdav` |
### Repo layout
@@ -434,12 +619,12 @@ packages/meta-core/
│ ├── cid/ CID parsing + algorithm ranking
│ ├── config/ env-driven configuration
│ ├── discovery/ service registry + dead-service cleaner
│ ├── kv/ SQLite kv engine + event streams
│ ├── identity/ secp256k1 (claim verification; account-key service)
│ ├── kv/ SQLite engine (single writer, group commit) + event streams
│ ├── leader/ LeaderInfoProvider (no election logic)
│ ├── mounts/ rclone mount manager + handlers + stats
│ ├── schema/ per-field schema from transactional counters
│ ├── snapshot/ snapshot export / import / wipe
│ ├── storage/ flat-key records + search + UUID + CID reverse index + schema gate
│ ├── storage/ claims write path, apply loop, merge, cid_group, search, schema gate
│ ├── watcher/ scanner + dispatcher + midhash + state
│ ├── watchers/ polling-based watcher configs
│ └── webdav/ /webdav/* handler
@@ -460,15 +645,22 @@ packages/meta-core/
```
1. Load configuration from environment
2. Build LeaderInfoProvider (no election; bash gate already won)
3. Open the SQLite store (META_CORE_DB_PATH or /meta-core/db/meta-core.db)
4. EnsureSchemaVersion (abort if the store layout is stale)
5. Start service discovery + dead-service cleaner
6. NewServer + Server.Start (boots watcher, mounts, WebDAV, API)
7. EnsureCounters (one-time build of the derived counters
on a store that predates them)
8. Rebuild FTS rows in the background if the FTS table is empty
3. Create the cache folder; open the truth database (META_CORE_DB_PATH or
/meta-core/db/meta-core.db) and the cache (META_CORE_CACHE_PATH or
/meta-core/db-cache/cache.db) (storage.Connect)
4. EnsureSchemaVersion (abort on a v1 kv store or an unknown layout)
5. storage.Start — cache catch-up (meta:applied_seq ≥ last_seq → nothing; behind →
replay claims with seq > applied_seq, or a full
rebuild if a cids/* link is among them; no
cache → full rebuild), then the apply loop
6. Derived counters ready
7. Start service discovery + dead-service cleaner
8. NewServer + Server.Start (boots watcher, mounts, WebDAV, API)
9. Wait for SIGINT/SIGTERM
10. Shutdown in reverse order
(v1.x ran `EnsureCounters` and a background FTS rebuild here; in v2.0.0
counters and FTS are cache rows maintained with every merged-row change.)
```
### Process management
@@ -489,10 +681,25 @@ internal crashes don't trigger re-election.
### Store durability
The SQLite file runs in WAL mode with `synchronous=NORMAL`: every
committed transaction survives a meta-core process crash. A record write — fields, root index, CID aliases,
FTS row and its events — is one transaction, so a crash never leaves a
half-written record.
Both SQLite files run in WAL mode; truth with `synchronous=NORMAL` (every
committed transaction survives a meta-core process crash), the cache with
`synchronous=OFF` (it is rebuildable). A claim batch
commits to truth in one transaction; the apply loop then commits the
derived cache changes. **A crash between the two commits leaves the cache
behind, never wrong**: at boot, if the cache's `applied_seq` is below
truth's `last_seq`, meta-core scans truth's `claim` table for
`seq > applied_seq` (a full scan — there is no seq index; it only happens
after an unclean shutdown) and applies them — or rebuilds the cache in full
if one of them is a `cids/*` link.
### Cache loss
The cache is never backed up and is fully derived (except `local_file`,
which a rescan of `/files` refills, and the service scratch keys, whose
loss costs a refetch). A missing cache folder (no `meta:applied_seq`) means
meta-core rebuilds every derived row from truth and stamps
`meta:rebuilt_at`. A rebuild emits no per-key events;
SSE consumers get `event: gap` and re-snapshot.
### Container crash
@@ -503,8 +710,9 @@ the container."
### Schema mismatch
`EnsureSchemaVersion` refuses to boot. Operators wipe with
`pnpm run clean:all` (or equivalent) and restart. This is an alpha-stage
hard-fail; there is no automated migration path.
`pnpm run clean:all` (or equivalent) and restart, or — once, on
watch.nsl.sh — convert a v1.x store with `tools/claims-migrate`. This is an
alpha-stage hard-fail; the service never migrates automatically.
### Shared volume unavailable
@@ -525,7 +733,8 @@ via `/health`; metadata I/O and SSE event streams stop.
| `SERVICE_VERSION` | `1.0.0` | Reported in `/status`. |
| `API_PORT` | `8180` | External port baked into `baseUrl`. |
| `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). |
| `META_CORE_DB_PATH` | `<META_CORE_PATH>/db/meta-core.db` | SQLite database file. Dev stacks set `/meta-core-db/meta-core.db` (named volume — SQLite WAL is unsafe on the 9p `/meta-core` bind of Windows dev boxes). |
| `META_CORE_DB_PATH` | `<META_CORE_PATH>/db/meta-core.db` | Truth database file (backed up). Dev stacks set `/meta-core-db/meta-core.db` (named volume — SQLite WAL is unsafe on the 9p `/meta-core` bind of Windows dev boxes). |
| `META_CORE_CACHE_PATH` | `/meta-core/db-cache/cache.db` | Cache database file (never backed up; rebuilt from truth when missing). Dev stacks keep it on a named volume too; store apps put the cache folder in Maison's `backup.exclude`. |
| `META_CORE_DB_CACHE_MB` | `64` | Page cache of the writer connection. |
| `META_CORE_DB_READER_CACHE_MB` | `16` | Page cache per reader connection. |
| `META_CORE_DB_READERS` | `8` | Reader connection pool size. |
@@ -547,7 +756,8 @@ via `/health`; metadata I/O and SSE event streams stop.
|---|---|
| `locks/kv-leader.lock` | Flock target. |
| `locks/kv-leader.info` | Leader API URL (plaintext). |
| `db/meta-core.db` (+ `-wal`, `-shm`) | Metadata store (SQLite). Replaced `db/redis/` in 2026-10. |
| `db/meta-core.db` (+ `-wal`, `-shm`) | Truth database (claims, principals, `cid_group`). Backed up. Replaced `db/redis/` in 2026-10; holds the claims schema since v2.0.0. |
| `db-cache/cache.db` (+ `-wal`, `-shm`) | Cache database (v1-layout `kv` table: merged view, lookup, counters, events, service scratch keys; field index, FTS, `claim_by_pid`, `local_file`). Never backed up. |
| `services/*.json` | Service registry. |
| `mounts/mounts.json` | rclone mount configuration. |
| `mounts/errors/{id}.error` | Mount error logs. |
@@ -561,7 +771,9 @@ via `/health`; metadata I/O and SSE event streams stop.
meta-core consolidates leader gating, metadata storage, event mirroring,
and file access for MetaMesh into one container with a typed HTTP+SSE
surface. The bash flock gate guarantees single-leader semantics without
an external consensus system. UUID-rooted storage with CID aliasing
treats all content hashes symmetrically and makes any-CID resolution
a single lookup. API-mediated access ensures no other service ever touches
an external consensus system. Metadata is a table of signed votes in a
backed-up truth database, merged by an apply loop into a rebuildable cache
that serves every read; meta-core verifies every claim and signs none.
CID groups behind a hidden local rid treat all content hashes
symmetrically. API-mediated access ensures no other service ever touches
the store.
+254 -317
View File
@@ -1,250 +1,199 @@
# Metadata Storage Structure
How meta-core lays out file metadata in its store. This document covers the
on-disk shape (UUID-rooted flat string keys, CID reverse index,
per-root sets) and the schema-version gate. It does **not** enumerate
field semantics — that's the job of the repo-root
[`METADATA_KEYS.md`](../../../METADATA_KEYS.md), the authoritative key
registry for everyone who writes to or reads from metadata.
How meta-core lays out metadata on disk since **v2.0.0 (claims model)**: the
two SQLite databases, their tables, how a record is assembled from signed
votes, and the schema-version gate. Implementation basis:
[`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md).
This document does **not** enumerate field semantics — that is the job of the
repo-root [`METADATA_KEYS.md`](../../../METADATA_KEYS.md), the authoritative
key registry for everyone who writes or reads metadata.
For the design rationale (why UUIDs instead of content hashes, why a
reverse index), see [`uuid-rooted-metadata.md`](uuid-rooted-metadata.md).
For the external-access contract (HTTP-only, SSE for events), see
Related: why rids are UUIDs and hidden —
[`uuid-rooted-metadata.md`](uuid-rooted-metadata.md); why the engine is
shaped this way, its guarantees and measurements —
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md);
the external-access contract (HTTP-only, SSE for events) —
[`api-mediated-access.md`](api-mediated-access.md).
> **Storage engine (since 2026-10): an embedded SQLite file, not Redis.**
> Until 2026-10 these keys lived in a Redis server spawned inside the
> container. They now live in one string→string table of an embedded SQLite
> database (pure-Go driver `modernc.org/sqlite`, CGO off). The **key names
> did not change** — only the Redis *types* that some bookkeeping used (SETs,
> HASHes, Streams) were flattened into plain keys, listed below. Holding
> everything in RAM had cost ~6.6 GB on watch.nsl.sh's 169k records (Redis
> plus the API's in-memory search copy) and caused repeated OOM kills.
> **History.** Until 2026-10 metadata lived in Redis; v1.1.0–v1.2.x kept the
> same flat keys (`file:<rid>/<field>`, `cid:<cid>`, `idx:*`, `dup:*`,
> `udl:*`, `ev:*`) in one SQLite string→string `kv` table. v2.0.0 replaced
> that keyspace entirely; the old layout is summarised at the end
> ([Previous layout](#previous-layout-v11v12-kv-table)).
## Quick summary
- **One table**: `kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`,
schema-free — a new metadata field is just a new key, never a migration.
File: `/meta-core/db/meta-core.db` (override `META_CORE_DB_PATH`), WAL
mode, `synchronous=NORMAL`.
- **Roots are UUIDv7** encoded as 26-char Crockford Base32 (ULID layout),
not content hashes. Examples in this doc use the placeholder
`01JKR8XW5T4QMV7AHNJ2DEFGHK`.
- **Properties are flat string keys**, one per leaf:
`file:<uuid>/<property/path>` → string value. A record is exactly the
key range `[file:<uuid>/, file:<uuid>0)` — there is no per-record field
list.
- **CIDs are reverse-index aliases**: `cid:<bareCid>` → `<uuid>`
(one key per known CID; no algorithm prefix — the multicodec
already carries it).
- **Per-root sets**: `file:<uuid>/cids/<bareCid>` (a *key-set*: one key
`"true"` per known CID, midhash included), `dup:<uuid>/<path>` (alternate
paths with the same content), plus `idx:roots/<uuid>` (every UUID).
There is **no stored `canonical_cid`** — it is derived by rank on read.
- **A schema-version sentinel** (`meta-core:schema-version`) refuses to
boot against legacy data.
- **Every metadata value is a signed claim**: `claim(uid, cid, key) → value`
+ `ts` + `sig` (MMUR2, secp256k1). One row per signer per field. Writers
sign; meta-core never signs.
- **Two databases.** *Truth* `/meta-core/db/meta-core.db`
(`META_CORE_DB_PATH`, backed up, primary keys only): `principal`, `claim`,
`cid_group`, `meta`. *Cache* `/meta-core/db-cache/cache.db`
(`META_CORE_CACHE_PATH`, never backed up, rebuildable): the v1 `kv` table
(merged view as `file:<rid>/<field>` rows, lookup, counters, event streams,
bookkeeping, service scratch keys) with its `kv_field_v` index and `fts`,
plus two real tables, `claim_by_pid` and `local_file`.
- **Schema-free.** A field is a `key` string in a row; a new field never
needs DDL.
- **Records are cid groups.** A vote names a cid, never a rid. `cid_group`
maps each cid to a **rid** — a hidden, locally minted UUIDv7 (26-char
Crockford) naming the group. Examples use `01JKR8XW5T4QMV7AHNJ2DEFGHK`.
- **What readers get is the merged view**: one precomputed value per
`(rid, key)`, stored as the cache row `file:<rid>/<key>`. `nsrc` (how many
signers support it) is computed by the merge and returned by
`POST /api/merge`, not stored. `canonical_cid` is still **derived on read**
by rank, never stored.
- **Watcher facts are not claims**: `filePath`, size, `mtimeNano`, midhash
live in `local_file` and are joined into the record view unsigned.
- **A schema-version sentinel** (`schema-version = "3"` in truth `meta`).
meta-core explicitly refuses a truth file holding the v1 `kv` table, and any
store with claims but no sentinel or another version.
Implementation lives in `internal/kv/kv.go` (engine),
`internal/kv/stream.go` (event streams), `internal/storage/client.go`,
`internal/storage/search.go`, `internal/storage/cid_resolution.go`,
`internal/storage/uuid.go`, and `internal/storage/schema_version.go`.
## Truth database — `/meta-core/db/meta-core.db`
## Key structure
Backed up; only primary keys (plus `principal.uid`'s implicit UNIQUE index).
Everything is one string→string table. The full layout:
| Key | Value | Notes |
| Table | Columns (PK first) | Notes |
|---|---|---|
| `file:<rid>/<field>` | field value | a record property — the **only** thing under `file:` |
| `cid:<cid>` | `<rid>` | reverse index |
| `meta-core:schema-version` | `"2"` | schema gate |
| `idx:roots/<rid>` | `""` | set of every root (was the Redis SET `file:__index__`) |
| `idx:fts/<rid>` | FTS rowid | the record's row in the FTS table |
| `idx:count/roots` | integer | number of roots — search's `total`, `/api/stats` records (v1.2.0) |
| `idx:schema/<field>\x1f<keyHint>\x1f<prim>\x1f<hint>` | integer | per-field value-type counts behind `/api/schema` (v1.2.0) |
| `idx:count/ready` | `"1"` | marks the counters as built (absent → one-time build at boot) |
| `dup:<rid>/<path>` | `""` | a duplicate path (was the SET `file:<rid>/duplicates`) |
| `udl:rec:<uid>/<cid>/<key>` | JSON `{record,version,ts,value?}` | User Data Layer entry (was a HASH) |
| `udl:idx:…<set>\x1f<member>` | `""` | UDL index sets, one key per member |
| `ev:<stream>/<ms13>-<seq10>` | JSON of the entry's fields | `meta:events` / `file:events` entries (were Redis Streams) |
| `evhwm:<stream>` | last issued ID | stream high-water mark |
| `btmagnet:*`, `searchcov:*`, `nzb:*` | — | written by other services via `/api/kv/value`; unchanged |
| `principal` | `pid` INTEGER PK, `uid` TEXT UNIQUE | `uid` = `"z" + base58btc(33-byte compressed pubkey)`. `pid` is a local surrogate: never signed, never on the wire |
| `claim` | `(cid, key, pid)` PK, `value` ANY, `ts`, `sig`, `seq` | `STRICT, WITHOUT ROWID`. `value`: TEXT = plaintext, BLOB = ciphertext envelope, NULL = retraction (tombstone, kept). `ts` (ms) is the version. `seq` = global change counter, not indexed |
| `cid_group` | `cid` PK, `rid` | the cid → rid map: the record identity layer |
| `meta` | `name` PK, `value` | `schema-version = "3"`, `last_seq` |
Keeping bookkeeping **out of `file:`** is load-bearing: the `file:` range is
exactly the records, so the field index covers only real fields and every
change under `file:` is a real field change worth publishing on
`meta:events`. The per-record `file:<rid>/__fields__` SET and the
`file:__dirty__` set of the Redis era no longer exist. Roots are split from
the field at the **first** `/`, so CID-rooted and UUID-rooted records both
parse.
`claim` is written **only** by `POST /api/claims`. A claim replaces the
stored row for its `(cid, key, pid)` only if its `ts` is strictly greater;
identical `(ts, sig)` is `unchanged`; the **same value** from the same signer
(any `ts`) is also `unchanged` and keeps the stored row with its older `ts`;
otherwise older or equal is `stale`, and a bad signature is refused. Why `STRICT` with an `ANY` column: a non-STRICT `ANY`
column gets NUMERIC affinity and turns the text `"007"` into the integer 7;
STRICT keeps the written type, which is the cipher flag.
### Roots
## Cache database — `/meta-core/db-cache/cache.db`
Never backed up (the store app lists the folder in `backup.exclude`).
Deleting it is the recovery procedure: meta-core rebuilds it from truth at
boot (no `meta:applied_seq` → full rebuild), and `local_file` refills from a
rescan.
It keeps the **v1 key/value layout** (`internal/storage/client.go`): one
`kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID` table, whose rows are
all derived — no client writes `file:` rows any more.
| `kv` rows | Holds | Written by | Read by |
|---|---|---|---|
| `file:<rid>/<field>` | the **merged view**, one row per `(rid, key)` — the only rows under `file:`. Includes the synthesised `cids/<cid> = "true"` members (from `cid_group`) and the box-local `filePath` / `sizeByte` / `mtimeNano` (from `local_file`) | apply loop | every record read; search |
| `cid:<cid>` | cid → rid (copy of truth `cid_group`) | apply loop, watcher | every cid-addressed request |
| `idx:roots/<rid>` | the set of every record | apply loop | listing, counts |
| `idx:fts/<rid>` | rowid of the record's `fts` row | apply loop | FTS maintenance |
| `idx:count/*`, `idx:schema/*` | record count, per-field schema counters | the cache engine's change hook, from `file:` deltas | search `total`, `/api/stats`, `/api/schema` |
| `ev:<stream>/<ms13>-<seq10>`, `evhwm:<stream>` | `meta:events` (every `file:` change, same transaction) and `file:events` (watcher) | cache engine; watcher | `/api/events/meta`, `/api/events/files` (SSE) |
| `meta:applied_seq`, `meta:rebuilt_at` | how far the cache has applied truth; last rebuild (older SSE cursors get `gap`) | apply loop, rebuild | boot catch-up; SSE |
| anything else | service scratch KV (`btmagnet:*`, `searchcov:*`, `nzb:*`) | other services via `/api/kv/value` (reserved prefixes refused) | same |
| Index / table | Shape | Written by | Read by |
|---|---|---|---|
| `kv_field_v` (index) | `kv(lower(field parsed from k), lower(v), v)` over `file:` rows only | — | filter search |
| `fts` | FTS5 trigram `(rid UNINDEXED, hay)`, one row per record | apply loop, when a haystack field's merged value changes | free-text search |
| `claim_by_pid` | `(pid, cid, key)` PK | apply loop | `?as=<uid>` overlay, `/api/udl/*` facade, identity retraction, `/api/udl/users/stats` |
| `local_file` | `path` PK → `midhash`, `size`, `mtime`; index `(midhash, path)` | file watcher, `POST /api/files/register` | record view (`filePath`, `sizeByte`, `mtimeNano`) |
The cache is **behind, never wrong**: after an unclean shutdown, boot finds
`meta:applied_seq < last_seq` and re-applies every truth claim with `seq >
applied_seq` (or rebuilds in full when one of them is a `cids/*` link). Write
path, apply loop and durability are detailed in
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md) §4.
## Keys
The `key` column carries the same field paths METADATA_KEYS.md defines —
what used to follow `file:<rid>/`:
```
file:<uuid>/<property/path> → string (always)
claim(cid=bagacbabaec…, key=title, uid=<tmdb plugin>) = "Inception"
claim(cid=bagacbabaec…, key=fileinfo/duration, uid=<ffmpeg plugin>) = "8878.5"
claim(cid=bagacbabaec…, key=fileinfo/streamdetails/video/0/codec, uid=<ffmpeg plugin>) = "h264"
claim(cid=bagacbabaec…, key=genres/Drama, uid=<tmdb plugin>) = "true"
claim(cid=bagacbabaec…, key=cids/bafybeigd…, uid=<full-hash plugin>) = "true"
```
UUIDs are produced by `storage.NewUUID()` (`internal/storage/uuid.go`):
UUIDv7 → 26-char Crockford Base32 (skips I/L/O/U; ULID-compatible). The
time-sortable prefix means roots minted in a given window sort together
(`k >= 'file:01JKR8' AND k < 'file:01JKR9'`), which restored some of the
debug-ability lost when midhash256 stopped being the root.
- `/` is the path separator; nested objects are flattened on write and
reconstructed on read.
- **Key-sets** put the member inside the key (`genres/<g>`, `cids/<cid>`,
`titles/eng/<name>`, `anchoredBy/tmdb:123`): one claim per member, union by
the ordinary merge, removal by a signed retraction.
- Plugins keep writing under their prefixes (`{plugin-id}/<field>`,
`{plugin-id}/<group>/<n>/<field>`), each signing with its own key. Two
sources compete on one key only when they make the same kind of statement;
a different fact (e.g. a filename guess vs a TMDB lookup) gets a different
key.
Properties use `/` as the path separator. Nested objects in source code
are flattened to flat keys on write and reconstructed on read. Examples:
## Records: `cid_group` and the hidden rid
```
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = "/files/watch/movies/Inception.2010.mkv"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/sizeByte = "15032385536"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtimeNano = "1729872000000000000"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = "8878.5"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/streamdetails/video/0/codec = "h264"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = "bafkreih..."
votes (truth, signed) cid_group (truth) cache kv rows (merged)
(A, M, title) = "The Matrix" M → R1 file:R1/title = "The Matrix"
(A, M, cids/Q) = "true" Q → R1 file:R1/cids/M = "true" (synthesised)
(B, Q, title) = "Matrix, The" file:R1/cids/Q = "true" (synthesised)
file:R1/sizeByte = "734003200"
(B, Q, sizeByte) = "734003200"
```
### Reverse-index aliases (CIDs)
- A text `cids/<other>` vote on cid X links X and `<other>` into one
group. Retracting it does **not** split the group (phase 1). In the merged
view, membership is synthesised from `cid_group` — `cids/<cid> = "true"` for
every cid of the group — and `cids/*` claims are never merged. Record R = every vote on every cid of R's group, merged per key,
each signer counted once per key.
- First-seen cid with no link → new group with a fresh rid
(`storage.NewUUID()`, UUIDv7 → 26-char Crockford Base32, ULID layout).
- Linking two groups → the **older rid survives**; the other group's cids
move to it; `/api/events/meta` emits `del` for each key of the losing rid
and `set` for the survivor. There is no rid alias table.
- `GET /api/meta/<cid>` is a `cid:<cid>` lookup in the cache, then a range
read `[file:<rid>/, file:<rid>0)` — any known cid, any hash algorithm, no
primary.
- Rids are local names: never signed, never on the swarm or gateway wire.
Local consumers (meta-sort, meta-fuse, meta-stremio) do hold them, which is
why `cid_group` lives in truth.
Every CID known for a file is registered as its own key, keyed by
the **bare CID** — no algorithm prefix. A CIDv1 already encodes its hash
function in the multicodec, so naming it again in the key would be
redundant (and would let the same digest be indexed under two spellings):
There is **no stored `canonical_cid`**. `GetMetadataDocument` ranks the
group's cids through `internal/cid/rank.go` (`cid.Better`) and returns the
winner as `canonical_cid` in the JSON response only.
```
cid:<bareCid> → <uuid>
```
## Merge
Examples:
```
cid:bafkreih5kapbjzq... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
cid:bagacbabaec7v3... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
```
The reverse index makes `GET /api/meta/<cid>` a primary-key lookup for any
registered CID, independent of which hash algorithm — there is no primary.
The record write path (`writeFieldsTx` in `internal/storage/client.go`, used
by `SetProperty`, `SetMetadataFlat` and `MergeMetadataFlat`) registers the
alias for any field whose name starts with `cids/`, in the same transaction,
so writers emit key-set members without knowing the reverse index exists.
> ⚠ The hook matches **only** the `cids/` prefix. A legacy `cid_*` or
> `midhash256` *named* field written to a root is stored verbatim and is
> **never indexed** — the record becomes unresolvable by that CID. Writers
> must collapse such fields to `cids/<cid>` members before the write. See
> `METADATA_KEYS.md` §14.13.
### Per-root sets
```
file:<uuid>/cids/<bareCid> → "true" — one key per known CID (a key-set)
dup:<uuid>/<path> → "" — one key per alternate path with same content
```
`cids` is a **key-set**: one flat `file:<uuid>/cids/<cid>` key per member,
which keeps it conflict-free to merge across peers (a single set-valued
field would not be).
The record's own address (the midhash) is just the `0x1000` member — it has
no separate field.
There is **no stored `canonical_cid`**. The canonical CID is *derived on
read*: `GetMetadataDocument` (`internal/storage/cid_resolution.go`) ranks the
key-set's members through `internal/cid/rank.go` (`cid.Better`) and returns
the winner as the `canonical_cid` field of the JSON response only. Nothing
reconciles or persists it — an earlier `reconcileCanonicalCIDLocked` did, and
was removed with the flat `cid_*` keys.
### Global index
```
idx:roots/<uuid> → "" (one key per root; was the Redis SET file:__index__)
```
`internal/storage/client.go::GetAllHashIDs` range-reads `idx:roots/` to
enumerate every file without scanning the records.
### Search structures (derived)
Two derived structures are maintained on top of `kv`, in the same
transaction as every record write. Both are rebuildable from the kv rows and
never authoritative (`internal/storage/search.go`):
- **`kv_field`** — a partial expression index
`ON kv(lower(substr(k,instr(k,'/')+1)), lower(v)) WHERE k>='file:' AND k<'file;' AND instr(k,'/')>0`.
It answers "which records have field F with a value like V" for **any**
field, including ones invented tomorrow — the index is over the key itself.
- **`fts`** — an FTS5 trigram table `fts(rid UNINDEXED, hay)` holding one row
per record: its search haystack plus its rid (`idx:fts/<rid>` points at the
row). Trigrams make every 3+ character substring indexable.
`POST /api/metadata/search` uses SQL to build a superset of candidates, then
verifies each with the unchanged Go matchers (`recordMatchesFilter`,
`matchAllTokens`), so semantics are identical to the old in-memory index.
Results still come in hashID order and are fresh immediately (no refresh
lag). This replaced the API's in-memory `searchIndex` (60 s refresh, ~30 KB
RAM per record).
### Schema-version sentinel
```
meta-core:schema-version → string (currently "2")
```
Set at first boot, checked on every subsequent boot
(`internal/storage/schema_version.go`). If the sentinel is missing but
the root index is non-empty, the gate refuses to boot (legacy data).
If the sentinel is present but doesn't match `SchemaVersion`, the gate
also refuses to boot. Alpha-stage policy: operators wipe and restart;
there is no automated migration.
## Why flat string keys?
The store keeps one flat key per leaf rather than a document or a
per-record row. The reasons are:
1. **Schema-free.** A plugin inventing a field writes a new key; nothing
declares it, and the table never needs a migration. The field index
covers it automatically.
2. **Conflict-free merging across peers.** Key-sets (`cids/<cid>`, and the
flat fields in general) merge by union of keys — no read-modify-write of
a shared value, nothing to reconcile.
3. **Selective updates without read-modify-write.** A plugin writing
`tmdb/poster` doesn't have to read the rest of the document.
4. **Compatibility with the CID alias hooks.** Field writes can trigger
alias-maintenance work in the write path without the plugin caring.
(Under Redis there was a fourth reason — field-level keyspace notifications
fed `meta:events`. That is obsolete: events are now written by the storage
engine itself, see [Event streams](#event-streams).)
The cache row `file:<rid>/<key>` holds the winner over the group's votes
(each signer's newest vote counts once; retractions and ciphertext drop out):
most distinct signers (values compared after **trim + case-fold only**, no
NFC) → oldest `ts` → byte-wise smallest. The stored value is the winner's
exact text; `nsrc` is not stored (`POST /api/merge` returns it). With
`?as=<uid>`, the viewer's own live text votes (found via `claim_by_pid`)
override at read time; they are never written to the cache. Box-local
fields: when several local paths hold the same content, `filePath` is the
lexicographically first (size and mtime from the same `local_file` row); a
signed `sizeByte` claim wins over the on-disk size. Full rules:
implementation basis §7 (`internal/storage/merge.go`).
## Reads and writes
All writes go through **one writer goroutine** with group commit: concurrent
writes share one SQLite transaction, each under its own SAVEPOINT so a
failure rolls back alone. A record write — fields + root index + CID aliases
+ FTS row + events — is atomic. (Measured ~835 records/s with 16 concurrent
writers of 40-field records.) Reads use a separate connection pool and see
the last committed state.
| Operation | Endpoint | Store access |
|---|---|---|
| write metadata | `POST /api/claims` (batch of signed claims) | truth upsert, then the apply loop updates the cache |
| removed writes | `PUT/PATCH/DELETE /meta/*`, `POST /meta/{h}/_add/{k}`, `PUT/DELETE /api/metadata/{id}`, `PUT /api/metadata/{id}/property`, `POST /api/metadata/batch` | `410 Gone`, pointing at `POST /api/claims` |
| read a record | `GET /api/meta/{cid}`, `GET /api/metadata/{rid}`, `GET /file/{cid}`, `GET /meta/{h}` | `cid:` lookup → `file:<rid>/*` rows, optional `?as=` |
| list / search | `GET /api/metadata/hash-ids`, `/list`, `POST /api/metadata/search`, `GET /api/files/tuples`, `/api/schema`, `/api/stats` | `file:` rows, `kv_field_v`, `fts`, `idx:count/*` / `idx:schema/*` |
| raw votes | `GET /api/meta/{cid}/claims`, `GET /api/metadata/{rid}/claims` (`?key=`, `?uid=`) | `{rid, cids, claims}`: every live signed claim of the group, all signers, tombstones excluded; 404 for an unknown cid / rid |
| merge without storing | `POST /api/merge` `{claims, as?}` → `{fields, nsrc, rejected}` | none — verify + merge in memory (plus `as`'s votes this core holds) |
| watcher facts | the watcher, `POST /api/files/register` | `local_file` + `cid_group` |
| service scratch | `PUT/GET/DELETE /api/kv/value` | non-reserved cache `kv` rows |
| wipe | `POST /api/metadata/clear` | both databases |
The Go API in `internal/storage/client.go`:
| Method | Behaviour |
|---|---|
| `GetMetadataFlat(uuid)` | Range-read `[file:<uuid>/, file:<uuid>0)`, return `map[string]string`. |
| `SetMetadataFlat(uuid, m)` | Put each field; add `idx:roots/<uuid>`; auto-register an alias for every `cids/<cid>` member; refresh the FTS row. One transaction. |
| `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. |
| `GetProperty(uuid, field)` | Get `file:<uuid>/<field>`. |
| `SetProperty(uuid, field, value)` | Put `file:<uuid>/<field>`; auto-register alias if the field is a CID. |
| `DeleteRoot(uuid)` | Delete the `file:<uuid>/` range, its duplicates, search row and index membership; unmap every reverse-index entry. |
| `Mint(filePath, size, mtimeNano)` | `NewUUID()` + write `filePath/sizeByte/mtimeNano` + add to index. |
| `AddAlias(uuid, "<bareCid>")` | Set `cid:<bareCid>` → `<uuid>` and the key-set member `file:<uuid>/cids/<bareCid>` = `"true"`. Nothing to reconcile — canonical is derived on read. |
| `ResolveRoot("<bareCid>")` | Get `cid:<bareCid>` → uuid (returns the input unchanged if it is already a UUID root). |
| `GetMetadataDocument(uuid)` | Flat fields + `cids` hoisted to an array + `canonical_cid` **derived** by `cid.Better` rank + `duplicates`. Backs `GET /api/meta/{cid}`. |
Truth writes go through **one writer goroutine** with group commit; reads use
a separate pool and see committed state (WAL). Read paths and shapes are
unchanged from v1.x — they are now served from the derived `file:` rows.
## Reconstruction (flat → nested)
When the HTTP API returns a document, it reconstructs the flattened
storage back into a nested JSON object:
The HTTP API reconstructs the merged flat keys into a nested JSON object:
```
flat nested
@@ -256,134 +205,122 @@ fileinfo/streamdetails/ streamdetails: { video:
streams/0/language = "eng" streams: [ { language: "eng" }, ... ]
```
Rules:
- Numeric path segments become array indices on read.
- All values are strings on the wire; type interpretation is the caller's
job (documented per field in `METADATA_KEYS.md`).
- Box-local fields from `local_file` are joined in unsigned; they never
appear in `GET /api/meta/{cid}/claims` or on the swarm.
- `/` is the path separator.
- Numeric path segments (`0`, `1`, ...) become array indices on read.
- All values are strings on the wire; type interpretation is the
caller's job (and is documented per-field in `METADATA_KEYS.md`).
- `null`/`undefined` round-trip as empty strings.
## Search structures
## Plugin contribution flow
Both live in the cache and are maintained in the same transaction as the
merged-row change, so results are fresh as soon as a change is applied:
Plugins don't talk to the store directly. They write metadata via meta-core's HTTP
API (`PUT /meta/{hash}/{key...}` or `PATCH /meta/{hash}`). Each plugin
writes under a prefix:
- **`kv_field_v`** — an expression index over the field name parsed out of
the key, `lower(v)` and `v`, restricted to `file:` rows. Answers
"which records have field F with a value like V" for any field, including
ones invented tomorrow; covering for the substring test.
- **`fts`** — FTS5 trigram `(rid UNINDEXED, hay)`, one row per record: the
haystack of its searchable merged fields plus the rid. Every 3+ character substring is
indexable.
```
{plugin-id}/<field> single value
{plugin-id}/<group>/<field> grouped
{plugin-id}/<group>/<n>/<field> indexed (array)
```
`POST /api/metadata/search` builds a candidate superset in SQL and verifies
each hydrated merged record with the Go matchers (`recordMatchesFilter`,
`matchAllTokens`); results come in rid order. Plan, rejected shapes and the
measurement status are in
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md) §8.
See [`METADATA_KEYS.md`](../../../METADATA_KEYS.md) for the full,
authoritative list of fields each plugin writes (content identification,
file stats, video classification, anime detection, subtitle references,
Jellyfin metadata, FFmpeg stream details, etc.) and the value formats
(`int-string`, `lang3`, `path-relfiles`, `csv-set`, ...). This doc
deliberately does not duplicate that registry — it would drift.
## Schema-version sentinel
Truth `meta` row `schema-version` = `"3"`, set at first boot and checked on
every boot (`internal/storage/schema_version.go`), after both files are
opened and before the cache catch-up. A truth file holding the v1 `kv` table
(v1.x, `meta-core:schema-version = "2"`) is **explicitly refused**, naming the
migration tool; claims without a sentinel, or another version, are refused
too; a fresh store is stamped. Alpha policy: no migration code in the
service binary; operators wipe and restart. watch.nsl.sh is converted once by
the separate one-off tool `packages/meta-core/tools/claims-migrate` (not in
the image; rids keep their UUIDs; legacy fields re-signed by a throwaway key;
UDL cells re-signed by their account keys — implementation basis §11).
## Event streams
Two event streams are kept inside the store, as rows
`ev:<stream>/<ms13>-<seq10>` (zero-padded so key order is ID order) plus a
high-water mark `evhwm:<stream>` (`internal/kv/stream.go`):
| Stream | Producer | Contents |
|---|---|---|
| `file:events` | `internal/watcher` dispatcher | `add` / `change` / `delete` / `rename` / `reset` events for files on the `/files` volume. |
| `meta:events` | the storage engine (`internal/kv`) | `{type: set\|del, key, ts}` for every Put/Delete of a `file:` key, appended **in the same transaction** as the change. |
| `/api/events/meta` | the cache engine, as `ev:meta:events/…` rows of the cache `kv` table | `{type: set\|del, key, ts}` for every change of a **merged** value, in the same cache transaction as the change |
| `/api/events/files` | `internal/watcher` dispatcher (`ev:file:events/…` rows) | `add` / `change` / `delete` / `rename` / `reset` for files on `/files` (unchanged) |
`set` is emitted only for a **real** change: a Put of the value a key
already holds is a no-op — no row write, no index churn, no event (v1.2.0;
Redis published `set` even then, and with gateway hits re-persisted on
every repeated query most writes were exactly that). `del` is emitted only
when the key existed. Retention is ~100k entries, trimmed every 10k appends.
There is **no boot-time republish** (v1.2.0). A consumer that wants the whole
current state asks `/api/events/meta` for `Last-Event-ID: 0` / `0-0` and gets
a **snapshot**: one `set` per existing record key, streamed from the kv
range, then the live tail from the stream head captured before the snapshot
(only the last snapshot event carries an `id:`, so a consumer that drops
mid-snapshot gets a fresh one). The old republish wrote every field into a
stream that retains only ~100k entries, so a consumer replaying it later
never saw most records — and it cost ~6 minutes on every boot.
The Redis-era path — keyspace notifications drained by `MetaPublisher`
through a PSUBSCRIBE / consumer-group hop — is gone (2026-10).
External consumers do **not** read these streams directly. They subscribe
via SSE:
- `GET /api/events/files` — mirror of `file:events`
- `GET /api/events/meta` — mirror of `meta:events`
The SSE wire contract uses `Last-Event-ID` for resume. IDs keep the Redis
`<ms>-<seq>` form so cursors persisted by meta-sort / meta-share /
meta-stremio across the switch still compare. Heartbeats are SSE comments
(`:keep-alive\n\n`) every 30s of silence. If retention has trimmed past
a client's cursor, the handler emits one `event: gap` payload before
resuming. See `internal/api/sse_events.go` and
[`api-mediated-access.md`](api-mediated-access.md) for the full
contract.
- A vote that does not change the merged value emits nothing.
- A group merge emits `del` for every key of the losing rid and `set` for the
survivor's keys.
- IDs keep the `<ms>-<seq>` form and stay time-based, so persisted cursors
keep comparing, including across a cache rebuild.
- `Last-Event-ID: 0` / `0-0` returns a snapshot (one `set` per merged key)
then the live tail; only the last snapshot event carries an `id:`.
- A trimmed cursor gets one `event: gap`. A cache rebuild emits no per-key
events; consumers get `gap` and take a snapshot.
- Heartbeats are SSE comments (`:keep-alive\n\n`) every 30 s of silence.
Contract: `internal/api/sse_events.go`, [`api-mediated-access.md`](api-mediated-access.md).
## Debugging
The database path is `/meta-core/db/meta-core.db` in the image default and
on the store app; dev stacks use `/meta-core-db/meta-core.db` (a named
volume — the `/meta-core` bind is 9p on Windows dev boxes, where SQLite WAL
is unsafe). Always open it **read-only**; writes must go through the API.
Paths: truth `/meta-core/db/meta-core.db`, cache `/meta-core/db-cache/cache.db`
(image default and store app); dev stacks keep both on named volumes (the
`/meta-core` bind is 9p on Windows dev boxes, where SQLite WAL is unsafe).
Always open **read-only**; writes go through `POST /api/claims`.
```bash
DB=/meta-core/db/meta-core.db # dev stack: /meta-core-db/meta-core.db
Q() { docker exec metacore-app sqlite3 -readonly "$DB" "$1"; }
T=/meta-core/db/meta-core.db; C=/meta-core/db-cache/cache.db # dev: the named-volume paths
QT() { docker exec metacore-app sqlite3 -readonly "$T" "$1"; }
QC() { docker exec metacore-app sqlite3 -readonly "$C" "$1"; }
# Roots
Q "SELECT count(*) FROM kv WHERE k >= 'idx:roots/' AND k < 'idx:roots0'"
Q "SELECT substr(k,11) FROM kv WHERE k >= 'idx:roots/' AND k < 'idx:roots0' LIMIT 10"
# Full document for one root
Q "SELECT k,v FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK0'"
# One property
Q "SELECT v FROM kv WHERE k = 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration'"
# Reverse-index lookup (bare CID — no algorithm prefix)
Q "SELECT v FROM kv WHERE k = 'cid:bafkreih...'" # → uuid
# The cids key-set
Q "SELECT k FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids0'"
# canonical_cid is NOT stored. Derive it via the API instead:
curl -k https://metacore-dev.localhost:8083/api/meta/<cid> | jq '.canonical_cid, .cids'
# Schema-version sentinel
Q "SELECT v FROM kv WHERE k = 'meta-core:schema-version'"
# Streams (server-side; external consumers should use SSE)
Q "SELECT k,v FROM kv WHERE k >= 'ev:meta:events/' AND k < 'ev:meta:events0' ORDER BY k DESC LIMIT 10"
QT "SELECT rid FROM cid_group WHERE cid = 'bafkreih...'" # cid → rid
QT "SELECT cid FROM cid_group WHERE rid = '01JKR8XW5T4QMV7AHNJ2DEFGHK'" # the group (full scan: no rid index)
QC "SELECT k, v FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK0'" # merged record
QT "SELECT c.key, typeof(c.value), c.value, p.uid, c.ts, c.seq
FROM claim c JOIN principal p USING(pid) WHERE c.cid = 'bafkreih...'" # every vote on one cid
QT "SELECT name, value FROM meta"; QC "SELECT k, v FROM kv WHERE k LIKE 'meta:%'" # last_seq vs applied_seq
QC "SELECT path, midhash FROM local_file WHERE path LIKE '/files/watch/%' LIMIT 10"
QC "SELECT k, v FROM kv WHERE k >= 'ev:meta:events/' AND k < 'ev:meta:events0' ORDER BY k DESC LIMIT 10"
# HTTP equivalents — preferred
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>' # CID → document
curl -k 'https://metacore-dev.localhost:8083/meta' # list root IDs
curl -k 'https://metacore-dev.localhost:8083/meta/<uuid>' # document by UUID
curl -k 'https://metacore-dev.localhost:8083/api/events/meta' # live SSE
curl -k 'https://metacore-dev.localhost:8083/api/kv/info' # KV stats
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>' # merged document (+ canonical_cid)
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>/claims' # signed votes
curl -k 'https://metacore-dev.localhost:8083/api/events/meta' # live SSE
```
## Key implementation files
| Component | Path |
|---|---|
| Storage engine (SQLite kv table, writer, group commit) | `internal/kv/kv.go` |
| Event streams as kv rows | `internal/kv/stream.go` |
| Flat-key record ops | `internal/storage/client.go` |
| Search (field index + FTS) | `internal/storage/search.go` |
| Single-writer, group-commit SQLite engine | `internal/kv/` |
| Truth, apply loop, merge, local files, counters | `internal/storage/{truth,apply,merge,localfile,client}.go` |
| Search (`kv_field_v` + FTS) | `internal/storage/search.go` |
| MMUR2 claim encoding + verification | `internal/claim/` (secp256k1 in `internal/identity/`) |
| UUIDv7 minting | `internal/storage/uuid.go` |
| CID reverse index + aliases + canonical-CID derivation | `internal/storage/cid_resolution.go` |
| Canonical-CID rank | `internal/cid/rank.go` |
| Schema-version gate | `internal/storage/schema_version.go` |
| `meta:events` snapshot for `0`/`0-0` | `internal/api/sse_events.go` (`streamSnapshot`) |
| Derived counters (root count, per-field schema) | `internal/storage/client.go` (`onChange`), `internal/schema/indexer.go` |
| Watcher → `file:events` dispatcher | `internal/watcher/dispatcher.go` |
| SSE event mirror | `internal/api/sse_events.go` |
| SSE event mirror + snapshot | `internal/api/sse_events.go` |
| Watcher → `local_file`, `/api/events/files` | `internal/watcher/` |
| Field semantics (authoritative registry) | [`/METADATA_KEYS.md`](../../../METADATA_KEYS.md) |
## Previous layout (v1.1–v1.2 `kv` table)
Kept so old notes and scripts can be read. In v2.0.0 the **truth** database
has no `kv` table at all; the **cache** keeps the same key layout for derived
rows (see above), but nothing in it is written by clients.
| Key | Value | Replaced by |
|---|---|---|
| `file:<rid>/<field>` | one value per field, last writer wins | `claim` (one row per signer) → derived cache `file:` rows |
| `cid:<cid>` | `<rid>` | `cid_group` (truth), copied to cache `cid:` rows |
| `idx:roots/<rid>`, `idx:count/*`, `idx:schema/*`, `idx:fts/<rid>` | root set, counters, FTS rowid | same keys, derived, in the cache |
| `dup:<rid>/<path>` | duplicate path | `local_file` rows |
| `udl:rec:<uid>/<cid>/<key>`, `udl:idx:*` | User Data Layer cells (MMUR1, `version`) | `claim` (MMUR2, `ts`) + `claim_by_pid` |
| `ev:<stream>/<ms13>-<seq10>`, `evhwm:<stream>` | stream rows | same keys, in the cache |
| `btmagnet:*`, `searchcov:*`, `nzb:*` | service scratch | same keys, in the cache |
| `meta-core:schema-version` = `"2"` | gate | truth `meta` `schema-version` = `"3"` |
The flat-key idea survived — a field is still one string key, a key-set
member is still inside the key — but the record moved from "one value per
field" to "one value per signer per field".
+202 -295
View File
@@ -2,368 +2,275 @@
## Status
**Status:** Implemented (2026 alpha clean-wipe gate landed). Kept for design rationale.
**Status:** Design record. Implemented in 2026 alpha (clean-wipe gate);
**reframed by meta-core v2.0.0 (claims model)** — see
[`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
§4. This doc keeps the *why* behind UUID rids and CID symmetry; it is not
the schema reference. For what is stored, see
[metadata-storage-structure.md](./metadata-storage-structure.md) and
`METADATA_KEYS.md` §2/§14.13.
> ⚠ **Two parts of this proposal were superseded before shipping.** This doc
> is the design record; it is *not* the schema reference. For what is actually
> stored, see `METADATA_KEYS.md` §2/§14.13 and
> [metadata-storage-structure.md](./metadata-storage-structure.md).
>
> 1. **`canonical_cid` is not a stored key.** The proposed reconciler
> (`reconcileCanonicalCIDLocked`) was never kept — a stored scalar isn't
> conflict-free to merge across peers. The canonical CID is **derived on
> read** by ranking the `cids` members (`internal/cid/rank.go` →
> `cid.Better`), and appears only in the `GET /api/meta/{cid}` response.
> 2. **`cids` is a key-set, not a Redis SET, and holds bare CIDs, not
> `<algo>:<value>` tokens.** One flat STRING per member:
> `file:<uuid>/cids/<bareCid>` = `"true"`. The reverse index is likewise
> `cid:<bareCid>` → uuid. A CIDv1's multicodec already names its hash
> function, so the `<algo>:` prefix was redundant.
>
> 3. **The store is no longer Redis (2026-10).** The same keys now live in
> one string→string table of an embedded SQLite file; Redis SETs became
> one key per member (`file:__index__` → `idx:roots/<uuid>`,
> `file:<uuid>/duplicates` → `dup:<uuid>/<path>`). Redis types named
> below are the shape at the time.
>
> Text below that references either shape describes the *proposal*, not the
> code. The rank ordering and everything else did ship as written.
What still holds, unchanged since the original proposal:
## Motivation
- **No CID is privileged.** Any known CID resolves to its record; all hash
types are symmetric.
- **Rids are UUIDv7**, 26-char Crockford Base32, **minted locally** with no
central allocator.
- **`canonical_cid` is derived on read** by rank (`internal/cid/rank.go` →
`cid.Better`), never stored.
- **`cids` is a key-set of bare CIDs** (`cids/<bareCid>` = `"true"`), no
`<algo>:` prefix — a CIDv1's multicodec already names the hash function.
Today, every metadata entry in Redis is keyed by a content hash, specifically
midhash256:
What changed in v2.0.0:
> 1. **The rid is a hidden local name for a group of cids.** Metadata is no
> longer stored under `file:<uuid>/<field>`. Every value is a signed claim
> about a **cid** (`claim(uid, cid, key) → value`); `cid_group(cid → rid)`
> says which cids form one record. The rid is **derived from cid groups,
> never signed and never on the wire** (swarm, gateway); each box mints its
> own.
> 2. **The `cid:<cid>` reverse index is gone**, replaced by the `cid_group`
> table (still a primary-key lookup, so still O(1)-class).
> 3. **meta-core no longer writes `cids/*` members.** They are signed votes
> from the writers that learn a cid (e.g. the full-hash plugin); a non-null
> `cids/<other>` vote links two groups. Only the watcher's midhash enters
> `cid_group` directly.
> 4. **`duplicates` / `dup:<uuid>/<path>` are gone.** Box-local paths live in
> the cache's `local_file(path → midhash, size, mtime)`; several paths with
> the same midhash are the duplicates.
>
> Earlier superseded points, kept for the record: the proposed
> `reconcileCanonicalCIDLocked` never shipped; `cids` never was a Redis SET
> of `<algo>:<value>` tokens; Redis itself went in v1.1.0 (2026-10). Text
> below marked *(proposal)* or *(v1.x)* describes those shapes, not v2.0.0.
## Motivation (history)
Before UUID rids, every metadata entry in Redis was keyed by a content hash,
specifically midhash256:
```
file:midhash256:abc.../filePath
file:midhash256:abc.../title
file:midhash256:abc.../fileinfo/duration
file:midhash256:abc.../tmdb/poster
...
```
Other digests (sha256, IPFS CIDs, BitTorrent infohashes) live as fields
*inside* that entry:
Other digests (sha256, IPFS CIDs, BitTorrent infohashes) lived as fields
*inside* that entry (`cid_sha256`, `cid_ipfs`). Two problems:
```
file:midhash256:abc.../cid_sha256 = sha256:def...
file:midhash256:abc.../cid_ipfs = bafy...
```
1. **midhash256 was privileged by accident.** It *was* the key, while every
other CID was a secondary field. Hash-algorithm changes, or new digest
types added by plugins, forced rewrites of every root. The privilege
itself was the issue, not which hash held it.
2. **CID lookup was O(n).** "Given any CID, return the metadata" scanned
every entry (`storage/client.go::LookupPathByCID`). Unacceptable for a
public API, especially when peers query by any CID variant.
Two problems with this:
1. **midhash256 is privileged by accident.** It IS the key, while every other
CID is a secondary field. There's nothing intrinsically special about
midhash256 — it's just the first hash computed. Hash-algorithm changes, or
new digest types added by plugins, force rewrites of every root. The
"promotion" workarounds people reach for (re-keying entries when a stronger
hash arrives) solve the wrong problem; the privilege itself is the issue.
2. **CID lookup is O(n).** Resolving "given any CID, return that file's
metadata" currently scans every indexed entry (see
`storage/client.go::LookupPathByCID`, which acknowledges this in a
comment). Acceptable for admin tooling, unacceptable for a public API
surface — especially when peers on meta-share may query by any CID variant.
The product requirement is clear: **any CID a client knows about must resolve
to that file's metadata in O(1).** IPFS-compatible CIDs are the externally
meaningful identity for content retrieval; the internal storage layout should
not bias toward any one digest type.
The requirement: **any CID a client knows must resolve to that content's
metadata in O(1)**, and the storage layout must not bias toward any digest
type. The claims model adds a second one: **what is signed must name
something every peer agrees on** — a rid minted at random on one box does
not qualify, a cid does.
## Goal
A single externally-meaningful contract:
```
GET /api/meta/<cid> → full metadata document for that file
GET /api/meta/<cid> → full (merged) metadata document for that content
```
Where `<cid>` may be any supported type (midhash256, sha256, IPFS, btih,
sha3, future additions). All CID types are treated symmetrically. No CID type
is "the key."
`<cid>` may be any supported type (midhash, sha256, IPFS, btih, sha3, future
additions), all treated symmetrically.
## Design
## Design (v2.0.0)
### Two-tier key structure
The root key becomes an opaque internal UUID; CIDs are aliases that point to
it.
### Votes name cids; a rid names a group
```
file:<uuid>/<property...> ← canonical storage (UUIDv7, never disclosed externally)
cid:<cid> → <uuid> ← reverse index (one Redis key per known CID)
votes (signed, travel) derived (local, persistent: truth cid_group)
(A, M, title) = "The Matrix" M → R1
(A, M, cids/Q) = "true" ← alias vote Q → R1
(B, Q, title) = "Matrix, The"
(B, Q, sizeByte)= "734003200" record R1 = every vote on M or Q, merged per key
```
The metadata keyspace under `file:<uuid>/` remains open and flat, exactly as
today — anything that wrote `file:<midhash>/fileinfo/duration` now writes
`file:<uuid>/fileinfo/duration`. Only the root identifier changes.
- **First-seen cid, no link** → new group, fresh UUIDv7 rid.
- **`cids/<other>` vote on X** → X and `<other>` join one group.
- **Link between two existing groups** → the group with the **older rid
survives**; the other's cids move to it; the losing rid gets a `del` event
for each of its keys, the survivor `set` events. No `rid_alias`: consumers
already handle deletes, and cids re-resolve.
- **Reading record R** = every vote on every cid of R's group, merged per key
(one signer counts once per key), precomputed into the cache's
`file:<rid>/<field>` rows (the v1 key layout, now derived).
- **Retracting a `cids/<other>` link does not split the group** (phase 1):
the tombstone drops out of the merge, `cid_group` keeps both cids under one
rid. In the record view, membership is synthesised from `cid_group`
(`cids/<cid> = "true"` for every cid of the group).
**Example layout:**
Why keep a rid at all, if votes never mention it: local consumers (meta-sort,
meta-fuse, meta-stremio) and the merged view need one stable name per record,
and a group's cid set grows over time. Why `cid_group` lives in **truth**
although it could be recomputed from `cids/*` votes: the rid names are minted
at random and consumers hold them, so a recomputation would rename every
record.
```
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = /watch/Inception.mkv
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/size = 4500000000
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtime = 1729872000000000000
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/bagacbabaec… = "true" ← midhash (record address)
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/bafybeigd… = "true" ← sha2-256
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/duplicates = SET{/watch/dup1.mkv, /watch/dup2.mkv}
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = 7200.5
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = bafy...
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/subtitle/eng = ...
### Why UUIDv7 (still valid)
cid:bagacbabaec… → 01JKR8XW5T4QMV7AHNJ2DEFGHK
cid:bafybeigd… → 01JKR8XW5T4QMV7AHNJ2DEFGHK
```
- **128 bits**: collision-free under distributed minting; no allocator. Each
box minting its own rids for the same content is now the normal case, not a
corner case — which is exactly why rids never travel.
- **Time-sortable prefix**: rids minted in a window sort together
(`k >= 'file:01JKR8' AND k < 'file:01JKR9'` on the cache's merged rows),
and "older rid
survives" on a group merge is a plain string comparison.
- **Monotonic-ish**: adjacent rids cluster in the cache's `file:<rid>/…`
key range.
*(As shipped — the proposal originally wrote `cids` as a Redis SET of
`<algo>:<value>` tokens plus a `canonical_cid` scalar. See the Status note.)*
Encoded as 26-char Crockford Base32 (ULID-compatible) for compactness and
case-insensitive matching. Migrated records kept their v1.x UUIDs as rids.
### UUID format: UUIDv7
### The `cids` key-set
UUIDv7 chosen over UUIDv4 or 64-bit random:
`cids/<bareCid>` = `"true"`, one claim per member, by whichever signer knows
the link. A key-set merges conflict-free: a member is present while at least
one vote supports it, and removal is a signed retraction. That is the reason
the proposal's Redis SET was reversed before shipping, and it is what lets
the claims model treat `cids` with no special merge code.
- **128 bits**: collision-free under distributed minting; no central allocator.
- **Time-sortable prefix**: `KEYS file:01JKR8*` retrieves entries minted in a
given window. Restores some of the debuggability lost by going opaque.
- **Monotonic-ish**: adjacent UUIDs cluster on disk; modestly better SCAN
locality.
Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are **not**
claims — meta-core cannot sign and the disk can always be rescanned. The
watcher records them in `local_file` and puts the midhash into `cid_group`;
the record view joins them in unsigned. When several local paths hold the
same content, `filePath` is the lexicographically first one.
Encoded as a 26-character Crockford Base32 string (ULID-compatible) for
compactness and case-insensitive matching.
### Canonical CID selection (unchanged)
### New root fields owned by meta-core
The metadata schema is open — any writer can put any key under
`file:<uuid>/`. This proposal does not change that. It adds three keys whose
sole writer is meta-core itself:
| Key | Type (as shipped) | Purpose |
|-----|------|---------|
| `cids/<bareCid>` | STRING `"true"` — a *key-set*, one key per member | All CIDs known for this file, midhash included. Mirror of the `cid:*` reverse index. Source of truth for delete-time GC. **Proposed as a Redis SET of `<algo>:<value>` tokens; shipped as a key-set of bare CIDs** — a key-set merges conflict-free across peers and the multicodec already names the algorithm. |
| `duplicates` | Redis SET at the time; since 2026-10 one `dup:<uuid>/<path>` key per path | Additional file paths whose content matches this entry. One UUID = one set of metadata; multiple physical files may share that content. Consumed by meta-dup. |
| ~~`canonical_cid`~~ | *(not stored)* | **Dropped.** Was to hold the externally-presented CID. Now derived on read by ranking the `cids` members; surfaced only on `GET /api/meta/{cid}`. |
Existing meta-core-owned fields (`filePath`, `size`, `mtime`, `midhash256`)
keep their names and writers. Plugin-written fields (`tmdb/*`, `fileinfo/*`,
`subtitle/<lang>`, …) are unaffected.
~~Redis SET is a departure from the all-strings convention in the current
storage doc, but `file:__index__` is already a SET — so the precedent exists
for using sets where they're the right structure.~~ **Reversed for `cids`.**
The all-strings convention won: `cids` ships as flat `cids/<cid>` STRING keys
so that two peers merging the same record can never disagree about set
membership. `duplicates` remained a Redis SET (it is leader-local and never
merged across peers) until the 2026-10 SQLite switch, which flattened it to
`dup:<uuid>/<path>` keys.
### Canonical CID selection
The canonical CID is a label, not a key — so nothing external breaks when it
changes. **As shipped it is not written at all**: it is computed on every read
from the `cids` members, which makes the "update" cost zero and removes the
reconciler entirely.
Selection rule, highest-wins (this part shipped as designed —
`internal/cid/rank.go`):
The canonical CID is a label, not a key, so nothing external breaks when it
changes. It is computed on every read from the group's cids:
1. IPFS CID (any variant)
2. SHA-256 / SHA-3 family
3. BitTorrent infohash (v2 preferred over v1)
4. midhash256
Ranked by **utility for external retrieval**, not by cryptographic strength.
Ranked by **utility for external retrieval**, not cryptographic strength:
midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are
externally meaningful for content addressing. btih ranks above midhash because
it enables swarm interop even if it's weaker as a digest.
externally meaningful; btih ranks above midhash because it enables swarm
interop. The full ladder (including the custom locator codecs) lives in
`internal/cid/rank.go`, mirrored in meta-search / meta-share
(`scripts/check-cid-vectors.sh`).
~~Reconciler runs on the leader, recomputes `canonical_cid` whenever
`AddAlias` registers a new CID for an entry.~~ **Not shipped** — there is no
reconciler. `GetMetadataDocument` folds the members through `cid.Better` on
each read.
## Lifecycles (v2.0.0)
## Internal operations
The meta-core storage interface adds five operations. None are exposed
externally — they're internal to meta-core. External callers see only the
CID-based API.
```go
// Mint creates a new root with no aliases. Called when the watcher
// computes a midhash for a previously-unseen file. Returns the new UUID.
Mint(filePath string, size int64, mtime int64) (uuid string, err error)
// AddAlias registers a CID as resolving to this UUID. Updates both the
// cid:<cid> reverse index and the file:<uuid>/cids/<cid> key-set member.
// (As shipped: no reconciliation step — canonical is derived on read.)
AddAlias(uuid string, cid string) error
// GetByCID resolves any known CID to its UUID. Returns ("", nil) if unknown.
GetByCID(cid string) (uuid string, err error)
// MergeIfAliasExists is called when the watcher mints a fresh UUID for a
// file path but, on hashing, discovers the resulting CID already belongs
// to a different UUID. Merges the new (mostly-empty) UUID into the
// existing one, transferring filePath into the duplicates set, and
// returns the canonical UUID.
MergeIfAliasExists(newUUID, cid string) (canonicalUUID string, merged bool, err error)
// DeleteRoot removes the entire entry, including every cid:* alias and
// every file:<uuid>/* key. Used for file deletion events.
DeleteRoot(uuid string) error
```
### Lifecycle: file arrival
### File arrival
```
1. Watcher sees /watch/foo.mkv created.
2. Watcher computes midhash256 → "midhash256:abc...".
3. Watcher calls GetByCID("midhash256:abc...").
→ uuid_X (existing entry) or "" (new file).
4a. If existing (uuid_X):
- Add "/watch/foo.mkv" to file:<uuid_X>/duplicates.
- No new root created.
- meta-dup is notified via the existing keyspace event channel.
4b. If new:
- Mint("/watch/foo.mkv", size, mtime) → uuid_Y.
- AddAlias(uuid_Y, "midhash256:abc...").
- Plugins now process the file, referenced by midhash256:abc...
(UUID is never disclosed to them.)
5. Later, the full-hash plugin computes sha256.
- Plugin reports back: "for CID midhash256:abc..., sha256 is sha256:def...".
- meta-core resolves midhash256:abc → uuid_Y via GetByCID.
- meta-core calls AddAlias(uuid_Y, "sha256:def...").
- Nothing else to do: the next read of this entry derives a canonical CID
of sha2-256, because it outranks the midhash.
1. Watcher sees /files/watch/foo.mkv, computes its midhash M.
2. local_file: /files/watch/foo.mkv → (M, size, mtime).
3. cid_group: M already grouped → nothing; otherwise M → fresh rid.
4. Plugins process the file by cid M (the rid is never disclosed to them)
and sign claims about M with their own keys.
5. The full-hash plugin computes sha256 S and signs (plugin, M, cids/S) = "true".
→ S joins M's group (or the two groups merge, older rid surviving).
→ The next read derives canonical_cid = S, because it outranks the midhash.
```
### Lifecycle: file deletion
A second path with the same midhash is just another `local_file` row: same
cid, same group, same record; the record's `filePath` is the
lexicographically first of the paths.
```
1. Watcher sees /watch/foo.mkv deleted.
2. Resolve which entry owns this path:
a. If /watch/foo.mkv is in some entry's duplicates set:
- Remove from set. Done.
b. If /watch/foo.mkv is the filePath of some entry:
- If duplicates is non-empty: promote one duplicate to filePath.
- If duplicates is empty: DeleteRoot(uuid).
- Iterates the cids set, deletes each cid:<cid> reverse-index key.
- Deletes every file:<uuid>/* key.
- Removes uuid from file:__index__.
```
### File deletion
The watcher removes the path's `local_file` row; the record view loses that
`filePath`. Claims are signed truth from their writers and are not deleted
by meta-core (the store is permanent and additive,
`docs/project-architecture/metacore-as-permanent-store.md`).
*(v1.x)* Deletion promoted a duplicate to `filePath`, or called `DeleteRoot`,
which removed every `cid:*` alias and every `file:<uuid>/*` key.
## Public API
```
GET /api/meta/<cid>
→ 200 { "filePath": "...", "canonical_cid": "...",
"cids": [...], "title": "...", "tmdb": {...}, ... }
→ 404 if no such CID
GET /api/file/<cid> (already exists — implementation now O(1))
→ streams file content
GET /api/meta/<cid> → 200 merged document { "filePath", "canonical_cid", "cids": [...], ... }
(optional ?as=<uid>: the viewer's own votes win)
→ 404 if no such cid
GET /api/meta/<cid>/claims → {rid, cids, claims}: every live signed claim of the cid's
group, all signers (?key=, ?uid=); 404 if no such cid
GET /api/metadata/<rid>/claims → the same, addressed by rid (local consumers)
```
The UUID is **not** included in the response. Clients address everything by
CID; meta-core's choice of internal identifier remains a private
implementation detail and may change.
The rid is not part of the cid-addressed contract and never appears in a
claim. It is visible only to local consumers that list or address records by
rid (`/api/metadata/{rid}`, `/api/metadata/{rid}/claims`, `hash-ids`, SSE event
keys; the claims routes also echo it as `rid`), and may change on a
group merge (delete event, then re-resolve by cid).
Auth: `/api/meta/<cid>` follows the same pattern as `/api/file/<cid>` and
bypasses the auth perimeter. It's the metadata twin of the content endpoint,
and meta-share calls it from federated search responses.
Auth: `/api/meta/<cid>` bypasses the perimeter like `/api/file/<cid>` — it is
the metadata twin of the content endpoint.
## Cross-service impact
## Cross-service impact (v2.0.0)
This is a meta-core schema change. Other services interact only through CIDs,
so the impact is contained but real.
- **Writers** (plugins, self-publishing feeders, gateway core, meta-share,
meta-search): address claims by cid only and sign them; they never resolve
a rid.
- **meta-fuse, meta-stremio**: unchanged — merged reads and SSE keep their
shape; a group merge reaches them as ordinary `del` + `set` events.
- **meta-search / meta-share**: announce the derived canonical cid (each
mirrors the Go rank) and relay raw claims; every cid of the group travels as
`cids/*` claims, so a peer querying by any digest gets a hit.
- **meta-sort**: writers must resolve plugin callbacks to a UUID before
writing. The existing `LeaderClient` gains a `getByCID` cache.
- **meta-fuse**: VFS rebuild already iterates `file:__index__`; only the
ID format changes.
- **meta-stremio**: queries by CID; needs no logic change beyond its storage
client.
- **meta-dup**: gains a cleaner data source — duplicates are now explicit in
the `duplicates` set, not implicit in colliding writes.
- **meta-search / meta-share**: announce the *derived* canonical CID rather
than the midhash — each mirrors the Go rank in `ingest::canonical_cid_from_metadata`
rather than reading a stored field. Federated search responses include all
CIDs from the `cids` key-set so any peer querying by any digest gets a hit.
(Written before the 2026-06 discovery/transport split; discovery is now
meta-search's job.)
- **Plugins**: no change. They speak CIDs only.
## Migration (history)
## Migration
> **The v1.0 sweep below was never run.** The alpha shipped behind a
> clean-wipe gate. The v1.x → v2.0.0 conversion of watch.nsl.sh is a
> one-off tool outside the service image, `packages/meta-core/tools/claims-migrate`
> (canonical cid by rank per record; `cid:` aliases → `cid_group`, rids keep
> their UUIDs, and the `cid:` index wins when a record's `cids/*` member
> disagrees; fields re-signed by a throwaway legacy key); every other box
> starts empty. Implementation basis §11.
> **Historical — this sweep was never run.** The alpha shipped behind a
> clean-wipe gate (Redis was flushed and the library re-ingested), so no
> in-place migration code exists. Kept for the record.
One-time sweep, idempotent, runs on first boot after upgrade:
```
For each hashID in file:__index__ (current shape: midhash256:abc, sha256:def, …):
1. Mint a fresh uuid_Y.
2. AddAlias(uuid_Y, hashID). The existing primary becomes an alias.
3. For each cid_* field on the old entry, AddAlias(uuid_Y, that value).
4. RENAME file:<hashID>/<property> → file:<uuid_Y>/<property> for every
property (Lua script, one entry at a time; non-atomic across entries,
but each entry stays internally consistent).
5. Reconcile canonical_cid.
6. Remove hashID from file:__index__, add uuid_Y.
```
Crash-recoverable: re-running the sweep finds entries with both old and new
roots and resumes by detecting which step completed (presence of
`file:<uuid>/cids` indicates step 3 finished; absence of any
`file:<hashID>/*` keys indicates step 4 finished).
*(proposal)* One-time sweep over `file:__index__`: mint a uuid, `AddAlias`
the old hashID and every `cid_*` field, `RENAME` every property under the new
root, reconcile canonical, swap the index entry; crash-recoverable by
detecting which step completed.
## Decisions recorded
| Question | Decision | Notes |
|---|---|---|
| When is the UUID minted? | At first-hash | Pragmatic for the initial migration. Long-term target: at discovery, fully decoupled from hashing. Tracked as an open question below. |
| UUID format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe. |
| UUID visible externally? | No | Pure meta-core internal. Clients address by CID only. |
| Merge semantics | One UUID per content; additional paths into `duplicates` | One file (logical) = one metadata set. Multiple physical paths supported via `duplicates`. |
| Canonical CID rank | IPFS > sha-family > btih > midhash | Utility-weighted, not strength-weighted. |
| ~~`cids` and `duplicates` field type~~ | ~~Redis SET~~ → **`cids` is a key-set of bare CIDs; `duplicates` stayed a SET** | Reversed before shipping. Flat `cids/<cid>` STRING keys merge conflict-free across peers; a SET does not. See §14.13 of `METADATA_KEYS.md`. |
| ~~Stored `canonical_cid`~~ | **Dropped — derived on read** | A stored scalar is not conflict-free to merge. `GetMetadataDocument` ranks the `cids` members per request. |
| Reverse-index format | Flat `cid:<cid>` STRING keys | Simple, scan-friendly, no hot-key contention. |
| When is the rid minted? | When a cid is first seen with no link to a known group | v1.x: at first hash of a watched file. Now any first-seen cid (watcher midhash or a claim about a new cid) mints one |
| Rid format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe |
| Rid visible externally? | No — never signed, never on the swarm/gateway wire | Local HTTP consumers may hold it |
| What do signed votes name? | A cid | Every peer agrees on a cid; rids differ per box |
| Record identity | A group of cids (`cid_group`, in truth) | Linked by `cids/<other>` votes |
| Group merge | Older rid survives; losing rid → `del` events | No `rid_alias` (out of scope for phase 1) |
| Duplicate paths | `local_file` rows sharing a midhash (cache) | v1.x: `duplicates` SET, then `dup:<uuid>/<path>` |
| Canonical CID rank | IPFS > sha-family > btih > midhash, derived on read | Utility-weighted; never stored |
| `cids` shape | Key-set of bare CIDs, one claim per member | Merges with the generic rule; a SET would not |
| Reverse index | `cid_group(cid PK → rid)` in truth, copied to cache `cid:<cid>` rows for reads | v1.x: flat `cid:<cid>` keys were the only copy |
| Unlinking | A retracted `cids/*` link does not split a group | Phase 1 |
## Open questions
- **Discovery-time UUID minting.** The cleaner long-term model: watcher mints
a UUID at discovery (before hashing), then `AddAlias` runs when the midhash
arrives. Requires an explicit "reconcile if alias already exists" step
because two paths with the same content would each get their own UUID until
hashing reveals the collision. Worth doing eventually; not required for
this iteration.
- ~~**meta-share announce strategy.**~~ **Settled.** The record carries *every*
member of `cids` on the wire (`Record.cids`) and addresses itself by the
single derived canonical (`Record.cid`), so a peer querying by any digest
gets a hit without the DHT footprint of announcing each one separately.
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` as a
content-streaming endpoint for external IPFS clients? Adjacent to this
design but not part of it.
- **Keyspace-notification consumers.** Today's parsers (`schema/indexer.go`)
handle `file:<type>:<hash>/...` with a special split rule for the colon
inside `midhash256:abc`. With UUIDs (no colons), `parseFieldPath` simplifies
— but any external consumer subscribed by hashID pattern needs updating.
Inventory those before flipping the migration.
- **Discovery-time rid minting.** Minting at discovery (before hashing)
would give two same-content paths two rids until hashing links them. The
claims model already handles that case — it is an ordinary group merge —
so this is now a policy choice, not a missing mechanism.
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` for
external IPFS clients? Adjacent, not part of this design.
- ~~**meta-share announce strategy.**~~ Settled: the canonical cid addresses
the record, every group cid travels as a `cids/*` claim.
- ~~**Keyspace-notification consumers.**~~ Obsolete since Redis left.
## Non-goals
- **Not** a content-replication or IPFS-pinning system. Registering an IPFS
CID alias does not pin the file anywhere — it only states "if somebody
hashes this file with IPFS's chunker, this is the CID they'd get."
- **Not** a versioning scheme. Metadata is a snapshot of current state;
historical versions are out of scope.
- **Not** a strong-consistency system. Reverse-index writes happen under the
leader lock; reads from followers may briefly miss a freshly-added alias.
- **Not** a content-replication or pinning system. A `cids/<ipfs-cid>` vote
pins nothing; it states "hashing these bytes with IPFS's chunker gives this
CID."
- **Not** a history. `ts` versions each `(uid, cid, key)` and the newest vote
replaces the older one; only tombstones are kept, to block replay.
- **Not** strongly consistent across the two databases: the cache is applied
after the truth commit. `POST /api/claims` waits for the apply before
answering, so the writer reads its own write; another reader may briefly
miss a just-accepted claim. It is behind, never wrong.
+135
View File
@@ -0,0 +1,135 @@
// Signed claims (meta-core v2.0.0). The editor no longer writes keys: an edit
// is a claim signed as the chosen identity account — through meta-core's
// account-key service (/api/identity/sign, compact low-S) — and posted to
// POST /api/claims. That vote wins in that account's view; the shared merged
// value only moves if enough signers agree.
//
// MMUR2 bytes (must match internal/claim and its golden vectors):
// "MMUR2\n" || canonical CBOR [cid, uid, key, value, ts]
export interface Claim {
cid: string;
uid: string;
key: string;
value: string | { b64: string } | null;
ts: number;
sig: string;
}
export interface ClaimsResponse {
rid: string;
cids: string[];
claims: Claim[];
}
export interface Account {
uid: string;
curve: string;
createdAt: number;
}
const SIGNER_LS_KEY = 'kv-editor.signer.v1';
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body?.message) msg = body.message;
else if (body?.error) msg = body.error;
} catch {}
throw new Error(msg);
}
return res.json();
}
// --- canonical CBOR for the five MMUR2 shapes -------------------------------
function head(out: number[], major: number, n: number) {
const m = major << 5;
if (n < 24) out.push(m | n);
else if (n <= 0xff) out.push(m | 24, n);
else if (n <= 0xffff) out.push(m | 25, n >> 8, n & 0xff);
else if (n <= 0xffffffff) out.push(m | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
else {
const hi = Math.floor(n / 2 ** 32);
const lo = n >>> 0;
out.push(m | 27, (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff,
(lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
}
}
function text(out: number[], s: string) {
const b = new TextEncoder().encode(s);
head(out, 3, b.length);
for (const x of b) out.push(x);
}
export function signedBytes(cid: string, uid: string, key: string, value: string | null, ts: number): Uint8Array {
const out: number[] = [...new TextEncoder().encode('MMUR2\n')];
head(out, 4, 5);
text(out, cid);
text(out, uid);
text(out, key);
if (value === null) out.push(0xf6);
else text(out, value);
head(out, 0, ts);
return new Uint8Array(out);
}
function b64(bytes: Uint8Array): string {
let s = '';
for (const x of bytes) s += String.fromCharCode(x);
return btoa(s);
}
// --- signer -----------------------------------------------------------------
export function getSigner(): string {
try { return localStorage.getItem(SIGNER_LS_KEY) ?? ''; } catch { return ''; }
}
export function setSigner(uid: string) {
try { localStorage.setItem(SIGNER_LS_KEY, uid); } catch {}
}
export const ClaimsAPI = {
async accounts(): Promise<Account[]> {
const r = await jsonOrThrow<{ accounts: Account[] }>(await fetch('/api/identity/accounts'));
return r.accounts ?? [];
},
async forRecord(rid: string, key?: string): Promise<ClaimsResponse> {
const q = key ? `?key=${encodeURIComponent(key)}` : '';
return jsonOrThrow(await fetch(`/api/metadata/${encodeURIComponent(rid)}/claims${q}`));
},
// vote signs value (null = retraction) for key on the record's subject cid
// as the chosen account and posts it.
async vote(rid: string, key: string, value: string | null): Promise<void> {
const uid = getSigner();
if (!uid) throw new Error('choose a signing account first (top right)');
const rec = await this.forRecord(rid);
const cid = rec.cids[0];
if (!cid) throw new Error(`record ${rid} has no cid`);
// ts must beat this account's previous vote on the key.
const prev = rec.claims.filter((c) => c.uid === uid && c.key === key).map((c) => c.ts);
const ts = Math.max(Date.now(), ...prev.map((t) => t + 1));
const bytes = signedBytes(cid, uid, key, value, ts);
const signed = await jsonOrThrow<{ sigB64: string }>(await fetch('/api/identity/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, bytesB64: b64(bytes), format: 'compact' }),
}));
const claim: Claim = { cid, uid, key, value, ts, sig: signed.sigB64 };
const res = await jsonOrThrow<{ status: string; reason?: string }[]>(await fetch('/api/claims', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([claim]),
}));
const st = res[0]?.status;
if (st !== 'accepted' && st !== 'unchanged') {
throw new Error(`claim ${st}${res[0]?.reason ? ': ' + res[0].reason : ''}`);
}
},
};
+135 -12
View File
@@ -1,8 +1,17 @@
// Client for the meta-core /api/kv endpoints. The keyspace is a slash-tree of
// string-valued keys; this module wraps the S3-style listing, search, and
// per-key get/put/delete operations.
// The editor's key tree. Since meta-core v2.0.0 the records are no longer raw
// keys: `file:<rid>/<field>` is the MERGED view, derived from signed claims.
// This module keeps the slash-tree the components were built on, as a view:
//
// - reads of `file:` keys come from the merged record API
// (/api/metadata/hash-ids, /api/metadata/{rid});
// - writes of `file:` keys are signed claims (claimsApi.ts): put = a vote,
// del = a retraction of the chosen account's vote;
// - every other key is service scratch KV, through /api/kv/*.
import { ClaimsAPI } from './claimsApi';
const API_BASE = '/api/kv';
const RECORD_NS = 'file:';
export interface KVTreeResponse {
prefix: string;
@@ -61,6 +70,45 @@ async function jsonOrThrow<T>(res: Response): Promise<T> {
return res.json();
}
// splitRecordKey decomposes file:<rid>/<field> (field may be empty).
export function splitRecordKey(key: string): { rid: string; field: string } | null {
if (!key.startsWith(RECORD_NS)) return null;
const rest = key.slice(RECORD_NS.length);
const slash = rest.indexOf('/');
if (slash <= 0) return null;
return { rid: rest.slice(0, slash), field: rest.slice(slash + 1) };
}
let hashIdsCache: { at: number; ids: string[] } | null = null;
async function hashIds(): Promise<string[]> {
if (hashIdsCache && Date.now() - hashIdsCache.at < 5000) return hashIdsCache.ids;
const r = await jsonOrThrow<{ hashIds: string[] }>(await fetch('/api/metadata/hash-ids'));
hashIdsCache = { at: Date.now(), ids: (r.hashIds ?? []).slice().sort() };
return hashIdsCache.ids;
}
async function recordFields(rid: string): Promise<Record<string, string>> {
const res = await fetch(`/api/metadata/${encodeURIComponent(rid)}`);
if (res.status === 404) return {};
const r = await jsonOrThrow<{ metadata: Record<string, string> }>(res);
return r.metadata ?? {};
}
// listUnder lists the children of prefix among keys, S3-style.
function listUnder(prefix: string, delimiter: string, keys: string[]) {
const branches = new Set<string>();
const leaves: string[] = [];
for (const k of keys) {
if (!k.startsWith(prefix)) continue;
const rest = k.slice(prefix.length);
const i = delimiter ? rest.indexOf(delimiter) : -1;
if (i >= 0) branches.add(prefix + rest.slice(0, i + delimiter.length));
else leaves.push(k);
}
return { branches: [...branches].sort(), leaves: leaves.sort() };
}
export const KVAPI = {
async info(): Promise<KVInfoResponse> {
return jsonOrThrow(await fetch(`${API_BASE}/info`));
@@ -72,17 +120,34 @@ export const KVAPI = {
cursor?: string;
max?: number;
}): Promise<KVTreeResponse> {
const prefix = params.prefix ?? '';
const delimiter = params.delimiter ?? '';
const done = (branches: string[], leaves: string[]): KVTreeResponse =>
({ prefix, delimiter, branches, leaves, cursor: '0', hasMore: false });
if (prefix.startsWith(RECORD_NS)) {
const parts = splitRecordKey(prefix);
if (!parts) {
const ids = await hashIds();
return done(ids.map((id) => `${RECORD_NS}${id}/`), []);
}
const fields = await recordFields(parts.rid);
const keys = Object.keys(fields).map((f) => `${RECORD_NS}${parts.rid}/${f}`);
const l = listUnder(prefix, delimiter, keys);
return done(l.branches, l.leaves);
}
const q = new URLSearchParams();
if (params.prefix !== undefined) q.set('prefix', params.prefix);
if (params.delimiter !== undefined) q.set('delimiter', params.delimiter);
q.set('prefix', prefix);
q.set('delimiter', delimiter);
if (params.cursor) q.set('cursor', params.cursor);
if (params.max) q.set('max', String(params.max));
return jsonOrThrow(await fetch(`${API_BASE}/tree?${q}`));
const svc = await jsonOrThrow<KVTreeResponse>(await fetch(`${API_BASE}/tree?${q}`));
if (prefix === '' && delimiter) {
svc.branches = [RECORD_NS, ...svc.branches.filter((b) => b !== RECORD_NS)];
}
return svc;
},
// Walk multiple SCAN cycles until the keyspace is exhausted or a soft cap
// is reached. Useful for branches with up to a few thousand children where
// pagination UX would just be a worse spinner.
async treeAll(prefix: string, delimiter: string, softCap = 5000): Promise<KVTreeResponse> {
const seenLeaves = new Set<string>();
const seenBranches = new Set<string>();
@@ -105,22 +170,73 @@ export const KVAPI = {
};
},
// search finds keys whose path contains the substring: records matched by
// free text (each matching field key), plus service keys.
async search(contains: string, limit = 500): Promise<KVSearchResponse> {
const r = await jsonOrThrow<{ results: { hashId: string; metadata: Record<string, string> }[] }>(
await fetch('/api/metadata/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: contains, limit: 100 }),
}));
const needle = contains.toLowerCase();
const keys: string[] = [];
for (const hit of r.results ?? []) {
for (const [f, v] of Object.entries(hit.metadata)) {
const k = `${RECORD_NS}${hit.hashId}/${f}`;
if (k.toLowerCase().includes(needle) || v.toLowerCase().includes(needle)) keys.push(k);
}
}
const q = new URLSearchParams({ contains, limit: String(limit) });
return jsonOrThrow(await fetch(`${API_BASE}/search?${q}`));
const svc = await jsonOrThrow<KVSearchResponse>(await fetch(`${API_BASE}/search?${q}`));
const all = [...keys, ...svc.keys].sort();
return { contains, keys: all.slice(0, limit), truncated: all.length > limit || svc.truncated };
},
// find returns record fields (one of `fields`) whose value contains the
// substring, via the merged-record search.
async find(contains: string, fields: string[], limit = 200): Promise<KVFindResponse> {
const q = new URLSearchParams({ contains, fields: fields.join(','), limit: String(limit) });
return jsonOrThrow(await fetch(`${API_BASE}/find?${q}`));
const matches: KVFindMatch[] = [];
let truncated = false;
for (const field of fields) {
const r = await jsonOrThrow<{ results: { hashId: string; metadata: Record<string, string> }[] }>(
await fetch('/api/metadata/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters: [{ property: field, values: [contains] }], limit }),
}));
for (const hit of r.results ?? []) {
const v = hit.metadata[field];
if (v === undefined) continue;
const entryPath = `${RECORD_NS}${hit.hashId}/`;
matches.push({ key: entryPath + field, value: v, entryPath, field });
if (matches.length >= limit) { truncated = true; break; }
}
if (truncated) break;
}
matches.sort((a, b) => a.key.localeCompare(b.key));
return { contains, fields, matches, truncated };
},
async get(key: string): Promise<KVValueResponse> {
const parts = splitRecordKey(key);
if (parts) {
const fields = await recordFields(parts.rid);
const value = fields[parts.field];
return { key, type: 'string', value: value ?? '', exists: value !== undefined };
}
const q = new URLSearchParams({ key });
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`));
},
// put on a record key is the chosen account's signed vote.
async put(key: string, value: string): Promise<KVValueResponse> {
const parts = splitRecordKey(key);
if (parts) {
await ClaimsAPI.vote(parts.rid, parts.field, value);
hashIdsCache = null;
return { key, type: 'string', value, exists: true };
}
const res = await fetch(`${API_BASE}/value`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -129,7 +245,14 @@ export const KVAPI = {
return jsonOrThrow(res);
},
// del on a record key retracts the chosen account's vote; the merged value
// stays while other signers still support it.
async del(key: string): Promise<{ key: string; deleted: boolean }> {
const parts = splitRecordKey(key);
if (parts) {
await ClaimsAPI.vote(parts.rid, parts.field, null);
return { key, deleted: true };
}
const q = new URLSearchParams({ key });
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' }));
},
-96
View File
@@ -1,96 +0,0 @@
const API_BASE = '/api/snapshot';
export type ImportMode = 'replace' | 'merge';
export type ImportConflict = 'mine' | 'source';
export interface FileImportResult {
hashId: string;
added: number;
updated: number;
unchanged: number;
kept: number;
deleted: number;
error?: string;
}
export interface PluginOutputResult {
written: number;
skipped: number;
}
export interface ImportResult {
mode: ImportMode;
conflict?: ImportConflict;
dryRun: boolean;
totalFiles: number;
filesOk: number;
filesFailed: number;
files?: FileImportResult[];
pluginOutput?: PluginOutputResult;
}
export interface WipeResult {
metadataDeleted: number;
}
export class SnapshotAPI {
/**
* Trigger a download of a metadata snapshot ZIP.
* Browser handles the download via a synthetic <a> click.
*/
static downloadExport(includes: { pluginOutput?: boolean } = {}): void {
const parts: string[] = [];
if (includes.pluginOutput) parts.push('plugin-output');
const qs = parts.length ? `?include=${parts.join(',')}` : '';
const a = document.createElement('a');
a.href = `${API_BASE}/export${qs}`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
/**
* Upload a snapshot ZIP and apply it.
* Mode replace deletes and rewrites each CID's properties.
* Mode merge preserves untouched fields; conflict picks who wins on overlap.
*/
static async runImport(
file: File,
opts: { mode: ImportMode; conflict?: ImportConflict; dryRun?: boolean }
): Promise<ImportResult> {
const params = new URLSearchParams();
params.set('mode', opts.mode);
if (opts.mode === 'merge') {
params.set('conflict', opts.conflict ?? 'source');
}
if (opts.dryRun) {
params.set('dry_run', 'true');
}
const fd = new FormData();
fd.append('snapshot', file);
const response = await fetch(`${API_BASE}/import?${params.toString()}`, {
method: 'POST',
body: fd,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Import failed');
}
return response.json();
}
/**
* Wipe all stored metadata. Pass scope=metadata.
*/
static async wipe(scope: 'metadata'): Promise<WipeResult> {
const response = await fetch(`${API_BASE}/wipe?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Wipe failed');
}
return response.json();
}
}
-209
View File
@@ -1,209 +0,0 @@
.snapshot-panel {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem;
}
.snapshot-panel h2 {
margin: 0 0 0.5rem;
}
.snapshot-intro {
color: var(--text-muted, #888);
margin-bottom: 1.5rem;
}
.snapshot-section {
border: 1px solid var(--border, #e0e0e0);
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.snapshot-section.danger {
border-color: #d04a4a;
background: rgba(208, 74, 74, 0.04);
}
.snapshot-section h3 {
margin: 0 0 0.5rem;
}
.snapshot-row {
display: flex;
gap: 1rem;
align-items: flex-start;
margin: 0.75rem 0;
flex-wrap: wrap;
}
.snapshot-file {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.snapshot-file span {
font-size: 0.85rem;
color: var(--text-muted, #888);
}
.snapshot-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 0.5rem 0.75rem;
min-width: 280px;
}
.snapshot-fieldset[disabled] {
opacity: 0.5;
}
.snapshot-fieldset legend {
font-weight: 600;
font-size: 0.85rem;
padding: 0 0.25rem;
}
.snapshot-fieldset label {
display: block;
margin: 0.25rem 0;
font-size: 0.9rem;
cursor: pointer;
}
.snapshot-fieldset input[type='radio'] {
margin-right: 0.5rem;
}
.snapshot-btn {
background: var(--bg-button, #f0f0f0);
border: 1px solid var(--border, #c0c0c0);
color: var(--text, #222);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
.snapshot-btn:hover:not(:disabled) {
background: var(--bg-button-hover, #e0e0e0);
}
.snapshot-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.snapshot-btn.primary {
background: #2a6df4;
border-color: #2a6df4;
color: white;
}
.snapshot-btn.primary:hover:not(:disabled) {
background: #1a5de4;
}
.snapshot-btn.danger {
background: #d04a4a;
border-color: #d04a4a;
color: white;
}
.snapshot-btn.danger:hover:not(:disabled) {
background: #b83838;
}
.snapshot-error {
background: rgba(208, 74, 74, 0.1);
color: #b83838;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-top: 0.75rem;
}
.snapshot-info {
background: rgba(42, 109, 244, 0.08);
color: #1a5de4;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-top: 0.75rem;
}
.snapshot-result {
margin-top: 0.75rem;
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 0.75rem;
}
.snapshot-result-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.snapshot-result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.snapshot-result-table th,
.snapshot-result-table td {
border: 1px solid var(--border, #e0e0e0);
padding: 0.35rem 0.5rem;
text-align: right;
}
.snapshot-result-table th {
background: var(--bg-subtle, #f5f5f5);
font-weight: 600;
}
.snapshot-result-errors {
margin-top: 0.5rem;
font-size: 0.85rem;
}
.snapshot-result-errors summary {
cursor: pointer;
color: #b83838;
}
.snapshot-result-errors ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
.snapshot-result-errors code {
font-size: 0.8rem;
word-break: break-all;
}
.snapshot-checkbox {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
cursor: pointer;
font-size: 0.95rem;
}
.snapshot-checkbox input {
margin-top: 0.2rem;
}
.snapshot-hint {
color: var(--text-muted, #888);
font-size: 0.9rem;
}
.snapshot-result-aside {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted, #555);
}
-271
View File
@@ -1,271 +0,0 @@
import { useState } from 'react';
import {
SnapshotAPI,
ImportMode,
ImportConflict,
ImportResult,
} from '../api/snapshotApi';
import './SnapshotPanel.css';
export function SnapshotPanel() {
const [exportPluginOutput, setExportPluginOutput] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importMode, setImportMode] = useState<ImportMode>('merge');
const [importConflict, setImportConflict] = useState<ImportConflict>('source');
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [wipeBusy, setWipeBusy] = useState(false);
const [wipeMessage, setWipeMessage] = useState<string | null>(null);
const runImport = async (dryRun: boolean) => {
if (!importFile) {
setImportError('Pick a snapshot ZIP first.');
return;
}
setImporting(true);
setImportError(null);
setImportResult(null);
try {
const result = await SnapshotAPI.runImport(importFile, {
mode: importMode,
conflict: importMode === 'merge' ? importConflict : undefined,
dryRun,
});
setImportResult(result);
} catch (e: any) {
setImportError(e.message || String(e));
} finally {
setImporting(false);
}
};
const runWipe = async () => {
const ok = window.confirm(
'This deletes ALL stored metadata. Files on disk are not touched, but every manual ' +
'edit you made via this editor will be lost. Continue?'
);
if (!ok) return;
setWipeBusy(true);
setWipeMessage(null);
try {
const r = await SnapshotAPI.wipe('metadata');
setWipeMessage(`Deleted ${r.metadataDeleted} file metadata entries.`);
} catch (e: any) {
setWipeMessage(`Failed: ${e.message || e}`);
} finally {
setWipeBusy(false);
}
};
return (
<div className="snapshot-panel">
<h2>Snapshot</h2>
<p className="snapshot-intro">
Export, import, or wipe the metadata KV store. Snapshots are portable ZIP
archives — one nested-JSON file per CID, plus a manifest.
</p>
<section className="snapshot-section">
<h3>Export</h3>
<p>Downloads every CID's metadata as a ZIP. Optional cache files can be added below.</p>
<label className="snapshot-checkbox">
<input
type="checkbox"
checked={exportPluginOutput}
onChange={(e) => setExportPluginOutput(e.target.checked)}
/>
<span>
Include plugin output
<span className="snapshot-hint">
{' '}— TMDB posters/backdrops, extracted subtitles. Adds ~120 MB on a populated stack.
</span>
</span>
</label>
<button
className="snapshot-btn primary"
onClick={() =>
SnapshotAPI.downloadExport({ pluginOutput: exportPluginOutput })
}
>
Download snapshot.zip
</button>
</section>
<section className="snapshot-section">
<h3>Import</h3>
<div className="snapshot-row">
<label className="snapshot-file">
<span>Snapshot ZIP</span>
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => {
setImportFile(e.target.files?.[0] ?? null);
setImportResult(null);
setImportError(null);
}}
/>
</label>
</div>
<div className="snapshot-row">
<fieldset className="snapshot-fieldset">
<legend>Mode</legend>
<label>
<input
type="radio"
name="mode"
value="merge"
checked={importMode === 'merge'}
onChange={() => setImportMode('merge')}
/>
Merge — keep existing fields not in the snapshot
</label>
<label>
<input
type="radio"
name="mode"
value="replace"
checked={importMode === 'replace'}
onChange={() => setImportMode('replace')}
/>
Replace — wipe each imported CID and rewrite from snapshot
</label>
</fieldset>
<fieldset className="snapshot-fieldset" disabled={importMode !== 'merge'}>
<legend>Conflict</legend>
<label>
<input
type="radio"
name="conflict"
value="source"
checked={importConflict === 'source'}
onChange={() => setImportConflict('source')}
/>
Source wins (overwrite my edits)
</label>
<label>
<input
type="radio"
name="conflict"
value="mine"
checked={importConflict === 'mine'}
onChange={() => setImportConflict('mine')}
/>
Mine wins (keep my edits, fill in only missing fields)
</label>
</fieldset>
</div>
<div className="snapshot-row">
<button
className="snapshot-btn"
disabled={!importFile || importing}
onClick={() => runImport(true)}
>
Dry run
</button>
<button
className="snapshot-btn primary"
disabled={!importFile || importing}
onClick={() => runImport(false)}
>
{importing ? 'Importing…' : 'Apply import'}
</button>
</div>
{importError && <div className="snapshot-error">{importError}</div>}
{importResult && <ImportResultView result={importResult} />}
</section>
<section className="snapshot-section danger">
<h3>Wipe metadata</h3>
<p>
Deletes every <code>file:*</code> key plus <code>file:__index__</code>.
Files on disk are untouched.
</p>
<button
className="snapshot-btn danger"
disabled={wipeBusy}
onClick={runWipe}
>
{wipeBusy ? 'Wiping…' : 'Wipe all metadata'}
</button>
{wipeMessage && <div className="snapshot-info">{wipeMessage}</div>}
</section>
</div>
);
}
function ImportResultView({ result }: { result: ImportResult }) {
const totals = (result.files ?? []).reduce(
(acc, f) => {
acc.added += f.added;
acc.updated += f.updated;
acc.unchanged += f.unchanged;
acc.kept += f.kept;
acc.deleted += f.deleted;
return acc;
},
{ added: 0, updated: 0, unchanged: 0, kept: 0, deleted: 0 }
);
return (
<div className="snapshot-result">
<div className="snapshot-result-summary">
<strong>{result.dryRun ? 'Dry run preview' : 'Import applied'}</strong>
<span>
{result.filesOk} ok / {result.filesFailed} failed of {result.totalFiles} files
</span>
<span>
mode={result.mode}
{result.conflict ? `, conflict=${result.conflict}` : ''}
</span>
</div>
<table className="snapshot-result-table">
<thead>
<tr>
<th>added</th>
<th>updated</th>
<th>unchanged</th>
<th>kept (mine)</th>
<th>deleted (replace)</th>
</tr>
</thead>
<tbody>
<tr>
<td>{totals.added}</td>
<td>{totals.updated}</td>
<td>{totals.unchanged}</td>
<td>{totals.kept}</td>
<td>{totals.deleted}</td>
</tr>
</tbody>
</table>
{result.pluginOutput && (
<div className="snapshot-result-aside">
plugin-output: {result.pluginOutput.written} written
{result.pluginOutput.skipped > 0
? `, ${result.pluginOutput.skipped} skipped (existed)`
: ''}
</div>
)}
{result.files && result.files.some((f) => f.error) && (
<details className="snapshot-result-errors">
<summary>Per-file errors</summary>
<ul>
{result.files
.filter((f) => f.error)
.map((f) => (
<li key={f.hashId}>
<code>{f.hashId}</code>: {f.error}
</li>
))}
</ul>
</details>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { Claim, ClaimsAPI, getSigner } from '../api/claimsApi';
import { splitRecordKey } from '../api/kvApi';
// ClaimsPanel lists the signed claims behind one merged field: who said what,
// and when. The merged value is what most signers agree on.
export function ClaimsPanel({ recordKey, reloadToken }: { recordKey: string; reloadToken: number }) {
const [claims, setClaims] = useState<Claim[] | null>(null);
const [error, setError] = useState('');
const parts = splitRecordKey(recordKey);
useEffect(() => {
if (!parts || !parts.field) return;
let cancelled = false;
ClaimsAPI.forRecord(parts.rid, parts.field)
.then((r) => { if (!cancelled) setClaims(r.claims); })
.catch((e) => { if (!cancelled) setError(e.message); });
return () => { cancelled = true; };
}, [recordKey, reloadToken]); // eslint-disable-line react-hooks/exhaustive-deps
if (!parts || !parts.field) return null;
if (error) return <div className="kv-claims kv-pane-status">claims: {error}</div>;
if (!claims) return <div className="kv-claims kv-pane-status">loading claims…</div>;
const me = getSigner();
return (
<div className="kv-claims">
<div className="kv-leaf-label">Claims ({claims.length})</div>
{claims.length === 0 ? (
<div className="kv-pane-hint">No signed claim — a box-local fact (file path, size, mtime) or a group member.</div>
) : (
<table className="kv-claims-table">
<tbody>
{claims.map((c) => (
<tr key={c.uid + c.cid}>
<td title={c.uid}><code>{c.uid.slice(0, 10)}…</code>{c.uid === me ? ' (you)' : ''}</td>
<td>{c.value === null ? <em>retracted</em> : typeof c.value === 'string' ? c.value : <em>ciphertext</em>}</td>
<td title={new Date(c.ts).toISOString()}>{new Date(c.ts).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+6
View File
@@ -894,3 +894,9 @@
.kv-modal-close:hover {
opacity: 1;
}
.kv-signer { font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
.kv-signer select { font-size: 12px; }
.kv-claims { margin-top: 16px; }
.kv-claims-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.kv-claims-table td { padding: 3px 6px; border-top: 1px solid rgba(127, 127, 127, 0.25); vertical-align: top; }
+2 -32
View File
@@ -10,7 +10,7 @@ const FILE_BRANCH_RE = /^file:[^/]+\/$/;
import { SearchFieldChips } from './SearchFieldChips';
import { ToastStack, makeToast } from './Toast';
import { ToastEntry } from './types';
import { SnapshotPanel } from '../components/SnapshotPanel';
import { SignerPicker } from './SignerPicker';
import './KVApp.css';
const SEARCH_FIELDS_LS_KEY = 'kv-editor.searchFields.v1';
@@ -32,7 +32,6 @@ function loadSearchFields(): string[] {
//
// A sticky header carries the search bar, the breadcrumb, and basic stats.
export default function KVApp() {
const [snapshotOpen, setSnapshotOpen] = useState(false);
// Selected node. branchPath is set when a branch is selected; selectedKey
// when a leaf is. Mutually exclusive.
const [selectedKey, setSelectedKey] = useState<string | null>(null);
@@ -176,14 +175,7 @@ export default function KVApp() {
onClick={() => setReloadToken((n) => n + 1)}
title="refresh tree"
>Refresh</button>
<button
className="kv-btn-icon kv-app-gear"
onClick={() => setSnapshotOpen(true)}
title="Snapshot — export / import / wipe"
aria-label="Open snapshot settings"
>
⚙
</button>
<SignerPicker onError={(msg) => pushToast(makeToast('error', msg))} />
</div>
<SearchFieldChips fields={searchFields} onChange={setSearchFields} />
{breadcrumb && breadcrumb.length > 0 && (
@@ -284,28 +276,6 @@ export default function KVApp() {
</main>
</div>
{snapshotOpen && (
<div
className="kv-modal-backdrop"
onClick={() => setSnapshotOpen(false)}
>
<div
className="kv-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<button
className="kv-modal-close"
onClick={() => setSnapshotOpen(false)}
aria-label="Close"
title="Close"
>×</button>
<SnapshotPanel />
</div>
</div>
)}
<ToastStack toasts={toasts} onDismiss={dismissToast} />
</div>
);
+6 -3
View File
@@ -5,9 +5,10 @@ import { ALL_TYPES, describeType, detectType } from './typeHeuristics';
import { lookupSchemaForKey } from './schemaLookup';
import { LeafValueEditor } from './leafRenderers';
import { FieldType } from './types';
import { ClaimsPanel } from './ClaimsPanel';
interface Props {
// Full Redis key path of the selected leaf.
// Full key path of the selected leaf (file:<rid>/<field> = merged view).
selectedKey: string;
// Bumped after a delete or external change to force refetch.
reloadToken: number;
@@ -71,7 +72,7 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
};
const del = async () => {
if (!confirm(`Delete ${selectedKey}?`)) return;
if (!confirm(`Retract your vote on ${selectedKey}? Other signers' claims keep the merged value.`)) return;
setSaving(true);
try {
await KVAPI.del(selectedKey);
@@ -136,9 +137,11 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
className="kv-btn kv-btn-danger"
onClick={del}
disabled={saving}
>Delete</button>
>Retract</button>
)}
</div>
<ClaimsPanel recordKey={selectedKey} reloadToken={reloadToken} />
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { Account, ClaimsAPI, getSigner, setSigner } from '../api/claimsApi';
// SignerPicker chooses the identity account whose key signs the editor's
// edits. Every save is that account's claim — it wins in that account's view.
export function SignerPicker({ onError }: { onError: (msg: string) => void }) {
const [accounts, setAccounts] = useState<Account[]>([]);
const [uid, setUid] = useState(getSigner());
useEffect(() => {
ClaimsAPI.accounts()
.then((list) => {
setAccounts(list);
if (!uid && list.length === 1) {
setSigner(list[0].uid);
setUid(list[0].uid);
}
})
.catch((e) => onError(`accounts: ${e.message}`));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<label className="kv-signer" title="Edits are claims signed by this account">
sign as{' '}
<select
value={uid}
onChange={(e) => { setSigner(e.target.value); setUid(e.target.value); }}
>
<option value="">— choose an account —</option>
{accounts.map((a) => (
<option key={a.uid} value={a.uid}>{a.uid.slice(0, 10)}…</option>
))}
</select>
</label>
);
}
+6
View File
@@ -56,6 +56,12 @@ func (s *Server) handleGetMetaByCID(w http.ResponseWriter, r *http.Request) {
return
}
if as := r.URL.Query().Get("as"); as != "" {
if flat, err := s.storage.GetMetadataFlatAs(uuid, as); err == nil && flat != nil {
doc.Flat = flat
}
}
// Response envelope mirrors GetMetadataDocument JSON tags (metadata /
// cids / canonical_cid / duplicates), with cid added so the caller can
// correlate when they fan out N lookups. canonical_cid is derived by
+172
View File
@@ -0,0 +1,172 @@
package api
// Signed claims (meta-core v2.0.0, docs/implementation/metacore-claims-model.md).
//
// POST /api/claims the only metadata write path
// GET /api/meta/{cid}/claims every live claim of the cid's group
// GET /api/metadata/{rid}/claims the same, addressed by rid
// POST /api/merge stateless merge of claims the caller holds
//
// meta-core never signs a claim here: it verifies each one (MMUR2, low-S) and
// the signature is the write authentication.
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
)
// GoneResponse is the body of every removed unsigned write route.
type GoneResponse struct {
Error string `json:"error"`
Use string `json:"use"`
}
// handleGone answers the unsigned write routes removed in v2.0.0.
func (s *Server) handleGone(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusGone, GoneResponse{Error: "gone", Use: "POST /api/claims"})
}
// decodeClaims accepts a JSON array of claims or {"claims": [...]}.
func decodeClaims(r *http.Request) ([]claim.Claim, error) {
var raw json.RawMessage
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<20)).Decode(&raw); err != nil {
return nil, err
}
var claims []claim.Claim
if len(raw) > 0 && raw[0] == '{' {
var env struct {
Claims []claim.Claim `json:"claims"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
return env.Claims, nil
}
if err := json.Unmarshal(raw, &claims); err != nil {
return nil, err
}
return claims, nil
}
// handlePostClaims handles POST /api/claims: one status per claim, in input
// order. 200 even when some claims are refused — the statuses say which.
func (s *Server) handlePostClaims(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
claims, err := decodeClaims(r)
if err != nil {
writeErrorSlug(w, http.StatusBadRequest, "invalid_json", "body must be a JSON array of claims: "+err.Error(), false)
return
}
if len(claims) > storage.MaxClaimsPerBatch {
writeErrorSlug(w, http.StatusRequestEntityTooLarge, "too_many_claims", "at most 10000 claims per batch", false)
return
}
results, err := s.storage.WriteClaims(r.Context(), claims)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, results)
}
// ClaimsResponse is the body of the claims read routes: the record's rid, the
// cids of its group, and every live signed claim on them.
type ClaimsResponse struct {
RID string `json:"rid"`
CIDs []string `json:"cids"`
Claims []claim.Claim `json:"claims"`
}
// handleClaimsByCID handles GET /api/meta/{cid}/claims[?key=…].
func (s *Server) handleClaimsByCID(w http.ResponseWriter, r *http.Request) {
cid := mux.Vars(r)["cid"]
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
rid, err := s.storage.GetByCID(cid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if rid == "" {
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "no record for this CID", false)
return
}
s.writeClaims(w, r, rid)
}
// handleClaimsByRID handles GET /api/metadata/{rid}/claims[?key=…]. The rid
// may also be a cid (resolved like every /api/metadata read).
func (s *Server) handleClaimsByRID(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
rid := s.storage.ResolveRoot(mux.Vars(r)["hashId"])
s.writeClaims(w, r, rid)
}
// writeClaims answers both claims routes: ?key= keeps one exact key, ?uid=
// one signer; 404 when the record does not exist.
func (s *Server) writeClaims(w http.ResponseWriter, r *http.Request, rid string) {
cids, claims, err := s.storage.RecordClaims(rid, r.URL.Query().Get("key"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if len(cids) == 0 {
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "no record for this rid or cid", false)
return
}
out := make([]claim.Claim, 0, len(claims))
uid := r.URL.Query().Get("uid")
for _, cl := range claims {
if uid == "" || cl.UID == uid {
out = append(out, cl)
}
}
writeJSON(w, http.StatusOK, ClaimsResponse{RID: rid, CIDs: cids, Claims: out})
}
// MergeRequest is the body of POST /api/merge.
type MergeRequest struct {
Claims []claim.Claim `json:"claims"`
As string `json:"as,omitempty"`
}
// MergeResponse is the merged field map, the number of agreeing signers per
// key, and how many claims failed verification (dropped, never an error).
type MergeResponse struct {
Fields map[string]string `json:"fields"`
Nsrc map[string]int `json:"nsrc"`
Rejected int `json:"rejected"`
}
// handleMerge handles POST /api/merge: verify, merge per key with the same
// rule the cache uses, overlay `as`'s own votes held by this core. No write.
func (s *Server) handleMerge(w http.ResponseWriter, r *http.Request) {
var req MergeRequest
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<20)).Decode(&req); err != nil {
writeErrorSlug(w, http.StatusBadRequest, "invalid_json", err.Error(), false)
return
}
if len(req.Claims) > storage.MaxClaimsPerBatch {
writeErrorSlug(w, http.StatusRequestEntityTooLarge, "too_many_claims", "at most 10000 claims per merge", false)
return
}
fields, nsrc, rejected, err := s.storage.MergeClaims(req.Claims, req.As)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, MergeResponse{Fields: fields, Nsrc: nsrc, Rejected: rejected})
}
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
)
func withVars(r *http.Request, vars map[string]string) *http.Request { return mux.SetURLVars(r, vars) }
// routedServer wires the real route table, so the test sees what clients see.
func routedServer(t *testing.T) *Server {
t.Helper()
s := &Server{storage: newTestStorage(t), router: mux.NewRouter()}
s.setupRoutes()
return s
}
func call(t *testing.T, s *Server, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
rr := httptest.NewRecorder()
s.router.ServeHTTP(rr, req)
return rr
}
// Every unsigned write route of v1.x answers 410 with a pointer to the claims.
func TestUnsignedWritesAreGone(t *testing.T) {
s := routedServer(t)
for _, c := range []struct{ method, path string }{
{"PUT", "/meta/bafy"}, {"PATCH", "/meta/bafy"}, {"DELETE", "/meta/bafy"},
{"POST", "/meta/bafy/_add/genres"}, {"PUT", "/meta/bafy/title"}, {"DELETE", "/meta/bafy/title"},
{"PUT", "/api/metadata/bafy"}, {"DELETE", "/api/metadata/bafy"}, {"PUT", "/api/metadata/bafy/property"},
{"POST", "/api/metadata/batch"},
} {
rr := call(t, s, c.method, c.path, `{"title":"x"}`)
if rr.Code != http.StatusGone || !strings.Contains(rr.Body.String(), `"use":"POST /api/claims"`) {
t.Errorf("%s %s = %d %s", c.method, c.path, rr.Code, rr.Body)
}
}
if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"file:R/title","value":"x"}`); rr.Code != http.StatusConflict {
t.Errorf("raw record write via /api/kv/value = %d", rr.Code)
}
if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"btmagnet:abc","value":"magnet:"}`); rr.Code != http.StatusOK {
t.Errorf("service key write = %d %s", rr.Code, rr.Body)
}
}
func TestPostClaimsAndReadBack(t *testing.T) {
s := routedServer(t)
a, b := testSigner(t, 1), testSigner(t, 2)
bad := signed(t, b, "bafyA", "year", "1999", 5)
bad.Value = claim.Text("2001")
body, _ := json.Marshal([]claim.Claim{
signed(t, a, "bafyA", "title", "Matrix", 5),
signed(t, b, "bafyA", "title", "Matrix", 6),
bad,
})
rr := call(t, s, "POST", "/api/claims", string(body))
if rr.Code != http.StatusOK {
t.Fatalf("POST /api/claims = %d %s", rr.Code, rr.Body)
}
var res []storage.ClaimResult
_ = json.Unmarshal(rr.Body.Bytes(), &res)
if len(res) != 3 || res[0].Status != "accepted" || res[1].Status != "accepted" || res[2].Status != "bad_signature" {
t.Fatalf("statuses %+v", res)
}
rr = call(t, s, "GET", "/api/meta/bafyA", "")
if !strings.Contains(rr.Body.String(), `"title":"Matrix"`) {
t.Fatalf("merged read = %s", rr.Body)
}
rr = call(t, s, "GET", "/api/meta/bafyA/claims", "")
var cr ClaimsResponse
_ = json.Unmarshal(rr.Body.Bytes(), &cr)
if rr.Code != http.StatusOK || len(cr.Claims) != 2 {
t.Fatalf("claims by cid = %d %s", rr.Code, rr.Body)
}
if cr.RID == "" || len(cr.CIDs) != 1 || cr.CIDs[0] != "bafyA" {
t.Fatalf("claims envelope rid/cids = %q %v", cr.RID, cr.CIDs)
}
rr = call(t, s, "GET", "/api/metadata/"+cr.RID+"/claims?key=title&uid="+a.UID, "")
var byRid ClaimsResponse
_ = json.Unmarshal(rr.Body.Bytes(), &byRid)
if rr.Code != http.StatusOK || len(byRid.Claims) != 1 || byRid.Claims[0].UID != a.UID || byRid.RID != cr.RID {
t.Fatalf("claims by rid with key+uid = %d %s", rr.Code, rr.Body)
}
if rr := call(t, s, "GET", "/api/meta/bafyA/claims?key=nope", ""); !strings.Contains(rr.Body.String(), `"claims":[]`) {
t.Fatalf("key filter = %s", rr.Body)
}
if rr := call(t, s, "GET", "/api/meta/bafyUnknown/claims", ""); rr.Code != http.StatusNotFound {
t.Fatalf("unknown cid claims = %d", rr.Code)
}
if rr := call(t, s, "GET", "/api/metadata/01NOSUCHRID/claims", ""); rr.Code != http.StatusNotFound {
t.Fatalf("unknown rid claims = %d", rr.Code)
}
// ?as= overlays the viewer's own vote.
me := testSigner(t, 9)
storeClaims(t, s.storage, signed(t, me, "bafyA", "title", "My title", 7))
if rr := call(t, s, "GET", "/api/meta/bafyA?as="+me.UID, ""); !strings.Contains(rr.Body.String(), `"title":"My title"`) {
t.Fatalf("?as overlay = %s", rr.Body)
}
if rr := call(t, s, "GET", "/api/meta/bafyA", ""); !strings.Contains(rr.Body.String(), `"title":"Matrix"`) {
t.Fatalf("shared view changed by a single viewer: %s", rr.Body)
}
}
func TestMergeEndpoint(t *testing.T) {
s := routedServer(t)
a, b, c := testSigner(t, 1), testSigner(t, 2), testSigner(t, 3)
bad := signed(t, c, "bafyX", "title", "Forged", 1)
bad.UID = a.UID
body, _ := json.Marshal(MergeRequest{Claims: []claim.Claim{
signed(t, a, "bafyX", "title", "Dune", 2),
signed(t, b, "bafyX", "title", "Dune", 3),
signed(t, c, "bafyX", "title", "Dune Part One", 1),
bad,
}})
rr := call(t, s, "POST", "/api/merge", string(body))
var mr MergeResponse
_ = json.Unmarshal(rr.Body.Bytes(), &mr)
if rr.Code != http.StatusOK || mr.Fields["title"] != "Dune" || mr.Nsrc["title"] != 2 || mr.Rejected != 1 {
t.Fatalf("merge = %d %s", rr.Code, rr.Body)
}
if !strings.Contains(rr.Body.String(), `"rejected":1`) || !strings.Contains(rr.Body.String(), `"nsrc":{"title":2}`) {
t.Fatalf("merge wire shape = %s", rr.Body)
}
// `as`: the viewer's own vote in the request wins…
me := testSigner(t, 9)
body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{
signed(t, a, "bafyX", "title", "Dune", 2),
signed(t, b, "bafyX", "title", "Dune", 3),
signed(t, me, "bafyX", "title", "Dune (my cut)", 4),
}})
_ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr)
if mr.Fields["title"] != "Dune (my cut)" || mr.Nsrc["title"] != 1 {
t.Fatalf("request overlay = %+v", mr)
}
// …and so do its votes held by this core for the same cids.
storeClaims(t, s.storage, signed(t, me, "bafyX", "year", "2021", 5))
body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{
signed(t, a, "bafyX", "title", "Dune", 2),
signed(t, a, "bafyX", "year", "1984", 2),
}})
_ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr)
if mr.Fields["year"] != "2021" || mr.Fields["title"] != "Dune" {
t.Fatalf("local overlay = %+v", mr)
}
// An all-invalid batch is still 200 with everything rejected.
bad2 := signed(t, a, "bafyX", "title", "x", 9)
bad2.Sig = bad2.Sig[:10]
body, _ = json.Marshal(MergeRequest{Claims: []claim.Claim{bad2}})
rr = call(t, s, "POST", "/api/merge", string(body))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"rejected":1`) {
t.Fatalf("all-invalid merge = %d %s", rr.Code, rr.Body)
}
}
func TestTooManyClaimsIs413(t *testing.T) {
s := routedServer(t)
var sb strings.Builder
sb.WriteString("[")
for i := 0; i <= storage.MaxClaimsPerBatch; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"cid":"c","uid":"u","key":"k","value":null,"ts":1,"sig":""}`)
}
sb.WriteString("]")
if rr := call(t, s, "POST", "/api/claims", sb.String()); rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized batch = %d", rr.Code)
}
}
-86
View File
@@ -1,86 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The per-algorithm CID fields (`cid_sha2-256`, `cid_midhash256`, …) and the
// stored `canonical_cid` scalar were removed (METADATA_KEYS.md §14.13). They
// must be rejected at the write boundary rather than stored.
//
// Why a hard 400 and not a silent rewrite: the reverse-index hook
// (maybeAddAliasFromFieldLocked) only registers an alias for a field named
// `cids/<cid>`. A `cid_*` field was therefore written verbatim and never
// indexed — the record silently became unresolvable by that CID. A loud
// failure is strictly better than a record that looks correct and cannot be
// found.
func TestRejectDeprecatedField(t *testing.T) {
rejected := []string{
"cid_midhash256",
"cid_sha2-256",
"cid_btih_v2",
"cid_crc32",
"midhash256",
"canonical_cid",
}
for _, f := range rejected {
if !rejectDeprecatedField(f) {
t.Errorf("rejectDeprecatedField(%q) = false, want true (deprecated shape)", f)
}
}
accepted := []string{
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
"filePath",
"title",
"fileinfo/duration",
"tmdb/poster",
// Near-misses that must NOT be caught: the prefix is `cid_`, with an
// underscore. A field merely starting with "cid" is fine.
"cids",
"cidrank",
}
for _, f := range accepted {
if rejectDeprecatedField(f) {
t.Errorf("rejectDeprecatedField(%q) = true, want false (legitimate field)", f)
}
}
}
func TestValidateFields_Rejects400WithSlug(t *testing.T) {
rr := httptest.NewRecorder()
blocked := validateFields(rr, map[string]string{
"title": "Inception",
"cid_midhash256": "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
})
if !blocked {
t.Fatal("validateFields returned false for a payload carrying cid_midhash256; the write would have been stored unindexed")
}
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
var body map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if got := body["error"]; got != ErrDeprecatedField {
t.Errorf("error slug = %v, want %q", got, ErrDeprecatedField)
}
}
func TestValidateFields_AllowsCidsKeyset(t *testing.T) {
rr := httptest.NewRecorder()
blocked := validateFields(rr, map[string]string{
"filePath": "/watch/Inception.mkv",
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4": "true",
"cids/baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua": "true",
})
if blocked {
t.Fatalf("validateFields rejected a legitimate cids/ key-set write: %s", rr.Body.String())
}
}
+10 -10
View File
@@ -28,25 +28,25 @@ func getFileTuples(t *testing.T, s *Server, query string) (int, FileTuplesRespon
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
t.Helper()
id, err := c.Mint(path, size, mtime)
id, _, err := c.RegisterFileTuple(path, size, mtime, "bafkmid"+path)
if err != nil {
t.Fatalf("mint %s: %v", path, err)
t.Fatalf("register %s: %v", path, err)
}
return id
}
// Only records that are actually files count: a gateway record and a record
// that merely carries a path + size (no mtimeNano) are both left out.
// Only local files count: a gateway record — even one whose claims carry a
// filePath and size — is left out.
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
s, c := newFileTuplesTestServer(t)
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
if err := c.SetMetadataFlat("bagcsgatewayrecord", map[string]string{"title": "Some Release"}); err != nil {
t.Fatal(err)
}
if err := c.SetMetadataFlat("01partialroot", map[string]string{"filePath": "/files/cache/x.mkv", "sizeByte": "999"}); err != nil {
t.Fatal(err)
}
gw := testSigner(t, 1)
storeClaims(t, c,
signed(t, gw, "bagcsgatewayrecord", "title", "Some Release", 1),
signed(t, gw, "bafkpartial", "filePath", "/files/cache/x.mkv", 1),
signed(t, gw, "bafkpartial", "sizeByte", "999", 1),
)
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
+1 -317
View File
@@ -4,7 +4,6 @@ import (
"crypto/sha256"
"encoding/base32"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
@@ -89,42 +88,6 @@ const (
ErrDeprecatedField = "deprecated_field"
)
// rejectDeprecatedField reports whether a field name is one of the removed
// per-algorithm CID shapes (`cid_sha2-256`, `cid_midhash256`, `midhash256`, …).
//
// This is a tripwire, not a compatibility shim. Such a field used to be
// accepted and written verbatim — but the reverse-index hook
// (`maybeAddAliasFromFieldLocked`) only registers aliases for fields named
// `cids/<cid>`, so a `cid_*` field was stored and then NEVER INDEXED: the
// record silently became unresolvable by that CID. Failing the write loudly is
// strictly better than a record that looks fine and cannot be found.
//
// Writers must emit the bare-CID key-set instead: `cids/<bareCid>` = "true".
// See METADATA_KEYS.md §2/§14.13.
func rejectDeprecatedField(field string) bool {
return strings.HasPrefix(field, "cid_") ||
field == "midhash256" ||
field == "canonical_cid"
}
// validateFields rejects a write carrying any deprecated CID field. Returns
// true if the request was rejected (the handler must return immediately).
func validateFields(w http.ResponseWriter, metadata map[string]string) bool {
for field := range metadata {
if rejectDeprecatedField(field) {
writeErrorSlug(w, http.StatusBadRequest, ErrDeprecatedField,
fmt.Sprintf("field %q is a removed per-algorithm CID shape; "+
"write the bare-CID key-set instead: \"cids/<bareCid>\": \"true\" "+
"(a cid_* field would be stored but never indexed, leaving the "+
"record unresolvable by that CID). See METADATA_KEYS.md §2/§14.13.",
field),
false)
return true
}
}
return false
}
// defaultSlugFor maps an HTTP status code to the most likely error slug.
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
// instead of "unknown_root" on 404) call writeErrorSlug directly.
@@ -250,7 +213,7 @@ func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
// the watcher minted the root, regardless of which CID the caller knows.
hashID = s.storage.ResolveRoot(hashID)
metadata, err := s.storage.GetMetadataFlat(hashID)
metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -269,76 +232,6 @@ func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// handlePutMeta handles PUT /meta/{hash}
func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// CID → UUID resolution so writes land on the canonical root, not on
// a parallel midhash-rooted entry the watcher's UUID never sees.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if validateFields(w, metadata) {
return
}
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
})
}
// handleDeleteMeta handles DELETE /meta/{hash}
func (s *Server) handleDeleteMeta(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
deleted, err := s.storage.DeleteMetadata(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"deleted": deleted,
})
}
// handleListMeta handles GET /meta
func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
@@ -623,50 +516,6 @@ func writeErrorSlug(w http.ResponseWriter, status int, slug, message string, ret
})
}
// handlePatchMeta handles PATCH /meta/{hash}
// Merges the provided metadata into existing (does not delete missing keys)
func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// PATCH is the hot write path for meta-sort. Resolve so plugin output
// merges into the watcher's UUID root rather than minting a parallel
// midhash-rooted entry.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if validateFields(w, metadata) {
return
}
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"updated": updated,
})
}
// handleGetProperty handles GET /meta/{hash}/{key}
// Gets a single property value
func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) {
@@ -713,171 +562,6 @@ type PropertyBody struct {
Value string `json:"value"`
}
// handlePutProperty handles PUT /meta/{hash}/{key}
// Sets a single property value
// Accepts JSON body with {"value": "..."} or plain text
func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
key := vars["key"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
if validateFields(w, map[string]string{key: ""}) {
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
// Read body
buf := make([]byte, 1024*1024) // 1MB max
n, err := r.Body.Read(buf)
if err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "failed to read body")
return
}
body := buf[:n]
// Try to parse as JSON {"value": "..."} first
var propBody PropertyBody
var value string
if err := json.Unmarshal(body, &propBody); err == nil && propBody.Value != "" {
// JSON body with value field
value = propBody.Value
} else {
// Plain text body
value = string(body)
}
if err := s.storage.SetProperty(hashID, key, value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"property": key,
})
}
// handleDeleteProperty handles DELETE /meta/{hash}/{key}
// Deletes a single property
func (s *Server) handleDeleteProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
key := vars["key"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
if err := s.storage.DeleteProperty(hashID, key); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"property": key,
})
}
// handleAddToSet handles POST /meta/{hash}/_add/{key}
// Adds a value to a set-type field
// Accepts JSON body with {"value": "..."} or plain text
func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
key := vars["key"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
if validateFields(w, map[string]string{key: ""}) {
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
// Read body
buf := make([]byte, 1024*1024) // 1MB max
n, err := r.Body.Read(buf)
if err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "failed to read body")
return
}
body := buf[:n]
// Try to parse as JSON {"value": "..."} first
var propBody PropertyBody
var value string
if err := json.Unmarshal(body, &propBody); err == nil && propBody.Value != "" {
// JSON body with value field
value = propBody.Value
} else {
// Plain text body
value = string(body)
}
if value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
added, err := s.storage.AddToSet(hashID, key, value)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"property": key,
"added": added,
})
}
// CIDRequest is the request body for POST /file/cid
type CIDRequest struct {
Path string `json:"path"`
+73 -7
View File
@@ -4,11 +4,14 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
@@ -120,6 +123,9 @@ type identityDeleteResponse struct {
type identitySignRequest struct {
BytesB64 string `json:"bytesB64"`
// Format is "der" or "compact" (64-byte r||s, low-S). Default: compact for
// MMUR2 claim payloads ("MMUR2\n" prefix), DER otherwise (MMUR1 back-compat).
Format string `json:"format,omitempty"`
// UID selects which account signs. Optional — when omitted and exactly one
// account exists, that account is used (single-identity back-compat).
UID string `json:"uid,omitempty"`
@@ -143,6 +149,8 @@ type identitySignBatchRequest struct {
// UID selects which account signs — same optional single-identity
// back-compat as identitySignRequest.
UID string `json:"uid,omitempty"`
// Format: see identitySignRequest.Format; applies to every payload.
Format string `json:"format,omitempty"`
}
type identitySignBatchResponse struct {
@@ -445,10 +453,10 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
return
}
purged, err := s.storage.UDLPurgeUser(id.UID)
purged, err := s.retractAllClaims(r, id)
if err != nil {
writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
"could not purge this account's data; the account was NOT deleted: "+err.Error(), true)
"could not retract this account's claims; the account was NOT deleted: "+err.Error(), true)
return
}
@@ -456,7 +464,7 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
return
}
log.Printf("[API] Deleted account %s and purged %d User Data Layer records across %d cids",
log.Printf("[API] Deleted account %s after retracting %d claims across %d cids",
id.UID, purged.Records, purged.Cids)
writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
}
@@ -485,9 +493,9 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw)
sig, err := signPayload(id, raw, body.Format)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, identitySignResponse{
@@ -537,9 +545,9 @@ func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request)
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw)
sig, err := signPayload(id, raw, body.Format)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"]: "+err.Error())
return
}
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
@@ -562,3 +570,61 @@ func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
AEADKeyB64: base64.StdEncoding.EncodeToString(key),
})
}
// signPayload signs raw with the account key in the requested encoding.
func signPayload(id *identity.Identity, raw []byte, format string) ([]byte, error) {
switch format {
case "compact":
return identity.SignCompact(id, raw)
case "der":
return identity.Sign(id, raw)
case "":
if strings.HasPrefix(string(raw), claim.Domain) {
return identity.SignCompact(id, raw)
}
return identity.Sign(id, raw)
}
return nil, fmt.Errorf("format must be \"compact\" or \"der\"")
}
// retractAllClaims signs a retraction for every live claim the account holds
// and stores them (G11): the tombstones stay, so the votes cannot be replayed.
func (s *Server) retractAllClaims(r *http.Request, id *identity.Identity) (storage.UDLUserStats, error) {
refs, err := s.storage.LiveClaimsBySigner(id.UID)
if err != nil {
return storage.UDLUserStats{}, err
}
cids, keys := map[string]bool{}, map[string]bool{}
now := uint64(time.Now().UnixMilli())
batch := make([]claim.Claim, 0, len(refs))
for _, ref := range refs {
cur, ok, err := s.storage.UDLGetClaim(id.UID, ref[0], ref[1])
if err != nil {
return storage.UDLUserStats{}, err
}
ts := now
if ok && cur.TS >= ts {
ts = cur.TS + 1
}
tomb := claim.Claim{CID: ref[0], UID: id.UID, Key: ref[1], Value: claim.Null(), TS: ts}
if err := tomb.Sign(id); err != nil {
return storage.UDLUserStats{}, err
}
batch = append(batch, tomb)
cids[ref[0]], keys[ref[1]] = true, true
}
for start := 0; start < len(batch); start += storage.MaxClaimsPerBatch {
end := min(start+storage.MaxClaimsPerBatch, len(batch))
results, err := s.storage.WriteClaims(r.Context(), batch[start:end])
if err != nil {
return storage.UDLUserStats{}, err
}
for i, res := range results {
if res.Status != storage.StatusAccepted && res.Status != storage.StatusUnchanged {
return storage.UDLUserStats{}, fmt.Errorf("retraction of %s/%s refused: %s %s",
batch[start+i].CID, batch[start+i].Key, res.Status, res.Reason)
}
}
}
return storage.UDLUserStats{Records: len(batch), Cids: len(cids), Keys: len(keys)}, nil
}
+10 -6
View File
@@ -96,12 +96,16 @@ func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
// Some data to lose.
for _, cell := range [][2]string{{"cidA", "like"}, {"cidA", "rating"}, {"cidB", "seek"}} {
if ok, err := s.storage.UDLUpsertIfNewer(uid, cell[0], cell[1], 1, 100, "rec", "", false, false); err != nil || !ok {
t.Fatalf("seed: %v", err)
}
// Some data to lose: claims signed by the account.
acct, err := identity.Import(priv)
if err != nil {
t.Fatal(err)
}
storeClaims(t, s.storage,
signed(t, acct, "cidA", "like", "true", 100),
signed(t, acct, "cidA", "userRating", "5", 100),
signed(t, acct, "cidB", "seek", []byte("enc"), 100),
)
// The count the UI shows in the confirmation.
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
@@ -175,7 +179,7 @@ func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("the account file survived the delete")
}
// Data gone — the orphaning this whole change exists to prevent.
// Every claim retracted (tombstones kept) — nothing left to orphan.
after, err := s.storage.UDLAllUserStats()
if err != nil {
t.Fatalf("stats after: %v", err)
+7 -38
View File
@@ -383,8 +383,9 @@ func (s *Server) handleKVValueGet(w http.ResponseWriter, r *http.Request) {
})
}
// handleKVValuePut handles PUT /api/kv/value with body {key, value}.
// Maintains the file index when the key matches file:<hashId>/...
// handleKVValuePut handles PUT /api/kv/value with body {key, value}: service
// scratch KV only (btmagnet:*, searchcov:*), kept in the cache. Merged records
// and bookkeeping are refused — metadata is written as signed claims.
func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
@@ -401,19 +402,11 @@ func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.storage.SetRawValue(req.Key, req.Value); err != nil {
if err := s.storage.SetServiceValue(req.Key, req.Value); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
if hashID := extractFileHashID(req.Key); hashID != "" {
if err := s.storage.IndexAdd(hashID); err != nil {
// Non-fatal: the value was written; index entry is best-effort.
// Surface in the log path of writeJSON via response header.
w.Header().Set("X-KV-Index-Warning", err.Error())
}
}
writeJSON(w, http.StatusOK, KVValueResponse{
Key: req.Key,
Type: "string",
@@ -422,9 +415,7 @@ func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
})
}
// handleKVValueDelete handles DELETE /api/kv/value?key=...
// When the key matches file:<hashId>/..., removes the file index entry if no
// sibling keys remain after the delete.
// handleKVValueDelete handles DELETE /api/kv/value?key=... (service keys only).
func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
@@ -437,36 +428,14 @@ func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
return
}
deleted, err := s.storage.DeleteRawKey(key)
deleted, err := s.storage.DeleteServiceValue(key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusConflict, err.Error())
return
}
if deleted {
if hashID := extractFileHashID(key); hashID != "" {
if _, err := s.storage.IndexRemoveIfEmpty(hashID); err != nil {
w.Header().Set("X-KV-Index-Warning", err.Error())
}
}
}
writeJSON(w, http.StatusOK, KVValueDeleteResponse{
Key: key,
Deleted: deleted,
})
}
// extractFileHashID returns the hashID for a key shaped like file:<hashId>/...
// Returns "" when the key does not follow that convention.
func extractFileHashID(key string) string {
if !strings.HasPrefix(key, "file:") {
return ""
}
rest := strings.TrimPrefix(key, "file:")
slash := strings.Index(rest, "/")
if slash <= 0 {
return ""
}
return rest[:slash]
}
+8 -200
View File
@@ -38,6 +38,9 @@ type MetadataSearchRequest struct {
Property string `json:"property,omitempty"`
PropertyValue string `json:"propertyValue,omitempty"`
Limit int `json:"limit,omitempty"`
// As is a viewer uid: the search sees that signer's own claims overlaid
// (and their records as candidates). Also accepted as ?as=.
As string `json:"as,omitempty"`
// Filters is the structured form: AND across entries, OR within an
// entry's Values. Narrowed in SQL through the field index alongside the
@@ -222,10 +225,10 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
return
}
// Same resolution as the PUT below: callers hold a CID, not the root.
// Callers may hold a CID, not the root.
hashID = s.storage.ResolveRoot(hashID)
metadata, err := s.storage.GetMetadataFlat(hashID)
metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -244,87 +247,6 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, response)
}
// handleUpdateMetadataByHashId handles PUT /api/metadata/{hashId}
func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// The {hashId} in the URL is whatever CID the caller knows the file by —
// the gateway's auto-store keys this PUT by the hash it just computed
// (dispatch.rs → store_metadata_only), which for a usenet→IPFS fetch is
// the dag-pb root, not the nzb-release cid the record was minted under.
// Without resolving, the write lands on file:<thatCid>/* and forks a
// second root for a file that already has one: a full duplicate record,
// cross-linked and separately indexed, which every downstream ingest then
// sees as two files. Unknown CIDs pass through unchanged, so a genuinely
// new gateway record still mints its root at the hash.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
// Exclude internal processing status field
delete(metadata, "processingStatus")
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"message": "Metadata updated successfully",
})
}
// handleDeleteMetadataByHashId handles DELETE /api/metadata/{hashId}
func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// meta-gateway retracts a dropped hit by whichever CID it holds
// (meta_core.rs `delete`, which documents this resolution as a
// precondition). Without it, deleting by a non-root CID is a silent
// no-op and the retracted record lingers.
hashID = s.storage.ResolveRoot(hashID)
deletedCount, err := s.storage.DeleteMetadata(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"deletedKeys": deletedCount,
})
}
// handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X
func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -364,46 +286,6 @@ func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Reques
})
}
// handleMetadataUpdateProperty handles PUT /api/metadata/{hashId}/property
func (s *Server) handleMetadataUpdateProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req PropertyUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Property == "" {
writeError(w, http.StatusBadRequest, "property is required")
return
}
if err := s.storage.SetProperty(hashID, req.Property, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"property": req.Property,
"value": req.Value,
"message": "Property updated successfully",
})
}
// handleSearchMetadata handles POST /api/metadata/search
func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
@@ -420,6 +302,9 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
if req.Limit == 0 {
req.Limit = 100
}
if req.As == "" {
req.As = r.URL.Query().Get("as")
}
// If hashId is provided, do exact match
if req.HashID != "" {
@@ -457,83 +342,6 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
s.serveIndexedSearch(w, req)
}
// handleBatchUpdate handles POST /api/metadata/batch
func (s *Server) handleBatchUpdate(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req MetadataBatchUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if len(req.Updates) == 0 {
writeError(w, http.StatusBadRequest, "updates array is required")
return
}
var results []MetadataBatchResult
successCount := 0
errorCount := 0
for _, update := range req.Updates {
if update.HashID == "" {
results = append(results, MetadataBatchResult{
HashID: "unknown",
Status: "error",
Error: "Missing hash ID",
})
errorCount++
continue
}
var updateErr error
// Update complete metadata
if update.Metadata != nil {
// Exclude internal processing status
delete(update.Metadata, "processingStatus")
updateErr = s.storage.SetMetadataFlat(update.HashID, update.Metadata)
}
// Update individual properties
if updateErr == nil && update.Properties != nil {
for property, value := range update.Properties {
if err := s.storage.SetProperty(update.HashID, property, value); err != nil {
updateErr = err
break
}
}
}
if updateErr != nil {
results = append(results, MetadataBatchResult{
HashID: update.HashID,
Status: "error",
Error: updateErr.Error(),
})
errorCount++
} else {
results = append(results, MetadataBatchResult{
HashID: update.HashID,
Status: "ok",
})
successCount++
}
}
writeJSON(w, http.StatusOK, MetadataBatchResponse{
Status: "completed",
Total: len(results),
Success: successCount,
Errors: errorCount,
Results: results,
})
}
// handleClearMetadata handles POST /api/metadata/clear
func (s *Server) handleClearMetadata(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
+1 -1
View File
@@ -29,7 +29,7 @@ func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchReq
for _, f := range filters {
narrow = append(narrow, storage.SearchFilter{Property: f.Property, Values: f.Values})
}
hits, err := s.storage.Search(req.Query, narrow, req.Limit, func(hashID string, fields map[string]string) bool {
hits, err := s.storage.SearchAs(req.Query, narrow, req.Limit, req.As, func(hashID string, fields map[string]string) bool {
if req.Query != "" &&
!matchAllTokens(storage.Haystack(fields), tokens) &&
!strings.Contains(strings.ToLower(hashID), queryLower) {
+14 -14
View File
@@ -176,7 +176,7 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
s.router.HandleFunc("/api/metadata/batch", s.handleGone).Methods("POST")
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
// User Data Layer signing identity (see internal/identity). Auth-gated
@@ -192,15 +192,18 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/identity/sign-batch", s.handleIdentitySignBatch).Methods("POST")
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
// Snapshot (export / import / wipe)
s.router.HandleFunc("/api/snapshot/export", s.handleSnapshotExport).Methods("GET")
s.router.HandleFunc("/api/snapshot/import", s.handleSnapshotImport).Methods("POST")
s.router.HandleFunc("/api/snapshot/wipe", s.handleSnapshotWipe).Methods("POST")
// Signed claims (v2.0.0): the only metadata write path, the raw claims
// behind a record, and the stateless merge clients use for search hits.
s.router.HandleFunc("/api/claims", s.handlePostClaims).Methods("POST")
s.router.HandleFunc("/api/merge", s.handleMerge).Methods("POST")
s.router.HandleFunc("/api/meta/{cid}/claims", s.handleClaimsByCID).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/claims", s.handleClaimsByRID).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataUpdateProperty).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleGone).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGone).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGone).Methods("DELETE")
// Schema inference
s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET")
@@ -232,18 +235,15 @@ func (s *Server) setupRoutes() {
// Metadata operations - base endpoints
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
s.router.HandleFunc("/meta/{hash}", s.handlePatchMeta).Methods("PATCH")
s.router.HandleFunc("/meta/{hash}", s.handleDeleteMeta).Methods("DELETE")
s.router.HandleFunc("/meta/{hash}", s.handleGone).Methods("PUT", "PATCH", "DELETE")
s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET")
// Metadata operations - set operations (must be before property routes)
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleAddToSet).Methods("POST")
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleGone).Methods("POST")
// Metadata operations - property-level (key can contain slashes)
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET")
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handlePutProperty).Methods("PUT")
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleDeleteProperty).Methods("DELETE")
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGone).Methods("PUT", "DELETE")
// Data operations
s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET")
-167
View File
@@ -1,167 +0,0 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/metazla/meta-core/internal/snapshot"
)
// Bound the upload size so a malicious client can't OOM the server.
// 2 GiB is well above any realistic export.
const maxImportBytes = 2 << 30
// pluginOutputDir is where plugin-produced artifacts live on disk
// (subtitles, posters, etc). Mounted from the host into the meta-core
// container at $FILES_PATH/plugin.
func (s *Server) pluginOutputDir() string {
return filepath.Join(s.config.FilesPath, "plugin")
}
// handleSnapshotExport streams a ZIP snapshot of all metadata.
// GET /api/snapshot/export?include=hash-index,plugin-output
func (s *Server) handleSnapshotExport(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// The server has a 30s WriteTimeout (server.go:222). Long exports
// blow past that, leaving a truncated ZIP without a central directory.
// Clear the deadline for this handler so the export can run as long as
// needed. ResponseController is the standard way to do this.
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{})
include := splitCSVParam(r.URL.Query().Get("include"))
opts := snapshot.ExportOptions{
IncludePluginOutput: include["plugin-output"],
PluginOutputDir: s.pluginOutputDir(),
}
filename := fmt.Sprintf("metamesh-snapshot-%s.zip", time.Now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
// We stream — no Content-Length.
exporter := snapshot.NewExporter(s.storage)
if _, err := exporter.Export(w, opts); err != nil {
// Headers may already be sent; log and bail.
// Client will see a truncated zip, which is the best signal we can give.
fmt.Printf("[Snapshot] export failed: %v\n", err)
return
}
}
// handleSnapshotImport applies an uploaded ZIP snapshot.
// POST /api/snapshot/import?mode=replace|merge&conflict=mine|source&dry_run=true
// Body: application/zip (raw) OR multipart/form-data with field "snapshot"
func (s *Server) handleSnapshotImport(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// Long imports can blow past the server's 30s ReadTimeout/WriteTimeout
// when the snapshot is large. Clear both for this handler.
rc := http.NewResponseController(w)
_ = rc.SetReadDeadline(time.Time{})
_ = rc.SetWriteDeadline(time.Time{})
q := r.URL.Query()
opts := snapshot.ImportOptions{
Mode: snapshot.Mode(q.Get("mode")),
Conflict: snapshot.Conflict(q.Get("conflict")),
DryRun: strings.EqualFold(q.Get("dry_run"), "true"),
PluginOutputDir: s.pluginOutputDir(),
}
if opts.Mode == "" {
opts.Mode = snapshot.ModeMerge
}
if opts.Mode == snapshot.ModeMerge && opts.Conflict == "" {
opts.Conflict = snapshot.ConflictSource
}
body, err := readImportBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
importer := snapshot.NewImporter(s.storage)
result, err := importer.Import(bytes.NewReader(body), int64(len(body)), opts)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// handleSnapshotWipe clears all stored metadata.
// POST /api/snapshot/wipe?scope=metadata
func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
scopes := splitCSVParam(r.URL.Query().Get("scope"))
if len(scopes) == 0 {
writeError(w, http.StatusBadRequest, "scope query param is required (e.g. scope=metadata)")
return
}
scope := snapshot.WipeScope{
Metadata: scopes["metadata"],
}
if !scope.Metadata {
writeError(w, http.StatusBadRequest, "no supported scope requested; scope=metadata is the only accepted value")
return
}
wiper := snapshot.NewWiper(s.storage)
res, err := wiper.Wipe(scope)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
// readImportBody accepts either a raw application/zip body or a multipart
// upload with a field named "snapshot". Buffers up to maxImportBytes.
func readImportBody(r *http.Request) ([]byte, error) {
r.Body = http.MaxBytesReader(nil, r.Body, maxImportBytes)
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
return nil, fmt.Errorf("parse multipart: %w", err)
}
f, _, err := r.FormFile("snapshot")
if err != nil {
return nil, fmt.Errorf("missing 'snapshot' file field: %w", err)
}
defer f.Close()
return io.ReadAll(f)
}
return io.ReadAll(r.Body)
}
// splitCSVParam parses "a,b,c" into a set. Empty values yield an empty set.
func splitCSVParam(v string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(v, ",") {
p := strings.TrimSpace(part)
if p != "" {
out[p] = true
}
}
return out
}
+22 -9
View File
@@ -112,7 +112,15 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
}
cursor = head
} else {
cursor = resolveStartCursor(ctx, store, stream, lastID, w, flusher)
if rebuilt := s.storage.RebuiltAt(); stream == storage.MetaEventsStream && lastID != "" && rebuilt != "" &&
compareStreamIDs(lastID, rebuilt) < 0 {
// The cache was rebuilt after this cursor: the rebuild published no
// per-key events, so the consumer must take a snapshot.
writeGap(w, flusher, lastID, rebuilt, "rebuild")
cursor = "0"
} else {
cursor = resolveStartCursor(ctx, store, stream, lastID, w, flusher)
}
}
lastWrite := time.Now()
@@ -256,14 +264,7 @@ func resolveStartCursor(ctx context.Context, store *kv.Store, stream, lastID str
}
// Cursor was trimmed. Emit a gap event so consumers that care can react.
gap := map[string]string{
"requested": lastID,
"resumeFrom": oldest,
"reason": "retention",
}
payload, _ := json.Marshal(gap)
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
flusher.Flush()
writeGap(w, flusher, lastID, oldest, "retention")
// Return "0" so the next read picks up entries starting from the
// stream's true minimum — including `oldest` itself.
@@ -327,3 +328,15 @@ func parseStreamID(id string) (int64, int64) {
}
return ms, seq
}
// writeGap emits the synthetic `gap` event: the consumer's cursor cannot be
// resumed losslessly and it should take a snapshot (Last-Event-ID: 0).
func writeGap(w http.ResponseWriter, flusher http.Flusher, requested, resumeFrom, reason string) {
payload, _ := json.Marshal(map[string]string{
"requested": requested,
"resumeFrom": resumeFrom,
"reason": reason,
})
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
flusher.Flush()
}
+44 -15
View File
@@ -33,11 +33,11 @@ func (r *syncRecorder) body() string {
func TestMetaEventsSnapshotThenTail(t *testing.T) {
stor := newTestStorage(t)
s := &Server{storage: stor}
for i, id := range []string{"R1", "R2"} {
if err := stor.SetMetadataFlat(id, map[string]string{"title": "T", "n": string(rune('a' + i))}); err != nil {
t.Fatal(err)
}
a := testSigner(t, 1)
for i, cid := range []string{"bafyR1", "bafyR2"} {
storeClaims(t, stor, signed(t, a, cid, "title", "T", 1), signed(t, a, cid, "n", string(rune('a'+i)), 1))
}
r1, r2 := stor.ResolveRoot("bafyR1"), stor.ResolveRoot("bafyR2")
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/api/events/meta", nil).WithContext(ctx)
@@ -56,34 +56,63 @@ func TestMetaEventsSnapshotThenTail(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}
waitFor(`"key":"file:R2/title"`)
if err := stor.SetProperty("R1", "title", "changed"); err != nil {
t.Fatal(err)
}
waitFor(`"key":"file:` + r2 + `/title"`)
storeClaims(t, stor, signed(t, a, "bafyR1", "title", "changed", 2))
waitFor(`event: set`)
time.Sleep(300 * time.Millisecond)
cancel()
<-done
body := rec.body()
for _, k := range []string{"file:R1/title", "file:R1/n", "file:R2/title", "file:R2/n"} {
keys := []string{"file:" + r1 + "/title", "file:" + r1 + "/n", "file:" + r2 + "/title", "file:" + r2 + "/n",
"file:" + r1 + "/cids/bafyR1", "file:" + r2 + "/cids/bafyR2"}
for _, k := range keys {
if !strings.Contains(body, `"key":"`+k+`"`) {
t.Errorf("snapshot is missing %s", k)
}
}
events := strings.Split(strings.TrimSpace(body), "\n\n")
if len(events) != 5 {
t.Fatalf("want 4 snapshot events + 1 live event, got %d:\n%s", len(events), body)
if len(events) != 7 {
t.Fatalf("want 6 snapshot events + 1 live event, got %d:\n%s", len(events), body)
}
for i, e := range events[:3] {
for i, e := range events[:5] {
if strings.Contains(e, "id: ") {
t.Errorf("snapshot event %d carries an id: %q", i, e)
}
}
if !strings.HasPrefix(events[3], "id: ") || !strings.HasPrefix(events[4], "id: ") {
if !strings.HasPrefix(events[5], "id: ") || !strings.HasPrefix(events[6], "id: ") {
t.Fatalf("the last snapshot event and the live event must carry ids:\n%s", body)
}
if !strings.Contains(events[4], `"key":"file:R1/title"`) {
t.Fatalf("live tail did not follow the snapshot: %q", events[4])
if !strings.Contains(events[6], `"key":"file:`+r1+`/title"`) {
t.Fatalf("live tail did not follow the snapshot: %q", events[6])
}
}
// A cursor older than the last cache rebuild gets `gap` (reason rebuild): the
// rebuild published no per-key events, so the consumer must re-snapshot.
func TestMetaEventsGapAfterRebuild(t *testing.T) {
stor := newTestStorage(t)
s := &Server{storage: stor}
storeClaims(t, stor, signed(t, testSigner(t, 1), "bafyA", "title", "T", 1))
if err := stor.Rebuild(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/api/events/meta", nil).WithContext(ctx)
req.Header.Set("Last-Event-ID", "1000-0")
rec := &syncRecorder{ResponseRecorder: httptest.NewRecorder()}
done := make(chan struct{})
go func() { s.handleEventsMeta(rec, req); close(done) }()
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(rec.body(), "event: gap") {
if time.Now().After(deadline) {
t.Fatalf("no gap after rebuild:\n%s", rec.body())
}
time.Sleep(20 * time.Millisecond)
}
cancel()
<-done
if !strings.Contains(rec.body(), `"reason":"rebuild"`) {
t.Fatalf("gap reason: %s", rec.body())
}
}
+2 -2
View File
@@ -30,7 +30,7 @@ type fileStatsJSON struct {
// cheap (an index range count, a cached row count, a file stat) plus a
// directory listing, recomputed on every call. The `redis*` JSON names
// predate the move to SQLite and are kept for dashboard compatibility:
// redisKeys is the store's row count, redisMemory its on-disk size. Files and UDLUsers need a keyspace walk — ~0.5s for the file
// redisKeys is the number of claim rows (truth), redisMemory both databases' on-disk size. Files and UDLUsers need a keyspace walk — ~0.5s for the file
// tuples and ~3s for the UDL SCAN on an 88k-record box — so they are served
// from a background-refreshed cache and are null until the first sweep lands.
type StatsResponse struct {
@@ -115,7 +115,7 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
} else {
resp.Records = n
}
if n, err := s.storage.CountKeys(); err == nil {
if n, err := s.storage.CountClaims(); err == nil {
resp.RedisKeys = n
}
if mem, err := s.storage.GetMemoryInfo(); err == nil {
+51 -2
View File
@@ -1,19 +1,68 @@
package api
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
// newTestStorage opens a fresh metadata store in the test's temp dir.
// newTestStorage opens (and starts) a fresh store in the test's temp dir.
func newTestStorage(t *testing.T) *storage.Client {
t.Helper()
dir := t.TempDir()
c := storage.NewClient()
if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
if err := c.Connect(filepath.Join(dir, "meta-core.db"), filepath.Join(dir, "cache.db")); err != nil {
t.Fatalf("open store: %v", err)
}
if err := c.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
t.Fatalf("schema: %v", err)
}
if err := c.Start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
func testSigner(t *testing.T, n byte) *identity.Identity {
t.Helper()
id, err := identity.Import(fmt.Sprintf("%02x%062x", n, 1))
if err != nil {
t.Fatal(err)
}
return id
}
// signed builds a claim signed by id; v is string, []byte or nil.
func signed(t *testing.T, id *identity.Identity, cid, key string, v any, ts uint64) claim.Claim {
t.Helper()
cl := claim.Claim{CID: cid, UID: id.UID, Key: key, TS: ts}
switch x := v.(type) {
case string:
cl.Value = claim.Text(x)
case []byte:
cl.Value = claim.Bytes(x)
}
if err := cl.Sign(id); err != nil {
t.Fatal(err)
}
return cl
}
func storeClaims(t *testing.T, c *storage.Client, claims ...claim.Claim) {
t.Helper()
res, err := c.WriteClaims(context.Background(), claims)
if err != nil {
t.Fatal(err)
}
for i, r := range res {
if r.Status != storage.StatusAccepted {
t.Fatalf("claim %d: %s %s", i, r.Status, r.Reason)
}
}
}
+109 -206
View File
@@ -1,13 +1,14 @@
package api
// User Data Layer (UDL) HTTP surface.
// User Data Layer facade (/api/udl/*). Since meta-core v2.0.0 a user's data are
// ordinary MMUR2 claims signed by the user's key (plaintext for public-social
// keys, ciphertext for private ones); these routes keep their paths for the
// clients (meta-watch, meta-read, meta-listen, the gateway mirror) and speak
// claims in the §3.4 JSON form:
//
// Backs meta-watch's per-user store (My List, Continue Watching, ratings,
// profile) now that the authoritative store lives in meta-core instead of
// meta-watch's local op-log. Records arrive already signed (and, for
// private-tier keys, already encrypted) as opaque base64 CBOR blobs; meta-core
// version-gates and indexes them but never inspects the crypto. See
// internal/storage/udl.go for the key model.
// {"cid","uid","key","value": "…" | {"b64": "…"} | null,"ts","sig"}
//
// A write is the same as POST /api/claims; newest ts wins per (uid, cid, key).
import (
"encoding/json"
@@ -17,82 +18,46 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
)
// UDLPutRequest is the body of PUT /api/udl/record.
type UDLPutRequest struct {
Uid string `json:"uid"`
Cid string `json:"cid"`
Key string `json:"key"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
Record string `json:"record"` // base64 CBOR, opaque + signed
Value *json.RawMessage `json:"value,omitempty"`
// Tombstone marks this write as a deletion of (uid, cid, key). The record
// itself is opaque here — meta-core never decodes the CBOR, and a private-
// tier value and a tombstone are equally "no plaintext value" — so the
// writer has to say. Without it the cid is never removed from
// udl:idx:user:<uid>:key:<key>, which then grows for the life of the
// account and is returned in full on every My List / Continue Watching
// read. Omitted by older writers, which keeps the previous behaviour.
Tombstone bool `json:"tombstone,omitempty"`
}
// UDLPutResponse is the response of PUT /api/udl/record.
// UDLPutResponse answers PUT /api/udl/record.
type UDLPutResponse struct {
Accepted bool `json:"accepted"`
Accepted bool `json:"accepted"`
Status storage.ClaimStatus `json:"status"`
}
// UDLPutBatchRequest is the body of PUT /api/udl/records — many cells, one
// request. Each entry is exactly a UDLPutRequest, so a client can build the
// same signed record for either endpoint.
// UDLPutBatchRequest is PUT /api/udl/records.
type UDLPutBatchRequest struct {
Records []UDLPutRequest `json:"records"`
Records []claim.Claim `json:"records"`
}
// UDLPutBatchResult is one cell's outcome. Accepted=false with an empty Error
// means the write was stale (the single-record endpoint's 409), which in a
// batch is an ordinary outcome rather than a failure.
// UDLPutBatchResult is one entry of the batch response.
type UDLPutBatchResult struct {
Key string `json:"key"`
Accepted bool `json:"accepted"`
Error string `json:"error,omitempty"`
Key string `json:"key"`
Accepted bool `json:"accepted"`
Status storage.ClaimStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// UDLPutBatchResponse mirrors the request order index-for-index.
// UDLPutBatchResponse answers PUT /api/udl/records.
type UDLPutBatchResponse struct {
Results []UDLPutBatchResult `json:"results"`
}
// maxUDLBatch bounds one batch. Each record is a store write while the
// request is held open; sized to match the sign batch on the other side of the
// same operation (see maxSignBatch).
const maxUDLBatch = 1000
// UDLRecordResponse is the response of GET /api/udl/record.
// UDLRecordResponse answers GET /api/udl/record. Claim is the stored claim,
// tombstone included (its ts is what a newer write must exceed); Exists is
// false when it is absent or a tombstone.
type UDLRecordResponse struct {
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
Exists bool `json:"exists"`
// Value is the plaintext JSON scalar stored beside the opaque record, and
// is present only for PUBLIC-tier keys (like, rating, profile:name,
// profile:avatar). json.RawMessage rather than string because the field is
// generic over keys — a bool for `like`, a number for `rating`, a string
// for a profile name (see aggregatePublicValues) — and because it is the
// exact type UDLPutRequest.Value uses, so a value written through PUT reads
// back byte-identical.
//
// Absent, never null, when there is no plaintext: private-tier cells,
// tombstones and never-written cells must not read as an empty public
// value. Readers key off presence, not `exists`.
Value *json.RawMessage `json:"value,omitempty"`
Exists bool `json:"exists"`
Claim *claim.Claim `json:"claim"`
}
// UDLAggregate mirrors meta-watch's Aggregate wire shape exactly (snake_case
// field names; `avg` is null for boolean keys like `like`), so the meta-watch
// client deserializes it into the same struct it serializes to the UI.
// UDLAggregate is one key's public aggregate on a cid.
type UDLAggregate struct {
Key string `json:"key"`
Count int `json:"count"`
@@ -101,10 +66,17 @@ type UDLAggregate struct {
SampleSize int `json:"sample_size"`
}
// handleUDLRecordGet handles GET /api/udl/record?uid&cid&key
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
func (s *Server) udlReady(w http.ResponseWriter) bool {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return false
}
return true
}
// handleUDLRecordGet handles GET /api/udl/record?uid=&cid=&key=.
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
q := r.URL.Query()
@@ -113,85 +85,53 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "uid, cid and key are required")
return
}
cell, exists, err := s.storage.UDLGetCell(uid, cid, key)
cl, ok, err := s.storage.UDLGetClaim(uid, cid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp := UDLRecordResponse{
Record: cell.Record,
Version: cell.Version,
Ts: cell.Ts,
Exists: exists,
}
// json.Valid guards the encoder: a malformed `value` hash field (hand-edited
// the store, a future writer bug) would otherwise fail mid-encode and emit a
// truncated body instead of a readable error.
if exists && cell.HasValue && json.Valid([]byte(cell.Value)) {
raw := json.RawMessage(cell.Value)
resp.Value = &raw
resp := UDLRecordResponse{}
if ok {
resp.Claim = &cl
resp.Exists = !cl.Value.IsNull()
}
writeJSON(w, http.StatusOK, resp)
}
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write
// (version <= stored) returns 409 stale_version so the client can re-read and
// retry with a higher version.
// handleUDLRecordPut handles PUT /api/udl/record with one claim as the body.
func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
var req UDLPutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
var cl claim.Claim
if err := json.NewDecoder(r.Body).Decode(&cl); err != nil {
writeError(w, http.StatusBadRequest, "body must be one claim: "+err.Error())
return
}
if req.Uid == "" || req.Cid == "" || req.Key == "" || req.Record == "" {
writeError(w, http.StatusBadRequest, "uid, cid, key and record are required")
return
}
publicValue := ""
hasValue := false
if req.Value != nil {
publicValue = string(*req.Value)
hasValue = true
}
accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue, req.Tombstone)
results, err := s.storage.WriteClaims(r.Context(), []claim.Claim{cl})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !accepted {
writeErrorSlug(w, http.StatusConflict, "stale_version", "a record with an equal or newer version already exists", true)
return
res := results[0]
switch res.Status {
case storage.StatusAccepted, storage.StatusUnchanged:
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true, Status: res.Status})
case storage.StatusStale:
writeErrorSlug(w, http.StatusConflict, "stale_version", "a claim with an equal or newer ts already exists", true)
default:
writeErrorSlug(w, http.StatusBadRequest, string(res.Status), res.Reason, false)
}
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
}
// handleUDLRecordsPut handles PUT /api/udl/records — the batch form of
// handleUDLRecordPut.
//
// Exists for the bulk verbs above this layer ("mark this whole series seen"),
// where one user action produces one cell per episode. Each cell still goes
// through the same version-gated Lua upsert, so the CRDT semantics are
// identical — this batches the transport only.
//
// **Not transactional, and deliberately not 409.** A per-record `accepted` flag
// replaces the single-record conflict status: in a batch, one stale cell is a
// normal outcome (another device already marked that episode) and must not fail
// the 299 cells beside it. Callers read the array, not the status code.
// handleUDLRecordsPut handles PUT /api/udl/records {records: [claim, …]}.
func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
var req UDLPutBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if len(req.Records) == 0 {
@@ -202,91 +142,66 @@ func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "too many records in one batch")
return
}
results := make([]UDLPutBatchResult, 0, len(req.Records))
for _, rec := range req.Records {
if rec.Uid == "" || rec.Cid == "" || rec.Key == "" || rec.Record == "" {
results = append(results, UDLPutBatchResult{
Key: rec.Key,
Error: "uid, cid, key and record are required",
})
continue
}
publicValue := ""
hasValue := false
if rec.Value != nil {
publicValue = string(*rec.Value)
hasValue = true
}
accepted, err := s.storage.UDLUpsertIfNewer(rec.Uid, rec.Cid, rec.Key, rec.Version, rec.Ts, rec.Record, publicValue, hasValue, rec.Tombstone)
if err != nil {
// A storage error is per-record here rather than fatal for the whole
// batch: the cells already written stay written, and reporting which
// ones failed lets the caller retry exactly those.
results = append(results, UDLPutBatchResult{Key: rec.Key, Error: err.Error()})
continue
}
results = append(results, UDLPutBatchResult{Key: rec.Key, Accepted: accepted})
results, err := s.storage.WriteClaims(r.Context(), req.Records)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: results})
out := make([]UDLPutBatchResult, len(results))
for i, res := range results {
out[i] = UDLPutBatchResult{
Key: req.Records[i].Key,
Status: res.Status,
Accepted: res.Status == storage.StatusAccepted || res.Status == storage.StatusUnchanged,
Error: res.Reason,
}
}
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: out})
}
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
// (active_for_user_key — My List, Continue Watching).
func (s *Server) writeUDLEntries(w http.ResponseWriter, uid, cid, key string) {
entries, err := s.storage.UDLClaimsForUser(uid, cid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if entries == nil {
entries = []claim.Claim{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
}
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}: every live claim
// of uid on key (My List, Continue Watching).
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
vars := mux.Vars(r)
uid, key := vars["uid"], vars["key"]
if uid == "" || key == "" {
if vars["uid"] == "" || vars["key"] == "" {
writeError(w, http.StatusBadRequest, "uid and key are required")
return
}
entries, err := s.storage.UDLListUserKey(uid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
s.writeUDLEntries(w, vars["uid"], "", vars["key"])
}
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}
// (list_for_user_cid).
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}.
func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
vars := mux.Vars(r)
uid, cid := vars["uid"], vars["cid"]
if uid == "" || cid == "" {
if vars["uid"] == "" || vars["cid"] == "" {
writeError(w, http.StatusBadRequest, "uid and cid are required")
return
}
entries, err := s.storage.UDLListUserCid(uid, cid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
s.writeUDLEntries(w, vars["uid"], vars["cid"], "")
}
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=<cid>&limit=<n>
// — every record a uid owns, for cross-instance profile recovery.
//
// The other user-scoped reads answer a question with one coordinate already
// known. Recovery knows only the uid: a box that has just imported a secret key
// has no idea which cids or keys the profile ever touched, so it needs the
// whole set or it gets nothing.
//
// Paged on a cid boundary — `nextCid` empty means the export is complete. The
// response is opaque signed records, exactly as stored; whoever consumes it is
// responsible for verifying each one before trusting it (meta-core has never
// held the account key and cannot verify on anyone's behalf).
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=&limit= — the
// whole profile, paged on cid boundaries (recovery / export).
func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
uid := mux.Vars(r)["uid"]
@@ -301,31 +216,22 @@ func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
if n < limit {
limit = n
}
limit = min(n, limit)
}
entries, nextCid, err := s.storage.UDLAllForUser(uid, r.URL.Query().Get("after"), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"entries": entries,
"nextCid": nextCid,
})
if entries == nil {
entries = []claim.Claim{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries, "nextCid": nextCid})
}
// handleUDLUserCount handles GET /api/udl/user/{uid}/count — how many cids a
// uid holds records for.
//
// Exists so a gateway mirror can enforce a per-profile quota without walking
// anything: 32 random bytes are a valid uid, so an unbounded mirror is a
// disk-fill DoS that needs no account. A quota check that itself cost a scan
// would just move the denial of service one layer up.
// handleUDLUserCount handles GET /api/udl/user/{uid}/count.
func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
uid := mux.Vars(r)["uid"]
@@ -341,10 +247,10 @@ func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"cids": n})
}
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users (uids_for_cid).
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users: signers holding live
// claims on the cid.
func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
cid := mux.Vars(r)["cid"]
@@ -360,12 +266,9 @@ func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"users": users})
}
// handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,rating.
// Computes per-key population stats from the stored public value fields
// (absorbs meta-watch's aggregate.rs). Reflects only records this core holds.
// handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,userRating.
func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
if !s.udlReady(w) {
return
}
cid := mux.Vars(r)["cid"]
@@ -375,7 +278,7 @@ func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
}
keysParam := r.URL.Query().Get("keys")
if keysParam == "" {
keysParam = "like,rating"
keysParam = "like,userRating"
}
aggregates := make([]UDLAggregate, 0)
for _, key := range strings.Split(keysParam, ",") {
+74 -178
View File
@@ -6,211 +6,107 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/metazla/meta-core/internal/claim"
)
// newUDLTestServer returns a Server wired to an in-memory Redis.
//
// The struct is built directly rather than through NewServer: NewServer boots
// the file watcher, mounts manager, watchers poller and WebDAV handler, none of
// which the UDL handlers touch — they use s.storage and nothing else.
func newUDLTestServer(t *testing.T) *Server {
t.Helper()
return &Server{storage: newTestStorage(t)}
}
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON
// fields, so a test can assert on the *encoding* of `value` (is it a quoted
// string? is the key there at all?) rather than on what a typed struct would
// have coerced it into.
func getRecord(t *testing.T, s *Server, uid, cid, key string) (int, map[string]json.RawMessage) {
func udlGet(t *testing.T, s *Server, uid, cid, key string) UDLRecordResponse {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
rr := httptest.NewRecorder()
s.handleUDLRecordGet(rr, req)
var body map[string]json.RawMessage
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
if rr.Code != http.StatusOK {
t.Fatalf("GET record = %d %s", rr.Code, rr.Body)
}
return rr.Code, body
var resp UDLRecordResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp
}
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and
// come back *as it was written* — a JSON string stays a quoted string, a bool
// stays a bool.
//
// Both halves matter. Without the value at all, the meta-core dashboard can
// only ever show 44-character base58 uids: display names live in the User Data
// Layer as the public cell (uid, "self", "profile:name"), and the plaintext
// sits in Redis where the storage layer has always read it — the handler just
// used to drop it. And the type has to stay json.RawMessage: this endpoint is
// generic over keys, so a `*string` would mangle `like` (bool) and `rating`
// (number) while looking correct for names.
func TestUDLRecordGet_ReturnsPublicValue(t *testing.T) {
s := newUDLTestServer(t)
const uid = "zAlice"
if ok, err := s.storage.UDLUpsertIfNewer(uid, "self", "profile:name", 1, 100, "cmVj", `"Ada Lovelace"`, true, false); err != nil || !ok {
t.Fatalf("seed name: ok=%v err=%v", ok, err)
}
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "like", 1, 100, "cmVj", `true`, true, false); err != nil || !ok {
t.Fatalf("seed like: ok=%v err=%v", ok, err)
}
code, body := getRecord(t, s, uid, "self", "profile:name")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if got := string(body["value"]); got != `"Ada Lovelace"` {
t.Fatalf("value = %s, want a quoted JSON string", got)
}
var name string
if err := json.Unmarshal(body["value"], &name); err != nil || name != "Ada Lovelace" {
t.Fatalf("value should unmarshal to the plain name, got %q err=%v", name, err)
}
// A boolean key survives unquoted — the assertion a *string field fails.
if _, body = getRecord(t, s, uid, "bagcsaaa", "like"); string(body["value"]) != "true" {
t.Fatalf("like value = %s, want the bare JSON literal true", body["value"])
}
}
// A cell with no plaintext must omit `value` entirely rather than send null.
//
// That is the shape of every private-tier key (seek, watched, card, localpath —
// their real value rides encrypted inside the opaque record) and of every
// tombstone. A reader keys off presence: "there is no plaintext here" and "the
// plaintext is null" are different facts, and a name cleared by its owner
// leaves the cell existing with no value, which must not read as a name.
func TestUDLRecordGet_OmitsValueForPrivateCell(t *testing.T) {
s := newUDLTestServer(t)
const uid = "zBob"
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "seek", 1, 100, "ZW5jcnlwdGVk", "", false, false); err != nil || !ok {
t.Fatalf("seed private cell: ok=%v err=%v", ok, err)
}
code, body := getRecord(t, s, uid, "bagcsaaa", "seek")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if _, ok := body["value"]; ok {
t.Fatalf("private cell must omit `value`, got %s", body["value"])
}
if string(body["exists"]) != "true" {
t.Fatalf("exists = %s, want true", body["exists"])
}
}
// A cell that was never written answers 200 with exists:false and no value —
// not a 404, and not an empty-string value that would render as a blank name.
func TestUDLRecordGet_MissingCellHasNoValue(t *testing.T) {
s := newUDLTestServer(t)
code, body := getRecord(t, s, "zNobody", "self", "profile:name")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if string(body["exists"]) != "false" {
t.Fatalf("exists = %s, want false", body["exists"])
}
if _, ok := body["value"]; ok {
t.Fatalf("missing cell must omit `value`, got %s", body["value"])
}
}
// putRecords drives handleUDLRecordsPut with a raw JSON body and decodes the
// per-record results.
func putRecords(t *testing.T, s *Server, body string) (int, UDLPutBatchResponse) {
func udlPutBatch(t *testing.T, s *Server, claims ...claim.Claim) UDLPutBatchResponse {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(body))
body, _ := json.Marshal(UDLPutBatchRequest{Records: claims})
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(string(body)))
rr := httptest.NewRecorder()
s.handleUDLRecordsPut(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("PUT records = %d %s", rr.Code, rr.Body)
}
var resp UDLPutBatchResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp
}
var out UDLPutBatchResponse
if rr.Code == http.StatusOK {
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
// The facade stores and returns MMUR2 claims: public values in clear,
// private ones as ciphertext, tombstones with their ts.
func TestUDLFacadeRoundTrip(t *testing.T) {
s := newUDLTestServer(t)
me := testSigner(t, 7)
res := udlPutBatch(t, s,
signed(t, me, "self", "profile:name", "Ada Lovelace", 100),
signed(t, me, "bagcsaaa", "like", "true", 100),
signed(t, me, "bagcsaaa", "seek", []byte("ciphertext"), 100),
)
for _, r := range res.Results {
if !r.Accepted {
t.Fatalf("not accepted: %+v", r)
}
}
return rr.Code, out
got := udlGet(t, s, me.UID, "self", "profile:name")
if !got.Exists || got.Claim == nil || got.Claim.Value.Text != "Ada Lovelace" || got.Claim.Verify() != nil {
t.Fatalf("profile = %+v", got)
}
if seek := udlGet(t, s, me.UID, "bagcsaaa", "seek"); seek.Claim == nil || seek.Claim.Value.Kind != claim.KindBytes {
t.Fatalf("seek = %+v", seek)
}
if miss := udlGet(t, s, me.UID, "bagcsaaa", "nope"); miss.Exists || miss.Claim != nil {
t.Fatalf("missing cell = %+v", miss)
}
// Retraction: stored, returned with its ts, no longer "exists".
udlPutBatch(t, s, signed(t, me, "bagcsaaa", "like", nil, 200))
if tomb := udlGet(t, s, me.UID, "bagcsaaa", "like"); tomb.Exists || tomb.Claim == nil || tomb.Claim.TS != 200 {
t.Fatalf("tombstone = %+v", tomb)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/udl/cid/self/aggregate?keys=profile:name", nil)
req = withVars(req, map[string]string{"cid": "self"})
s.handleUDLAggregate(rr, req)
if !strings.Contains(rr.Body.String(), `"sample_size":1`) {
t.Fatalf("aggregate = %s", rr.Body)
}
}
// **The load-bearing property of the batch endpoint: one stale cell does not
// fail the batch.**
//
// The single-record PUT answers a stale version with 409, which is right when
// the caller wrote one thing. In a batch it would be wrong: bulk verbs ("mark
// this whole series seen") routinely include episodes another device already
// marked, and 409-ing there would abandon every cell after it. So staleness is
// reported per record as accepted:false, with 200 on the request itself.
func TestUDLRecordsPut_StaleCellDoesNotFailTheBatch(t *testing.T) {
// A stale claim in a batch is reported in its slot without failing the rest.
func TestUDLRecordsPut_StaleSlotDoesNotFailTheBatch(t *testing.T) {
s := newUDLTestServer(t)
const uid = "zCarol"
const cid = "bagdsaaaseries"
// Seed s01e01 at version 4 — the batch below tries to write it at 2.
if ok, err := s.storage.UDLUpsertIfNewer(uid, cid, "seen:s01e01", 4, 100, "b2xk", "", false, false); err != nil || !ok {
t.Fatalf("seed: ok=%v err=%v", ok, err)
}
code, out := putRecords(t, s, `{"records":[
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e01","version":2,"ts":200,"record":"c3RhbGU"},
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e02","version":1,"ts":200,"record":"ZnJlc2g"}
]}`)
if code != http.StatusOK {
t.Fatalf("status = %d, want 200 — a stale cell is an outcome, not an error", code)
}
if len(out.Results) != 2 {
t.Fatalf("results = %d, want 2 (one per record, in order)", len(out.Results))
}
if out.Results[0].Accepted || out.Results[0].Error != "" {
t.Fatalf("stale cell = %+v, want accepted:false with no error", out.Results[0])
}
if !out.Results[1].Accepted {
t.Fatalf("fresh cell = %+v, want accepted:true", out.Results[1])
}
// The fresh cell really landed, and the stale one did not overwrite.
if _, body := getRecord(t, s, uid, cid, "seen:s01e02"); string(body["exists"]) != "true" {
t.Fatalf("s01e02 was not written")
}
if _, body := getRecord(t, s, uid, cid, "seen:s01e01"); string(body["version"]) != "4" {
t.Fatalf("s01e01 version = %s, want 4 (the stale write must not apply)", body["version"])
me := testSigner(t, 7)
udlPutBatch(t, s, signed(t, me, "bagcsaaa", "seen:s01e01", "true", 400))
res := udlPutBatch(t, s,
signed(t, me, "bagcsaaa", "seen:s01e01", "false", 300),
signed(t, me, "bagcsaaa", "seen:s01e02", "true", 300),
)
if res.Results[0].Accepted || res.Results[0].Status != "stale" || !res.Results[1].Accepted {
t.Fatalf("results = %+v", res.Results)
}
}
// A malformed record is reported in its own slot rather than rejecting the
// request, for the same reason: the caller retries that cell, not the batch.
func TestUDLRecordsPut_ReportsBadRecordPerSlot(t *testing.T) {
s := newUDLTestServer(t)
code, out := putRecords(t, s, `{"records":[
{"uid":"zDan","cid":"bagdsaaa","key":"","version":1,"ts":1,"record":"eA"},
{"uid":"zDan","cid":"bagdsaaa","key":"seen:s01e01","version":1,"ts":1,"record":"eA"}
]}`)
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if out.Results[0].Error == "" {
t.Fatalf("record with no key must report an error, got %+v", out.Results[0])
}
if !out.Results[1].Accepted {
t.Fatalf("the valid record beside it must still land, got %+v", out.Results[1])
}
}
// An empty batch is a client bug, not a no-op: it almost always means the
// caller built its record list wrong, and answering 200 with an empty array
// would hide that.
func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
s := newUDLTestServer(t)
if code, _ := putRecords(t, s, `{"records":[]}`); code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", code)
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(`{"records":[]}`))
rr := httptest.NewRecorder()
s.handleUDLRecordsPut(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("empty batch = %d", rr.Code)
}
}
+240
View File
@@ -0,0 +1,240 @@
// Package claim is the MMUR2 signed claim: signer uid says key = value about
// content cid, at ts (milliseconds, also the version).
//
// Wire spec (load-bearing for every implementation — Go here, Rust in
// meta-feeder-sdk's `claims` feature and the clients' record/ mirror, TS in
// the plugins' meta-core-client.ts; all pinned by testdata/claim-vectors.json):
//
// domain = "MMUR2\n"
// payload = canonical CBOR array [cid, uid, key, value, ts]
// bytes_to_sign = domain || payload
// sig = ECDSA-secp256k1(SHA-256(bytes_to_sign)), compact r||s, low-S
//
// value is CBOR text (public), bytes (private envelope) or null (retraction).
// Canonical = definite lengths, smallest integer heads — the encoder below is
// hand-rolled because those five shapes are all MMUR2 ever needs.
package claim
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/metazla/meta-core/internal/identity"
)
// Domain separates MMUR2 bytes from every other signed payload (MMUR1 records,
// identity challenges). Changing it invalidates every claim everywhere.
const Domain = "MMUR2\n"
// Kind of a claim value. The SQLite storage type mirrors it: TEXT, BLOB, NULL.
type Kind uint8
const (
KindNull Kind = iota
KindText
KindBytes
)
// Value is a claim's value. The zero Value is a retraction (null).
type Value struct {
Kind Kind
Text string
Bytes []byte
}
func Text(s string) Value { return Value{Kind: KindText, Text: s} }
func Bytes(b []byte) Value { return Value{Kind: KindBytes, Bytes: b} }
func Null() Value { return Value{} }
func (v Value) IsNull() bool { return v.Kind == KindNull }
// Equal reports byte-for-byte equality, kind included.
func (v Value) Equal(o Value) bool {
if v.Kind != o.Kind {
return false
}
switch v.Kind {
case KindText:
return v.Text == o.Text
case KindBytes:
return string(v.Bytes) == string(o.Bytes)
}
return true
}
// Claim is one signed vote.
type Claim struct {
CID string
UID string
Key string
Value Value
TS uint64
Sig []byte // 64-byte compact r||s
}
// SignedBytes returns the exact bytes the signature covers.
func SignedBytes(cid, uid, key string, v Value, ts uint64) []byte {
b := make([]byte, 0, len(Domain)+len(cid)+len(uid)+len(key)+len(v.Text)+len(v.Bytes)+32)
b = append(b, Domain...)
b = appendHead(b, 4, 5) // array(5)
b = appendText(b, cid)
b = appendText(b, uid)
b = appendText(b, key)
switch v.Kind {
case KindText:
b = appendText(b, v.Text)
case KindBytes:
b = appendHead(b, 2, uint64(len(v.Bytes)))
b = append(b, v.Bytes...)
default:
b = append(b, 0xf6) // null
}
return appendHead(b, 0, ts)
}
// SignedBytes of c.
func (c *Claim) SignedBytes() []byte {
return SignedBytes(c.CID, c.UID, c.Key, c.Value, c.TS)
}
func appendText(b []byte, s string) []byte {
b = appendHead(b, 3, uint64(len(s)))
return append(b, s...)
}
// appendHead writes a CBOR major type + argument in its smallest form.
func appendHead(b []byte, major byte, n uint64) []byte {
m := major << 5
switch {
case n < 24:
return append(b, m|byte(n))
case n <= 0xff:
return append(b, m|24, byte(n))
case n <= 0xffff:
return append(b, m|25, byte(n>>8), byte(n))
case n <= 0xffffffff:
return append(b, m|26, byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
default:
return append(b, m|27, byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32),
byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
}
}
// ErrBadSignature is returned for a signature that is malformed, high-S, or
// does not verify against the uid.
var ErrBadSignature = errors.New("bad signature")
// IsLowS reports whether a compact signature's S is in the lower half.
func IsLowS(sig []byte) bool {
if len(sig) != 64 {
return false
}
var s secp256k1.ModNScalar
if overflow := s.SetByteSlice(sig[32:]); overflow {
return false
}
return !s.IsOverHalfOrder()
}
// Verify checks the claim's shape and signature.
func (c *Claim) Verify() error {
if c.CID == "" || c.Key == "" || !identity.ValidUID(c.UID) {
return fmt.Errorf("%w: missing cid, key or valid uid", ErrBadSignature)
}
if len(c.Sig) != 64 || !IsLowS(c.Sig) {
return fmt.Errorf("%w: want a 64-byte low-S compact signature", ErrBadSignature)
}
if err := identity.Verify(c.UID, c.SignedBytes(), c.Sig); err != nil {
return fmt.Errorf("%w: %v", ErrBadSignature, err)
}
return nil
}
// Sign fills c.Sig with id's compact low-S signature. Used by the account-key
// service (users who sign through meta-core until its separate ticket removes
// it), tests and the one-off migration tool — never by the claims engine.
func (c *Claim) Sign(id *identity.Identity) error {
if id.UID != c.UID {
return fmt.Errorf("claim uid %s is not the signing identity %s", c.UID, id.UID)
}
sig, err := identity.SignCompact(id, c.SignedBytes())
if err != nil {
return err
}
c.Sig = sig
return nil
}
// jsonClaim is the §3.4 HTTP form.
type jsonClaim struct {
CID string `json:"cid"`
UID string `json:"uid"`
Key string `json:"key"`
Value json.RawMessage `json:"value"`
TS uint64 `json:"ts"`
Sig string `json:"sig"`
}
type jsonBytes struct {
B64 string `json:"b64"`
}
// MarshalJSON emits {"cid","uid","key","value","ts","sig"}; bytes values as
// {"b64": "..."}, retractions as null.
func (c Claim) MarshalJSON() ([]byte, error) {
var v json.RawMessage
var err error
switch c.Value.Kind {
case KindText:
v, err = json.Marshal(c.Value.Text)
case KindBytes:
v, err = json.Marshal(jsonBytes{B64: base64.StdEncoding.EncodeToString(c.Value.Bytes)})
default:
v = json.RawMessage("null")
}
if err != nil {
return nil, err
}
return json.Marshal(jsonClaim{
CID: c.CID, UID: c.UID, Key: c.Key, Value: v, TS: c.TS,
Sig: base64.StdEncoding.EncodeToString(c.Sig),
})
}
// UnmarshalJSON parses the §3.4 form.
func (c *Claim) UnmarshalJSON(data []byte) error {
var j jsonClaim
if err := json.Unmarshal(data, &j); err != nil {
return err
}
sig, err := base64.StdEncoding.DecodeString(j.Sig)
if err != nil {
return fmt.Errorf("sig: %w", err)
}
*c = Claim{CID: j.CID, UID: j.UID, Key: j.Key, TS: j.TS, Sig: sig}
raw := j.Value
if len(raw) == 0 || string(raw) == "null" {
return nil
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return err
}
c.Value = Text(s)
return nil
}
var b jsonBytes
if err := json.Unmarshal(raw, &b); err != nil || b.B64 == "" && string(raw) != `{"b64":""}` {
return fmt.Errorf("value must be a string, {\"b64\": ...} or null")
}
bs, err := base64.StdEncoding.DecodeString(b.B64)
if err != nil {
return fmt.Errorf("value.b64: %w", err)
}
c.Value = Bytes(bs)
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package claim
import (
"encoding/hex"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/metazla/meta-core/internal/identity"
)
// The golden file every MMUR2 implementation is pinned to. Regenerate with
// CLAIM_VECTORS_UPDATE=1 go test ./internal/claim/ — then re-sync the vendored
// copies (scripts/check-claim-vectors.sh --fix). A change here is a wire break.
const vectorsPath = "testdata/claim-vectors.json"
// Fixed test key: the scalar 0x0101…01. Never used outside tests.
const testKeyHex = "0101010101010101010101010101010101010101010101010101010101010101"
type vector struct {
Name string `json:"name"`
CID string `json:"cid"`
UID string `json:"uid"`
Key string `json:"key"`
Value *string `json:"value"` // text value, or null
ValueB64 *string `json:"valueBytesHex,omitempty"` // bytes value as hex
TS uint64 `json:"ts"`
Signed string `json:"signedBytesHex"`
Sig string `json:"sigHex"` // deterministic RFC 6979, low-S
}
type vectorFile struct {
Comment string `json:"_comment"`
PrivateKeyHex string `json:"privateKeyHex"`
UID string `json:"uid"`
Vectors []vector `json:"vectors"`
HighS struct {
Note string `json:"note"`
Vector string `json:"vector"`
SigHex string `json:"sigHex"`
} `json:"highS"`
}
func (v vector) value(t *testing.T) Value {
t.Helper()
switch {
case v.ValueB64 != nil:
b, err := hex.DecodeString(*v.ValueB64)
if err != nil {
t.Fatal(err)
}
return Bytes(b)
case v.Value != nil:
return Text(*v.Value)
}
return Null()
}
func strp(s string) *string { return &s }
func testIdentity(t *testing.T) *identity.Identity {
t.Helper()
id, err := identity.Import(testKeyHex)
if err != nil {
t.Fatal(err)
}
return id
}
func cases(uid string) []vector {
return []vector{
{Name: "text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp("The Matrix"), TS: 1793001234567},
{Name: "empty-text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp(""), TS: 1},
{Name: "unicode", CID: "bafkreih5kapbjzq", UID: uid, Key: "titles/jpn/攻殻機動隊", Value: strp("true"), TS: 1793001234567},
{Name: "keyset-member", CID: "bagacbabaec7v3", UID: uid, Key: "cids/bafkreih5kapbjzq", Value: strp("true"), TS: 23},
{Name: "bytes", CID: "bafkreih5kapbjzq", UID: uid, Key: "seek", ValueB64: strp("00ff10ab"), TS: 24},
{Name: "retraction", CID: "bafkreih5kapbjzq", UID: uid, Key: "genres/Drama", TS: 255},
{Name: "long-value", CID: "bafkreih5kapbjzq", UID: uid, Key: "plot/eng", Value: strp(strings.Repeat("a", 300)), TS: 65536},
{Name: "ts-64bit", CID: "bafkreih5kapbjzq", UID: uid, Key: "year", Value: strp("1999"), TS: 1 << 40},
}
}
func TestVectors(t *testing.T) {
id := testIdentity(t)
if os.Getenv("CLAIM_VECTORS_UPDATE") == "1" {
writeVectors(t, id)
}
raw, err := os.ReadFile(vectorsPath)
if err != nil {
t.Fatalf("read vectors: %v (generate with CLAIM_VECTORS_UPDATE=1)", err)
}
var f vectorFile
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
if f.UID != id.UID {
t.Fatalf("vector uid %s != test key uid %s", f.UID, id.UID)
}
for _, v := range f.Vectors {
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
if got := hex.EncodeToString(c.SignedBytes()); got != v.Signed {
t.Errorf("%s: signed bytes\n got %s\nwant %s", v.Name, got, v.Signed)
}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
if got := hex.EncodeToString(c.Sig); got != v.Sig {
t.Errorf("%s: sig\n got %s\nwant %s", v.Name, got, v.Sig)
}
if err := c.Verify(); err != nil {
t.Errorf("%s: verify: %v", v.Name, err)
}
}
// The high-S twin of a valid signature must be refused.
for _, v := range f.Vectors {
if v.Name != f.HighS.Vector {
continue
}
sig, _ := hex.DecodeString(f.HighS.SigHex)
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS, Sig: sig}
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
t.Errorf("high-S signature accepted: %v", err)
}
}
}
func writeVectors(t *testing.T, id *identity.Identity) {
t.Helper()
f := vectorFile{
Comment: "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
PrivateKeyHex: testKeyHex,
UID: id.UID,
}
for _, v := range cases(id.UID) {
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
v.Signed = hex.EncodeToString(c.SignedBytes())
v.Sig = hex.EncodeToString(c.Sig)
f.Vectors = append(f.Vectors, v)
}
first := f.Vectors[0]
sig, _ := hex.DecodeString(first.Sig)
f.HighS.Note = "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it"
f.HighS.Vector = first.Name
f.HighS.SigHex = hex.EncodeToString(highS(sig))
out, err := json.MarshalIndent(f, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(vectorsPath, append(out, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
func highS(sig []byte) []byte {
var s secp256k1.ModNScalar
s.SetByteSlice(sig[32:])
s.Negate()
out := append([]byte{}, sig[:32]...)
b := s.Bytes()
return append(out, b[:]...)
}
func TestJSONRoundTrip(t *testing.T) {
id := testIdentity(t)
for _, v := range []Value{Text("x"), Bytes([]byte{0, 1, 2}), Null()} {
c := Claim{CID: "bafy", UID: id.UID, Key: "k", Value: v, TS: 42}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
b, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
var back Claim
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("%s: %v", b, err)
}
if !back.Value.Equal(v) || back.TS != 42 || string(back.Sig) != string(c.Sig) {
t.Errorf("round trip changed the claim: %s", b)
}
if err := back.Verify(); err != nil {
t.Errorf("%s: %v", b, err)
}
}
}
func TestTamperRejected(t *testing.T) {
id := testIdentity(t)
c := Claim{CID: "bafy", UID: id.UID, Key: "title", Value: Text("A"), TS: 1}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
c.Value = Text("B")
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
t.Fatalf("tampered value verified: %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"_comment": "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
"privateKeyHex": "0101010101010101010101010101010101010101010101010101010101010101",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"vectors": [
{
"name": "text",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "title",
"value": "The Matrix",
"ts": 1793001234567,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656a546865204d61747269781b000001a17733a087",
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3d2e560a2b61f4a81e320287cde753e1990d7949f4f518127f6505b8fb698ee8a8"
},
{
"name": "empty-text",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "title",
"value": "",
"ts": 1,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656001",
"sigHex": "6eeb9ace8afe3987006c166eb0ec564b71e23d6a1c6211fdd55e8b4779c12dbb4f38c443faa3255c2ce7c6592e9d024e4ea57b5fa23446d1d4df647947e37642"
},
{
"name": "unicode",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "titles/jpn/攻殻機動隊",
"value": "true",
"ts": 1793001234567,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a781a7469746c65732f6a706e2fe694bbe6aebbe6a99fe58b95e99a8a64747275651b000001a17733a087",
"sigHex": "64ec6342c598782df278b49636a34da4f4c009423a584454a3284efad5426aa517d83873b09a4fdd3fd32a5e9a5fef0880126edd11db773045caeb6e634bc207"
},
{
"name": "keyset-member",
"cid": "bagacbabaec7v3",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "cids/bafkreih5kapbjzq",
"value": "true",
"ts": 23,
"signedBytesHex": "4d4d5552320a856e6261676163626162616563377633782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a75636964732f6261666b72656968356b6170626a7a71647472756517",
"sigHex": "8f707847895ccb522f1507dbab23330bd2c567d6fd1882df3bfae65bef6601024832f0b181146dcfb089aae91f5beac47da970373ab14370c4ac41410f3bf761"
},
{
"name": "bytes",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "seek",
"value": null,
"valueBytesHex": "00ff10ab",
"ts": 24,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647365656b4400ff10ab1818",
"sigHex": "09671b27c03edd510029ec550b35e95e3900dd37a5d688ea04c3f0409979c7236bc4a1b5fa9d1b76265da85df24693a51f0ced528a6171c90a70b92db9f246e4"
},
{
"name": "retraction",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "genres/Drama",
"value": null,
"ts": 255,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a6c67656e7265732f4472616d61f618ff",
"sigHex": "d0c29d8edac4b78005eaf895cd9d534cb080b22ba9f7e79a5abf4d9bd906dc494464a2b388653452bca16e351132a9a92370953fa047e693459e55b7965449ac"
},
{
"name": "long-value",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "plot/eng",
"value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"ts": 65536,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a68706c6f742f656e6779012c6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161611a00010000",
"sigHex": "383b2e40f913eb5240e1eae2cfa392f967321d3701623c2b3df344984686d888198bce1c78847d1898b35f991ec3465c382c45e03b9a77f2ae7d1fd740391dd2"
},
{
"name": "ts-64bit",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "year",
"value": "1999",
"ts": 1099511627776,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647965617264313939391b0000010000000000",
"sigHex": "2b9b42ef4ebd7fc8d3ca0a514679ca4104f2e627da3a84c7b9664b27ebbdf73946cc37a04e4ea15dbed23a0e48e8797c92fcba5598b012305a314bf776398e0d"
}
],
"highS": {
"note": "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it",
"vector": "text",
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3dd1a9f5d49e0b57e1cdfd783218ac1e65ad3592f1ba308dbc5acca59166a75899"
}
}
+13 -2
View File
@@ -20,7 +20,8 @@ type Config struct {
PublicURL string // Browser-facing URL for the service-bar nav; overrides BaseURL when set (e.g. a debug-direct port with no Caddy in front)
// Storage configuration
DBPathOverride string // META_CORE_DB_PATH; empty = <MetaCorePath>/db/meta-core.db
DBPathOverride string // META_CORE_DB_PATH; empty = <MetaCorePath>/db/meta-core.db (truth)
CachePathOverride string // META_CORE_CACHE_PATH; empty = <MetaCorePath>/db-cache/cache.db
// HTTP API configuration
HTTPPort int // HTTP API port (default: 9000)
@@ -61,6 +62,7 @@ func Load() *Config {
BaseURL: getEnv("BASE_URL", ""),
PublicURL: getEnv("META_CORE_PUBLIC_URL", ""),
DBPathOverride: getEnv("META_CORE_DB_PATH", ""),
CachePathOverride: getEnv("META_CORE_CACHE_PATH", ""),
HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000),
HTTPHost: getEnv("META_CORE_HTTP_HOST", "0.0.0.0"),
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
@@ -91,7 +93,7 @@ func (c *Config) LockFilePath() string {
return c.MetaCorePath + "/locks/kv-leader.lock"
}
// DBPath returns the path of meta-core's SQLite database file.
// DBPath returns the path of meta-core's truth database (backed up).
func (c *Config) DBPath() string {
if c.DBPathOverride != "" {
return c.DBPathOverride
@@ -99,6 +101,15 @@ func (c *Config) DBPath() string {
return c.MetaCorePath + "/db/meta-core.db"
}
// CachePath returns the path of meta-core's cache database: everything
// derived from truth, never backed up, rebuilt when missing.
func (c *Config) CachePath() string {
if c.CachePathOverride != "" {
return c.CachePathOverride
}
return c.MetaCorePath + "/db-cache/cache.db"
}
// MountsFilePath returns the path to the mounts configuration file
func (c *Config) MountsFilePath() string {
return c.MountsDir + "/mounts.json"
+24
View File
@@ -307,6 +307,30 @@ func Sign(id *Identity, bytes []byte) ([]byte, error) {
return sig.Serialize(), nil
}
// SignCompact is Sign with the 64-byte compact r||s encoding MMUR2 claims
// use. decred's RFC 6979 signer always emits low-S, which MMUR2 requires.
func SignCompact(id *Identity, bytes []byte) ([]byte, error) {
if id == nil {
return nil, errors.New("no identity loaded")
}
raw, err := hex.DecodeString(id.PrivateKeyHex)
if err != nil {
return nil, fmt.Errorf("decode private key: %w", err)
}
if len(raw) != 32 {
return nil, fmt.Errorf("private key length %d", len(raw))
}
priv := secp256k1.PrivKeyFromBytes(raw)
digest := sha256.Sum256(bytes)
sig := ecdsa.Sign(priv, digest[:])
r, s := sig.R(), sig.S()
out := make([]byte, 64)
rb, sb := r.Bytes(), s.Bytes()
copy(out[:32], rb[:])
copy(out[32:], sb[:])
return out, nil
}
// DeriveAEADKey derives the symmetric key used to encrypt private-tier
// User Data Layer records. The KDF parameters (salt, info, length) are
// fixed for the v1 wire format.
+52 -10
View File
@@ -84,6 +84,14 @@ type Options struct {
// values — the hook derived counters hang off (Tx.AddCounter). It must not
// write kv rows itself.
OnChange func(tx *Tx, key, old string, hadOld bool, new string, hasNew bool)
// NoKVTable opens the file without the kv table and its prepared
// statements: the caller owns every table through Schema and writes them
// with Tx.Exec / Tx.Query (meta-core's truth database). The kv methods
// (Get, Put, streams, counters) must not be used on such a store.
NoKVTable bool
// Synchronous is SQLite's synchronous pragma; default NORMAL. A store that
// is rebuildable from another (meta-core's cache) runs OFF.
Synchronous string
}
// Store is an open database.
@@ -134,7 +142,10 @@ func Open(path string, opts Options) (*Store, error) {
opts.EventMaxLen = 100_000
}
w, err := sql.Open("sqlite", dsn(path, opts.WriterCacheMB, false))
if opts.Synchronous == "" {
opts.Synchronous = "NORMAL"
}
w, err := sql.Open("sqlite", dsn(path, opts.WriterCacheMB, false, opts.Synchronous))
if err != nil {
return nil, fmt.Errorf("open writer: %w", err)
}
@@ -144,9 +155,11 @@ func Open(path string, opts Options) (*Store, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil {
w.Close()
return nil, fmt.Errorf("create kv: %w", err)
if !opts.NoKVTable {
if _, err := w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil {
w.Close()
return nil, fmt.Errorf("create kv: %w", err)
}
}
if opts.Schema != "" {
// No deadline: on an existing store, the first open after a new index
@@ -162,7 +175,7 @@ func Open(path string, opts Options) (*Store, error) {
}
}
r, err := sql.Open("sqlite", dsn(path, opts.ReaderCacheMB, true))
r, err := sql.Open("sqlite", dsn(path, opts.ReaderCacheMB, true, opts.Synchronous))
if err != nil {
w.Close()
return nil, fmt.Errorf("open readers: %w", err)
@@ -174,7 +187,7 @@ func Open(path string, opts Options) (*Store, error) {
// Fresh deadline: the schema step above may legitimately have taken minutes.
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, p := range []struct {
stmts := []struct {
dst **sql.Stmt
query string
}{
@@ -183,7 +196,11 @@ func Open(path string, opts Options) (*Store, error) {
{&st.stDelete, `DELETE FROM kv WHERE k = ? RETURNING v`},
{&st.stGet, `SELECT v FROM kv WHERE k = ?`},
{&st.stInsert, `INSERT INTO kv(k, v) VALUES(?, ?)`},
} {
}
if opts.NoKVTable {
stmts = nil
}
for _, p := range stmts {
if *p.dst, err = w.PrepareContext(ctx, p.query); err != nil {
r.Close()
w.Close()
@@ -199,11 +216,11 @@ func Open(path string, opts Options) (*Store, error) {
// dsn builds a modernc DSN. WAL + synchronous=NORMAL is the durability class
// Redis' AOF-everysec gave us: a power loss may drop the last transactions,
// never corrupt the file.
func dsn(path string, cacheMB int, readOnly bool) string {
func dsn(path string, cacheMB int, readOnly bool, synchronous string) string {
q := url.Values{}
q.Add("_pragma", "busy_timeout(30000)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "synchronous(NORMAL)")
q.Add("_pragma", "synchronous("+synchronous+")")
q.Add("_pragma", fmt.Sprintf("cache_size(-%d)", cacheMB*1024))
q.Add("_pragma", "temp_store(MEMORY)")
q.Add("_pragma", "journal_size_limit(268435456)")
@@ -520,6 +537,7 @@ func (s *Store) runGroup(reqs []*writeReq) {
return
}
g := &group{tx: sqlTx}
var commitHooks []func()
for i, r := range reqs {
if _, err := sqlTx.ExecContext(ctx, `SAVEPOINT req`); err != nil {
_ = sqlTx.Rollback()
@@ -534,6 +552,7 @@ func (s *Store) runGroup(reqs []*writeReq) {
}
if err == nil {
g.mergeCounters(tx.counters)
commitHooks = append(commitHooks, tx.onCommit...)
}
if err != nil {
results[i] = err
@@ -565,6 +584,12 @@ func (s *Store) runGroup(reqs []*writeReq) {
return
}
s.afterCommit(g.pending)
// Hooks run in the writer goroutine, in commit order, before any request
// returns: a caller that orders work by commit (meta-core's apply queue)
// sees it in exactly that order.
for _, h := range commitHooks {
h()
}
for i, r := range reqs {
r.done <- results[i]
}
@@ -578,6 +603,23 @@ type Tx struct {
g *group
events []map[string]string
counters map[string]int64
onCommit []func()
quiet bool
}
// OnCommit registers fn to run after the transaction commits, only if this
// request succeeded. Hooks run in the writer goroutine in commit order and
// must not block or call Update.
func (t *Tx) OnCommit(fn func()) { t.onCommit = append(t.onCommit, fn) }
// Quiet stops this request's changes from publishing events (a bulk rebuild
// whose consumers are told to take a snapshot instead). Counters and the
// OnChange hook still run.
func (t *Tx) Quiet() { t.quiet = true }
// Query runs a raw multi-row query in the transaction.
func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) {
return t.sqlTx().QueryContext(t.ctx, query, args...)
}
func (t *Tx) sqlTx() *sql.Tx { return t.g.tx }
@@ -800,7 +842,7 @@ func (s *Store) WriteStats() (changed, unchanged int64) {
}
func (t *Tx) event(op, k string) {
if t.s.opts.EventStream == "" || t.s.opts.EventKey == nil || !t.s.opts.EventKey(k) {
if t.quiet || t.s.opts.EventStream == "" || t.s.opts.EventKey == nil || !t.s.opts.EventKey(k) {
return
}
t.events = append(t.events, map[string]string{
-143
View File
@@ -1,143 +0,0 @@
package snapshot
import (
"archive/zip"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
)
// PluginOutputArchivePrefix is where plugin-output files live inside the ZIP.
// Trailing slash because ZIP entry names are forward-slash relative paths.
const PluginOutputArchivePrefix = "cache/plugin-output/"
// addPluginOutput walks srcDir (typically $FILES_PATH/plugin) and copies every
// file under it into zw at PluginOutputArchivePrefix + relPath.
//
// Returns the number of files added. A missing srcDir is not an error: it
// just means no plugin has produced output yet, so the section stays empty.
func addPluginOutput(zw *zip.Writer, srcDir string) (int, error) {
info, err := os.Stat(srcDir)
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("stat %s: %w", srcDir, err)
}
if !info.IsDir() {
return 0, fmt.Errorf("%s is not a directory", srcDir)
}
count := 0
err = filepath.WalkDir(srcDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
entry := PluginOutputArchivePrefix + filepath.ToSlash(rel)
w, err := zw.Create(entry)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(w, f); err != nil {
return err
}
count++
return nil
})
if err != nil {
return count, fmt.Errorf("walk %s: %w", srcDir, err)
}
return count, nil
}
// extractPluginOutput pulls every entry under PluginOutputArchivePrefix from
// the ZIP and writes it under dstDir. Existing files are overwritten when
// overwrite=true; otherwise they're left alone (treats existing files as the
// "mine wins" choice — same toggle the metadata import uses).
//
// Path traversal is rejected: any entry whose relative path resolves outside
// dstDir is skipped with a warning.
func extractPluginOutput(zr *zip.Reader, dstDir string, overwrite bool) (written int, skipped int, err error) {
if dstDir == "" {
return 0, 0, fmt.Errorf("plugin-output destination dir is empty")
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return 0, 0, fmt.Errorf("mkdir %s: %w", dstDir, err)
}
cleanRoot, err := filepath.Abs(dstDir)
if err != nil {
return 0, 0, fmt.Errorf("abs %s: %w", dstDir, err)
}
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, PluginOutputArchivePrefix) {
continue
}
if strings.HasSuffix(f.Name, "/") {
continue
}
rel := strings.TrimPrefix(f.Name, PluginOutputArchivePrefix)
dst := filepath.Join(cleanRoot, filepath.FromSlash(rel))
// Reject anything that escapes the dst root.
if !strings.HasPrefix(dst, cleanRoot+string(os.PathSeparator)) && dst != cleanRoot {
log.Printf("[Snapshot] import: skipping path-escaping entry %q", f.Name)
skipped++
continue
}
if !overwrite {
if _, err := os.Stat(dst); err == nil {
skipped++
continue
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return written, skipped, fmt.Errorf("mkdir for %s: %w", dst, err)
}
if err := writeZipFile(f, dst); err != nil {
return written, skipped, err
}
written++
}
return written, skipped, nil
}
func writeZipFile(f *zip.File, dst string) error {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
}
defer rc.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return fmt.Errorf("write %s: %w", dst, err)
}
return nil
}
-132
View File
@@ -1,132 +0,0 @@
package snapshot
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"os"
"time"
"github.com/metazla/meta-core/internal/storage"
)
// ExportOptions controls what an export includes.
type ExportOptions struct {
IncludePluginOutput bool
// PluginOutputDir is the absolute path on disk that holds plugin-produced
// artifacts (typically $FILES_PATH/plugin). Required when
// IncludePluginOutput is true.
PluginOutputDir string
// SourceHost is recorded in the manifest so imports can attribute
// where the snapshot came from (defaults to OS hostname).
SourceHost string
}
// Exporter writes snapshot ZIPs from the live metadata store.
type Exporter struct {
storage *storage.Client
}
func NewExporter(stor *storage.Client) *Exporter {
return &Exporter{storage: stor}
}
// Export streams a ZIP archive to w. The archive layout is:
//
// manifest.json
// index.json
// metadata/{cid}.json (one per known CID)
//
// Optional sections (hash-index, plugin-output) are gated by ExportOptions
// but are not yet implemented — they live on filesystems meta-core does not
// own, and need cross-service collection. The flag is plumbed for forward
// compatibility but does nothing today.
func (e *Exporter) Export(w io.Writer, opts ExportOptions) (Manifest, error) {
if e.storage == nil || !e.storage.IsConnected() {
return Manifest{}, fmt.Errorf("storage not connected")
}
hashIDs, err := e.storage.GetAllHashIDs()
if err != nil {
return Manifest{}, fmt.Errorf("list hashIDs: %w", err)
}
zw := zip.NewWriter(w)
defer zw.Close()
// 1. manifest.json — written last after we know FileCount, but we need
// the entry order to put it first in the archive for readers. Solve
// by buffering in memory: it's tiny.
host := opts.SourceHost
if host == "" {
if h, err := os.Hostname(); err == nil {
host = h
}
}
manifest := Manifest{
SchemaVersion: ManifestSchemaVersion,
GeneratedAt: time.Now().UTC(),
SourceHost: host,
FileCount: len(hashIDs),
Includes: Includes{
Metadata: true,
PluginOutput: opts.IncludePluginOutput,
},
}
if err := writeJSON(zw, ManifestFile, manifest); err != nil {
return Manifest{}, fmt.Errorf("write manifest: %w", err)
}
// 2. index.json — flat list of CIDs. Redundant with metadata/ but
// cheap and useful for readers that only need the keyset.
if err := writeJSON(zw, IndexFile, IndexFileBody{HashIDs: hashIDs}); err != nil {
return Manifest{}, fmt.Errorf("write index: %w", err)
}
// 3. metadata/{cid}.json — one nested document per CID.
for _, hashID := range hashIDs {
flat, err := e.storage.GetMetadataFlat(hashID)
if err != nil {
log.Printf("[Snapshot] export: skipping %s: %v", hashID, err)
continue
}
if len(flat) == 0 {
continue
}
nested := Reconstruct(flat)
entry := MetadataDir + hashID + ".json"
if err := writeJSON(zw, entry, nested); err != nil {
return Manifest{}, fmt.Errorf("write %s: %w", entry, err)
}
}
// 4. cache/plugin-output/* — optional. Walks the on-disk plugin dir and
// streams each file into the ZIP. Heavy (~120 MB on a populated dev
// stack), so it's gated by the ExportOptions toggle.
if opts.IncludePluginOutput {
if opts.PluginOutputDir == "" {
return Manifest{}, fmt.Errorf("PluginOutputDir is required when IncludePluginOutput is set")
}
n, err := addPluginOutput(zw, opts.PluginOutputDir)
if err != nil {
return Manifest{}, fmt.Errorf("add plugin-output: %w", err)
}
log.Printf("[Snapshot] exported %d plugin-output files from %s", n, opts.PluginOutputDir)
}
log.Printf("[Snapshot] exported %d files (host=%s, pluginOutput=%v)",
len(hashIDs), host, opts.IncludePluginOutput)
return manifest, nil
}
func writeJSON(zw *zip.Writer, name string, body interface{}) error {
w, err := zw.Create(name)
if err != nil {
return err
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(body)
}
-195
View File
@@ -1,195 +0,0 @@
package snapshot
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Flat keys use "/" as the separator. Property paths inside the store are
// stored as e.g. "fileinfo/streamdetails/video/0/codec".
const flatSep = "/"
// numericKey matches a path segment that is a non-negative decimal integer.
// Used to detect array slots (".../video/0/codec").
var numericKey = regexp.MustCompile(`^\d+$`)
// Reconstruct rebuilds a nested JSON-shaped value from a flat property map.
// Keys are property paths (no leading slash, no file:{cid}/ prefix).
//
// Sibling keys whose path segments are all sequential integers starting at 0
// are emitted as JSON arrays. Other groups become JSON objects. Leaf values
// are coerced from string to int/float/bool/null when they parse cleanly so
// the exported JSON is human-readable; on import we round-trip back to strings.
func Reconstruct(flat map[string]string) interface{} {
root := map[string]interface{}{}
for path, value := range flat {
parts := strings.Split(path, flatSep)
setPath(root, parts, parseValue(value))
}
return convertNumericMapsToArrays(root)
}
// Flatten is the inverse of Reconstruct: walks a nested value and produces a
// flat property map whose values are all strings (store-ready).
//
// Arrays become numeric path segments ("genre/0", "genre/1"). Nil/missing
// values are stored as "" to preserve the field's presence — this matches
// what flattenMetadata in the TS implementation does.
func Flatten(value interface{}) map[string]string {
out := map[string]string{}
flattenInto(value, "", out)
return out
}
func setPath(root map[string]interface{}, parts []string, leaf interface{}) {
if len(parts) == 0 {
return
}
cur := root
for i, part := range parts {
if i == len(parts)-1 {
cur[part] = leaf
return
}
next, ok := cur[part].(map[string]interface{})
if !ok {
next = map[string]interface{}{}
cur[part] = next
}
cur = next
}
}
func convertNumericMapsToArrays(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
// Recurse first so child arrays are detected before parent.
for k, child := range t {
t[k] = convertNumericMapsToArrays(child)
}
if !looksLikeArray(t) {
return t
}
// Sort numerically and emit as []interface{}.
idx := make([]int, 0, len(t))
for k := range t {
n, _ := strconv.Atoi(k)
idx = append(idx, n)
}
sort.Ints(idx)
arr := make([]interface{}, len(idx))
for i, n := range idx {
arr[i] = t[strconv.Itoa(n)]
}
return arr
case []interface{}:
for i := range t {
t[i] = convertNumericMapsToArrays(t[i])
}
return t
default:
return v
}
}
// looksLikeArray returns true when every key in m is a non-negative integer
// and the keys form a contiguous range starting at 0. Empty maps return false
// — we can't tell whether the original was {} or [], so prefer {}.
func looksLikeArray(m map[string]interface{}) bool {
if len(m) == 0 {
return false
}
seen := make(map[int]bool, len(m))
for k := range m {
if !numericKey.MatchString(k) {
return false
}
n, err := strconv.Atoi(k)
if err != nil || n < 0 {
return false
}
seen[n] = true
}
for i := 0; i < len(m); i++ {
if !seen[i] {
return false
}
}
return true
}
func flattenInto(v interface{}, path string, out map[string]string) {
switch t := v.(type) {
case nil:
out[path] = ""
case map[string]interface{}:
if len(t) == 0 && path != "" {
// Empty objects don't have a flat-key representation; skip.
return
}
for k, child := range t {
next := k
if path != "" {
next = path + flatSep + k
}
flattenInto(child, next, out)
}
case []interface{}:
for i, child := range t {
next := strconv.Itoa(i)
if path != "" {
next = path + flatSep + next
}
flattenInto(child, next, out)
}
case string:
out[path] = t
case bool:
if t {
out[path] = "true"
} else {
out[path] = "false"
}
case float64:
// JSON numbers always decode to float64; emit ints without decimals.
if t == float64(int64(t)) {
out[path] = strconv.FormatInt(int64(t), 10)
} else {
out[path] = strconv.FormatFloat(t, 'f', -1, 64)
}
default:
out[path] = fmt.Sprintf("%v", t)
}
}
// parseValue mirrors the TS reconstruct logic: convert obvious primitives
// back to their typed form for nicer exported JSON, leave anything ambiguous
// as a string so we don't lose data (e.g. "5.1" channel layouts).
func parseValue(s string) interface{} {
if s == "" {
return nil
}
switch s {
case "true":
return true
case "false":
return false
case "null":
return nil
}
// Only treat as number when the string is a clean integer or decimal,
// no leading zeros / trailing junk. Matches the TS regex.
if isCleanNumber(s) {
if n, err := strconv.ParseFloat(s, 64); err == nil {
return n
}
}
return s
}
var cleanNumber = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
func isCleanNumber(s string) bool { return cleanNumber.MatchString(s) }
-262
View File
@@ -1,262 +0,0 @@
package snapshot
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"github.com/metazla/meta-core/internal/storage"
)
// Mode controls how an import reconciles existing CIDs.
type Mode string
const (
// ModeReplace deletes every existing property of an imported CID and
// writes the snapshot's values verbatim. Untouched CIDs are left alone.
ModeReplace Mode = "replace"
// ModeMerge keeps unmentioned existing properties and only touches the
// fields present in the snapshot. Conflict resolution within merge is
// controlled by Conflict.
ModeMerge Mode = "merge"
)
// Conflict picks a winner for fields present on both sides during merge.
type Conflict string
const (
// ConflictMine keeps the existing (target) value when both sides have one.
ConflictMine Conflict = "mine"
// ConflictSource overwrites the existing value with the snapshot's value.
ConflictSource Conflict = "source"
)
// ImportOptions controls how Import applies a snapshot.
type ImportOptions struct {
Mode Mode
Conflict Conflict
DryRun bool
// PluginOutputDir is the on-disk destination for cache/plugin-output/*
// entries. When empty, plugin-output is silently skipped on import.
PluginOutputDir string
}
// FileResult summarizes what happened (or would happen, in dry-run) for one CID.
type FileResult struct {
HashID string `json:"hashId"`
Added int `json:"added"` // new property keys written
Updated int `json:"updated"` // existing property keys whose value changed
Unchanged int `json:"unchanged"` // existing property keys whose value matched
Kept int `json:"kept"` // existing property keys preserved by ConflictMine
Deleted int `json:"deleted"` // existing property keys removed (replace mode)
Error string `json:"error,omitempty"`
}
// PluginOutputResult summarizes what happened to cache/plugin-output entries.
type PluginOutputResult struct {
Written int `json:"written"`
Skipped int `json:"skipped"`
}
// ImportResult is the response body for the import endpoint.
type ImportResult struct {
Mode Mode `json:"mode"`
Conflict Conflict `json:"conflict,omitempty"`
DryRun bool `json:"dryRun"`
TotalFiles int `json:"totalFiles"`
FilesOK int `json:"filesOk"`
FilesFailed int `json:"filesFailed"`
Files []FileResult `json:"files,omitempty"`
PluginOutput *PluginOutputResult `json:"pluginOutput,omitempty"`
}
// Importer applies snapshot ZIPs to the live metadata store.
type Importer struct {
storage *storage.Client
}
func NewImporter(stor *storage.Client) *Importer {
return &Importer{storage: stor}
}
// Import reads a ZIP from r (which must support io.ReaderAt; callers pass
// *bytes.Reader after buffering the upload) and applies it to the store.
func (i *Importer) Import(r io.ReaderAt, size int64, opts ImportOptions) (*ImportResult, error) {
if i.storage == nil || !i.storage.IsConnected() {
return nil, fmt.Errorf("storage not connected")
}
if opts.Mode != ModeReplace && opts.Mode != ModeMerge {
return nil, fmt.Errorf("invalid mode %q (want replace|merge)", opts.Mode)
}
if opts.Mode == ModeMerge && opts.Conflict != ConflictMine && opts.Conflict != ConflictSource {
return nil, fmt.Errorf("invalid conflict %q (want mine|source)", opts.Conflict)
}
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var manifest Manifest
if err := readJSON(zr, ManifestFile, &manifest); err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
if manifest.SchemaVersion > ManifestSchemaVersion {
return nil, fmt.Errorf("snapshot schemaVersion %d exceeds supported %d",
manifest.SchemaVersion, ManifestSchemaVersion)
}
if manifest.SchemaVersion < ManifestSchemaVersion {
return nil, fmt.Errorf("snapshot schemaVersion %d is no longer supported (current: %d); v1 used content-hash root keys which are incompatible with the UUID-rooted schema",
manifest.SchemaVersion, ManifestSchemaVersion)
}
result := &ImportResult{
Mode: opts.Mode,
Conflict: opts.Conflict,
DryRun: opts.DryRun,
}
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, MetadataDir) || !strings.HasSuffix(f.Name, ".json") {
continue
}
hashID := strings.TrimSuffix(strings.TrimPrefix(f.Name, MetadataDir), ".json")
if hashID == "" {
continue
}
fr := i.applyOne(f, hashID, opts)
result.TotalFiles++
if fr.Error != "" {
result.FilesFailed++
} else {
result.FilesOK++
}
result.Files = append(result.Files, fr)
}
// Plugin-output extraction. Only applied on real imports (not dry-run).
// If the snapshot doesn't contain a cache/plugin-output/ section, this is
// a no-op. If the destination is unset, we skip silently — the manifest's
// pluginOutput flag tells the caller it was on the source side either way.
if !opts.DryRun && opts.PluginOutputDir != "" && manifest.Includes.PluginOutput {
// Existing files are kept under ConflictMine / replace-doesn't-apply,
// overwritten under ConflictSource. Replace mode behaves like
// "overwrite" for cache files (it's the destructive choice for
// metadata; mirror that intent here).
overwrite := opts.Mode == ModeReplace || opts.Conflict == ConflictSource
written, skipped, err := extractPluginOutput(zr, opts.PluginOutputDir, overwrite)
if err != nil {
return result, fmt.Errorf("extract plugin-output: %w", err)
}
result.PluginOutput = &PluginOutputResult{Written: written, Skipped: skipped}
log.Printf("[Snapshot] import: plugin-output written=%d skipped=%d", written, skipped)
}
log.Printf("[Snapshot] import: mode=%s conflict=%s dryRun=%v files=%d ok=%d failed=%d",
opts.Mode, opts.Conflict, opts.DryRun, result.TotalFiles, result.FilesOK, result.FilesFailed)
return result, nil
}
func (i *Importer) applyOne(f *zip.File, hashID string, opts ImportOptions) FileResult {
fr := FileResult{HashID: hashID}
rc, err := f.Open()
if err != nil {
fr.Error = fmt.Sprintf("open entry: %v", err)
return fr
}
defer rc.Close()
var nested interface{}
if err := json.NewDecoder(rc).Decode(&nested); err != nil {
fr.Error = fmt.Sprintf("decode json: %v", err)
return fr
}
incoming := Flatten(nested)
existing, err := i.storage.GetMetadataFlat(hashID)
if err != nil {
fr.Error = fmt.Sprintf("read existing: %v", err)
return fr
}
if existing == nil {
existing = map[string]string{}
}
switch opts.Mode {
case ModeReplace:
// Diff for reporting.
toWrite := map[string]string{}
for k, v := range incoming {
toWrite[k] = v
if cur, ok := existing[k]; ok {
if cur == v {
fr.Unchanged++
} else {
fr.Updated++
}
} else {
fr.Added++
}
}
for k := range existing {
if _, ok := incoming[k]; !ok {
fr.Deleted++
}
}
if !opts.DryRun {
if _, err := i.storage.DeleteMetadata(hashID); err != nil {
fr.Error = fmt.Sprintf("delete existing: %v", err)
return fr
}
if err := i.storage.SetMetadataFlat(hashID, toWrite); err != nil {
fr.Error = fmt.Sprintf("write: %v", err)
return fr
}
}
case ModeMerge:
toWrite := map[string]string{}
for k, v := range incoming {
cur, present := existing[k]
switch {
case !present:
toWrite[k] = v
fr.Added++
case cur == v:
fr.Unchanged++
case opts.Conflict == ConflictSource:
toWrite[k] = v
fr.Updated++
case opts.Conflict == ConflictMine:
fr.Kept++
}
}
if !opts.DryRun && len(toWrite) > 0 {
if _, err := i.storage.MergeMetadataFlat(hashID, toWrite); err != nil {
fr.Error = fmt.Sprintf("merge: %v", err)
return fr
}
}
}
return fr
}
func readJSON(zr *zip.Reader, name string, out interface{}) error {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
return json.NewDecoder(rc).Decode(out)
}
return fmt.Errorf("entry %q not found", name)
}
-55
View File
@@ -1,55 +0,0 @@
// Package snapshot implements export/import/wipe of MetaMesh metadata.
//
// The on-disk format is a ZIP archive containing one nested-JSON file per
// indexed root. Schema is defined by ManifestSchemaVersion; readers must
// reject archives whose schemaVersion exceeds what they know.
//
// v2 (current): root identifiers are UUIDv7 (Crockford Base32). Entries
// under metadata/<root>.json carry the flat string properties; the cid
// reverse-index and the duplicates SET are NOT serialized — they are rebuilt
// transparently on import because the cids/<cid> key-set members ARE flat
// properties, and SetMetadataFlat / MergeMetadataFlat auto-register a
// reverse-index alias for every one they see (see
// internal/storage/cid_resolution.go: cidFromKeysetField). The legacy flat
// cid_*/midhash256 named fields are not recognised.
//
// v1 (legacy): roots were content-hash-typed tokens like "midhash256:abc".
// v1 archives can no longer be imported — they would resurrect the
// privileged-hashID schema this layer was built to retire.
package snapshot
import "time"
// ManifestSchemaVersion is the on-disk format version. Bump when the layout
// of metadata/{root}.json or the manifest changes in a non-additive way.
const ManifestSchemaVersion = 2
// MetadataDir is the path inside the ZIP that holds per-CID JSON files.
const MetadataDir = "metadata/"
// ManifestFile is the path inside the ZIP that holds the manifest JSON.
const ManifestFile = "manifest.json"
// IndexFile is the path inside the ZIP that holds the file index (CID list).
const IndexFile = "index.json"
// Manifest is the metadata header stored at the root of every snapshot ZIP.
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
GeneratedAt time.Time `json:"generatedAt"`
SourceHost string `json:"sourceHost,omitempty"`
MetaCoreVer string `json:"metaCoreVersion,omitempty"`
FileCount int `json:"fileCount"`
Includes Includes `json:"includes"`
}
// Includes records which optional sections are present in a snapshot.
type Includes struct {
Metadata bool `json:"metadata"`
PluginOutput bool `json:"pluginOutput,omitempty"`
}
// IndexFileBody is the structure of index.json inside the ZIP.
type IndexFileBody struct {
HashIDs []string `json:"hashIds"`
}
-42
View File
@@ -1,42 +0,0 @@
package snapshot
import (
"fmt"
"github.com/metazla/meta-core/internal/storage"
)
// WipeScope picks which classes of data to clear.
type WipeScope struct {
Metadata bool // file:* keys + file:__index__
}
// WipeResult reports what was cleared.
type WipeResult struct {
MetadataDeleted int64 `json:"metadataDeleted"`
}
// Wiper clears metadata from the store.
type Wiper struct {
storage *storage.Client
}
func NewWiper(stor *storage.Client) *Wiper {
return &Wiper{storage: stor}
}
// Wipe applies the requested scope. Returns the count of files removed.
func (w *Wiper) Wipe(scope WipeScope) (*WipeResult, error) {
if w.storage == nil || !w.storage.IsConnected() {
return nil, fmt.Errorf("storage not connected")
}
res := &WipeResult{}
if scope.Metadata {
n, err := w.storage.ClearAllMetadata()
if err != nil {
return nil, fmt.Errorf("clear metadata: %w", err)
}
res.MetadataDeleted = n
}
return res, nil
}
+612
View File
@@ -0,0 +1,612 @@
package storage
// The apply loop: folds committed truth changes into the cache.
//
// The cache's merged view keeps the v1 record layout — `file:<rid>/<field>`
// rows of the cache kv table — so every reader (record reads, search, the
// field index, FTS, counters, SSE and its snapshot) is unchanged; those rows
// are now derived, never written by a client. Each committed change re-merges
// only the (rid, key) it touched, from the truth votes of every cid in the
// group. Group membership is synthesised: every cid of a group appears as
// `file:<rid>/cids/<cid> = "true"` plus the `cid:<cid> → rid` lookup, whatever
// the cids/* claims say.
//
// Changes arrive in commit order (kv.Tx.OnCommit runs in the truth writer), so
// `meta:applied_seq` only ever covers a prefix of the truth log. A crash
// between the truth commit and the cache apply leaves the cache behind, never
// wrong; Connect catches up from truth.
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
"github.com/metazla/meta-core/internal/kv"
)
const (
appliedSeqKey = "meta:applied_seq"
// rebuiltAtKey holds the stream id at which the cache was last rebuilt.
// An SSE cursor older than it gets `gap`: the rebuild emitted no
// per-key events, so the consumer must take a snapshot.
rebuiltAtKey = "meta:rebuilt_at"
)
var localFields = []string{"filePath", "sizeByte", "mtimeNano"}
type applier struct {
c *Client
mu sync.Mutex
queue []change
applied int64
notify chan struct{}
wake chan struct{}
stop chan struct{}
done chan struct{}
}
func newApplier(c *Client, applied int64) *applier {
a := &applier{
c: c, applied: applied,
notify: make(chan struct{}),
wake: make(chan struct{}, 1),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go a.run()
return a
}
func (a *applier) enqueue(ch []change) {
a.mu.Lock()
a.queue = append(a.queue, ch...)
a.mu.Unlock()
select {
case a.wake <- struct{}{}:
default:
}
}
// wait blocks until the cache has applied every change up to seq.
func (a *applier) wait(ctx context.Context, seq int64) error {
for {
a.mu.Lock()
if a.applied >= seq {
a.mu.Unlock()
return nil
}
n := a.notify
a.mu.Unlock()
select {
case <-n:
case <-ctx.Done():
return ctx.Err()
case <-a.stop:
return errors.New("storage closed before the cache applied the write")
}
}
}
func (a *applier) close() {
close(a.stop)
<-a.done
}
const applyBatchMax = 2000
func (a *applier) run() {
defer close(a.done)
for {
select {
case <-a.wake:
case <-a.stop:
return
}
for {
a.mu.Lock()
n := min(len(a.queue), applyBatchMax)
batch := append([]change(nil), a.queue[:n]...)
a.mu.Unlock()
if len(batch) == 0 {
break
}
if err := a.c.applyBatch(batch); err != nil {
log.Printf("[Storage] apply of %d changes failed, retrying: %v", len(batch), err)
select {
case <-time.After(time.Second):
continue
case <-a.stop:
return
}
}
a.mu.Lock()
a.queue = a.queue[n:]
if s := batch[len(batch)-1].seq; s > a.applied {
a.applied = s
}
close(a.notify)
a.notify = make(chan struct{})
a.mu.Unlock()
}
}
}
// applyBatch folds committed changes into the cache in one transaction.
func (c *Client) applyBatch(batch []change) error {
s, err := c.kv()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
return s.Update(ctx, func(tx *kv.Tx) error {
return c.applyTx(tx, batch)
})
}
func (c *Client) applyTx(tx *kv.Tx, batch []change) error {
ctx := tx.Context()
marks := map[string]map[string]bool{}
mark := func(rid, key string) {
if marks[rid] == nil {
marks[rid] = map[string]bool{}
}
marks[rid][key] = true
}
all := map[string]bool{}
dirty := map[string]bool{}
var maxSeq int64
for _, it := range batch {
maxSeq = max(maxSeq, it.seq)
if it.regroup != nil {
loser, survivor := it.regroup[0], it.regroup[1]
if _, err := tx.DeletePrefix(recordPrefix(loser), 0); err != nil {
return err
}
if _, err := tx.Delete(rootKey(loser)); err != nil {
return err
}
if err := deleteSearchRowTx(tx, loser); err != nil {
return err
}
delete(marks, loser)
cids, err := c.truthMembers(ctx, survivor)
if err != nil {
return err
}
for _, cid := range cids {
if _, err := syncMember(tx, cid, survivor); err != nil {
return err
}
}
all[survivor], dirty[survivor] = true, true
continue
}
rid, err := c.truthRid(ctx, it.cid)
if err != nil {
return err
}
if rid == "" {
continue
}
newRoot, err := syncMember(tx, it.cid, rid)
if err != nil {
return err
}
if newRoot {
dirty[rid] = true
}
if it.key == "" {
continue
}
if it.live {
_, err = tx.Exec(`INSERT OR IGNORE INTO claim_by_pid(pid, cid, key) VALUES(?, ?, ?)`, it.pid, it.cid, it.key)
} else {
_, err = tx.Exec(`DELETE FROM claim_by_pid WHERE pid = ? AND cid = ? AND key = ?`, it.pid, it.cid, it.key)
}
if err != nil {
return err
}
mark(rid, it.key)
}
for rid := range all {
cids, err := cidsForRoot(ctx, tx, rid)
if err != nil {
return err
}
keys, err := c.claimKeys(ctx, cids)
if err != nil {
return err
}
for _, k := range append(keys, localFields...) {
mark(rid, k)
}
}
for rid, keys := range marks {
cids, err := cidsForRoot(ctx, tx, rid)
if err != nil {
return err
}
for key := range keys {
changed, err := c.remergeTx(tx, rid, cids, key)
if err != nil {
return err
}
if changed && affectsHaystack(key) {
dirty[rid] = true
}
}
}
for rid := range dirty {
if err := refreshSearchTx(tx, rid); err != nil {
return err
}
}
if maxSeq > 0 {
return tx.Put(appliedSeqKey, strconv.FormatInt(maxSeq, 10))
}
return nil
}
// syncMember makes cid a member of rid in the cache: the lookup row, the
// synthesised key-set member, and the root. Reports whether rid is new.
func syncMember(tx *kv.Tx, cid, rid string) (bool, error) {
cur, _, err := tx.Get(tx.Context(), CIDIndexPrefix+cid)
if err != nil {
return false, err
}
if cur != rid {
if cur != "" {
if _, err := tx.Delete(recordPrefix(cur) + CIDsKeyPrefix + cid); err != nil {
return false, err
}
}
if err := tx.Put(CIDIndexPrefix+cid, rid); err != nil {
return false, err
}
}
if err := tx.Put(recordPrefix(rid)+CIDsKeyPrefix+cid, "true"); err != nil {
return false, err
}
return tx.Set(rootKey(rid), "")
}
// remergeTx recomputes file:<rid>/<key> from the group's votes (plus the
// box-local facts for filePath / sizeByte / mtimeNano) and reports whether
// the merged row changed.
func (c *Client) remergeTx(tx *kv.Tx, rid string, cids []string, key string) (bool, error) {
if strings.HasPrefix(key, CIDsKeyPrefix) {
return false, nil // membership is synthesised, never merged
}
votes, err := c.votesFor(tx.Context(), cids, key)
if err != nil {
return false, err
}
res := Merge(votes, "")
val, ok := res.Value, res.OK
if isLocalField(key) {
lv, lok, err := localFact(tx, cids, key)
if err != nil {
return false, err
}
// The disk is the truth for where and when a file is; a signed
// sizeByte (a content fact) wins over the on-disk size.
if lok && (key != "sizeByte" || !ok) {
val, ok = lv, true
}
}
k := recordPrefix(rid) + key
if ok {
return tx.Set(k, val)
}
return tx.Delete(k)
}
// localFact reads a box-local field from local_file for the group's cids.
func localFact(tx *kv.Tx, cids []string, key string) (string, bool, error) {
if len(cids) == 0 {
return "", false, nil
}
args := make([]any, len(cids))
for i, c := range cids {
args[i] = c
}
var path string
var size, mtime int64
err := tx.QueryRow(`SELECT path, size, mtime FROM local_file WHERE midhash IN (?`+
strings.Repeat(",?", len(cids)-1)+`) ORDER BY path LIMIT 1`, args...).Scan(&path, &size, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
switch key {
case "filePath":
return path, true, nil
case "sizeByte":
return strconv.FormatInt(size, 10), true, nil
default:
return strconv.FormatInt(mtime, 10), true, nil
}
}
// truthMembers lists every cid of rid's group (scans cid_group: truth has no
// rid index; only regroups and rebuilds call this).
func (c *Client) truthMembers(ctx context.Context, rid string) ([]string, error) {
rows, err := c.truth.DB().QueryContext(ctx, `SELECT cid FROM cid_group WHERE rid = ?`, rid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var cid string
if err := rows.Scan(&cid); err != nil {
return nil, err
}
out = append(out, cid)
}
return out, rows.Err()
}
// truthLastSeq reads the newest committed seq.
func (c *Client) truthLastSeq(ctx context.Context) (int64, error) {
var v string
err := c.truth.DB().QueryRowContext(ctx, `SELECT value FROM meta WHERE name = ?`, lastSeqName).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return strconv.ParseInt(v, 10, 64)
}
// catchUp brings the cache level with truth at boot: nothing to do, a replay
// of the changes after applied_seq (found by a full scan — there is no seq
// index, and this only runs after an unclean shutdown), or a full rebuild
// when the cache is missing or a group changed while it was behind.
func (c *Client) catchUp() (int64, error) {
s := c.store
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
last, err := c.truthLastSeq(ctx)
if err != nil {
return 0, err
}
appliedStr, has, err := s.Get(ctx, appliedSeqKey)
if err != nil {
return 0, err
}
applied, _ := strconv.ParseInt(appliedStr, 10, 64)
if has && applied >= last {
return applied, nil
}
if !has {
if last == 0 {
return 0, nil // fresh store
}
log.Printf("[Storage] cache is empty, rebuilding from truth (last_seq %d)", last)
return last, c.rebuild(ctx, last)
}
rows, err := c.truth.DB().QueryContext(ctx,
`SELECT cid, key, pid, value IS NOT NULL, seq FROM claim WHERE seq > ? ORDER BY seq`, applied)
if err != nil {
return 0, err
}
var missed []change
regroup := false
for rows.Next() {
var ch change
if err := rows.Scan(&ch.cid, &ch.key, &ch.pid, &ch.live, &ch.seq); err != nil {
rows.Close()
return 0, err
}
if strings.HasPrefix(ch.key, CIDsKeyPrefix) {
regroup = true
}
missed = append(missed, ch)
}
if err := rows.Close(); err != nil {
return 0, err
}
if regroup {
log.Printf("[Storage] cache behind by %d changes including a group link, rebuilding", len(missed))
return last, c.rebuild(ctx, last)
}
log.Printf("[Storage] cache behind (applied %d < last %d), replaying %d changes", applied, last, len(missed))
for start := 0; start < len(missed); start += applyBatchMax {
end := min(start+applyBatchMax, len(missed))
if err := c.applyBatch(missed[start:end]); err != nil {
return 0, err
}
}
if err := s.Update(ctx, func(tx *kv.Tx) error { return tx.Put(appliedSeqKey, strconv.FormatInt(last, 10)) }); err != nil {
return 0, err
}
return last, nil
}
// Rebuild drops every derived row of the cache and recomputes it from truth.
// Exposed for tests and operators; boot calls it when the cache is missing.
func (c *Client) Rebuild() error {
if _, err := c.truthStore(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
last, err := c.truthLastSeq(ctx)
if err != nil {
return err
}
return c.rebuild(ctx, last)
}
const rebuildBatch = 500
func (c *Client) rebuild(ctx context.Context, last int64) error {
s := c.store
started := time.Now()
for _, prefix := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS} {
for {
var n int64
if err := s.Update(ctx, func(tx *kv.Tx) error {
tx.Quiet()
var err error
n, err = tx.DeletePrefix(prefix, bulkBatch)
return err
}); err != nil {
return fmt.Errorf("clear %s: %w", prefix, err)
}
if n < bulkBatch {
break
}
}
}
if err := s.Update(ctx, func(tx *kv.Tx) error {
if _, err := tx.Exec(`DELETE FROM fts`); err != nil {
return err
}
_, err := tx.Exec(`DELETE FROM claim_by_pid`)
return err
}); err != nil {
return err
}
groups := map[string][]string{}
var order []string
rows, err := c.truth.DB().QueryContext(ctx, `SELECT cid, rid FROM cid_group`)
if err != nil {
return err
}
for rows.Next() {
var cid, rid string
if err := rows.Scan(&cid, &rid); err != nil {
rows.Close()
return err
}
if _, ok := groups[rid]; !ok {
order = append(order, rid)
}
groups[rid] = append(groups[rid], cid)
}
if err := rows.Close(); err != nil {
return err
}
for start := 0; start < len(order); start += rebuildBatch {
end := min(start+rebuildBatch, len(order))
if err := s.Update(ctx, func(tx *kv.Tx) error {
tx.Quiet()
for _, rid := range order[start:end] {
cids := groups[rid]
for _, cid := range cids {
if _, err := syncMember(tx, cid, rid); err != nil {
return err
}
}
keys, err := c.claimKeys(ctx, cids)
if err != nil {
return err
}
for _, key := range append(keys, localFields...) {
if _, err := c.remergeTx(tx, rid, cids, key); err != nil {
return err
}
}
if err := refreshSearchTx(tx, rid); err != nil {
return err
}
}
return nil
}); err != nil {
return fmt.Errorf("rebuild records: %w", err)
}
}
if err := c.rebuildClaimIndex(ctx); err != nil {
return err
}
if err := c.RebuildCounters(); err != nil {
return err
}
rebuiltAt := strconv.FormatInt(time.Now().UnixMilli(), 10) + "-0"
if err := s.Update(ctx, func(tx *kv.Tx) error {
if err := tx.Put(rebuiltAtKey, rebuiltAt); err != nil {
return err
}
return tx.Put(appliedSeqKey, strconv.FormatInt(last, 10))
}); err != nil {
return err
}
log.Printf("[Storage] cache rebuilt: %d records in %s", len(order), time.Since(started).Round(time.Millisecond))
return nil
}
// rebuildClaimIndex refills claim_by_pid from truth, a page at a time.
func (c *Client) rebuildClaimIndex(ctx context.Context) error {
type row struct {
pid int64
cid, key string
}
var after row
first := true
for {
q := `SELECT pid, cid, key FROM claim WHERE value IS NOT NULL`
var args []any
if !first {
q += ` AND (cid, key, pid) > (?, ?, ?)`
args = append(args, after.cid, after.key, after.pid)
}
q += ` ORDER BY cid, key, pid LIMIT 5000`
rs, err := c.truth.DB().QueryContext(ctx, q, args...)
if err != nil {
return err
}
var page []row
for rs.Next() {
var r row
if err := rs.Scan(&r.pid, &r.cid, &r.key); err != nil {
rs.Close()
return err
}
page = append(page, r)
}
if err := rs.Close(); err != nil {
return err
}
if len(page) == 0 {
return nil
}
if err := c.store.Update(ctx, func(tx *kv.Tx) error {
for _, r := range page {
if _, err := tx.Exec(`INSERT OR IGNORE INTO claim_by_pid(pid, cid, key) VALUES(?, ?, ?)`, r.pid, r.cid, r.key); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
after, first = page[len(page)-1], false
}
}
// RebuiltAt is the stream id of the last cache rebuild ("" if never).
func (c *Client) RebuiltAt() string {
s, err := c.kv()
if err != nil {
return ""
}
ctx, cancel := opCtx()
defer cancel()
v, _, _ := s.Get(ctx, rebuiltAtKey)
return v
}
+15 -226
View File
@@ -3,34 +3,22 @@ package storage
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/metazla/meta-core/internal/cid"
"github.com/metazla/meta-core/internal/kv"
)
// This file implements the CID-resolution layer that lives on top of the
// flat metadata storage in client.go. The short version:
// The cid → record resolution layer, read side. A record (rid) is a group of
// cids known to be the same content; the group lives in truth's cid_group and
// is mirrored into the cache as
//
// - Roots are opaque UUIDv7s — file:<uuid>/<property> for every property.
// The UUID is an internal implementation detail; the mental model is
// "N CIDs → one record of keys→metadata".
// - Every known CID for a file is registered as a reverse-index entry:
// cid:<bareCid> → <uuid>
// and as a member of the per-root bare-CID key-set:
// file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value)
// - A CID is self-describing: its algorithm is recoverable from the CIDv1
// multicodec (see internal/cid), so there is no per-algorithm field name
// and no <algo>: token prefix. The canonical externally-visible CID is
// *derived by rank* from the key-set on read (GetMetadataDocument), never
// stored.
// cid:<bareCid> → <rid>
// file:<rid>/cids/<bareCid> = "true" (synthesised membership, one per cid)
//
// The record write path (writeFieldsTx) catches any write of a cids/<cid>
// field and registers the reverse-index entry in the same transaction, so
// writers that just emit the key-set members don't need to know about the
// reverse index at all. The helpers here take the caller's *kv.Tx: a write
// must never open a second transaction (there is one writer connection).
// by the apply loop (apply.go). A CID is self-describing (its algorithm is in
// the CIDv1 multicodec, see internal/cid); the canonical externally-visible
// CID is derived by rank on read (GetMetadataDocument), never stored.
// CIDIndexPrefix is the key prefix for the reverse index. Combined
// with a bare CID, it forms the full key: "cid:bafk…".
@@ -43,114 +31,6 @@ const CIDIndexPrefix = "cid:"
// reverse-index entries that need cleanup).
const CIDsKeyPrefix = "cids/"
// Duplicate paths — other physical locations whose content hashes to the same
// CIDs as a root — live as dup:<rid>/<path> = "" (a key-set). The watcher
// writes one when it sees a new path for content that's already indexed.
// Mint creates a new root with filePath/sizeByte/mtimeNano and registers it
// in the root index. Returns the new UUID. The caller is expected to follow
// up with AddAlias for at least one CID — a root without any alias is
// reachable only via the index, which works but defeats the point.
func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
var uuid string
err := c.update(func(tx *kv.Tx) error {
var err error
uuid, err = mintTx(tx, filePath, size, mtimeNano)
return err
})
return uuid, err
}
func mintTx(tx *kv.Tx, filePath string, size, mtimeNano int64) (string, error) {
uuid, err := NewUUID()
if err != nil {
return "", fmt.Errorf("uuid: %w", err)
}
return uuid, writeFieldsTx(tx, uuid, map[string]string{
"filePath": filePath,
"sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
})
}
// AddAlias registers cidStr as a sibling CID of uuid: it writes the reverse
// index cid:<cidStr> → uuid and the key-set member cids/<cidStr> = "true".
// Idempotent: registering the same CID twice is a no-op.
func (c *Client) AddAlias(uuid, cidStr string) error {
if uuid == "" || cidStr == "" {
return fmt.Errorf("uuid and cid are required")
}
return c.update(func(tx *kv.Tx) error {
return writeFieldsTx(tx, uuid, map[string]string{CIDsKeyPrefix + cidStr: "true"})
})
}
// addAliasTx registers the reverse-index entry for a cids/<cid> member the
// caller is writing (writeFieldsTx writes the member itself).
func addAliasTx(tx *kv.Tx, uuid, cidStr string) error {
if uuid == "" || cidStr == "" {
return fmt.Errorf("uuid and cid are required")
}
// Self-pointing alias guard. If the caller is trying to register
// cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
// dual-root failure mode in disguise — it means somebody wrote
// /meta/<cid> without first resolving via ResolveRoot. Refuse the alias
// write; leave any existing alias alone. The key-set membership is still
// recorded by the caller, so the field-level write succeeds.
if uuid == cidStr {
return nil
}
// Guard against the dual-root pattern: if cid:<cid> already points at a
// DIFFERENT uuid, don't silently overwrite. Leave the existing alias; the
// key-set membership keeps the CID discoverable via root → cids.
indexKey := CIDIndexPrefix + cidStr
existing, _, err := tx.Get(tx.Context(), indexKey)
if err != nil {
return fmt.Errorf("read existing alias: %w", err)
}
if existing != "" && existing != uuid {
return nil
}
if existing == uuid {
return nil
}
return tx.Put(indexKey, uuid)
}
// removeAliasTx is the inverse of addAliasTx: it drops the reverse-index
// entry cid:<cid> → <uuid>. The caller deletes the cids/<cid> key-set member
// itself (DeleteProperty does both).
//
// ⚠ Why this exists. A cids/ member write touches two keys — the flat
// file:<uuid>/cids/<cid> and this reverse alias — while the delete path once
// only removed the first. A "deleted" CID therefore still resolved: GET
// /meta/<cid>, ResolveRoot and GetByCID all kept answering with the record.
// The asymmetry is not theoretical. meta-share published dag-pb roots computed
// over RAR volumes and par2 files onto six records on watch.nsl.sh; removing
// the member was expressible, removing the claim was not, so the false
// identity survived its own retraction.
//
// Guarded, not blind: the alias is removed only when it currently points at
// THIS uuid. Two records can legitimately race to claim a cid (see the
// dual-root guard in addAliasTx, which leaves the first writer's alias in
// place); deleting a member from the loser must not unpick the winner's index.
func removeAliasTx(tx *kv.Tx, uuid, cidStr string) error {
if uuid == "" || cidStr == "" {
return nil
}
indexKey := CIDIndexPrefix + cidStr
existing, ok, err := tx.Get(tx.Context(), indexKey)
if err != nil {
return fmt.Errorf("read existing alias: %w", err)
}
if !ok || existing != uuid {
// Absent, or somebody else owns this alias. Leave it.
return nil
}
_, err = tx.Delete(indexKey)
return err
}
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
// lookup, an SSE consumer) to the actual storage root key.
//
@@ -202,23 +82,6 @@ func (c *Client) GetByCID(cidStr string) (string, error) {
return uuid, nil
}
// AddDuplicatePath records that path is another physical location whose
// content matches uuid. Returns true if path was newly added. The watcher
// calls this when it sees a file whose midhash matches an existing root but
// at a different filePath.
func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) {
if uuid == "" || path == "" {
return false, nil
}
added := false
err := c.update(func(tx *kv.Tx) error {
var err error
added, err = tx.PutIfAbsent(dupPrefix(uuid)+path, "")
return err
})
return added, err
}
// cidsForRoot returns a root's bare-CID key-set members (cids/ prefix
// stripped).
func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error) {
@@ -234,20 +97,6 @@ func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error
return out, nil
}
// DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property
// (including the cids/<cid> key-set), every reverse-index entry for those
// CIDs, its duplicates, search row and index membership. Idempotent — a uuid
// with no remaining keys is a successful no-op.
func (c *Client) DeleteRoot(uuid string) error {
if uuid == "" {
return nil
}
return c.update(func(tx *kv.Tx) error {
_, err := deleteRecordTx(tx, uuid)
return err
})
}
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
// or "" if the field isn't a key-set member.
func cidFromKeysetField(field string) string {
@@ -311,13 +160,16 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
}
}
dupRows, err := s.Range(ctx, dupPrefix(uuid), "", 0)
// Duplicates: every other local path holding the same content.
paths, err := localPaths(ctx, s.DB(), cids)
if err != nil {
return nil, fmt.Errorf("read duplicates: %w", err)
}
dups := make([]string, 0, len(dupRows))
for _, row := range dupRows {
dups = append(dups, row.K[len(dupPrefix(uuid)):])
dups := make([]string, 0, len(paths))
for _, p := range paths {
if p != flat["filePath"] {
dups = append(dups, p)
}
}
if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 {
@@ -330,66 +182,3 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
Duplicates: dups,
}, nil
}
// RegisterFileTuple records one physical file under its midhash256 CID, the
// same way the file watcher does for every file it scans: mint a UUID root
// (filePath/sizeByte/mtimeNano) and write `cids/<midhash>`, whose hook
// registers the cid:<midhash> reverse alias. Content already known under
// another path is recorded as a duplicate; the same path just refreshes
// size/mtime.
//
// Returns the root and whether it was newly minted. Shared by the watcher and
// POST /api/files/register, which is how a box running with the watcher off
// (a meta-watch client core) still gets files it writes under /files/plugin
// resolvable by CID.
func (c *Client) RegisterFileTuple(absPath string, size, mtimeNano int64, midhash string) (string, bool, error) {
if midhash == "" {
return "", false, fmt.Errorf("midhash is required")
}
var (
uuid string
minted bool
)
// One transaction: the lookup and the mint cannot interleave with another
// registration of the same content, so two concurrent scans of one file
// can no longer mint two roots for it.
err := c.update(func(tx *kv.Tx) error {
ctx := tx.Context()
existing, _, err := tx.Get(ctx, CIDIndexPrefix+midhash)
if err != nil {
return fmt.Errorf("GetByCID(%s): %w", midhash, err)
}
uuid = existing
if uuid == "" {
if uuid, err = mintTx(tx, absPath, size, mtimeNano); err != nil {
return fmt.Errorf("Mint(%s): %w", absPath, err)
}
minted = true
if err := writeFieldsTx(tx, uuid, map[string]string{CIDsKeyPrefix + midhash: "true"}); err != nil {
return fmt.Errorf("cids/%s for %s: %w", midhash, uuid, err)
}
return nil
}
current, _, err := tx.Get(ctx, recordPrefix(uuid)+"filePath")
if err != nil {
return err
}
if current != absPath {
if _, err := tx.PutIfAbsent(dupPrefix(uuid)+absPath, ""); err != nil {
return fmt.Errorf("AddDuplicatePath %s @ %s: %w", uuid, absPath, err)
}
return nil
}
if err := writeFieldsTx(tx, uuid, map[string]string{
"sizeByte": strconv.FormatInt(size, 10),
"mtimeNano": strconv.FormatInt(mtimeNano, 10),
}); err != nil {
return fmt.Errorf("refresh tuple for %s: %w", uuid, err)
}
return nil
})
if err != nil {
return "", false, err
}
return uuid, minted, nil
}
-159
View File
@@ -1,159 +0,0 @@
package storage
import (
"context"
"testing"
)
// Tests for retracting a `cids/<cid>` key-set member (cid_resolution.go's
// removeAliasTx, reached through DeleteProperty).
//
// Why this is worth its own file. Adding a member writes two keys — the flat
// `file:<uuid>/cids/<cid>` and the reverse alias `cid:<cid>` → `<uuid>`. Until
// removeAliasLocked (now removeAliasTx) existed, deleting removed only the
// first, so a retracted CID kept resolving to the record: the member was gone
// from the document and the claim was not.
//
// That asymmetry had a live consequence. meta-share minted dag-pb roots and
// midhash256s over whatever `archive::pick_media` elected, which on an
// obfuscated posting was an arbitrary RAR volume or a par2 file. Six records on
// watch.nsl.sh carry such members. `METADATA_KEYS.md` §2 defines a `cids/`
// member as a digest of *this record's file* and notes that every member is a
// reverse index back to the record — so those entries assert that an archive
// volume is the episode. Retracting one has to remove the assertion, not just
// the row.
// aliasTarget returns the uuid that cid:<cidStr> currently resolves to, or "".
func aliasTarget(t *testing.T, c *Client, cidStr string) string {
t.Helper()
got, _, err := c.Store().Get(context.Background(), CIDIndexPrefix+cidStr)
if err != nil {
t.Fatalf("get alias: %v", err)
}
return got
}
func hasField(t *testing.T, c *Client, hashID, field string) bool {
t.Helper()
fields, err := c.GetMetadataFlat(hashID)
if err != nil {
t.Fatalf("read record: %v", err)
}
_, ok := fields[field]
return ok
}
// The real shape: `…glnrqztt…` is the nzb-release locator whose record published
// a dag-pb root computed over volume 3 of a 35-volume RAR5 set.
const (
poisonedRoot = "bafybeicicuzdvuztca2hp2g2k7scgr4nnibilzgoppljeprphybqot7v6i"
goodMidhash = "bagacbabaed6uiulnv7tscpxne22eztkjvnxg5almn43dpfecppixarfe7xhlu"
)
func TestRetractingACidMemberRemovesTheClaimNotJustTheRow(t *testing.T) {
withClient(t, func(t *testing.T, c *Client) {
const uuid = "01KSBHMTESTRECORD"
if err := c.SetMetadataFlat(uuid, map[string]string{
"cids/" + poisonedRoot: "true",
"cids/" + goodMidhash: "true",
"fileType": "video",
}); err != nil {
t.Fatalf("seed: %v", err)
}
// All three writes landed, and the CID resolves to the record.
if got := aliasTarget(t, c, poisonedRoot); got != uuid {
t.Fatalf("alias should point at the record before retraction, got %q", got)
}
if !hasField(t, c, uuid, "cids/"+poisonedRoot) {
t.Fatal("field index should list the member before retraction")
}
if got := c.ResolveRoot(poisonedRoot); got != uuid {
t.Fatalf("ResolveRoot should find the record before retraction, got %q", got)
}
if err := c.DeleteProperty(uuid, "cids/"+poisonedRoot); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
// 1. the flat key
if v, ok, _ := c.Store().Get(context.Background(),
recordPrefix(uuid)+"cids/"+poisonedRoot); ok {
t.Fatalf("flat key should be gone, still %q", v)
}
// 2. the record read
if hasField(t, c, uuid, "cids/"+poisonedRoot) {
t.Fatal("record read still lists the retracted member")
}
// 3. the reverse alias — the one that used to survive
if got := aliasTarget(t, c, poisonedRoot); got != "" {
t.Fatalf("reverse alias survived retraction, still points at %q", got)
}
if got := c.ResolveRoot(poisonedRoot); got == uuid {
t.Fatal("a retracted cid must not resolve to the record any more")
}
// The siblings are untouched: retraction is surgical, not a purge.
if got := aliasTarget(t, c, goodMidhash); got != uuid {
t.Fatalf("an unrelated sibling lost its alias, got %q", got)
}
if !hasField(t, c, uuid, "cids/"+goodMidhash) {
t.Fatal("an unrelated sibling lost its field-index entry")
}
if v, err := c.GetProperty(uuid, "fileType"); err != nil || v != "video" {
t.Fatalf("an unrelated field changed: %q %v", v, err)
}
})
}
// Two records can race to claim one cid; addAliasLocked leaves the first
// writer's alias in place. Retracting the loser's member must not unpick the
// winner's index — which is why removeAliasLocked compares before deleting.
func TestRetractingDoesNotStealAnotherRecordsAlias(t *testing.T) {
withClient(t, func(t *testing.T, c *Client) {
const winner = "01KSBHMWINNER"
const loser = "01KSBHMLOSER"
const shared = "bafybeiaeb2lqi4z3np3q4tarldtyjp2t5lvnoodmtpvpi2tz73azn5td2m"
if err := c.SetMetadataFlat(winner, map[string]string{"cids/" + shared: "true"}); err != nil {
t.Fatalf("seed winner: %v", err)
}
if err := c.SetMetadataFlat(loser, map[string]string{"cids/" + shared: "true"}); err != nil {
t.Fatalf("seed loser: %v", err)
}
if got := aliasTarget(t, c, shared); got != winner {
t.Fatalf("the first writer should own the alias, got %q", got)
}
if err := c.DeleteProperty(loser, "cids/"+shared); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
if got := aliasTarget(t, c, shared); got != winner {
t.Fatalf("retracting the loser's member stole the winner's alias, now %q", got)
}
if hasField(t, c, loser, "cids/"+shared) {
t.Fatal("the loser's own member should still be gone")
}
})
}
// A non-cids field has no alias to clean up, and the delete must not invent one.
func TestRetractingAnOrdinaryFieldTouchesNoAlias(t *testing.T) {
withClient(t, func(t *testing.T, c *Client) {
const uuid = "01KSBHMORDINARY"
if err := c.SetMetadataFlat(uuid, map[string]string{
"cids/" + goodMidhash: "true",
"quality": "1080p",
}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := c.DeleteProperty(uuid, "quality"); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
if got := aliasTarget(t, c, goodMidhash); got != uuid {
t.Fatalf("deleting an ordinary field disturbed a cid alias, got %q", got)
}
})
}
+419
View File
@@ -0,0 +1,419 @@
package storage
import (
"context"
"database/sql"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/kv"
)
func TestWriteClaimStatuses(t *testing.T) {
c := newTestClient(t)
a := signer(t, 1)
good := vote(t, a, "bafyA", "title", "The Matrix", 100)
tampered := vote(t, a, "bafyA", "year", "1999", 100)
tampered.Value = claim.Text("2000")
highS := vote(t, a, "bafyA", "studio", "WB", 100)
highS.Sig = flipS(highS.Sig)
got := write(t, c,
good,
tampered,
highS,
claim.Claim{CID: "bafyA", UID: a.UID, TS: 1}, // no key
vote(t, a, "bafyA", "cid_sha256", "x", 100), // removed CID shape
)
want := []ClaimStatus{StatusAccepted, StatusBadSignature, StatusBadSignature, StatusBadRequest, StatusBadRequest}
if !reflect.DeepEqual(got, want) {
t.Fatalf("statuses %v, want %v", got, want)
}
if st := write(t, c, good); st[0] != StatusUnchanged {
t.Fatalf("identical re-send: %s, want unchanged", st[0])
}
if st := write(t, c, vote(t, a, "bafyA", "title", "Older", 50)); st[0] != StatusStale {
t.Fatalf("older ts: %s, want stale", st[0])
}
if st := write(t, c, vote(t, a, "bafyA", "title", "Same ts other value", 100)); st[0] != StatusStale {
t.Fatalf("equal ts, different claim: %s, want stale", st[0])
}
if got := record(t, c, "bafyA")["title"]; got != "The Matrix" {
t.Fatalf("title = %q", got)
}
// A re-persist of the same value with a newer ts keeps the stored row (and
// its older ts): it is "unchanged", not a new version.
if st := write(t, c, vote(t, a, "bafyA", "title", "The Matrix", 200)); st[0] != StatusUnchanged {
t.Fatalf("same value, newer ts: %s, want unchanged", st[0])
}
// Proof the stored ts stayed 100: a new value at 150 (< 200) is accepted.
if st := write(t, c, vote(t, a, "bafyA", "title", "Updated", 150)); st[0] != StatusAccepted {
t.Fatalf("new value at ts 150 after a same-value re-send at 200: %s, want accepted", st[0])
}
}
func flipS(sig []byte) []byte {
// n - s for secp256k1, computed via the vectors' helper in internal/claim
// would be cleaner; here big-endian subtraction on bytes is enough.
n := []byte{
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41,
}
out := append([]byte{}, sig...)
borrow := 0
for i := 31; i >= 0; i-- {
d := int(n[i]) - int(sig[32+i]) - borrow
borrow = 0
if d < 0 {
d += 256
borrow = 1
}
out[32+i] = byte(d)
}
return out
}
// A retraction is kept as a tombstone, so the vote it retracted cannot be
// replayed — by its signer's old copy or by a peer that still holds it.
func TestRetractionKeepsTombstoneAndBlocksReplay(t *testing.T) {
c := newTestClient(t)
a := signer(t, 1)
old := vote(t, a, "bafyA", "genres/Drama", "true", 10)
mustAccept(t, c, old, vote(t, a, "bafyA", "title", "X", 10))
mustAccept(t, c, vote(t, a, "bafyA", "genres/Drama", nil, 20))
if _, ok := record(t, c, "bafyA")["genres/Drama"]; ok {
t.Fatal("retracted member still merged")
}
if st := write(t, c, old); st[0] != StatusStale {
t.Fatalf("replay of the retracted vote: %s, want stale", st[0])
}
if n, _ := c.CountClaims(); n != 2 {
t.Fatalf("claims = %d, want 2 (tombstone kept)", n)
}
live, _ := c.ClaimsForCIDs([]string{"bafyA"}, "")
if len(live) != 1 || live[0].Key != "title" {
t.Fatalf("live claims = %+v, want only title", live)
}
}
func TestMergeRule(t *testing.T) {
tx := func(s string) claim.Value { return claim.Text(s) }
cases := []struct {
name string
votes []Vote
viewer string
want string
nsrc int
ok bool
}{
{"support wins", []Vote{{Uid: "a", Value: tx("X"), TS: 9}, {Uid: "b", Value: tx("x "), TS: 9}, {Uid: "c", Value: tx("Y"), TS: 1}}, "", "X", 2, true},
{"tie: oldest wins", []Vote{{Uid: "a", Value: tx("New"), TS: 9}, {Uid: "b", Value: tx("Old"), TS: 1}}, "", "Old", 1, true},
{"tie: smallest wins", []Vote{{Uid: "a", Value: tx("b"), TS: 5}, {Uid: "b", Value: tx("a"), TS: 5}}, "", "a", 1, true},
{"signer counts once, newest", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "a", Value: tx("Y"), TS: 2}, {Uid: "b", Value: tx("Y"), TS: 3}}, "", "Y", 2, true},
{"retraction drops", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "a", Value: claim.Null(), TS: 2}}, "", "", 0, false},
{"ciphertext never merges", []Vote{{Uid: "a", Value: claim.Bytes([]byte{1}), TS: 1}}, "", "", 0, false},
{"viewer wins", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "b", Value: tx("X"), TS: 1}, {Uid: "v", Value: tx("Mine"), TS: 9}}, "v", "Mine", 1, true},
{"viewer retraction does not hide", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "v", Value: claim.Null(), TS: 9}}, "v", "X", 1, true},
}
for _, tc := range cases {
got := Merge(tc.votes, tc.viewer)
if got.OK != tc.ok || got.Value != tc.want || (tc.ok && got.Nsrc != tc.nsrc) {
t.Errorf("%s: got %+v, want %q nsrc %d ok %v", tc.name, got, tc.want, tc.nsrc, tc.ok)
}
}
}
func TestMergedViewAndClaims(t *testing.T) {
c := newTestClient(t)
gwA, gwB, gwC := signer(t, 1), signer(t, 2), signer(t, 3)
mustAccept(t, c,
vote(t, gwA, "bafyA", "title", "Matrix", 10),
vote(t, gwB, "bafyA", "title", "Matrix", 20),
vote(t, gwC, "bafyA", "title", "The Matrix (1999)", 5),
)
rec := record(t, c, "bafyA")
if rec["title"] != "Matrix" || rec["cids/bafyA"] != "true" {
t.Fatalf("merged %v", rec)
}
claims, err := c.ClaimsForRID(rid(t, c, "bafyA"), "title")
if err != nil || len(claims) != 3 {
t.Fatalf("claims %d (err %v), want 3", len(claims), err)
}
for _, cl := range claims {
if err := cl.Verify(); err != nil {
t.Fatalf("returned claim does not verify: %v", err)
}
}
}
// A cids/* link merges two groups: the older rid survives, the loser's rows
// go with `del` events, and the survivor carries both groups' votes.
func TestGroupLinkMergesRecords(t *testing.T) {
c := newTestClient(t)
a, b := signer(t, 1), signer(t, 2)
mustAccept(t, c, vote(t, a, "bafyM", "title", "Local copy", 10))
older := rid(t, c, "bafyM")
mustAccept(t, c, vote(t, b, "bafyQ", "year", "1999", 10))
newer := rid(t, c, "bafyQ")
if older == newer || older > newer {
t.Fatalf("expected two groups, older first: %s %s", older, newer)
}
before, _ := c.Store().LastID(context.Background(), MetaEventsStream)
mustAccept(t, c, vote(t, a, "bafyM", "cids/bafyQ", "true", 11))
if got := rid(t, c, "bafyQ"); got != older {
t.Fatalf("bafyQ → %s, want the older rid %s", got, older)
}
rec := record(t, c, "bafyM")
if rec["title"] != "Local copy" || rec["year"] != "1999" || rec["cids/bafyQ"] != "true" || rec["cids/bafyM"] != "true" {
t.Fatalf("survivor %v", rec)
}
if f, _ := c.GetMetadataFlat(newer); f != nil {
t.Fatalf("loser still has rows: %v", f)
}
entries, _ := c.Store().ReadAfter(context.Background(), MetaEventsStream, before, 1000)
sawDel := false
for _, e := range entries {
if e.Values["type"] == "del" && strings.HasPrefix(e.Values["key"], "file:"+newer+"/") {
sawDel = true
}
}
if !sawDel {
t.Fatal("no del event for the losing rid")
}
if n, _ := c.CountRoots(); n != 1 {
t.Fatalf("roots = %d, want 1", n)
}
}
func TestViewerOverlay(t *testing.T) {
c := newTestClient(t)
a, b, me := signer(t, 1), signer(t, 2), signer(t, 9)
mustAccept(t, c,
vote(t, a, "bafyA", "title", "Shared", 1),
vote(t, b, "bafyA", "title", "Shared", 1),
vote(t, me, "bafyA", "title", "Mine", 2),
vote(t, me, "bafyA", "seek", []byte{1, 2, 3}, 2), // ciphertext: never shown
)
r := rid(t, c, "bafyA")
shared, _ := c.GetMetadataFlat(r)
mine, _ := c.GetMetadataFlatAs(r, me.UID)
if shared["title"] != "Shared" || mine["title"] != "Mine" {
t.Fatalf("shared %q, mine %q", shared["title"], mine["title"])
}
if _, ok := mine["seek"]; ok {
t.Fatal("ciphertext leaked into the view")
}
match := func(_ string, f map[string]string) bool { return strings.Contains(strings.ToLower(f["title"]), "mine") }
if hits, _ := c.Search("", nil, 10, match); len(hits) != 0 {
t.Fatalf("shared search found the viewer's value: %v", hits)
}
hits, err := c.SearchAs("", nil, 10, me.UID, match)
if err != nil || len(hits) != 1 {
t.Fatalf("viewer search hits %v (err %v), want 1", hits, err)
}
merged, _, dropped, err := c.MergeClaims([]claim.Claim{
vote(t, a, "bafyA", "title", "Shared", 1),
vote(t, b, "bafyZ", "title", "Other", 1),
}, me.UID)
if err != nil || dropped != 0 || merged["title"] != "Mine" {
t.Fatalf("stateless merge as viewer = %v dropped %d err %v", merged, dropped, err)
}
}
// A crash between the truth commit and the cache apply leaves the cache
// behind; the next boot replays the missed changes from truth.
func TestCrashBetweenCommitsIsCaughtUp(t *testing.T) {
dir := t.TempDir()
c := openClient(t, dir)
a := signer(t, 1)
mustAccept(t, c, vote(t, a, "bafyA", "title", "Before", 1))
// Commit straight to truth, as WriteClaims does, but never apply.
lost := vote(t, a, "bafyA", "title", "After", 2)
if err := c.truth.Update(context.Background(), func(tx *kv.Tx) error {
seq, err := readLastSeq(tx)
if err != nil {
return err
}
pid, err := ensurePrincipal(tx, lost.UID)
if err != nil {
return err
}
seq++
if _, err := tx.Exec(`UPDATE claim SET value = ?, ts = ?, sig = ?, seq = ? WHERE cid = ? AND key = ? AND pid = ?`,
"After", int64(lost.TS), lost.Sig, seq, lost.CID, lost.Key, pid); err != nil {
return err
}
return writeLastSeq(tx, seq)
}); err != nil {
t.Fatal(err)
}
if got := record(t, c, "bafyA")["title"]; got != "Before" {
t.Fatalf("cache applied a change it was never given: %q", got)
}
c.Close()
c = openClient(t, dir)
defer c.Close()
if got := record(t, c, "bafyA")["title"]; got != "After" {
t.Fatalf("after catch-up title = %q, want After", got)
}
}
// The cache is a pure function of truth: deleting it and rebuilding yields
// exactly what incremental application produced.
func TestRebuildEqualsIncremental(t *testing.T) {
dir := t.TempDir()
c := openClient(t, dir)
a, b, u := signer(t, 1), signer(t, 2), signer(t, 3)
mustAccept(t, c,
vote(t, a, "bafy1", "title", "One", 1), vote(t, a, "bafy1", "genres/Action", "true", 1),
vote(t, b, "bafy1", "title", "Uno", 2), vote(t, b, "bafy2", "title", "Two", 2),
vote(t, a, "bafy2", "sizeByte", "123", 1), vote(t, u, "bafy2", "like", "true", 3),
vote(t, u, "bafy2", "seek", []byte{9}, 3),
)
mustAccept(t, c,
vote(t, a, "bafy1", "genres/Action", nil, 4), // retraction
vote(t, b, "bafy1", "cids/bafy2", "true", 5), // group merge
vote(t, a, "bafy3", "title", "Three", 5),
)
incremental := cacheState(t, c)
c.Close()
for _, f := range []string{"cache.db", "cache.db-wal", "cache.db-shm"} {
_ = os.Remove(filepath.Join(dir, f))
}
c = openClient(t, dir)
defer c.Close()
if rebuilt := cacheState(t, c); !reflect.DeepEqual(rebuilt, incremental) {
t.Fatalf("rebuild differs from incremental:\n incremental %v\n rebuilt %v", incremental, rebuilt)
}
if c.RebuiltAt() == "" {
t.Fatal("rebuild did not record rebuilt_at (SSE consumers would not get a gap)")
}
}
// cacheState is every derived row that must be a function of truth.
func cacheState(t *testing.T, c *Client) []string {
t.Helper()
var out []string
for _, k := range allKeys(t, c) {
if strings.HasPrefix(k, "ev:") || strings.HasPrefix(k, "evhwm:") || strings.HasPrefix(k, "meta:") ||
strings.HasPrefix(k, ftsNS) || k == countersReady {
continue
}
v, _, _ := c.Store().Get(context.Background(), k)
out = append(out, k+"="+v)
}
rows, err := c.Store().DB().Query(`SELECT pid, cid, key FROM claim_by_pid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var pid int64
var cid, key string
_ = rows.Scan(&pid, &cid, &key)
out = append(out, "byp:"+strconv.FormatInt(pid, 10)+"/"+cid+"/"+key)
}
var hays []string
hr, _ := c.Store().DB().Query(`SELECT rid, hay FROM fts`)
for hr.Next() {
var r, h string
_ = hr.Scan(&r, &h)
hays = append(hays, "fts:"+r+"="+h)
}
hr.Close()
out = append(out, hays...)
sort.Strings(out)
return out
}
func TestLocalFileFactsAndEvents(t *testing.T) {
c := newTestClient(t)
before, _ := c.Store().LastID(context.Background(), MetaEventsStream)
r, created, err := c.RegisterFileTuple("/files/watch/a.mkv", 1000, 42, "bafkmid")
if err != nil || !created {
t.Fatalf("register: %v created=%v", err, created)
}
rec, _ := c.GetMetadataFlat(r)
if rec["filePath"] != "/files/watch/a.mkv" || rec["sizeByte"] != "1000" || rec["mtimeNano"] != "42" || rec["cids/bafkmid"] != "true" {
t.Fatalf("record %v", rec)
}
entries, _ := c.Store().ReadAfter(context.Background(), MetaEventsStream, before, 100)
sawPath := false
for _, e := range entries {
if e.Values["type"] == "set" && e.Values["key"] == "file:"+r+"/filePath" {
sawPath = true
}
}
if !sawPath {
t.Fatal("no set event for file:<rid>/filePath (meta-fuse builds its tree from it)")
}
// A signed sizeByte (content fact) wins over the on-disk size.
mustAccept(t, c, vote(t, signer(t, 1), "bafkmid", "sizeByte", "999", 1))
if got, _ := c.GetProperty(r, "sizeByte"); got != "999" {
t.Fatalf("sizeByte = %q, want the signed 999", got)
}
// A second path of the same content is a duplicate.
if _, _, err := c.RegisterFileTuple("/files/watch/copy.mkv", 1000, 43, "bafkmid"); err != nil {
t.Fatal(err)
}
doc, _ := c.GetMetadataDocument(r)
if len(doc.Duplicates) != 1 || doc.Duplicates[0] != "/files/watch/copy.mkv" {
t.Fatalf("duplicates %v", doc.Duplicates)
}
tuples, _ := c.GetTuplesForAllFiles()
if len(tuples) != 2 || tuples[0].HashID != r {
t.Fatalf("tuples %+v", tuples)
}
// Deleting the files drops the local facts, keeps the claims.
mark, _ := c.Store().LastID(context.Background(), MetaEventsStream)
for _, p := range []string{"/files/watch/a.mkv", "/files/watch/copy.mkv"} {
if err := c.RemoveFileTuple(p); err != nil {
t.Fatal(err)
}
}
rec, _ = c.GetMetadataFlat(r)
if _, ok := rec["filePath"]; ok || rec["sizeByte"] != "999" {
t.Fatalf("after delete %v", rec)
}
entries, _ = c.Store().ReadAfter(context.Background(), MetaEventsStream, mark, 100)
sawDel := false
for _, e := range entries {
if e.Values["type"] == "del" && e.Values["key"] == "file:"+r+"/filePath" {
sawDel = true
}
}
if !sawDel {
t.Fatal("no del event for filePath")
}
}
func TestSchemaGateRefusesV1Store(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "meta-core.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID;
INSERT INTO kv VALUES('meta-core:schema-version','2')`); err != nil {
t.Fatal(err)
}
db.Close()
c := NewClient()
if err := c.Connect(path, filepath.Join(dir, "cache.db")); err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.EnsureSchemaVersion(SchemaVersion); err == nil || !strings.Contains(err.Error(), "v1.x") {
t.Fatalf("v1 store accepted: %v", err)
}
}
+41 -118
View File
@@ -1,79 +1,27 @@
package storage
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/metazla/meta-core/internal/kv"
)
// newTestClient opens a fresh database in the test's temp dir.
func newTestClient(t *testing.T) *Client {
t.Helper()
c := NewClient()
if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
// allKeys lists every row (test helper; full scan).
func allKeys(t *testing.T, c *Client) []string {
t.Helper()
var keys []string
if err := c.Store().ScanPrefix(context.Background(), "", 1000, func(rows []kv.KV) error {
for _, r := range rows {
keys = append(keys, r.K)
}
return nil
}); err != nil {
t.Fatalf("scan: %v", err)
}
return keys
}
// TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the
// leak: ClearAllMetadata used to delete every record key and the root index,
// but left the cid:<cid> → root reverse-index entries behind. Those stale
// aliases are not cosmetic — ResolveRoot consults them, so a surviving entry
// silently redirects a later write into a deleted root.
func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
// ClearAllMetadata wipes BOTH databases: votes, signers and groups in truth;
// merged records, the cid lookup, roots, search rows, the claim index and the
// local files in the cache. Service scratch keys are not metadata and survive.
func TestClearAllMetadataWipesTruthAndCache(t *testing.T) {
c := newTestClient(t)
// Two records, each with several CID aliases (a midhash plus siblings) —
// exactly what the watcher produces.
aliases := map[string][]string{}
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
if err != nil {
t.Fatalf("mint: %v", err)
}
cids := []string{"bafkmid" + uuid, "bafkfull" + uuid}
for _, cidStr := range cids {
if err := c.AddAlias(uuid, cidStr); err != nil {
t.Fatalf("add alias %s: %v", cidStr, err)
}
}
if _, err := c.AddDuplicatePath(uuid, "watch/copy-"+f); err != nil {
t.Fatalf("add duplicate: %v", err)
}
aliases[uuid] = cids
a := signer(t, 1)
mustAccept(t, c,
vote(t, a, "bafkone", "title", "One", 1),
vote(t, a, "bafkone", "cids/bafkonefull", "true", 1),
vote(t, a, "bafktwo", "title", "Two", 1),
)
if _, _, err := c.RegisterFileTuple("/files/watch/a.mkv", 10, 20, "bafkmid"); err != nil {
t.Fatal(err)
}
// An entry already orphaned by a previous (buggy) clear: a reverse-index
// key whose root no longer exists. The sweep is expected to collect it too.
if err := c.SetRawValue(CIDIndexPrefix+"bafkorphan", "01OLDDEADROOT"); err != nil {
t.Fatalf("seed orphan: %v", err)
}
for uuid, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != uuid {
t.Fatalf("pre-clear ResolveRoot(%s) = %q, want %q", cidStr, got, uuid)
}
for k, v := range map[string]string{"btmagnet:abc": "magnet:?xt=urn:btih:abc", "searchcov:tmdb:1": "1"} {
if err := c.SetServiceValue(k, v); err != nil {
t.Fatalf("seed %s: %v", k, err)
}
}
@@ -81,72 +29,47 @@ func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
if err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
if deleted != 2 {
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
if deleted != 3 {
t.Errorf("deleted = %d, want 3 records", deleted)
}
// Nothing record-shaped may survive: not the records, not their aliases,
// duplicates or search rows, not the pre-existing orphan. (The event
// streams and the schema sentinel are not metadata and stay.)
for _, k := range allKeys(t, c) {
for _, ns := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS, dupNS} {
for _, ns := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS} {
if strings.HasPrefix(k, ns) {
t.Errorf("key survived the clear: %q", k)
}
}
}
var ftsRows int
if err := c.Store().DB().QueryRow(`SELECT count(*) FROM fts`).Scan(&ftsRows); err != nil || ftsRows != 0 {
t.Errorf("fts rows after clear = %d (err %v), want 0", ftsRows, err)
}
for _, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != cidStr {
t.Errorf("post-clear ResolveRoot(%s) = %q, want the hash unchanged (stale alias)", cidStr, got)
}
for _, table := range []string{"fts", "claim_by_pid", "local_file"} {
var n int
if err := c.Store().DB().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil || n != 0 {
t.Errorf("%s rows after clear = %d (err %v)", table, n, err)
}
}
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
if n, err := c.CountClaims(); err != nil || n != 0 {
t.Errorf("claims after clear = %d (err %v)", n, err)
}
}
// TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius:
// keys outside the metadata namespaces (magnet cache, coverage markers, a key
// that merely CONTAINS "cid:") must survive a metadata wipe.
func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
c := newTestClient(t)
uuid, err := c.Mint("watch/a.mkv", 100, 1000)
if err != nil {
t.Fatalf("mint: %v", err)
if got := c.ResolveRoot("bafkone"); got != "bafkone" {
t.Errorf("post-clear ResolveRoot = %q, want the hash unchanged", got)
}
if err := c.AddAlias(uuid, "bafkmine"); err != nil {
t.Fatalf("add alias: %v", err)
}
for k, v := range map[string]string{"other:cid:bafktheirs": "01THEIRROOT", "btmagnet:abc": "magnet:?xt=urn:btih:abc", "unrelated": "keep-me"} {
if err := c.SetRawValue(k, v); err != nil {
t.Fatalf("seed %s: %v", k, err)
}
}
if _, err := c.ClearAllMetadata(); err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
for _, k := range []string{"other:cid:bafktheirs", "btmagnet:abc", "unrelated"} {
for _, k := range []string{"btmagnet:abc", "searchcov:tmdb:1"} {
if _, _, ok, _ := c.GetKeyInfo(k); !ok {
t.Errorf("clear deleted foreign key %q", k)
t.Errorf("clear deleted service key %q", k)
}
}
if _, _, ok, _ := c.GetKeyInfo(CIDIndexPrefix + "bafkmine"); ok {
t.Errorf("own reverse-index entry survived the clear")
// The store keeps working after a wipe.
mustAccept(t, c, vote(t, a, "bafkone", "title", "Again", 1))
if got := record(t, c, "bafkone")["title"]; got != "Again" {
t.Errorf("title after re-write = %q", got)
}
}
// withClient runs fn against a fresh client.
func withClient(t *testing.T, fn func(t *testing.T, c *Client)) {
t.Helper()
fn(t, newTestClient(t))
// Raw writes are limited to service scratch keys: merged records and
// bookkeeping cannot be written around the claims.
func TestServiceValueRefusesRecordKeys(t *testing.T) {
c := newTestClient(t)
for _, k := range []string{"file:R1/title", "cid:bafk", "idx:roots/R1", "ev:meta:events/1", "meta:applied_seq"} {
if err := c.SetServiceValue(k, "x"); err == nil {
t.Errorf("SetServiceValue(%q) accepted", k)
}
}
}
+214 -394
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -16,21 +15,31 @@ import (
"github.com/metazla/meta-core/internal/schema"
)
// Key layout (everything is one string→string table, see internal/kv):
// meta-core keeps two SQLite files (docs/implementation/metacore-claims-model.md):
//
// file:<rid>/<field> a record property — the ONLY thing under `file:`
// cid:<cid> reverse index → <rid>
// idx:roots/<rid> the set of every root (was the Redis SET file:__index__)
// idx:fts/<rid> rowid of the record's row in the FTS table
// idx:count/…, idx:schema/… derived counters (root count, per-field schema), see onChange
// dup:<rid>/<path> a duplicate path of the record (was the SET file:<rid>/duplicates)
// udl:… User Data Layer, see udl.go
// ev:<stream>/…, evhwm:… event streams, see internal/kv/stream.go
// - the TRUTH database (truth.go): principal, claim, cid_group, meta — the
// signed votes and the locally minted rid names, primary keys only, backed
// up;
//
// - the CACHE database: everything derived from truth, never backed up,
// rebuilt when missing (apply.go). Its kv table keeps the v1 layout:
//
// file:<rid>/<field> the MERGED record view — the ONLY thing under `file:`
// cid:<cid> cid → rid lookup (copy of truth's cid_group)
// idx:roots/<rid> the set of every record
// idx:fts/<rid> rowid of the record's row in the FTS table
// idx:count/…, idx:schema/… derived counters (root count, per-field schema), see onChange
// ev:<stream>/…, evhwm:… event streams, see internal/kv/stream.go
// meta:applied_seq, meta:rebuilt_at apply-loop bookkeeping
// anything else service scratch KV (btmagnet:*, searchcov:*), /api/kv/value
//
// plus the tables claim_by_pid and local_file (localfile.go), and the
// field index + FTS (search.go).
//
// Keeping bookkeeping OUT of `file:` is load-bearing: the `file:` range is
// exactly the records, so a record is the key range [file:<rid>/, file:<rid>0),
// the field expression index covers only real fields, and every change under
// `file:` is a real field change worth publishing on meta:events.
// exactly the merged records, so a record is the key range [file:<rid>/,
// file:<rid>0), the field expression index covers only real fields, and every
// change under `file:` is a real field change worth publishing on meta:events.
const (
recordNS = "file:"
rootsNS = "idx:roots/"
@@ -43,10 +52,9 @@ const (
readerPage = 5_000
)
// MetaEventsStream receives a set/del entry for every record-key change. It is
// written by the storage engine in the same transaction as the change (the
// replacement for Redis keyspace notifications), and served as SSE by
// /api/events/meta.
// MetaEventsStream receives a set/del entry for every merged-record change. It
// is written by the cache engine in the same transaction as the change, and
// served as SSE by /api/events/meta.
const MetaEventsStream = "meta:events"
// MetaEventsMaxLen is the approximate cap on meta:events. Bounded retention is
@@ -54,10 +62,13 @@ const MetaEventsStream = "meta:events"
// "Backing-store retention and lifecycle".
const MetaEventsMaxLen = 100_000
// Client is meta-core's metadata store.
// Client is meta-core's metadata store: the truth database, the cache, and
// the apply loop between them.
type Client struct {
store *kv.Store
mu sync.RWMutex
store *kv.Store // cache
truth *kv.Store
applier *applier
mu sync.RWMutex
keysMu sync.Mutex
keysCount int64
@@ -69,60 +80,98 @@ func NewClient() *Client {
return &Client{}
}
// Connect opens (creating if needed) the database file at path.
func (c *Client) Connect(path string) error {
// Connect opens (creating if needed) the truth database at truthPath and the
// cache at cachePath. Call EnsureSchemaVersion, then Start.
func (c *Client) Connect(truthPath, cachePath string) error {
c.mu.Lock()
defer c.mu.Unlock()
store, err := kv.Open(path, kv.Options{
truth, err := kv.Open(truthPath, kv.Options{
NoKVTable: true,
Schema: truthSchema,
WriterCacheMB: envInt("META_CORE_DB_CACHE_MB", 64),
ReaderCacheMB: envInt("META_CORE_DB_READER_CACHE_MB", 16),
Readers: envInt("META_CORE_DB_READERS", 8),
})
if err != nil {
return fmt.Errorf("open truth %s: %w", truthPath, err)
}
store, err := kv.Open(cachePath, kv.Options{
EventStream: MetaEventsStream,
EventMaxLen: MetaEventsMaxLen,
EventKey: func(k string) bool { return strings.HasPrefix(k, recordNS) },
WriterCacheMB: envInt("META_CORE_DB_CACHE_MB", 64),
ReaderCacheMB: envInt("META_CORE_DB_READER_CACHE_MB", 16),
Readers: envInt("META_CORE_DB_READERS", 8),
Schema: searchSchema,
Schema: searchSchema + cacheTablesSchema,
OnChange: onChange,
Synchronous: "OFF",
})
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
truth.Close()
return fmt.Errorf("open cache %s: %w", cachePath, err)
}
c.store = store
log.Printf("[Storage] Opened %s", path)
c.truth, c.store = truth, store
log.Printf("[Storage] Opened truth %s, cache %s", truthPath, cachePath)
return nil
}
// Close closes the database.
// Start brings the cache level with truth (replay or rebuild) and starts the
// apply loop. Writes before Start fail.
func (c *Client) Start() error {
applied, err := c.catchUp()
if err != nil {
return fmt.Errorf("cache catch-up: %w", err)
}
c.mu.Lock()
c.applier = newApplier(c, applied)
c.mu.Unlock()
return nil
}
// Close stops the apply loop and closes both databases.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.store == nil {
return nil
if c.applier != nil {
c.applier.close()
c.applier = nil
}
err := c.store.Close()
c.store = nil
return err
var errs []error
if c.store != nil {
errs = append(errs, c.store.Close())
c.store = nil
}
if c.truth != nil {
errs = append(errs, c.truth.Close())
c.truth = nil
}
return errors.Join(errs...)
}
// IsConnected reports whether the database is open.
// IsConnected reports whether the databases are open.
func (c *Client) IsConnected() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.store != nil
return c.store != nil && c.truth != nil
}
// Health checks that the database answers.
// Health checks that both databases answer.
func (c *Client) Health() bool {
s, err := c.kv()
if err != nil {
return false
}
t, err := c.truthStore()
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return s.Ping(ctx) == nil
return s.Ping(ctx) == nil && t.Ping(ctx) == nil
}
// Store exposes the underlying engine (event streams for SSE and the schema
// Store exposes the cache engine (event streams for SSE and the schema
// indexer).
func (c *Client) Store() *kv.Store {
c.mu.RLock()
@@ -139,7 +188,16 @@ func (c *Client) kv() (*kv.Store, error) {
return c.store, nil
}
// update runs fn in one write transaction with the standard timeout.
func (c *Client) truthStore() (*kv.Store, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.truth == nil || c.applier == nil {
return nil, fmt.Errorf("not connected")
}
return c.truth, nil
}
// update runs fn in one cache write transaction with the standard timeout.
func (c *Client) update(fn func(tx *kv.Tx) error) error {
s, err := c.kv()
if err != nil {
@@ -184,8 +242,92 @@ func splitRecordKey(key string) (hashID, field string, ok bool) {
// isInternalKey reports keys that are derived bookkeeping, which the raw KV
// editor must never write (it would desynchronise the derived state).
func isInternalKey(key string) bool {
return strings.HasPrefix(key, "idx:") || strings.HasPrefix(key, dupNS) ||
strings.HasPrefix(key, streamNS) || strings.HasPrefix(key, hwmNS)
for _, p := range []string{recordNS, CIDIndexPrefix, "idx:", dupNS, streamNS, hwmNS, "meta:", "meta-core:"} {
if strings.HasPrefix(key, p) {
return true
}
}
return false
}
// SetServiceValue writes service scratch KV (btmagnet:*, searchcov:*) — the
// only raw key writes left. Merged records and bookkeeping are refused.
func (c *Client) SetServiceValue(key, value string) error {
if key == "" || isInternalKey(key) {
return fmt.Errorf("key %q is not a service key", key)
}
return c.update(func(tx *kv.Tx) error { return tx.Put(key, value) })
}
// DeleteServiceValue deletes a service scratch key. Returns true if it existed.
func (c *Client) DeleteServiceValue(key string) (bool, error) {
if key == "" || isInternalKey(key) {
return false, fmt.Errorf("key %q is not a service key", key)
}
existed := false
err := c.update(func(tx *kv.Tx) error {
var err error
existed, err = tx.Delete(key)
return err
})
return existed, err
}
// ClearAllMetadata wipes BOTH databases: every claim, signer and group in
// truth, and every derived row of the cache. Irreversible — truth holds votes
// nothing else can recover. Returns the number of records that existed.
func (c *Client) ClearAllMetadata() (int64, error) {
truth, err := c.truthStore()
if err != nil {
return 0, err
}
roots, err := c.CountRoots()
if err != nil {
return 0, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := truth.Update(ctx, func(tx *kv.Tx) error {
for _, t := range []string{"claim", "principal", "cid_group"} {
if _, err := tx.Exec(`DELETE FROM ` + t); err != nil {
return err
}
}
_, err := tx.Exec(`DELETE FROM meta WHERE name = ?`, lastSeqName)
return err
}); err != nil {
return 0, fmt.Errorf("clear truth: %w", err)
}
for _, prefix := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS} {
for {
var n int64
if err := c.update(func(tx *kv.Tx) error {
var err error
n, err = tx.DeletePrefix(prefix, bulkBatch)
return err
}); err != nil {
return 0, fmt.Errorf("clear %s: %w", prefix, err)
}
if n < bulkBatch {
break
}
}
}
if err := c.update(func(tx *kv.Tx) error {
for _, q := range []string{`DELETE FROM fts`, `DELETE FROM claim_by_pid`, `DELETE FROM local_file`} {
if _, err := tx.Exec(q); err != nil {
return err
}
}
return tx.Put(appliedSeqKey, "0")
}); err != nil {
return 0, fmt.Errorf("clear cache: %w", err)
}
c.applier.mu.Lock()
c.applier.applied = 0
c.applier.mu.Unlock()
log.Printf("[Storage] Cleared both databases (%d records)", roots)
return roots, nil
}
// Derived counters, maintained by onChange in the same transaction as the
@@ -300,44 +442,6 @@ func readRecord(ctx context.Context, r kv.Reader, hashID string) (map[string]str
return out, nil
}
// writeFieldsTx is the one write path for record fields: the keys, the root
// index, the CID reverse aliases and the search row, all in the caller's
// transaction.
func writeFieldsTx(tx *kv.Tx, hashID string, metadata map[string]string) error {
if hashID == "" {
return fmt.Errorf("empty hash id")
}
prefix := recordPrefix(hashID)
searchChanged := false
for field, value := range metadata {
if isReservedFieldName(field) {
continue
}
changed, err := tx.Set(prefix+field, value)
if err != nil {
return err
}
searchChanged = searchChanged || (changed && affectsHaystack(field))
}
newRoot, err := tx.Set(rootKey(hashID), "")
if err != nil {
return err
}
for field := range metadata {
if cidStr := cidFromKeysetField(field); cidStr != "" {
if err := addAliasTx(tx, hashID, cidStr); err != nil {
return err
}
}
}
// The FTS row is rewritten only when the haystack can have changed — a
// re-persisted record whose values are all in place costs reads only.
if searchChanged || newRoot {
return refreshSearchTx(tx, hashID)
}
return nil
}
// GetMetadataFlat retrieves all metadata for a file as a flat map.
func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) {
s, err := c.kv()
@@ -349,28 +453,6 @@ func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) {
return readRecord(ctx, s, hashID)
}
// SetMetadataFlat stores metadata for a file using flat keys (merge: fields
// absent from metadata are kept — the Redis implementation never cleared
// either).
func (c *Client) SetMetadataFlat(hashID string, metadata map[string]string) error {
if len(metadata) == 0 {
return nil
}
return c.update(func(tx *kv.Tx) error { return writeFieldsTx(tx, hashID, metadata) })
}
// MergeMetadataFlat merges new metadata into existing (PATCH semantics): new
// keys are added, existing keys are updated, missing keys are NOT deleted.
func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (int, error) {
if len(metadata) == 0 {
return 0, nil
}
if err := c.update(func(tx *kv.Tx) error { return writeFieldsTx(tx, hashID, metadata) }); err != nil {
return 0, err
}
return len(metadata), nil
}
// GetAllHashIDs returns every root id, sorted.
func (c *Client) GetAllHashIDs() ([]string, error) {
s, err := c.kv()
@@ -401,57 +483,6 @@ func (c *Client) GetProperty(hashID, property string) (string, error) {
return v, err
}
// SetProperty sets a single property value.
func (c *Client) SetProperty(hashID, property, value string) error {
if isReservedFieldName(property) {
return fmt.Errorf("field name %q is reserved", property)
}
return c.update(func(tx *kv.Tx) error {
return writeFieldsTx(tx, hashID, map[string]string{property: value})
})
}
// deleteRecordTx removes everything keyed off hashID: its properties, the
// reverse-index entries its cids/ members own, its duplicates, its root-index
// entry and its search row. Returns the number of property keys removed.
func deleteRecordTx(tx *kv.Tx, hashID string) (int64, error) {
ctx := tx.Context()
cids, err := cidsForRoot(ctx, tx, hashID)
if err != nil {
return 0, err
}
for _, cidStr := range cids {
if _, err := tx.Delete(CIDIndexPrefix + cidStr); err != nil {
return 0, err
}
}
n, err := tx.DeletePrefix(recordPrefix(hashID), 0)
if err != nil {
return 0, err
}
if _, err := tx.DeletePrefix(dupPrefix(hashID), 0); err != nil {
return n, err
}
if _, err := tx.Delete(rootKey(hashID)); err != nil {
return n, err
}
return n, deleteSearchRowTx(tx, hashID)
}
// DeleteMetadata deletes all metadata for a file, and unmaps every
// reverse-index entry registered for this root, so a subsequent
// GET /api/meta/<old-cid> correctly returns 404 instead of pointing at a root
// with no keys.
func (c *Client) DeleteMetadata(hashID string) (int64, error) {
var n int64
err := c.update(func(tx *kv.Tx) error {
var err error
n, err = deleteRecordTx(tx, hashID)
return err
})
return n, err
}
// CountFiles returns the number of indexed roots.
func (c *Client) CountFiles() (int, error) {
n, err := c.CountRoots()
@@ -496,161 +527,28 @@ func normalizeFilesRelativePath(p string) string {
// DeleteProperty deletes a single property.
//
// For a cids/<cid> member this is the full inverse of the write: the flat key
// AND the cid:<cid> reverse alias both go. Anything less leaves the CID still
// resolving to this record — see removeAliasTx.
func (c *Client) DeleteProperty(hashID, property string) error {
return c.update(func(tx *kv.Tx) error {
if _, err := tx.Delete(recordPrefix(hashID) + property); err != nil {
return err
}
if cidStr := cidFromKeysetField(property); cidStr != "" {
if err := removeAliasTx(tx, hashID, cidStr); err != nil {
return fmt.Errorf("remove alias for %s: %w", cidStr, err)
}
}
if affectsHaystack(property) {
return refreshSearchTx(tx, hashID)
}
return nil
})
}
// AddToSet adds a value to a set-type field (stored as a pipe-delimited string
// in one flat key). Returns true if value was added, false if it already
// existed.
func (c *Client) AddToSet(hashID, property, value string) (bool, error) {
added := false
err := c.update(func(tx *kv.Tx) error {
current, _, err := tx.Get(tx.Context(), recordPrefix(hashID)+property)
if err != nil {
return err
}
var values []string
if current != "" {
values = strings.Split(current, "|")
}
for _, v := range values {
if v == value {
return nil
}
}
added = true
return writeFieldsTx(tx, hashID, map[string]string{property: strings.Join(append(values, value), "|")})
})
return added, err
}
// RemoveFromSet removes a value from a set-type field (stored in one flat key).
// Returns true if value was removed, false if it didn't exist.
func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) {
removed := false
err := c.update(func(tx *kv.Tx) error {
key := recordPrefix(hashID) + property
current, ok, err := tx.Get(tx.Context(), key)
if err != nil || !ok {
return err
}
values := strings.Split(current, "|")
kept := make([]string, 0, len(values))
for _, v := range values {
if v == value {
removed = true
} else {
kept = append(kept, v)
}
}
if !removed {
return nil
}
if len(kept) == 0 {
if _, err := tx.Delete(key); err != nil {
return err
}
if affectsHaystack(property) {
return refreshSearchTx(tx, hashID)
}
return nil
}
return writeFieldsTx(tx, hashID, map[string]string{property: strings.Join(kept, "|")})
})
return removed, err
}
// FileTuple holds the (uuid, path, size, mtime) record for one file. Used by
// the watcher hydrator at startup to avoid re-mid-hashing files whose tuple
// matches what's already stored.
type FileTuple struct {
HashID string
FilePath string
Size int64
MtimeNano int64
}
// GetTuplesForAllFiles returns one FileTuple per indexed root, in batched
// multi-key reads. Skips files missing any of filePath, sizeByte, mtimeNano.
func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*opTimeout)
defer cancel()
fields := [3]string{"filePath", "sizeByte", "mtimeNano"}
var out []FileTuple
err = s.ScanPrefix(ctx, rootsNS, 1000, func(rows []kv.KV) error {
keys := make([]string, 0, len(rows)*len(fields))
for _, row := range rows {
prefix := recordPrefix(row.K[len(rootsNS):])
for _, f := range fields {
keys = append(keys, prefix+f)
}
}
vals, err := s.MGet(ctx, keys)
if err != nil {
return err
}
for _, row := range rows {
h := row.K[len(rootsNS):]
prefix := recordPrefix(h)
filePath, sizeStr, mtimeStr := vals[prefix+fields[0]], vals[prefix+fields[1]], vals[prefix+fields[2]]
if filePath == "" || sizeStr == "" || mtimeStr == "" {
continue
}
size, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
continue
}
mtime, err := strconv.ParseInt(mtimeStr, 10, 64)
if err != nil {
continue
}
out = append(out, FileTuple{HashID: h, FilePath: filePath, Size: size, MtimeNano: mtime})
}
return nil
})
return out, err
}
// GetMemoryInfo reports the store's footprint for dashboards: the database
// file plus its WAL, human-readable. (Under Redis this was used_memory_human;
// the data no longer lives in RAM, so the disk footprint is the honest figure.)
func (c *Client) GetMemoryInfo() (string, error) {
s, err := c.kv()
if err != nil {
if _, err := c.kv(); err != nil {
return "", err
}
return humanBytes(s.FileSize()), nil
return humanBytes(c.DBSize()), nil
}
// DBSize is the database file plus WAL, in bytes.
// DBSize is both database files plus their WALs, in bytes.
func (c *Client) DBSize() int64 {
s, err := c.kv()
if err != nil {
return 0
c.mu.RLock()
defer c.mu.RUnlock()
var n int64
for _, st := range []*kv.Store{c.store, c.truth} {
if st != nil {
n += st.FileSize()
}
}
return s.FileSize()
return n
}
func humanBytes(n int64) string {
@@ -674,37 +572,6 @@ func humanBytes(n int64) string {
// redirects the next /meta/<cid> write back into that dead root, resurrecting
// a zombie record under an old identity.
//
// Runs as many bounded transactions rather than one: a single transaction over
// millions of rows would hold the writer for minutes and balloon the WAL.
func (c *Client) ClearAllMetadata() (int64, error) {
roots, err := c.CountRoots()
if err != nil {
return 0, err
}
for _, prefix := range []string{recordNS, CIDIndexPrefix, dupNS, rootsNS, ftsNS} {
for {
var n int64
if err := c.update(func(tx *kv.Tx) error {
var err error
n, err = tx.DeletePrefix(prefix, bulkBatch)
return err
}); err != nil {
return 0, fmt.Errorf("clear %s: %w", prefix, err)
}
if n < bulkBatch {
break
}
}
}
if err := c.update(func(tx *kv.Tx) error {
_, err := tx.Exec(`DELETE FROM fts`)
return err
}); err != nil {
return 0, fmt.Errorf("clear search rows: %w", err)
}
log.Printf("[Storage] Cleared %d file metadata entries", roots)
return roots, nil
}
// ScanKeys lists the immediate children at the given prefix, S3-style.
//
@@ -767,11 +634,24 @@ func (c *Client) ScanKeys(prefix, delimiter, cursorStr string, maxResults int) (
leaves = append(leaves, k)
from, op = k, ">"
}
leaves, branches = serviceOnly(leaves), serviceOnly(branches)
sort.Strings(leaves)
sort.Strings(branches)
return leaves, branches, next, nil
}
// serviceOnly drops merged-record and bookkeeping keys: the /api/kv browser
// shows service scratch KV only.
func serviceOnly(keys []string) []string {
out := keys[:0]
for _, k := range keys {
if !isInternalKey(k) {
out = append(out, k)
}
}
return out
}
// FindMatch is a single value-search hit.
type FindMatch struct {
Key string `json:"key"`
@@ -876,10 +756,11 @@ func (c *Client) SearchKeys(contains string, limit int) ([]string, bool, error)
if err := rows.Err(); err != nil {
return nil, false, err
}
if len(results) > limit {
return results[:limit], true, nil
truncated := len(results) > limit
if truncated {
results = results[:limit]
}
return results, false, nil
return serviceOnly(results), truncated, nil
}
// GetKeyInfo returns the key's type — "string" when it exists (every row is a
@@ -889,6 +770,9 @@ func (c *Client) GetKeyInfo(key string) (kind string, value string, exists bool,
if err != nil {
return "", "", false, err
}
if isInternalKey(key) {
return "", "", false, nil
}
ctx, cancel := opCtx()
defer cancel()
v, ok, err := s.Get(ctx, key)
@@ -898,70 +782,6 @@ func (c *Client) GetKeyInfo(key string) (kind string, value string, exists bool,
return "string", v, true, nil
}
// SetRawValue writes a string value at the exact key (the raw KV editor).
// Refuses derived bookkeeping keys. A record key keeps the search row honest
// like every other write path.
func (c *Client) SetRawValue(key, value string) error {
if isInternalKey(key) {
return fmt.Errorf("key %q is internal bookkeeping and not editable", key)
}
return c.update(func(tx *kv.Tx) error {
if err := tx.Put(key, value); err != nil {
return err
}
if hashID, field, ok := splitRecordKey(key); ok && affectsHaystack(field) {
return refreshSearchTx(tx, hashID)
}
return nil
})
}
// DeleteRawKey deletes the exact key. Returns true if the key existed.
func (c *Client) DeleteRawKey(key string) (bool, error) {
if isInternalKey(key) {
return false, fmt.Errorf("key %q is internal bookkeeping and not editable", key)
}
existed := false
err := c.update(func(tx *kv.Tx) error {
var err error
if existed, err = tx.Delete(key); err != nil || !existed {
return err
}
if hashID, field, ok := splitRecordKey(key); ok && affectsHaystack(field) {
return refreshSearchTx(tx, hashID)
}
return nil
})
return existed, err
}
// IndexAdd adds a hash ID to the root index. Idempotent.
func (c *Client) IndexAdd(hashID string) error {
return c.update(func(tx *kv.Tx) error {
if err := tx.Put(rootKey(hashID), ""); err != nil {
return err
}
return ensureSearchRowTx(tx, hashID)
})
}
// IndexRemoveIfEmpty removes hashID from the root index (and drops its search
// row) when no file:<hashID>/* keys remain. Returns true if it was removed.
func (c *Client) IndexRemoveIfEmpty(hashID string) (bool, error) {
removed := false
err := c.update(func(tx *kv.Tx) error {
n, err := tx.Count(tx.Context(), recordPrefix(hashID))
if err != nil || n > 0 {
return err
}
if removed, err = tx.Delete(rootKey(hashID)); err != nil {
return err
}
return deleteSearchRowTx(tx, hashID)
})
return removed, err
}
// GetPrefix returns the key namespace prefix. Always empty: meta-core owns its
// database file outright, so there is nothing to share a keyspace with.
func (c *Client) GetPrefix() string { return "" }
+38 -36
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"testing"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/schema"
)
@@ -19,48 +20,42 @@ func schemaJSON(t *testing.T, c *Client) string {
return string(b)
}
// The counters behind /api/schema and search's `total` are maintained by the
// write path. Whatever sequence of writes, modifications and deletions runs,
// they must equal what a full rebuild from the records computes.
// The counters behind /api/schema and search's `total` follow the merged view.
// Whatever sequence of claims, retractions and edits runs, they must equal what
// a full rebuild from the records computes.
func TestCountersStayExactAcrossWrites(t *testing.T) {
c := newTestClient(t)
seed(t, c, map[string]map[string]string{
"R1": {"title": "One", "sizeByte": "100", "title/eng": "One"},
"R2": {"title": "Two", "sizeByte": "x", "genres/Action": "true"},
"R3": {"title": "Three", "sizeByte": "300"},
})
if err := c.SetProperty("R2", "sizeByte", "200"); err != nil { // label string → int
t.Fatal(err)
}
if err := c.SetProperty("R1", "title", "One"); err != nil { // unchanged: no delta
t.Fatal(err)
}
if err := c.DeleteProperty("R1", "title/eng"); err != nil {
t.Fatal(err)
}
if _, err := c.DeleteMetadata("R3"); err != nil {
t.Fatal(err)
}
if n, err := c.CountRoots(); err != nil || n != 2 {
t.Fatalf("CountRoots = %d (err %v), want 2", n, err)
a, b := signer(t, 1), signer(t, 2)
mustAccept(t, c,
vote(t, a, "bafyR1", "title", "One", 10), vote(t, a, "bafyR1", "sizeByte", "100", 10),
vote(t, a, "bafyR1", "title/eng", "One", 10),
vote(t, a, "bafyR2", "title", "Two", 10), vote(t, a, "bafyR2", "sizeByte", "x", 10),
vote(t, a, "bafyR2", "genres/Action", "true", 10),
vote(t, a, "bafyR3", "title", "Three", 10), vote(t, a, "bafyR3", "sizeByte", "300", 10),
)
mustAccept(t, c,
vote(t, a, "bafyR2", "sizeByte", "200", 20), // label string → int
vote(t, b, "bafyR1", "title", "One", 20), // agreeing vote: merged value unchanged
vote(t, a, "bafyR1", "title/eng", nil, 20), // retraction
)
if n, err := c.CountRoots(); err != nil || n != 3 {
t.Fatalf("CountRoots = %d (err %v), want 3", n, err)
}
incremental := schemaJSON(t, c)
snap, _ := schema.NewIndexer(c.Store()).Snapshot(context.Background())
if f := snap.Fields["sizeByte"]; f == nil || f.Type != schema.PrimInt || f.Breakdown["int"] != 2 {
t.Fatalf("sizeByte = %+v, want 2 ints", f)
if f := snap.Fields["sizeByte"]; f == nil || f.Type != schema.PrimInt || f.Breakdown["int"] != 3 {
t.Fatalf("sizeByte = %+v, want 3 ints", f)
}
if _, ok := snap.Fields["title"]; !ok {
t.Fatalf("title missing: %v", snap.Fields)
if _, ok := snap.Fields["title/eng"]; ok {
t.Fatalf("retracted title/eng still counted: %v", snap.Fields)
}
if err := c.RebuildCounters(); err != nil {
t.Fatal(err)
}
if rebuilt := schemaJSON(t, c); rebuilt != incremental {
t.Fatalf("incremental counters drifted from a rebuild:\n incremental %s\n rebuilt %s", incremental, rebuilt)
}
if n, _ := c.CountRoots(); n != 2 {
if n, _ := c.CountRoots(); n != 3 {
t.Fatalf("CountRoots after rebuild = %d", n)
}
}
@@ -77,16 +72,23 @@ func TestEnsureCountersBuildsOnce(t *testing.T) {
}
}
// A re-persisted record with nothing new writes nothing and publishes nothing.
func TestUnchangedRecordWriteIsANoOp(t *testing.T) {
// Re-persisting the same signed claims writes nothing and publishes nothing —
// the gateway's constant repeats.
func TestUnchangedClaimWriteIsANoOp(t *testing.T) {
c := newTestClient(t)
rec := map[string]string{"title": "Naruto", "fileType": "video", "cids/bafkreiabc": "true"}
seed(t, c, map[string]map[string]string{"R1": rec})
a := signer(t, 1)
batch := []claim.Claim{
vote(t, a, "bafkreiabc", "title", "Naruto", 5),
vote(t, a, "bafkreiabc", "fileType", "video", 5),
}
mustAccept(t, c, batch...)
before, _ := c.GetStreamLength(MetaEventsStream)
if err := c.SetMetadataFlat("R1", rec); err != nil {
t.Fatal(err)
for _, st := range write(t, c, batch...) {
if st != StatusUnchanged {
t.Fatalf("re-sent claim status %s, want unchanged", st)
}
}
if after, _ := c.GetStreamLength(MetaEventsStream); after != before {
t.Fatalf("re-persisting an unchanged record published %d events", after-before)
t.Fatalf("re-persisting unchanged claims published %d events", after-before)
}
}
+166
View File
@@ -0,0 +1,166 @@
package storage
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/kv"
)
// openClient opens (and starts) a client on dir's truth + cache files.
func openClient(t *testing.T, dir string) *Client {
t.Helper()
c := NewClient()
if err := c.Connect(filepath.Join(dir, "meta-core.db"), filepath.Join(dir, "cache.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := c.EnsureSchemaVersion(SchemaVersion); err != nil {
t.Fatalf("schema: %v", err)
}
if err := c.Start(); err != nil {
t.Fatalf("start: %v", err)
}
return c
}
// newTestClient opens a fresh store in the test's temp dir.
func newTestClient(t *testing.T) *Client {
t.Helper()
c := openClient(t, t.TempDir())
t.Cleanup(func() { _ = c.Close() })
return c
}
// signer returns a deterministic test identity (scalar n repeated).
func signer(t *testing.T, n byte) *identity.Identity {
t.Helper()
id, err := identity.Import(fmt.Sprintf("%02x%062x", n, 1))
if err != nil {
t.Fatalf("identity: %v", err)
}
return id
}
// vote builds a signed claim; v is a string (text), []byte (ciphertext) or nil
// (retraction).
func vote(t *testing.T, id *identity.Identity, cid, key string, v any, ts uint64) claim.Claim {
t.Helper()
cl := claim.Claim{CID: cid, UID: id.UID, Key: key, TS: ts}
switch x := v.(type) {
case string:
cl.Value = claim.Text(x)
case []byte:
cl.Value = claim.Bytes(x)
case nil:
default:
t.Fatalf("vote value %T", v)
}
if err := cl.Sign(id); err != nil {
t.Fatalf("sign: %v", err)
}
return cl
}
// write posts claims and returns their statuses.
func write(t *testing.T, c *Client, claims ...claim.Claim) []ClaimStatus {
t.Helper()
res, err := c.WriteClaims(context.Background(), claims)
if err != nil {
t.Fatalf("write: %v", err)
}
out := make([]ClaimStatus, len(res))
for i, r := range res {
out[i] = r.Status
}
return out
}
// mustAccept posts claims and fails unless every one is accepted.
func mustAccept(t *testing.T, c *Client, claims ...claim.Claim) {
t.Helper()
for i, st := range write(t, c, claims...) {
if st != StatusAccepted {
t.Fatalf("claim %d (%s/%s): %s", i, claims[i].CID, claims[i].Key, st)
}
}
}
// seed writes merged rows straight into the cache — the derived layout search
// and the counters work on — bypassing claims. Search tests pin SQL behaviour
// over that layout; how rows get there is the apply loop's business.
func seed(t *testing.T, c *Client, records map[string]map[string]string) {
t.Helper()
for id, fields := range records {
setMerged(t, c, id, fields)
}
}
func setMerged(t *testing.T, c *Client, rid string, fields map[string]string) {
t.Helper()
if err := c.update(func(tx *kv.Tx) error {
for k, v := range fields {
if err := tx.Put(recordPrefix(rid)+k, v); err != nil {
return err
}
}
if err := tx.Put(rootKey(rid), ""); err != nil {
return err
}
return refreshSearchTx(tx, rid)
}); err != nil {
t.Fatalf("seed %s: %v", rid, err)
}
}
func deleteMerged(t *testing.T, c *Client, rid string) {
t.Helper()
if err := c.update(func(tx *kv.Tx) error {
if _, err := tx.DeletePrefix(recordPrefix(rid), 0); err != nil {
return err
}
if _, err := tx.Delete(rootKey(rid)); err != nil {
return err
}
return deleteSearchRowTx(tx, rid)
}); err != nil {
t.Fatalf("delete %s: %v", rid, err)
}
}
// allKeys lists every cache kv row (full scan).
func allKeys(t *testing.T, c *Client) []string {
t.Helper()
var keys []string
if err := c.Store().ScanPrefix(context.Background(), "", 1000, func(rows []kv.KV) error {
for _, r := range rows {
keys = append(keys, r.K)
}
return nil
}); err != nil {
t.Fatalf("scan: %v", err)
}
return keys
}
// rid resolves a cid to its record (fails when unknown).
func rid(t *testing.T, c *Client, cid string) string {
t.Helper()
r, err := c.GetByCID(cid)
if err != nil || r == "" {
t.Fatalf("cid %s unresolved (%v)", cid, err)
}
return r
}
func record(t *testing.T, c *Client, cid string) map[string]string {
t.Helper()
f, err := c.GetMetadataFlat(rid(t, c, cid))
if err != nil {
t.Fatal(err)
}
return f
}
+196
View File
@@ -0,0 +1,196 @@
package storage
// Box-local file facts: where the watcher saw each file on this disk.
//
// local_file(path) → midhash, size, mtime (cache table, unsigned)
//
// These are facts about this disk, not about the content, so they are never
// claims and never travel. meta-core cannot sign anyway, and a rescan of
// /files rebuilds them. The midhash gets a cid_group entry in truth (local
// naming, not a vote), and the record view joins filePath / sizeByte /
// mtimeNano in as `file:<rid>/<field>` rows — which publishes the meta events
// meta-fuse builds its tree from.
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/metazla/meta-core/internal/kv"
)
const cacheTablesSchema = `
CREATE TABLE IF NOT EXISTS claim_by_pid(
pid INTEGER NOT NULL,
cid TEXT NOT NULL,
key TEXT NOT NULL,
PRIMARY KEY (pid, cid, key)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS local_file(
path TEXT PRIMARY KEY,
midhash TEXT NOT NULL,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS local_file_midhash ON local_file(midhash, path);
`
// RegisterFileTuple records one physical file under its midhash256 CID, the
// way the watcher does for every file it scans (and POST /api/files/register
// for a box with the watcher off). Returns the record's rid and whether its
// group was newly created.
func (c *Client) RegisterFileTuple(absPath string, size, mtimeNano int64, midhash string) (string, bool, error) {
if midhash == "" {
return "", false, fmt.Errorf("midhash is required")
}
truth, err := c.truthStore()
if err != nil {
return "", false, err
}
var rid string
var created bool
ctx, cancel := opCtx()
defer cancel()
if err := truth.Update(ctx, func(tx *kv.Tx) error {
var err error
rid, created, err = ensureGroup(tx, midhash)
return err
}); err != nil {
return "", false, err
}
err = c.update(func(tx *kv.Tx) error {
var prev string
err := tx.QueryRow(`SELECT midhash FROM local_file WHERE path = ?`, absPath).Scan(&prev)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if _, err := tx.Exec(`INSERT INTO local_file(path, midhash, size, mtime) VALUES(?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET midhash = excluded.midhash, size = excluded.size, mtime = excluded.mtime`,
absPath, midhash, size, mtimeNano); err != nil {
return err
}
if _, err := syncMember(tx, midhash, rid); err != nil {
return err
}
if err := c.remergeLocalTx(tx, rid); err != nil {
return err
}
if prev != "" && prev != midhash {
// The file at this path changed content: its old record loses it.
if old, _, err := tx.Get(tx.Context(), CIDIndexPrefix+prev); err == nil && old != "" && old != rid {
return c.remergeLocalTx(tx, old)
}
}
return nil
})
if err != nil {
return "", false, err
}
return rid, created, nil
}
// RemoveFileTuple forgets a path the watcher saw disappear. Only the local
// facts go — the record's claims stay (facts about content stay true).
func (c *Client) RemoveFileTuple(absPath string) error {
return c.update(func(tx *kv.Tx) error {
var midhash string
err := tx.QueryRow(`SELECT midhash FROM local_file WHERE path = ?`, absPath).Scan(&midhash)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM local_file WHERE path = ?`, absPath); err != nil {
return err
}
rid, _, err := tx.Get(tx.Context(), CIDIndexPrefix+midhash)
if err != nil || rid == "" {
return err
}
return c.remergeLocalTx(tx, rid)
})
}
// remergeLocalTx recomputes the box-local fields of rid (and its search row
// when filePath changed).
func (c *Client) remergeLocalTx(tx *kv.Tx, rid string) error {
cids, err := cidsForRoot(tx.Context(), tx, rid)
if err != nil {
return err
}
searchChanged := false
for _, f := range localFields {
changed, err := c.remergeTx(tx, rid, cids, f)
if err != nil {
return err
}
searchChanged = searchChanged || (changed && affectsHaystack(f))
}
if searchChanged {
return refreshSearchTx(tx, rid)
}
return nil
}
// FileTuple holds the (rid, path, size, mtime) record for one file. Used by
// the watcher hydrator at startup to avoid re-mid-hashing files whose tuple
// matches what's already stored.
type FileTuple struct {
HashID string
FilePath string
Size int64
MtimeNano int64
}
// GetTuplesForAllFiles returns one FileTuple per local file.
func (c *Client) GetTuplesForAllFiles() ([]FileTuple, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*opTimeout)
defer cancel()
rows, err := s.DB().QueryContext(ctx,
`SELECT l.path, l.size, l.mtime, COALESCE(k.v, '') FROM local_file l
LEFT JOIN kv k ON k.k = 'cid:' || l.midhash ORDER BY l.path`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FileTuple
for rows.Next() {
var t FileTuple
if err := rows.Scan(&t.FilePath, &t.Size, &t.MtimeNano, &t.HashID); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// localPaths lists every local path whose midhash is one of cids.
func localPaths(ctx context.Context, q interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, cids []string) ([]string, error) {
var out []string
for _, cid := range cids {
rows, err := q.QueryContext(ctx, `SELECT path FROM local_file WHERE midhash = ? ORDER BY path`, cid)
if err != nil {
return nil, err
}
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
rows.Close()
return nil, err
}
out = append(out, p)
}
if err := rows.Close(); err != nil {
return nil, err
}
}
return out, nil
}
+102
View File
@@ -0,0 +1,102 @@
package storage
import (
"strconv"
"strings"
"github.com/metazla/meta-core/internal/claim"
)
// Vote is one signer's claim on one key, as the merge sees it. Pid identifies
// the signer (one vote per signer per key per group); Uid is carried for the
// stateless /api/merge, where claims arrive without a local pid.
type Vote struct {
Pid int64
Uid string
Value claim.Value
TS uint64
}
// MergeResult is the merged value of one key.
type MergeResult struct {
Value string
Nsrc int // signers agreeing on the winning value
OK bool
}
// Merge is the one merge rule (spec §7), shared by the apply loop, the
// ?as=<uid> overlay and POST /api/merge:
//
// 1. a signer counts once per key: its newest vote wins (it may have voted on
// several cids of the group);
// 2. the viewer's own live text vote wins outright (viewer == "" for the
// shared view);
// 3. retractions and ciphertext drop out — only text merges;
// 4. support: votes grouped by normalised value, most signers wins;
// 5. tie → the value first seen (oldest ts) — stable, and rewriting does not
// win;
// 6. tie → byte-wise smallest value, so every peer agrees.
//
// The stored value is the exact text of the oldest vote of the winning group.
func Merge(votes []Vote, viewer string) MergeResult {
newest := make(map[string]Vote, len(votes))
for _, v := range votes {
id := signerID(v)
if cur, ok := newest[id]; !ok || v.TS > cur.TS {
newest[id] = v
}
}
type bucket struct {
n int
minTS uint64
exemplar string
}
buckets := make(map[string]*bucket)
for _, v := range newest {
if v.Value.Kind != claim.KindText {
continue
}
if viewer != "" && v.Uid == viewer {
return MergeResult{Value: v.Value.Text, Nsrc: 1, OK: true}
}
norm := normalizeValue(v.Value.Text)
b := buckets[norm]
if b == nil {
buckets[norm] = &bucket{n: 1, minTS: v.TS, exemplar: v.Value.Text}
continue
}
b.n++
if v.TS < b.minTS || (v.TS == b.minTS && v.Value.Text < b.exemplar) {
b.minTS, b.exemplar = v.TS, v.Value.Text
}
}
var best *bucket
for _, b := range buckets {
switch {
case best == nil,
b.n > best.n,
b.n == best.n && b.minTS < best.minTS,
b.n == best.n && b.minTS == best.minTS && b.exemplar < best.exemplar:
best = b
}
}
if best == nil {
return MergeResult{}
}
return MergeResult{Value: best.exemplar, Nsrc: best.n, OK: true}
}
func signerID(v Vote) string {
if v.Uid != "" {
return v.Uid
}
return "#" + strconv.FormatInt(v.Pid, 10)
}
// normalizeValue folds case and surrounding whitespace so "The Matrix" and
// "the matrix " count as the same value. (Unicode NFC folding is not applied:
// the module carries no normalisation table; values written by one plugin
// family are already consistent.)
func normalizeValue(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
+51 -45
View File
@@ -1,70 +1,76 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"github.com/metazla/meta-core/internal/kv"
)
// SchemaVersion is the key-layout version this build of meta-core expects.
// Distinct from the snapshot manifest version (which is on-disk format);
// this number describes the record layout — primarily, "are roots UUIDs or
// content-hash tokens, and do reverse-index keys cid:* exist?"
// SchemaVersion is the truth-database layout this build of meta-core expects.
//
// The Redis → SQLite move did not change the record layout (file:<rid>/<field>,
// cid:<cid>), so it did not bump this: the one-time migration copied records
// verbatim.
// 2 — v1.x: one kv table holding records, the CID index and UDL cells
// 3 — v2.0.0: the claims model (truth: principal, claim, cid_group, meta)
//
// Bump only when a layout change can't be detected from the data itself or
// healed by the write path. Each bump is a flag day in alpha: operators wipe
// and restart.
const SchemaVersion = 2
// Bump only when a layout change can't be detected from the data itself. Each
// bump is a flag day: there is no migration code in meta-core (the one-off
// v2 → v3 conversion of watch.nsl.sh is a manual tool, tools/claims-migrate).
const SchemaVersion = 3
// SchemaVersionKey is the sentinel under which we record SchemaVersion.
// Checked on every meta-core boot.
const SchemaVersionKey = "meta-core:schema-version"
// SchemaVersionKey is the truth `meta` row recording SchemaVersion.
const SchemaVersionKey = "schema-version"
// EnsureSchemaVersion verifies the store matches the expected schema version.
// Called once at startup, before any other reads/writes.
// EnsureSchemaVersion verifies the truth database matches the expected
// version. Called once at startup, before Start.
//
// Behavior:
// - sentinel present and matches expected → OK
// - sentinel missing AND no roots → fresh store, stamp sentinel
// - sentinel missing AND roots exist → legacy data, refuse
// - sentinel present but mismatched → refuse, point operator at clean:all
//
// Refusal is hard-fail; the caller is expected to log.Fatalf and exit.
// The error message names the wipe remediation because in alpha there
// is no automated migration — operators bring up a clean store.
// - a v1.x store (the old kv table, which the default path still points at)
// → refuse
// - sentinel present and matching → OK
// - sentinel missing and no claims → fresh store, stamp it
// - sentinel missing with claims, or mismatched → refuse
func (c *Client) EnsureSchemaVersion(expected int) error {
return c.update(func(tx *kv.Tx) error {
ctx := tx.Context()
have, ok, err := tx.Get(ctx, SchemaVersionKey)
if err != nil {
return fmt.Errorf("get schema version: %w", err)
c.mu.RLock()
truth := c.truth
c.mu.RUnlock()
if truth == nil {
return fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
return truth.Update(ctx, func(tx *kv.Tx) error {
var name string
err := tx.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`).Scan(&name)
if err == nil {
return fmt.Errorf(
"%s holds a meta-core v1.x store (schema version 2, the kv table). v2.0.0 "+
"does not migrate it: convert it with tools/claims-migrate, or move it "+
"away to start empty", truth.Path())
}
if !ok {
n, err := tx.Count(ctx, rootsNS)
if err != nil {
return fmt.Errorf("count roots: %w", err)
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var have string
err = tx.QueryRow(`SELECT value FROM meta WHERE name = ?`, SchemaVersionKey).Scan(&have)
if errors.Is(err, sql.ErrNoRows) {
var n int64
if err := tx.QueryRow(`SELECT count(*) FROM claim`).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf(
"the store has %d indexed roots but no %q sentinel — this is alpha "+
"data from before the UUID-rooted schema landed. Delete the "+
"database file to wipe and restart, or downgrade meta-core to a compatible build",
n, SchemaVersionKey)
return fmt.Errorf("truth database has %d claims but no %q sentinel; refusing to guess its layout", n, SchemaVersionKey)
}
return tx.Put(SchemaVersionKey, strconv.Itoa(expected))
_, err := tx.Exec(`INSERT INTO meta(name, value) VALUES(?, ?)`, SchemaVersionKey, strconv.Itoa(expected))
return err
}
if err != nil {
return fmt.Errorf("get schema version: %w", err)
}
if have == strconv.Itoa(expected) {
return nil
}
return fmt.Errorf(
"store schema version is %q, this build of meta-core expects %d. "+
"Delete the database file to wipe and restart, or use a meta-core "+
"build that matches the existing data",
have, expected)
return fmt.Errorf("truth schema version is %q, this build of meta-core expects %d", have, expected)
})
}
+36 -1
View File
@@ -201,6 +201,14 @@ type SearchHit struct {
// match is the authoritative test, run on each hydrated record. Pass the same
// query/filters match evaluates, or results will be missing.
func (c *Client) Search(query string, filters []SearchFilter, limit int, match func(hashID string, fields map[string]string) bool) ([]SearchHit, error) {
return c.SearchAs(query, filters, limit, "", match)
}
// SearchAs is Search seen by viewer (a uid, "" for the shared view): every
// candidate is matched with the viewer's own claims overlaid, and the records
// the viewer holds claims on are candidates too, so their own corrections are
// searchable for them.
func (c *Client) SearchAs(query string, filters []SearchFilter, limit int, viewer string, match func(hashID string, fields map[string]string) bool) ([]SearchHit, error) {
s, err := c.kv()
if err != nil {
return nil, err
@@ -245,7 +253,7 @@ func (c *Client) Search(query string, filters []SearchFilter, limit int, match f
}
}
for _, id := range survivors {
fields, err := readRecord(ctx, s, id)
fields, err := c.readRecordAs(ctx, s, id, viewer)
if err != nil {
return nil, err
}
@@ -261,6 +269,33 @@ func (c *Client) Search(query string, filters []SearchFilter, limit int, match f
break
}
}
if viewer == "" {
return hits, nil
}
seen := make(map[string]bool, len(hits))
for _, h := range hits {
seen[h.HashID] = true
}
own, err := c.viewerRIDs(ctx, viewer)
if err != nil {
return nil, err
}
for _, id := range own {
if seen[id] {
continue
}
fields, err := c.readRecordAs(ctx, s, id, viewer)
if err != nil {
return nil, err
}
if fields != nil && match(id, fields) {
hits = append(hits, SearchHit{HashID: id, Fields: fields})
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].HashID < hits[j].HashID })
if len(hits) > limit {
hits = hits[:limit]
}
return hits, nil
}
+2 -15
View File
@@ -19,15 +19,6 @@ import (
// The matcher here mirrors api.recordMatchesFilter closely enough to exercise
// every candidate path; the API tests own the matcher itself.
func seed(t *testing.T, c *Client, records map[string]map[string]string) {
t.Helper()
for id, fields := range records {
if err := c.SetMetadataFlat(id, fields); err != nil {
t.Fatalf("seed %s: %v", id, err)
}
}
}
// matchFilter is a faithful copy of api.recordMatchesFilter's semantics.
func matchFilter(fields map[string]string, prop string, exact bool, values ...string) bool {
for _, v := range values {
@@ -197,14 +188,10 @@ func TestSearchRidAndTitleTokensDoNotCombine(t *testing.T) {
func TestSearchFollowsWritesAndDeletes(t *testing.T) {
c := newTestClient(t)
seed(t, c, map[string]map[string]string{"R1": {"title": "Before"}})
if err := c.SetProperty("R1", "title", "After"); err != nil {
t.Fatal(err)
}
setMerged(t, c, "R1", map[string]string{"title": "After"})
equalIDs(t, search(t, c, "after", nil, textMatch("after")), "R1")
equalIDs(t, search(t, c, "before", nil, textMatch("before")))
if _, err := c.DeleteMetadata("R1"); err != nil {
t.Fatal(err)
}
deleteMerged(t, c, "R1")
equalIDs(t, search(t, c, "after", nil, textMatch("after")))
equalIDs(t, search(t, c, "", nil, func(string, map[string]string) bool { return true }))
}
+701
View File
@@ -0,0 +1,701 @@
package storage
// The truth database: the only data meta-core cannot rebuild.
//
// principal(pid, uid) who signed (pid = local short form)
// claim(cid, key, pid) → value, ts, sig, seq every vote, signed (MMUR2)
// cid_group(cid) → rid which record a cid belongs to
// meta(name) → value schema version, last_seq
//
// Primary keys only — every other index lives in the cache, which is rebuilt
// from these four tables. A claim never mentions a rid: the rid is a local
// name for a group of cids, minted here (UUIDv7) and kept because consumers
// hold rids.
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/kv"
)
const truthSchema = `
CREATE TABLE IF NOT EXISTS principal(
pid INTEGER PRIMARY KEY,
uid TEXT NOT NULL UNIQUE
) STRICT;
CREATE TABLE IF NOT EXISTS claim(
cid TEXT NOT NULL,
key TEXT NOT NULL,
pid INTEGER NOT NULL,
value ANY,
ts INTEGER NOT NULL,
sig BLOB NOT NULL,
seq INTEGER NOT NULL,
PRIMARY KEY (cid, key, pid)
) STRICT, WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS cid_group(cid TEXT PRIMARY KEY, rid TEXT NOT NULL) STRICT, WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS meta(name TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT, WITHOUT ROWID;
`
const lastSeqName = "last_seq"
// ClaimStatus is the per-claim outcome of POST /api/claims.
type ClaimStatus string
const (
StatusAccepted ClaimStatus = "accepted"
StatusUnchanged ClaimStatus = "unchanged"
StatusStale ClaimStatus = "stale"
StatusBadSignature ClaimStatus = "bad_signature"
StatusBadRequest ClaimStatus = "bad_request"
)
// ClaimResult is one entry of the POST /api/claims response.
type ClaimResult struct {
Status ClaimStatus `json:"status"`
Reason string `json:"reason,omitempty"`
}
// MaxClaimsPerBatch bounds one POST /api/claims.
const MaxClaimsPerBatch = 10_000
// change is one committed truth change, queued for the apply loop in commit
// order. key == "" means "membership only" (a cid joined a group).
type change struct {
seq int64
cid string
key string
pid int64
live bool // the claim now holds a value (false: tombstone)
regroup *[2]string // {loser, survivor}: two groups merged
}
func valueArg(v claim.Value) any {
switch v.Kind {
case claim.KindText:
return v.Text
case claim.KindBytes:
if v.Bytes == nil {
return []byte{}
}
return v.Bytes
}
return nil
}
// scanValue turns a claim.value column (TEXT / BLOB / NULL) back into a Value.
func scanValue(raw any) claim.Value {
switch x := raw.(type) {
case string:
return claim.Text(x)
case []byte:
return claim.Bytes(x)
}
return claim.Null()
}
type storedClaim struct {
ts int64
sig []byte
value claim.Value
}
func readStored(ctx context.Context, q interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, cid, key, uid string) (storedClaim, bool, error) {
var sc storedClaim
var raw any
err := q.QueryRowContext(ctx,
`SELECT c.ts, c.sig, c.value FROM claim c JOIN principal p ON p.pid = c.pid
WHERE c.cid = ? AND c.key = ? AND p.uid = ?`, cid, key, uid).Scan(&sc.ts, &sc.sig, &raw)
sc.value = scanValue(raw)
if errors.Is(err, sql.ErrNoRows) {
return sc, false, nil
}
return sc, err == nil, err
}
// precheck classifies a claim against the committed state without the writer:
// skip-identical happens BEFORE verification (most writes are re-persists of
// the same signed claim), stale is refused, everything else is verified.
func (c *Client) precheck(ctx context.Context, cl *claim.Claim) (ClaimStatus, string) {
if cl.CID == "" || cl.Key == "" || cl.UID == "" {
return StatusBadRequest, "cid, uid and key are required"
}
if !claimKeyAllowed(cl.Key) {
return StatusBadRequest, "key is reserved or malformed"
}
if len(cl.Key) > 4096 || len(cl.CID) > 512 {
return StatusBadRequest, "cid or key too long"
}
if cl.TS == 0 || cl.TS > 1<<62 {
return StatusBadRequest, "ts out of range"
}
sc, ok, err := readStored(ctx, c.truth.DB(), cl.CID, cl.Key, cl.UID)
if err != nil {
return StatusBadRequest, err.Error()
}
if ok {
if sc.ts == int64(cl.TS) && string(sc.sig) == string(cl.Sig) {
return StatusUnchanged, ""
}
// Same signer re-asserting the value it already holds (the gateway
// re-persists every search hit with a fresh ts): keep the stored row and
// its older ts, so oldest-ts tie-breaking stays anchored and truth takes
// no write. Nothing is stored, so the new signature needs no check.
if sc.value.Equal(cl.Value) {
return StatusUnchanged, ""
}
if sc.ts >= int64(cl.TS) {
return StatusStale, "a newer or equal ts is stored for this signer, cid and key"
}
}
if err := cl.Verify(); err != nil {
return StatusBadSignature, err.Error()
}
return StatusAccepted, ""
}
// WriteClaims is POST /api/claims: verify, store, regroup, and wait until the
// cache has applied the batch (read-your-writes for the caller).
func (c *Client) WriteClaims(ctx context.Context, claims []claim.Claim) ([]ClaimResult, error) {
truth, err := c.truthStore()
if err != nil {
return nil, err
}
results := make([]ClaimResult, len(claims))
var todo []int
for i := range claims {
st, reason := c.precheck(ctx, &claims[i])
results[i] = ClaimResult{Status: st, Reason: reason}
if st == StatusAccepted {
todo = append(todo, i)
}
}
if len(todo) == 0 {
return results, nil
}
var maxSeq int64
err = truth.Update(ctx, func(tx *kv.Tx) error {
seq, err := readLastSeq(tx)
if err != nil {
return err
}
var changes []change
pids := map[string]int64{}
for _, i := range todo {
cl := &claims[i]
// Re-check under the writer: a concurrent batch may have stored a
// newer vote since the precheck.
sc, ok, err := readStored(tx.Context(), txQuerier{tx}, cl.CID, cl.Key, cl.UID)
if err != nil {
return err
}
if ok && (sc.ts >= int64(cl.TS) || sc.value.Equal(cl.Value)) {
if sc.value.Equal(cl.Value) {
results[i] = ClaimResult{Status: StatusUnchanged}
} else {
results[i] = ClaimResult{Status: StatusStale, Reason: "a newer or equal ts is stored for this signer, cid and key"}
}
continue
}
pid, ok := pids[cl.UID]
if !ok {
if pid, err = ensurePrincipal(tx, cl.UID); err != nil {
return err
}
pids[cl.UID] = pid
}
seq++
if _, err := tx.Exec(
`INSERT INTO claim(cid, key, pid, value, ts, sig, seq) VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(cid, key, pid) DO UPDATE SET value = excluded.value, ts = excluded.ts,
sig = excluded.sig, seq = excluded.seq`,
cl.CID, cl.Key, pid, valueArg(cl.Value), int64(cl.TS), cl.Sig, seq); err != nil {
return fmt.Errorf("store claim: %w", err)
}
if _, _, err := ensureGroup(tx, cl.CID); err != nil {
return err
}
changes = append(changes, change{seq: seq, cid: cl.CID, key: cl.Key, pid: pid, live: !cl.Value.IsNull()})
if other := cidFromKeysetField(cl.Key); other != "" && cl.Value.Kind == claim.KindText && other != cl.CID {
more, err := linkGroups(tx, cl.CID, other, seq)
if err != nil {
return err
}
changes = append(changes, more...)
}
}
if len(changes) == 0 {
return nil
}
if err := writeLastSeq(tx, seq); err != nil {
return err
}
maxSeq = seq
tx.OnCommit(func() { c.applier.enqueue(changes) })
return nil
})
if err != nil {
return nil, err
}
if maxSeq > 0 {
if err := c.applier.wait(ctx, maxSeq); err != nil {
return results, err
}
}
return results, nil
}
// txQuerier adapts a kv.Tx to the QueryRowContext shape readStored takes.
type txQuerier struct{ tx *kv.Tx }
func (q txQuerier) QueryRowContext(_ context.Context, query string, args ...any) *sql.Row {
return q.tx.QueryRow(query, args...)
}
func readLastSeq(tx *kv.Tx) (int64, error) {
var v string
err := tx.QueryRow(`SELECT value FROM meta WHERE name = ?`, lastSeqName).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return strconv.ParseInt(v, 10, 64)
}
func writeLastSeq(tx *kv.Tx, seq int64) error {
_, err := tx.Exec(`INSERT INTO meta(name, value) VALUES(?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value`, lastSeqName, strconv.FormatInt(seq, 10))
return err
}
func ensurePrincipal(tx *kv.Tx, uid string) (int64, error) {
var pid int64
err := tx.QueryRow(`SELECT pid FROM principal WHERE uid = ?`, uid).Scan(&pid)
if err == nil {
return pid, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
if err := tx.QueryRow(`INSERT INTO principal(uid) VALUES(?) RETURNING pid`, uid).Scan(&pid); err != nil {
return 0, fmt.Errorf("new principal: %w", err)
}
return pid, nil
}
// ensureGroup returns cid's rid, minting a new group for an unknown cid.
func ensureGroup(tx *kv.Tx, cid string) (string, bool, error) {
var rid string
err := tx.QueryRow(`SELECT rid FROM cid_group WHERE cid = ?`, cid).Scan(&rid)
if err == nil {
return rid, false, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", false, err
}
if rid, err = NewUUID(); err != nil {
return "", false, err
}
if _, err := tx.Exec(`INSERT INTO cid_group(cid, rid) VALUES(?, ?)`, cid, rid); err != nil {
return "", false, fmt.Errorf("new group: %w", err)
}
return rid, true, nil
}
// linkGroups records that a and b are the same content. An unknown b joins a's
// group; two known groups merge, the older rid surviving (UUIDv7 sorts by
// time). A retraction of the link does not split the group (phase 1).
func linkGroups(tx *kv.Tx, a, b string, seq int64) ([]change, error) {
ra, _, err := ensureGroup(tx, a)
if err != nil {
return nil, err
}
var rb string
err = tx.QueryRow(`SELECT rid FROM cid_group WHERE cid = ?`, b).Scan(&rb)
if errors.Is(err, sql.ErrNoRows) {
if _, err := tx.Exec(`INSERT INTO cid_group(cid, rid) VALUES(?, ?)`, b, ra); err != nil {
return nil, err
}
return []change{{seq: seq, cid: b}}, nil
}
if err != nil {
return nil, err
}
if ra == rb {
return nil, nil
}
survivor, loser := ra, rb
if rb < ra {
survivor, loser = rb, ra
}
// cid_group has no rid index (truth keeps primary keys only); a merge is
// rare, so the scan is acceptable.
if _, err := tx.Exec(`UPDATE cid_group SET rid = ? WHERE rid = ?`, survivor, loser); err != nil {
return nil, fmt.Errorf("merge groups: %w", err)
}
return []change{{seq: seq, regroup: &[2]string{loser, survivor}}}, nil
}
// truthRid reads cid's committed group.
func (c *Client) truthRid(ctx context.Context, cid string) (string, error) {
var rid string
err := c.truth.DB().QueryRowContext(ctx, `SELECT rid FROM cid_group WHERE cid = ?`, cid).Scan(&rid)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return rid, err
}
// votesFor reads every committed vote on key across cids, with signer uids.
func (c *Client) votesFor(ctx context.Context, cids []string, key string) ([]Vote, error) {
var out []Vote
for _, cid := range cids {
rows, err := c.truth.DB().QueryContext(ctx,
`SELECT c.pid, p.uid, c.value, c.ts FROM claim c JOIN principal p ON p.pid = c.pid
WHERE c.cid = ? AND c.key = ?`, cid, key)
if err != nil {
return nil, err
}
for rows.Next() {
var v Vote
var raw any
var ts int64
if err := rows.Scan(&v.Pid, &v.Uid, &raw, &ts); err != nil {
rows.Close()
return nil, err
}
v.Value, v.TS = scanValue(raw), uint64(ts)
out = append(out, v)
}
if err := rows.Close(); err != nil {
return nil, err
}
}
return out, nil
}
// claimKeys lists the distinct keys claimed on any of cids.
func (c *Client) claimKeys(ctx context.Context, cids []string) ([]string, error) {
seen := map[string]bool{}
var out []string
for _, cid := range cids {
rows, err := c.truth.DB().QueryContext(ctx, `SELECT DISTINCT key FROM claim WHERE cid = ?`, cid)
if err != nil {
return nil, err
}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return nil, err
}
if !seen[k] {
seen[k] = true
out = append(out, k)
}
}
if err := rows.Close(); err != nil {
return nil, err
}
}
return out, nil
}
// ClaimsForCIDs returns every live signed claim on cids (tombstones excluded),
// optionally only for one key.
func (c *Client) ClaimsForCIDs(cids []string, key string) ([]claim.Claim, error) {
if _, err := c.truthStore(); err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
var out []claim.Claim
for _, cid := range cids {
q := `SELECT c.cid, p.uid, c.key, c.value, c.ts, c.sig FROM claim c JOIN principal p ON p.pid = c.pid
WHERE c.cid = ? AND c.value IS NOT NULL`
args := []any{cid}
if key != "" {
q += ` AND c.key = ?`
args = append(args, key)
}
q += ` ORDER BY c.key, p.uid`
rows, err := c.truth.DB().QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var cl claim.Claim
var raw any
var ts int64
if err := rows.Scan(&cl.CID, &cl.UID, &cl.Key, &raw, &ts, &cl.Sig); err != nil {
rows.Close()
return nil, err
}
cl.Value, cl.TS = scanValue(raw), uint64(ts)
out = append(out, cl)
}
if err := rows.Close(); err != nil {
return nil, err
}
}
return out, nil
}
// ClaimsForRID returns the live claims of every cid in rid's group.
func (c *Client) ClaimsForRID(rid, key string) ([]claim.Claim, error) {
_, claims, err := c.RecordClaims(rid, key)
return claims, err
}
// RecordClaims returns the cids of rid's group (empty: no such record) and
// their live claims, optionally for one key.
func (c *Client) RecordClaims(rid, key string) ([]string, []claim.Claim, error) {
s, err := c.kv()
if err != nil {
return nil, nil, err
}
ctx, cancel := opCtx()
defer cancel()
cids, err := cidsForRoot(ctx, s, rid)
if err != nil || len(cids) == 0 {
return cids, nil, err
}
claims, err := c.ClaimsForCIDs(cids, key)
return cids, claims, err
}
// LiveClaimsBySigner lists (cid, key) of every live claim uid has signed — the
// set DELETE /api/identity retracts.
func (c *Client) LiveClaimsBySigner(uid string) ([][2]string, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
pid, ok, err := c.pidOf(uid)
if err != nil || !ok {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
rows, err := s.DB().QueryContext(ctx, `SELECT cid, key FROM claim_by_pid WHERE pid = ? ORDER BY cid, key`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out [][2]string
for rows.Next() {
var p [2]string
if err := rows.Scan(&p[0], &p[1]); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (c *Client) pidOf(uid string) (int64, bool, error) {
if _, err := c.truthStore(); err != nil {
return 0, false, err
}
ctx, cancel := opCtx()
defer cancel()
var pid int64
err := c.truth.DB().QueryRowContext(ctx, `SELECT pid FROM principal WHERE uid = ?`, uid).Scan(&pid)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
return pid, err == nil, err
}
// CountClaims counts claim rows (tombstones included) — /api/stats' redisKeys.
func (c *Client) CountClaims() (int64, error) {
if _, err := c.truthStore(); err != nil {
return 0, err
}
ctx, cancel := opCtx()
defer cancel()
var n int64
err := c.truth.DB().QueryRowContext(ctx, `SELECT count(*) FROM claim`).Scan(&n)
return n, err
}
func isLocalField(key string) bool {
return key == "filePath" || key == "mtimeNano" || key == "sizeByte"
}
// claimKeyAllowed rejects keys that would corrupt the merged view's key
// layout, and the removed per-algorithm CID shapes (cid_*, midhash256,
// canonical_cid — METADATA_KEYS.md §14.13: write the cids/<bareCid> key-set).
func claimKeyAllowed(key string) bool {
if strings.HasPrefix(key, "cid_") || key == "midhash256" || key == "canonical_cid" {
return false
}
return !strings.HasPrefix(key, "/") && !strings.Contains(key, "\x00") && !isReservedFieldName(key)
}
// GetMetadataFlatAs is GetMetadataFlat seen by viewer: the merged record with
// the viewer's own live text claims overlaid (their value wins in their view).
func (c *Client) GetMetadataFlatAs(rid, viewer string) (map[string]string, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
return c.readRecordAs(ctx, s, rid, viewer)
}
func (c *Client) readRecordAs(ctx context.Context, s *kv.Store, rid, viewer string) (map[string]string, error) {
fields, err := readRecord(ctx, s, rid)
if err != nil || viewer == "" {
return fields, err
}
over, err := c.viewerOverrides(ctx, s, rid, viewer)
if err != nil {
return nil, err
}
if len(over) == 0 {
return fields, nil
}
if fields == nil {
fields = make(map[string]string, len(over))
}
for k, v := range over {
fields[k] = v
}
return fields, nil
}
// viewerOverrides returns the viewer's newest live text vote per key on the
// cids of rid's group.
func (c *Client) viewerOverrides(ctx context.Context, s *kv.Store, rid, viewer string) (map[string]string, error) {
pid, ok, err := c.pidOf(viewer)
if err != nil || !ok {
return nil, err
}
cids, err := cidsForRoot(ctx, s, rid)
if err != nil || len(cids) == 0 {
return nil, err
}
out := map[string]string{}
newest := map[string]int64{}
for _, cid := range cids {
rows, err := s.DB().QueryContext(ctx, `SELECT key FROM claim_by_pid WHERE pid = ? AND cid = ?`, pid, cid)
if err != nil {
return nil, err
}
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return nil, err
}
keys = append(keys, k)
}
if err := rows.Close(); err != nil {
return nil, err
}
for _, k := range keys {
var raw any
var ts int64
if err := c.truth.DB().QueryRowContext(ctx,
`SELECT value, ts FROM claim WHERE cid = ? AND key = ? AND pid = ?`, cid, k, pid).Scan(&raw, &ts); err != nil {
continue
}
if v := scanValue(raw); v.Kind == claim.KindText && ts > newest[k] {
out[k], newest[k] = v.Text, ts
}
}
}
return out, nil
}
// viewerRIDs lists the records the viewer holds live claims on.
func (c *Client) viewerRIDs(ctx context.Context, viewer string) ([]string, error) {
pid, ok, err := c.pidOf(viewer)
if err != nil || !ok {
return nil, err
}
s := c.store
rows, err := s.DB().QueryContext(ctx,
`SELECT DISTINCT k.v FROM claim_by_pid b JOIN kv k ON k.k = 'cid:' || b.cid WHERE b.pid = ? LIMIT 10000`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var rid string
if err := rows.Scan(&rid); err != nil {
return nil, err
}
out = append(out, rid)
}
return out, rows.Err()
}
// MergeClaims is the stateless POST /api/merge: verify each claim (invalid
// ones are dropped and counted, never an error for the batch), merge per key
// with the §7 rule, and overlay viewer's own votes — those in the request and
// those this core holds (claim_by_pid) for the same cids. Returns the merged
// fields, the agreeing-signer count per key, and how many claims were rejected.
func (c *Client) MergeClaims(claims []claim.Claim, viewer string) (map[string]string, map[string]int, int, error) {
byKey := map[string][]Vote{}
rejected := 0
cids := map[string]bool{}
for i := range claims {
cl := &claims[i]
if err := cl.Verify(); err != nil {
rejected++
continue
}
cids[cl.CID] = true
byKey[cl.Key] = append(byKey[cl.Key], Vote{Uid: cl.UID, Value: cl.Value, TS: cl.TS})
}
fields := make(map[string]string, len(byKey))
nsrc := make(map[string]int, len(byKey))
for key, votes := range byKey {
if res := Merge(votes, viewer); res.OK {
fields[key], nsrc[key] = res.Value, res.Nsrc
}
}
if viewer == "" {
return fields, nsrc, rejected, nil
}
s, err := c.kv()
if err != nil {
return fields, nsrc, rejected, nil // no local core: request overlay only
}
if _, err := c.truthStore(); err != nil {
return fields, nsrc, rejected, nil
}
ctx, cancel := opCtx()
defer cancel()
seen := map[string]bool{}
for cid := range cids {
rid, _, err := s.Get(ctx, CIDIndexPrefix+cid)
if err != nil || rid == "" || seen[rid] {
continue
}
seen[rid] = true
over, err := c.viewerOverrides(ctx, s, rid, viewer)
if err != nil {
return nil, nil, rejected, err
}
for k, v := range over {
fields[k], nsrc[k] = v, 1
}
}
return fields, nsrc, rejected, nil
}
+125 -381
View File
@@ -1,441 +1,185 @@
package storage
// User Data Layer (UDL) storage.
//
// Holds per-user, identity-signed records keyed by (uid, cid, key) — the
// subjective/per-user state that METADATA_KEYS.md rule #4 bars from the shared
// /file/{cid} hash (likes, ratings, watch progress, local paths). Each record
// is an opaque, client-signed (and, for private-tier keys, client-encrypted)
// CBOR blob; meta-core stores it verbatim and never needs the account key.
//
// Convergence is version-gated: a write is accepted only if its `version`
// strictly exceeds the stored one (a per-cell high-water-mark, replacing the
// op-log merge that used to live in meta-watch's store.rs).
//
// Key model (all under the literal "udl:" prefix, mirroring the "file:" one):
//
// udl:rec:<uid>/<cid>/<key> JSON { record, version, ts, value? }
// udl:idx:user:<uid>:key:<key> set of <cid> -> active_for_user_key (My List, Continue Watching)
// udl:idx:user:<uid>:cid:<cid> set of <key> -> list_for_user_cid
// udl:idx:user:<uid>:cids set of <cid> -> whole-profile export (recovery)
// udl:idx:cid:<cid>:key:<key> set of <uid> -> aggregate
// udl:idx:cid:<cid>:users set of <uid> -> uids_for_cid
//
// A "set" is a key-set: one key `<set>\x1f<member>` = "" per member, read as
// the key range under `<set>\x1f`. The separator is a control character
// because UDL keys are not validated — with `/` the members of
// `…:key:a/b` would show up as members of `…:key:a`.
//
// The `value` field is the plaintext JSON scalar for PUBLIC keys only
// (like/rating), used for aggregation; it is absent for private-tier keys
// (whose real value rides encrypted inside `record`) and for tombstones.
// The User Data Layer facade (/api/udl/*). Since v2.0.0 a user's data are
// ordinary claims signed by the user's key — plaintext for public-social keys
// (like, userRating, profile:*), ciphertext for private ones (seek, watched,
// …). There is no separate store: these helpers read the claims of one signer
// through the cache's claim_by_pid index and the truth claim table.
import (
"context"
"encoding/json"
"fmt"
"database/sql"
"errors"
"sort"
"github.com/metazla/meta-core/internal/kv"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
)
// UDLCell is a decoded (uid, cid, key) record cell.
type UDLCell struct {
Record string // base64 CBOR, opaque + signed
Version int64
Ts int64
Value string // plaintext JSON scalar, public keys only
HasValue bool
// UDLUserStats summarises one signer's live claims.
type UDLUserStats struct {
Records int `json:"records"`
Cids int `json:"cids"`
Keys int `json:"keys"`
}
// UDLUserKeyEntry is one row of active_for_user_key (all cids for a uid+key).
type UDLUserKeyEntry struct {
Cid string `json:"cid"`
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
}
// UDLUserCidEntry is one row of list_for_user_cid (all keys for a uid+cid).
type UDLUserCidEntry struct {
Key string `json:"key"`
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
}
func udlRecKey(uid, cid, key string) string {
return fmt.Sprintf("udl:rec:%s/%s/%s", uid, cid, key)
}
func udlIdxUserKey(uid, key string) string { return fmt.Sprintf("udl:idx:user:%s:key:%s", uid, key) }
func udlIdxUserCid(uid, cid string) string { return fmt.Sprintf("udl:idx:user:%s:cid:%s", uid, cid) }
func udlIdxCidKey(cid, key string) string { return fmt.Sprintf("udl:idx:cid:%s:key:%s", cid, key) }
func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s:users", cid) }
// udlIdxUserCids is the set of every cid a uid has ANY record for — the one
// index that makes "export this whole profile" a set read instead of a
// keyspace scan.
//
// The other four indexes each answer a question with one coordinate already
// known (a key, a cid). Cross-instance recovery knows neither: it has a uid and
// nothing else. Without this set the only ways to enumerate a profile are to
// iterate the hardcoded key list — which silently misses any key added later —
// or to SCAN `udl:rec:<uid>/*`, which is the full-keyspace-read pathology that
// already made `/api/udl/users/stats` admin-only.
func udlIdxUserCids(uid string) string { return fmt.Sprintf("udl:idx:user:%s:cids", uid) }
// udlSetSep separates a set key from a member (see the key model above).
const udlSetSep = "\x1f"
// udlCellJSON is a cell's stored form. Value is a pointer so an absent value
// (private-tier key, tombstone) stays distinguishable from an empty one.
type udlCellJSON struct {
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
Value *string `json:"value,omitempty"`
}
func decodeUDLCell(raw string) (UDLCell, error) {
var j udlCellJSON
if err := json.Unmarshal([]byte(raw), &j); err != nil {
return UDLCell{}, err
}
cell := UDLCell{Record: j.Record, Version: j.Version, Ts: j.Ts}
if j.Value != nil {
cell.Value, cell.HasValue = *j.Value, true
}
return cell, nil
}
// udlMembers lists a key-set's members, sorted.
func udlMembers(ctx context.Context, r kv.Reader, set string) ([]string, error) {
prefix := set + udlSetSep
rows, err := r.Range(ctx, prefix, "", 0)
if err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.K[len(prefix):])
}
return out, nil
}
func udlAdd(tx *kv.Tx, set, member string) error {
_, err := tx.PutIfAbsent(set+udlSetSep+member, "")
return err
}
func udlRem(tx *kv.Tx, set, member string) error {
_, err := tx.Delete(set + udlSetSep + member)
return err
}
// readUDLCell reads one cell. exists is false when it was never written.
func readUDLCell(ctx context.Context, r kv.Reader, recKey string) (UDLCell, bool, error) {
raw, ok, err := r.Get(ctx, recKey)
if err != nil || !ok {
return UDLCell{}, false, err
}
cell, err := decodeUDLCell(raw)
if err != nil {
return UDLCell{}, false, fmt.Errorf("decode %s: %w", recKey, err)
}
return cell, true, nil
}
// readUDLCells reads many cells in one multi-key read; absent cells are
// omitted from the map.
func readUDLCells(ctx context.Context, r kv.Reader, recKeys []string) (map[string]UDLCell, error) {
raws, err := r.MGet(ctx, recKeys)
if err != nil {
return nil, err
}
out := make(map[string]UDLCell, len(raws))
for k, raw := range raws {
cell, err := decodeUDLCell(raw)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", k, err)
}
out[k] = cell
}
return out, nil
}
// UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored
// one, and maintains the indexes — one transaction, so the gate and the write
// cannot interleave with a concurrent upsert of the same cell.
// publicValue/hasValue carry the plaintext JSON scalar for public keys (used by
// aggregation); pass hasValue=false for private-tier keys and tombstones. Set
// tombstone=true when the record deletes the cell.
//
// A tombstone still writes the cell — that is the CRDT state, and dropping it
// would let an older write resurrect the value — but it removes the cid from
// the user+key index. That index is what `active_for_user_key` returns, so
// leaving deleted cids in it means every My List / Continue Watching read
// carries every title ever un-liked or finished, forever. The other four
// indexes are left alone: they answer "who has an opinion about this cid",
// where a tombstone is still an answer.
//
// ⚠ `idx:user:cids` in particular must NEVER be pruned on a tombstone, even
// though it looks symmetric with the user+key index. It is the export index: a
// cid dropped from it is a cid whose tombstone never travels, and a tombstone
// that does not travel is exactly how an un-liked title comes back from the
// dead on the other box — the peer still holds the older `like` at a lower
// version, and with no tombstone to beat it, nothing ever supersedes it.
//
// Returns accepted=false when the write is stale.
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue, tombstone bool) (bool, error) {
accepted := false
err := c.update(func(tx *kv.Tx) error {
recKey := udlRecKey(uid, cid, key)
stored, ok, err := readUDLCell(tx.Context(), tx, recKey)
if err != nil {
return err
}
if ok && stored.Version >= version {
return nil
}
cell := udlCellJSON{Record: record, Version: version, Ts: ts}
if hasValue {
v := publicValue
cell.Value = &v
}
body, err := json.Marshal(cell)
if err != nil {
return err
}
if err := tx.Put(recKey, string(body)); err != nil {
return err
}
if tombstone {
err = udlRem(tx, udlIdxUserKey(uid, key), cid)
} else {
err = udlAdd(tx, udlIdxUserKey(uid, key), cid)
}
if err != nil {
return err
}
for _, add := range [][2]string{
{udlIdxUserCid(uid, cid), key},
{udlIdxCidKey(cid, key), uid},
{udlIdxCidUsers(cid), uid},
{udlIdxUserCids(uid), cid},
} {
if err := udlAdd(tx, add[0], add[1]); err != nil {
return err
}
}
accepted = true
return nil
})
if err != nil {
return false, fmt.Errorf("udl upsert failed: %w", err)
}
return accepted, nil
}
// UDLGetCell returns the record cell for (uid, cid, key). exists is false when
// no record has ever been written for the cell.
func (c *Client) UDLGetCell(uid, cid, key string) (UDLCell, bool, error) {
s, err := c.kv()
if err != nil {
return UDLCell{}, false, err
// UDLGetClaim returns the stored claim of uid on (cid, key), tombstone
// included (a client needs its ts to write a newer one). ok is false when the
// signer never voted on it.
func (c *Client) UDLGetClaim(uid, cid, key string) (claim.Claim, bool, error) {
if _, err := c.truthStore(); err != nil {
return claim.Claim{}, false, err
}
ctx, cancel := opCtx()
defer cancel()
return readUDLCell(ctx, s, udlRecKey(uid, cid, key))
cl := claim.Claim{CID: cid, UID: uid, Key: key}
var raw any
var ts int64
err := c.truth.DB().QueryRowContext(ctx,
`SELECT c.value, c.ts, c.sig FROM claim c JOIN principal p ON p.pid = c.pid
WHERE c.cid = ? AND c.key = ? AND p.uid = ?`, cid, key, uid).Scan(&raw, &ts, &cl.Sig)
if errors.Is(err, sql.ErrNoRows) {
return claim.Claim{}, false, nil
}
if err != nil {
return claim.Claim{}, false, err
}
cl.Value, cl.TS = scanValue(raw), uint64(ts)
return cl, true, nil
}
// UDLListUserKey returns every cell for (uid, key) across all cids — the
// projection behind active_for_user_key (My List, Continue Watching). Includes
// tombstones; the caller decodes each record and filters.
func (c *Client) UDLListUserKey(uid, key string) ([]UDLUserKeyEntry, error) {
s, err := c.kv()
// UDLClaimsForUser returns uid's live claims, optionally narrowed to one cid
// and/or one key, ordered by cid then key.
func (c *Client) UDLClaimsForUser(uid, cid, key string) ([]claim.Claim, error) {
refs, err := c.LiveClaimsBySigner(uid)
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
cids, err := udlMembers(ctx, s, udlIdxUserKey(uid, key))
if err != nil {
return nil, err
}
recKeys := make([]string, len(cids))
for i, cid := range cids {
recKeys[i] = udlRecKey(uid, cid, key)
}
cells, err := readUDLCells(ctx, s, recKeys)
if err != nil {
return nil, err
}
out := make([]UDLUserKeyEntry, 0, len(cids))
for i, cid := range cids {
cell, ok := cells[recKeys[i]]
if !ok {
var out []claim.Claim
for _, r := range refs {
if (cid != "" && r[0] != cid) || (key != "" && r[1] != key) {
continue
}
out = append(out, UDLUserKeyEntry{Cid: cid, Record: cell.Record, Version: cell.Version, Ts: cell.Ts})
}
return out, nil
}
// UDLListUserCid returns every cell for (uid, cid) across all keys — the
// projection behind list_for_user_cid.
func (c *Client) UDLListUserCid(uid, cid string) ([]UDLUserCidEntry, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
keys, err := udlMembers(ctx, s, udlIdxUserCid(uid, cid))
if err != nil {
return nil, err
}
recKeys := make([]string, len(keys))
for i, key := range keys {
recKeys[i] = udlRecKey(uid, cid, key)
}
cells, err := readUDLCells(ctx, s, recKeys)
if err != nil {
return nil, err
}
out := make([]UDLUserCidEntry, 0, len(keys))
for i, key := range keys {
cell, ok := cells[recKeys[i]]
if !ok {
continue
cl, ok, err := c.UDLGetClaim(uid, r[0], r[1])
if err != nil {
return nil, err
}
if ok && !cl.Value.IsNull() {
out = append(out, cl)
}
out = append(out, UDLUserCidEntry{Key: key, Record: cell.Record, Version: cell.Version, Ts: cell.Ts})
}
return out, nil
}
// UDLUserAllEntry is one row of a whole-profile export.
type UDLUserAllEntry struct {
Cid string `json:"cid"`
Key string `json:"key"`
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
}
// UDLAllForUser returns every cell a uid owns, for cross-instance recovery.
//
// Ordered by cid, starting strictly after `afterCid`. A cid's keys are never
// split across a page: the page ends on a cid boundary once `limit` rows have
// been emitted, and `nextCid` is the last cid included — pass it back as
// `afterCid` to continue. `nextCid` is empty when the export is complete.
//
// Tombstones are included. They are the CRDT state that stops a deleted value
// resurrecting on the box that receives this export, so omitting them would
// make recovery actively wrong rather than merely incomplete.
func (c *Client) UDLAllForUser(uid, afterCid string, limit int) ([]UDLUserAllEntry, string, error) {
s, err := c.kv()
// UDLAllForUser pages uid's live claims by cid: claims of cids strictly after
// `after`, stopping at a cid boundary once limit claims are collected.
// nextCid is "" when the walk is complete.
func (c *Client) UDLAllForUser(uid, after string, limit int) ([]claim.Claim, string, error) {
all, err := c.UDLClaimsForUser(uid, "", "")
if err != nil {
return nil, "", err
}
if limit <= 0 {
limit = 1000
}
ctx, cancel := opCtx()
defer cancel()
cids, err := udlMembers(ctx, s, udlIdxUserCids(uid))
if err != nil {
return nil, "", err
}
sort.Strings(cids)
out := make([]UDLUserAllEntry, 0, limit)
nextCid := ""
for _, cid := range cids {
if afterCid != "" && cid <= afterCid {
var out []claim.Claim
for _, cl := range all {
if after != "" && cl.CID <= after {
continue
}
if len(out) >= limit {
nextCid = out[len(out)-1].Cid
break
}
keys, err := udlMembers(ctx, s, udlIdxUserCid(uid, cid))
if err != nil {
return nil, "", err
}
for _, key := range keys {
cell, ok, err := readUDLCell(ctx, s, udlRecKey(uid, cid, key))
if err != nil {
return nil, "", err
}
if !ok {
continue
}
out = append(out, UDLUserAllEntry{
Cid: cid, Key: key, Record: cell.Record, Version: cell.Version, Ts: cell.Ts,
})
if len(out) >= limit && cl.CID != out[len(out)-1].CID {
return out, out[len(out)-1].CID, nil
}
out = append(out, cl)
}
return out, nextCid, nil
return out, "", nil
}
// UDLCountUserCids is the number of distinct cids a uid holds records for.
//
// One index range count, which is what makes it usable as a per-push quota
// check on the gateway mirror — a quota that cost a keyspace walk would be a
// denial-of-service in its own right. Counts cids rather than cells because
// that is what the index holds; a profile is bounded by the titles it has
// touched, not by how many keys it has per title.
func (c *Client) UDLCountUserCids(uid string) (int64, error) {
s, err := c.kv()
// UDLCountUserCids counts the distinct cids uid holds live claims on.
func (c *Client) UDLCountUserCids(uid string) (int, error) {
refs, err := c.LiveClaimsBySigner(uid)
if err != nil {
return 0, err
}
ctx, cancel := opCtx()
defer cancel()
return s.Count(ctx, udlIdxUserCids(uid)+udlSetSep)
seen := map[string]bool{}
for _, r := range refs {
seen[r[0]] = true
}
return len(seen), nil
}
// UDLCidUsers returns every uid that has any record for cid — uids_for_cid.
// UDLCidUsers lists the signers holding live claims on cid.
func (c *Client) UDLCidUsers(cid string) ([]string, error) {
s, err := c.kv()
claims, err := c.ClaimsForCIDs([]string{cid}, "")
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
return udlMembers(ctx, s, udlIdxCidUsers(cid))
seen := map[string]bool{}
out := []string{}
for _, cl := range claims {
if !seen[cl.UID] {
seen[cl.UID] = true
out = append(out, cl.UID)
}
}
sort.Strings(out)
return out, nil
}
// UDLPublicValues returns the live plaintext public JSON scalar values for
// (cid, key) across all uids — the input to aggregation. Cells without a value
// (private-tier or tombstoned) are skipped.
// UDLPublicValues returns the plaintext values of every live claim on
// (cid, key) — the input of /api/udl/cid/{cid}/aggregate.
func (c *Client) UDLPublicValues(cid, key string) ([]string, error) {
s, err := c.kv()
claims, err := c.ClaimsForCIDs([]string{cid}, key)
if err != nil {
return nil, err
}
out := []string{}
for _, cl := range claims {
if cl.Value.Kind == claim.KindText {
out = append(out, cl.Value.Text)
}
}
return out, nil
}
// UDLAllUserStats summarises every signer that holds live claims, keyed by
// uid. Callers that want only human accounts intersect it with the account
// roster.
func (c *Client) UDLAllUserStats() (map[string]UDLUserStats, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
if _, err := c.truthStore(); err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
uids, err := udlMembers(ctx, s, udlIdxCidKey(cid, key))
rows, err := s.DB().QueryContext(ctx,
`SELECT pid, count(*), count(DISTINCT cid), count(DISTINCT key) FROM claim_by_pid GROUP BY pid`)
if err != nil {
return nil, err
}
recKeys := make([]string, len(uids))
for i, uid := range uids {
recKeys[i] = udlRecKey(uid, cid, key)
type agg struct {
pid int64
st UDLUserStats
}
cells, err := readUDLCells(ctx, s, recKeys)
if err != nil {
var aggs []agg
for rows.Next() {
var a agg
if err := rows.Scan(&a.pid, &a.st.Records, &a.st.Cids, &a.st.Keys); err != nil {
rows.Close()
return nil, err
}
aggs = append(aggs, a)
}
if err := rows.Close(); err != nil {
return nil, err
}
out := make([]string, 0, len(uids))
for _, k := range recKeys {
if cell, ok := cells[k]; ok && cell.HasValue {
out = append(out, cell.Value)
out := make(map[string]UDLUserStats, len(aggs))
for _, a := range aggs {
var uid string
if err := c.truth.DB().QueryRowContext(ctx, `SELECT uid FROM principal WHERE pid = ?`, a.pid).Scan(&uid); err != nil {
continue
}
if identity.ValidUID(uid) {
out[uid] = a.st
}
}
return out, nil
-179
View File
@@ -1,179 +0,0 @@
package storage
// Account-scoped UDL administration: what a uid holds, and removing all of it.
//
// Both operations enumerate the same thing — every `udl:rec:<uid>/<cid>/<key>`
// cell. Keys are ordered, so one account's cells are the key range under
// `udl:rec:<uid>/`, and every account's are the range under `udl:rec:`.
// UDLAllUserStats stays a bulk call (one walk buckets every account) because
// the accounts page needs a number for each.
import (
"fmt"
"strings"
"github.com/metazla/meta-core/internal/kv"
)
// UDLUserStats counts what one account holds. Records is the number of cells,
// including tombstones — a retracted rating is still a row this account owns
// and still something a purge has to remove.
type UDLUserStats struct {
Records int `json:"records"`
Cids int `json:"cids"`
Keys int `json:"keys"`
}
// safeUIDForPattern rejects anything that would reach across the key
// structure (a `/` would let a uid prefix-match another account's range). meta-core's uids are alphanumeric
// (identity.validUID), so this only ever fires on a hostile or malformed one —
// but it fires here, in the layer that builds the pattern, rather than relying
// on every caller having validated first.
func safeUIDForPattern(uid string) error {
if uid == "" {
return fmt.Errorf("empty uid")
}
if strings.ContainsAny(uid, "*?[]\\/:") {
return fmt.Errorf("uid contains characters that are not valid in a uid: %q", uid)
}
return nil
}
// splitUDLRecSuffix splits the "<uid>/<cid>/<key>" tail of a udl:rec key.
//
// SplitN with a limit of 3 so a key containing a slash survives intact. cids
// never contain one; keys are a controlled vocabulary today, but a namespaced
// one arriving later must not silently truncate here — a truncated key would
// make the purge SREM the wrong index member and leave the real one behind.
func splitUDLRecSuffix(suffix string) (uid, cid, key string, ok bool) {
parts := strings.SplitN(suffix, "/", 3)
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return "", "", "", false
}
return parts[0], parts[1], parts[2], true
}
// UDLUserCells returns every (cid, key) pair this account holds a cell for,
// keyed by the "cid/key" tail with the cid as value (the purge needs them
// grouped).
func (c *Client) UDLUserCells(uid string) (map[string]string, error) {
if err := safeUIDForPattern(uid); err != nil {
return nil, err
}
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
out := make(map[string]string)
err = s.ScanPrefix(ctx, "udl:rec:"+uid+"/", readerPage, func(rows []kv.KV) error {
for _, row := range rows {
gotUID, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(row.K, "udl:rec:"))
if !ok || gotUID != uid {
continue
}
out[cid+"/"+key] = cid
}
return nil
})
return out, err
}
// UDLAllUserStats counts every account's cells in one walk.
//
// Returns only accounts that actually hold records; a uid absent from the map
// holds none. Callers render a zero for those rather than treating it as an
// error — an account created a minute ago legitimately has nothing.
func (c *Client) UDLAllUserStats() (map[string]UDLUserStats, error) {
s, err := c.kv()
if err != nil {
return nil, err
}
ctx, cancel := opCtx()
defer cancel()
type acc struct {
cells int
cids map[string]struct{}
keys map[string]struct{}
}
byUID := make(map[string]*acc)
err = s.ScanPrefix(ctx, "udl:rec:", readerPage, func(rows []kv.KV) error {
for _, row := range rows {
uid, cid, key, ok := splitUDLRecSuffix(strings.TrimPrefix(row.K, "udl:rec:"))
if !ok {
continue
}
a := byUID[uid]
if a == nil {
a = &acc{cids: make(map[string]struct{}), keys: make(map[string]struct{})}
byUID[uid] = a
}
a.cells++
a.cids[cid] = struct{}{}
a.keys[key] = struct{}{}
}
return nil
})
if err != nil {
return nil, err
}
out := make(map[string]UDLUserStats, len(byUID))
for uid, a := range byUID {
out[uid] = UDLUserStats{Records: a.cells, Cids: len(a.cids), Keys: len(a.keys)}
}
return out, nil
}
// UDLPurgeUser removes every trace of one account from the User Data Layer:
// its cells, its three user-scoped indexes, and its membership in the shared
// cid-scoped indexes. Returns what was removed.
//
// The shared indexes are the part that is easy to miss and expensive to leave.
// `udl:idx:cid:<cid>:key:<key>` and `udl:idx:cid:<cid>:users` are keyed by cid,
// not by uid, so a purge that only deleted `udl:rec:<uid>/*` would leave this
// uid listed as an opinion-holder on every title it ever touched.
//
// Idempotent: purging an account with nothing stored is a no-op returning
// zeroes, so a retry is safe.
func (c *Client) UDLPurgeUser(uid string) (UDLUserStats, error) {
cells, err := c.UDLUserCells(uid)
if err != nil {
return UDLUserStats{}, err
}
stats := UDLUserStats{Records: len(cells)}
distinctCids := make(map[string]struct{})
distinctKeys := make(map[string]struct{})
err = c.update(func(tx *kv.Tx) error {
for cidKey, cid := range cells {
key := strings.TrimPrefix(cidKey, cid+"/")
distinctCids[cid] = struct{}{}
distinctKeys[key] = struct{}{}
if _, err := tx.Delete(udlRecKey(uid, cid, key)); err != nil {
return err
}
if err := udlRem(tx, udlIdxCidKey(cid, key), uid); err != nil {
return err
}
}
for cid := range distinctCids {
if err := udlRem(tx, udlIdxCidUsers(cid), uid); err != nil {
return err
}
}
// The three user-scoped indexes (`:key:*`, `:cid:*`, `:cids`) are one
// key range, dropped whole — which also catches index entries whose
// cell is already gone (the state a half-finished purge leaves).
_, err := tx.DeletePrefix("udl:idx:user:"+uid+":", 0)
return err
})
if err != nil {
return UDLUserStats{}, fmt.Errorf("purge: %w", err)
}
stats.Cids = len(distinctCids)
stats.Keys = len(distinctKeys)
return stats, nil
}
-233
View File
@@ -1,233 +0,0 @@
package storage
import (
"testing"
)
// seedCell writes one live UDL cell the way a real client would.
func seedCell(t *testing.T, c *Client, uid, cid, key string) {
t.Helper()
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec", "", false, false); err != nil || !ok {
t.Fatalf("seed %s/%s/%s: ok=%v err=%v", uid, cid, key, ok, err)
}
}
// The purge has to reach the cid-scoped indexes, not just the user's own rows.
//
// This is the failure that motivated the whole endpoint: deleting an account
// while leaving `udl:idx:cid:<cid>:users` intact means every aggregate read on
// every title that account ever touched keeps naming a uid nobody can resolve,
// forever — and the rows are unreachable through any API, so nothing will ever
// clean them up.
func TestUDLPurgeUser_RemovesCellsAndBothIndexFamilies(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAlice", "cidOne", "rating")
seedCell(t, c, "zAlice", "cidTwo", "seek")
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if stats.Records != 3 || stats.Cids != 2 || stats.Keys != 3 {
t.Fatalf("stats = %+v, want 3 records / 2 cids / 3 keys", stats)
}
// Cells gone.
if _, ok, err := c.UDLGetCell("zAlice", "cidOne", "like"); err != nil || ok {
t.Fatalf("cell survived the purge: ok=%v err=%v", ok, err)
}
// User-scoped indexes gone.
if entries, err := c.UDLListUserKey("zAlice", "like"); err != nil || len(entries) != 0 {
t.Fatalf("user+key index survived: %d entries, err=%v", len(entries), err)
}
if entries, err := c.UDLListUserCid("zAlice", "cidOne"); err != nil || len(entries) != 0 {
t.Fatalf("user+cid index survived: %d entries, err=%v", len(entries), err)
}
// Cid-scoped index gone — the one that is easy to miss.
for _, cid := range []string{"cidOne", "cidTwo"} {
uids, err := c.UDLCidUsers(cid)
if err != nil {
t.Fatalf("cid users %s: %v", cid, err)
}
for _, u := range uids {
if u == "zAlice" {
t.Fatalf("%s still lists the deleted account in uids_for_cid", cid)
}
}
}
}
// Purging one account must not touch another's rows, including on a cid they
// both have an opinion about — where the two share an index set and a careless
// DEL of the whole key would take the survivor's membership with it.
func TestUDLPurgeUser_LeavesOtherAccountsIntact(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "shared", "rating")
seedCell(t, c, "zBob", "shared", "rating")
if _, err := c.UDLPurgeUser("zAlice"); err != nil {
t.Fatalf("purge: %v", err)
}
if _, ok, err := c.UDLGetCell("zBob", "shared", "rating"); err != nil || !ok {
t.Fatalf("bob's cell must survive alice's purge: ok=%v err=%v", ok, err)
}
uids, err := c.UDLCidUsers("shared")
if err != nil {
t.Fatalf("cid users: %v", err)
}
if len(uids) != 1 || uids[0] != "zBob" {
t.Fatalf("uids_for_cid = %v, want exactly [zBob]", uids)
}
entries, err := c.UDLListUserKey("zBob", "rating")
if err != nil || len(entries) != 1 {
t.Fatalf("bob's user+key index must survive: %d entries, err=%v", len(entries), err)
}
}
// A uid that is a prefix of another must not drag its neighbour's rows out.
// The key layout separates them with '/' and ':', so this passes only as long
// as the scan patterns keep those separators.
func TestUDLPurgeUser_DoesNotMatchUIDsByPrefix(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAliceExtra", "cidOne", "like")
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if stats.Records != 1 {
t.Fatalf("purged %d records, want exactly alice's 1", stats.Records)
}
if _, ok, err := c.UDLGetCell("zAliceExtra", "cidOne", "like"); err != nil || !ok {
t.Fatalf("zAliceExtra's cell must survive: ok=%v err=%v", ok, err)
}
}
// Tombstoned cells still belong to the account. They are absent from the
// user+key index by design (see udl.go), so a purge that enumerated *that*
// index instead of the cells would leave every retracted rating behind — the
// rows most likely to have accumulated over an account's life.
func TestUDLPurgeUser_RemovesTombstonedCellsToo(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
if ok, err := c.UDLUpsertIfNewer("zAlice", "cidOne", "like", 2, 200, "rec-tomb", "", false, true); err != nil || !ok {
t.Fatalf("tombstone: ok=%v err=%v", ok, err)
}
// Precondition: the tombstone left the cell but dropped it from the index.
if entries, _ := c.UDLListUserKey("zAlice", "like"); len(entries) != 0 {
t.Fatalf("precondition: tombstone should be out of the user+key index")
}
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if stats.Records != 1 {
t.Fatalf("purged %d records, want the 1 tombstoned cell", stats.Records)
}
if _, ok, _ := c.UDLGetCell("zAlice", "cidOne", "like"); ok {
t.Fatal("tombstoned cell survived the purge")
}
}
// Retry-safe: the delete handler purges before removing the key, so a caller
// that retries after a partial failure runs this a second time.
func TestUDLPurgeUser_IsIdempotent(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
if _, err := c.UDLPurgeUser("zAlice"); err != nil {
t.Fatalf("first purge: %v", err)
}
stats, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("second purge must be a no-op, got: %v", err)
}
if stats.Records != 0 || stats.Cids != 0 {
t.Fatalf("second purge = %+v, want zeroes", stats)
}
}
func TestUDLPurgeUser_RefusesGlobInjection(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
// "*" would otherwise match every account in the keyspace — a wildcard
// purge dressed up as a uid.
for _, uid := range []string{"*", "z*", "", "z:Alice", "z/Alice"} {
if _, err := c.UDLPurgeUser(uid); err == nil {
t.Errorf("uid %q must be refused before it reaches a SCAN pattern", uid)
}
}
if _, ok, _ := c.UDLGetCell("zAlice", "cidOne", "like"); !ok {
t.Fatal("a refused purge must not have deleted anything")
}
}
func TestUDLAllUserStats_CountsEachAccountSeparately(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAlice", "cidOne", "rating")
seedCell(t, c, "zAlice", "cidTwo", "like")
seedCell(t, c, "zBob", "cidOne", "seek")
stats, err := c.UDLAllUserStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
if got := stats["zAlice"]; got.Records != 3 || got.Cids != 2 || got.Keys != 2 {
t.Fatalf("alice = %+v, want 3 records / 2 cids / 2 keys", got)
}
if got := stats["zBob"]; got.Records != 1 || got.Cids != 1 || got.Keys != 1 {
t.Fatalf("bob = %+v, want 1/1/1", got)
}
// An account with nothing stored is simply absent — the caller renders 0.
if _, present := stats["zNobody"]; present {
t.Fatal("an account with no records must not appear in the map")
}
}
// The stats and the purge must agree: the number shown in the delete
// confirmation is the number of rows the delete then removes.
func TestUDLAllUserStats_AgreesWithWhatPurgeRemoves(t *testing.T) {
c := newTestClient(t)
seedCell(t, c, "zAlice", "cidOne", "like")
seedCell(t, c, "zAlice", "cidTwo", "rating")
seedCell(t, c, "zAlice", "cidTwo", "seek")
before, err := c.UDLAllUserStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
purged, err := c.UDLPurgeUser("zAlice")
if err != nil {
t.Fatalf("purge: %v", err)
}
if before["zAlice"] != purged {
t.Fatalf("stats said %+v but purge removed %+v", before["zAlice"], purged)
}
}
func TestSplitUDLRecSuffix_KeepsSlashesInTheKey(t *testing.T) {
// A namespaced key arriving later must not be truncated: the purge SREMs
// `udl:idx:cid:<cid>:key:<key>`, so a half-read key removes the wrong
// member and leaves the real one behind.
uid, cid, key, ok := splitUDLRecSuffix("zAlice/cidOne/ns/sub:key")
if !ok || uid != "zAlice" || cid != "cidOne" || key != "ns/sub:key" {
t.Fatalf("got (%q,%q,%q,%v)", uid, cid, key, ok)
}
for _, bad := range []string{"", "zAlice", "zAlice/cidOne", "zAlice//key", "/cid/key"} {
if _, _, _, ok := splitUDLRecSuffix(bad); ok {
t.Errorf("%q should not parse", bad)
}
}
}
-160
View File
@@ -1,160 +0,0 @@
package storage
import (
"testing"
)
// The whole-profile export is what cross-instance recovery reads: a box that
// has just imported a secret key knows the uid and nothing else — not which
// cids the profile touched, not which keys. Every one of these tests is about
// that one caller.
func TestUDLAllForUser_ReturnsEveryCellAcrossCidsAndKeys(t *testing.T) {
c := newTestClient(t)
const uid = "zAlice"
writes := []struct{ cid, key, rec string }{
{"bagcsaaaaone", "like", "rec-one-like"},
{"bagcsaaaaone", "seek", "rec-one-seek"},
{"bagcsaaaatwo", "rating", "rec-two-rating"},
{"bagcsaaaatwo", "profile:name", "rec-two-name"},
{"bagcsaaathree", "watched", "rec-three-watched"},
}
for _, w := range writes {
if ok, err := c.UDLUpsertIfNewer(uid, w.cid, w.key, 1, 100, w.rec, "", false, false); err != nil || !ok {
t.Fatalf("upsert %s/%s: ok=%v err=%v", w.cid, w.key, ok, err)
}
}
entries, next, err := c.UDLAllForUser(uid, "", 0)
if err != nil {
t.Fatalf("export: %v", err)
}
if next != "" {
t.Fatalf("export of 5 rows should be complete, got nextCid=%q", next)
}
if len(entries) != len(writes) {
t.Fatalf("want %d rows, got %d: %+v", len(writes), len(entries), entries)
}
seen := map[string]string{}
for _, e := range entries {
seen[e.Cid+"/"+e.Key] = e.Record
}
for _, w := range writes {
if got := seen[w.cid+"/"+w.key]; got != w.rec {
t.Errorf("%s/%s: want record %q, got %q", w.cid, w.key, w.rec, got)
}
}
}
// The failure this guards is silent and total: a uid whose cids were never
// indexed exports as empty, and recovery reports success having restored
// nothing.
func TestUDLAllForUser_IsEmptyForAnUnknownUid(t *testing.T) {
c := newTestClient(t)
if ok, err := c.UDLUpsertIfNewer("zAlice", "bagcsaaaaone", "like", 1, 100, "rec", "", false, false); err != nil || !ok {
t.Fatalf("upsert: ok=%v err=%v", ok, err)
}
entries, _, err := c.UDLAllForUser("zBob", "", 0)
if err != nil {
t.Fatalf("export: %v", err)
}
if len(entries) != 0 {
t.Fatalf("Bob owns nothing; got %+v", entries)
}
}
// ⚠ The tombstone rule, and the reason the cids index is never pruned.
//
// A tombstone must EXPORT. It is the only thing that can beat the older live
// value the other box still holds at a lower version — drop it from the export
// and an un-liked title comes back from the dead on every peer that already
// saw the `like`. This is the one place the cids index deliberately diverges
// from the user+key index, which does prune.
func TestUDLAllForUser_ExportsTombstones(t *testing.T) {
c := newTestClient(t)
const (
uid = "zAlice"
cid = "bagcsaaaaone"
key = "like"
)
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-liked", "", false, false); err != nil || !ok {
t.Fatalf("live upsert: ok=%v err=%v", ok, err)
}
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 2, 200, "rec-unliked", "", false, true); err != nil || !ok {
t.Fatalf("tombstone upsert: ok=%v err=%v", ok, err)
}
// The user+key index prunes it — that is what keeps My List short.
live, err := c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key: %v", err)
}
if len(live) != 0 {
t.Fatalf("tombstoned cid should leave the user+key index, got %+v", live)
}
// The export must still carry it, at the tombstone's version.
entries, _, err := c.UDLAllForUser(uid, "", 0)
if err != nil {
t.Fatalf("export: %v", err)
}
if len(entries) != 1 {
t.Fatalf("tombstone must export, got %+v", entries)
}
if entries[0].Record != "rec-unliked" || entries[0].Version != 2 {
t.Fatalf("export must carry the tombstone, not the superseded value: %+v", entries[0])
}
}
func TestUDLAllForUser_PagesOnACidBoundaryAndResumes(t *testing.T) {
c := newTestClient(t)
const uid = "zAlice"
cids := []string{"bagcsaaaaa1", "bagcsaaaaa2", "bagcsaaaaa3"}
for _, cid := range cids {
for _, key := range []string{"like", "seek"} {
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-"+cid+"-"+key, "", false, false); err != nil || !ok {
t.Fatalf("upsert: ok=%v err=%v", ok, err)
}
}
}
// A cid's keys are never split across a page, so a limit of 1 still yields
// both of the first cid's rows.
page1, next1, err := c.UDLAllForUser(uid, "", 1)
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1) != 2 || page1[0].Cid != cids[0] {
t.Fatalf("page 1 should hold both rows of %s, got %+v", cids[0], page1)
}
if next1 != cids[0] {
t.Fatalf("cursor should name the last cid emitted, want %q got %q", cids[0], next1)
}
// Walking the cursor must visit every row exactly once.
all := append([]UDLUserAllEntry{}, page1...)
cursor := next1
for cursor != "" {
page, next, err := c.UDLAllForUser(uid, cursor, 1)
if err != nil {
t.Fatalf("page after %q: %v", cursor, err)
}
all = append(all, page...)
cursor = next
}
if len(all) != 6 {
t.Fatalf("want 6 rows across all pages, got %d: %+v", len(all), all)
}
seen := map[string]bool{}
for _, e := range all {
k := e.Cid + "/" + e.Key
if seen[k] {
t.Fatalf("%s emitted twice across pages", k)
}
seen[k] = true
}
}
-79
View File
@@ -1,79 +0,0 @@
package storage
import (
"testing"
)
// A tombstone must leave the cell in place but drop the cid from the user+key
// index.
//
// The cell has to stay: it *is* the CRDT state, and deleting it would let an
// older write resurrect the value the tombstone retracted. The index entry has
// to go: `active_for_user_key` returns every member of that set, so a set that
// only ever grows means each My List / Continue Watching read carries every
// title the user has ever un-liked or finished — with its full record blob —
// forever. The reader filters them out again, so the cost is invisible until
// the account is old.
func TestUDLUpsert_TombstonePrunesUserKeyIndexButKeepsCell(t *testing.T) {
c := newTestClient(t)
const (
uid = "zAlice"
cid = "bagcsaaalocator"
key = "seek"
)
// A live position: indexed, readable.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-v1", "", false, false); err != nil || !ok {
t.Fatalf("live upsert: ok=%v err=%v", ok, err)
}
entries, err := c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key: %v", err)
}
if len(entries) != 1 || entries[0].Cid != cid {
t.Fatalf("live position should be indexed, got %+v", entries)
}
// Finishing it tombstones the seek.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 2, 200, "rec-v2-tombstone", "", false, true); err != nil || !ok {
t.Fatalf("tombstone upsert: ok=%v err=%v", ok, err)
}
entries, err = c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key after tombstone: %v", err)
}
if len(entries) != 0 {
t.Fatalf("tombstoned cid must leave the user+key index, got %+v", entries)
}
// ...but the cell itself survives, at the tombstone's version.
cell, exists, err := c.UDLGetCell(uid, cid, key)
if err != nil {
t.Fatalf("get cell: %v", err)
}
if !exists {
t.Fatal("tombstone must not delete the cell — an older write could then resurrect the value")
}
if cell.Version != 2 || cell.Record != "rec-v2-tombstone" {
t.Fatalf("cell should hold the tombstone record at v2, got %+v", cell)
}
// And the high-water mark still rejects the superseded write.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 300, "rec-v1-replay", "", false, false); err != nil || ok {
t.Fatalf("stale replay must be rejected: ok=%v err=%v", ok, err)
}
// Re-watching re-establishes the cell and puts it back in the index.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 3, 400, "rec-v3", "", false, false); err != nil || !ok {
t.Fatalf("re-establish upsert: ok=%v err=%v", ok, err)
}
entries, err = c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key after re-establish: %v", err)
}
if len(entries) != 1 || entries[0].Cid != cid {
t.Fatalf("re-established cid must return to the index, got %+v", entries)
}
}
+47 -90
View File
@@ -1,109 +1,66 @@
package storage
import (
"context"
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/metazla/meta-core/internal/claim"
)
// BenchmarkConcurrentRecordWrites is the ingest shape the single-writer model
// has to absorb: many goroutines (feeder flushes, gateway persists, plugin
// callbacks) each writing a ~40-field record. SQLite serialises them; this
// measures what that costs. Reported as records/s.
func BenchmarkConcurrentRecordWrites(b *testing.B) {
c := NewClient()
if err := c.Connect(filepath.Join(b.TempDir(), "bench.db")); err != nil {
b.Fatal(err)
// benchRecord is a 40-field signed record, the shape gateway hits persist.
func benchRecord(b *testing.B, c *Client, i int) []claim.Claim {
t := &testing.T{}
id := signer(t, byte(1+i%7))
cid := fmt.Sprintf("bafkbench%012d", i)
out := make([]claim.Claim, 0, 40)
for f := 0; f < 40; f++ {
out = append(out, vote(t, id, cid, fmt.Sprintf("field%02d", f), fmt.Sprintf("value-%d-%d", i, f), 1000))
}
defer c.Close()
record := func(i int64) map[string]string {
m := map[string]string{
"title": fmt.Sprintf("Some Show S01E%02d", i%40),
"fileName": fmt.Sprintf("some.show.s01e%02d.1080p.mkv", i%40),
"filePath": fmt.Sprintf("/files/watch/some.show.%d.mkv", i),
"fileType": "video",
"tmdbid": fmt.Sprintf("%d", 1000+i%500),
fmt.Sprintf("cids/bafkbench%020d", i): "true",
}
for f := 0; len(m) < 40; f++ {
m[fmt.Sprintf("categories/%d", f)] = "true"
}
return m
}
const writers = 16
var next int64
b.ResetTimer()
var wg sync.WaitGroup
for w := 0; w < writers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
i := atomic.AddInt64(&next, 1)
if i > int64(b.N) {
return
}
if err := c.SetMetadataFlat(fmt.Sprintf("01BENCH%019d", i), record(i)); err != nil {
b.Error(err)
return
}
}
}()
}
wg.Wait()
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "records/s")
return out
}
// BenchmarkConcurrentRepersistUnchanged is the dominant write on a permanent
// store: a gateway re-persisting hits it already holds. The kv layer skips
// every unchanged field, so this should cost reads only.
func BenchmarkConcurrentRepersistUnchanged(b *testing.B) {
c := NewClient()
if err := c.Connect(filepath.Join(b.TempDir(), "bench.db")); err != nil {
b.Fatal(err)
}
// BenchmarkConcurrentClaimWrites measures new 40-claim records, 16 writers.
func BenchmarkConcurrentClaimWrites(b *testing.B) {
c := openClient(&testing.T{}, b.TempDir())
defer c.Close()
const distinct = 2000
rec := func(i int64) map[string]string {
m := map[string]string{
"title": fmt.Sprintf("Some Show S01E%02d", i%40),
"fileType": "video",
fmt.Sprintf("cids/bafkbench%020d", i): "true",
}
for f := 0; len(m) < 40; f++ {
m[fmt.Sprintf("categories/%d", f)] = "true"
}
return m
recs := make([][]claim.Claim, b.N)
for i := range recs {
recs[i] = benchRecord(b, c, i)
}
for i := int64(0); i < distinct; i++ {
if err := c.SetMetadataFlat(fmt.Sprintf("01BENCH%019d", i), rec(i)); err != nil {
b.ResetTimer()
b.SetParallelism(16)
var next int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := int(atomicAdd(&next) - 1)
if _, err := c.WriteClaims(context.Background(), recs[i]); err != nil {
b.Error(err)
}
}
})
}
// BenchmarkRepersistUnchangedClaims measures the 94% case: the same signed
// claims again (skip-identical before verification).
func BenchmarkRepersistUnchangedClaims(b *testing.B) {
c := openClient(&testing.T{}, b.TempDir())
defer c.Close()
const distinct = 200
recs := make([][]claim.Claim, distinct)
for i := range recs {
recs[i] = benchRecord(b, c, i)
if _, err := c.WriteClaims(context.Background(), recs[i]); err != nil {
b.Fatal(err)
}
}
const writers = 16
var next int64
b.ResetTimer()
var wg sync.WaitGroup
for w := 0; w < writers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
i := atomic.AddInt64(&next, 1)
if i > int64(b.N) {
return
}
if err := c.SetMetadataFlat(fmt.Sprintf("01BENCH%019d", i%distinct), rec(i%distinct)); err != nil {
b.Error(err)
return
}
}
}()
for i := 0; i < b.N; i++ {
if _, err := c.WriteClaims(context.Background(), recs[i%distinct]); err != nil {
b.Fatal(err)
}
}
wg.Wait()
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "records/s")
}
func atomicAdd(p *int64) int64 { return atomic.AddInt64(p, 1) }
+7
View File
@@ -261,6 +261,13 @@ func (w *Watcher) ScanDirectoryDiff(root string, watcherID string) DiffScanResul
w.dispatcher.Dispatch(event)
state.Delete(relPath)
result.Deleted++
// Only the box-local facts go; the record's claims stay.
if w.storage != nil {
abs := filepath.Join(w.filesPath, filepath.FromSlash(relPath))
if err := w.storage.RemoveFileTuple(abs); err != nil {
log.Printf("[Watcher] forget %s: %v", abs, err)
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Command claims-migrate is the one-off conversion of a meta-core v1.2.0 store
// (one kv table, schema version 2) into a v2.0.0 truth database (the claims
// model, schema version 3) — docs/implementation/metacore-claims-model.md §11.
// It is run once by hand on watch.nsl.sh with meta-core stopped, and deleted
// afterwards; nothing in the build references it.
//
// claims-migrate -old /meta-core/db/meta-core.db -truth /out/meta-core.db \
// -accounts /meta-core/identity/accounts [-dry-run]
//
// What it does:
// - generates a throwaway "legacy" secp256k1 key in memory (its uid is
// printed; the private key is never written anywhere);
// - every record (rid) with at least one cid: each file:<rid>/<field> becomes
// a claim on the record's canonical cid (internal/cid rank) signed by the
// legacy key at one conversion ts; cids/<other> members become alias claims
// (canonical, cids/<other>) = "true"; filePath / mtimeNano (watcher facts,
// refilled into local_file) and keys meta-core refuses (cid_*, midhash256,
// canonical_cid, __fields__) are skipped; records with no cid are dropped;
// - cid_group from the cid:<cid> → rid index (rids keep their UUIDs);
// - UDL cells (MMUR1 records, signature checked) become MMUR2 claims signed
// by the account key found in -accounts; rating → userRating; private-tier
// envelopes are decrypted (AAD bound to the old version) and re-encrypted
// with a fresh nonce and the MMUR2 AAD (bound to ts); cells whose account is
// not on the box are dropped;
// - btmagnet:* / searchcov:* / other service scratch keys are dropped (they
// live in the cache now and are refetched); derived bookkeeping is skipped.
//
// Every claim is checked with claim.Verify before it is stored. The output is
// created with meta-core's own storage code, so the truth schema cannot drift;
// meta-core rebuilds the cache from it on first boot.
package main
import (
"flag"
"fmt"
"os"
"runtime"
)
func main() {
var o options
flag.StringVar(&o.oldPath, "old", "", "path to the v1 meta-core.db (opened read-only; copy it with its -wal/-shm first)")
flag.StringVar(&o.truthPath, "truth", "", "output truth database path (must not exist)")
flag.StringVar(&o.accountsDir, "accounts", "", "identity accounts dir ({META_CORE_PATH}/identity/accounts); empty = drop every UDL cell")
flag.BoolVar(&o.dryRun, "dry-run", false, "convert, sign and verify everything but write nothing; print counts")
flag.IntVar(&o.workers, "workers", runtime.NumCPU(), "signing goroutines")
flag.Parse()
if o.oldPath == "" || (o.truthPath == "" && !o.dryRun) {
fmt.Fprintln(os.Stderr, "usage: claims-migrate -old <v1 meta-core.db> -truth <new truth db> [-accounts <dir>] [-dry-run]")
flag.PrintDefaults()
os.Exit(2)
}
if o.accountsDir != "" {
if fi, err := os.Stat(o.accountsDir); err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr, "accounts dir %s is not a directory\n", o.accountsDir)
os.Exit(2)
}
} else {
fmt.Fprintln(os.Stderr, "warning: no -accounts: every UDL cell will be dropped")
}
o.log = os.Stderr
st, err := migrate(o)
if err != nil {
fmt.Fprintf(os.Stderr, "claims-migrate: %v\n", err)
os.Exit(1)
}
printSummary(os.Stdout, st, o.dryRun)
if !o.dryRun {
fmt.Printf("\ntruth database written: %s\n", o.truthPath)
}
}
+943
View File
@@ -0,0 +1,943 @@
package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
"github.com/metazla/meta-core/internal/cid"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
type options struct {
oldPath string
truthPath string
accountsDir string
dryRun bool
now time.Time // conversion timestamp; zero = time.Now()
workers int
log io.Writer
}
// counts is a labelled tally, printed sorted.
type counts map[string]int
func (c counts) total() int {
n := 0
for _, v := range c {
n += v
}
return n
}
type stats struct {
LegacyUID string
ConvTS uint64
Rids int // records migrated (have at least one cid)
RidsNoClaims int // migrated records whose only rows were cids / watcher facts
RecordsNoCID int // dropped: no cid at all
FieldClaims int
AliasClaims int
WatcherFacts counts // filePath / mtimeNano skipped
RefusedFields counts // by reason
Malformed int // file: keys without <rid>/<field>
CIDGroupRows int
CIDConflicts int // cids/<c> on a rid while cid:<c> names another rid
CIDShared int // cids/<c> (no cid: row) already kept by an earlier record
RecordsFolded int // records whose every cid an earlier record kept: merged into it
DupClaims int // (cid, key) already claimed by the legacy signer (folded records)
OrphanCIDRows int // cid:<c> → rid with no file: rows
UDLMigrated counts
UDLDropped counts
UDLSigners int
GroupsMinted int // cid_group rows minted for UDL cids not in any record
Scratch counts // service scratch keys dropped (btmagnet, searchcov, …)
Derived counts // derived bookkeeping skipped (idx, ev, dup, udl:idx, …)
Principals int
ClaimsWritten int
Elapsed time.Duration
canon map[string]string // rid → the canonical cid its claims sit on
}
func newStats() *stats {
return &stats{
WatcherFacts: counts{}, RefusedFields: counts{},
UDLMigrated: counts{}, UDLDropped: counts{},
Scratch: counts{}, Derived: counts{},
canon: map[string]string{},
}
}
// job is one claim to sign with id.
type job struct {
cl claim.Claim
id *identity.Identity
}
const batchSize = 4096
func (o *options) logf(format string, args ...any) {
if o.log != nil {
fmt.Fprintf(o.log, format+"\n", args...)
}
}
// migrate converts a meta-core v1.2.0 kv store into a v2.0.0 truth database.
func migrate(o options) (st *stats, err error) {
started := time.Now()
st = newStats()
if o.workers <= 0 {
o.workers = runtime.NumCPU()
}
if o.now.IsZero() {
o.now = time.Now()
}
st.ConvTS = uint64(o.now.UnixMilli())
old, err := openOld(o.oldPath)
if err != nil {
return nil, err
}
defer old.Close()
legacy, err := identity.Generate() // in memory only, never saved
if err != nil {
return nil, err
}
st.LegacyUID = legacy.UID
o.logf("legacy signer uid: %s (private key kept in memory only)", legacy.UID)
var out *sql.DB
if !o.dryRun {
if out, err = createTruth(o.truthPath); err != nil {
return nil, err
}
defer func() {
if out != nil {
out.Close()
}
if err != nil {
removeDB(o.truthPath)
}
}()
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Pipeline: producer (this goroutine) → signers → one writer.
jobs := make(chan []job, o.workers*2)
signed := make(chan []claim.Claim, o.workers*2)
var firstErr error
var errMu sync.Mutex
fail := func(e error) {
errMu.Lock()
if firstErr == nil {
firstErr = e
}
errMu.Unlock()
cancel()
}
var wg sync.WaitGroup
for range o.workers {
wg.Add(1)
go func() {
defer wg.Done()
for batch := range jobs {
res := make([]claim.Claim, len(batch))
for i := range batch {
cl := batch[i].cl
if e := cl.Sign(batch[i].id); e != nil {
fail(fmt.Errorf("sign %s/%s: %w", cl.CID, cl.Key, e))
return
}
if e := cl.Verify(); e != nil {
fail(fmt.Errorf("verify %s/%s: %w", cl.CID, cl.Key, e))
return
}
res[i] = cl
}
select {
case signed <- res:
case <-ctx.Done():
return
}
}
}()
}
go func() { wg.Wait(); close(signed) }()
w := &writer{db: out, pids: map[string]int64{}}
writerDone := make(chan error, 1)
go func() {
var e error
for batch := range signed {
if e == nil {
if e = w.write(batch); e != nil {
fail(e)
}
}
}
writerDone <- e
}()
var pending []job
send := func(j job) error {
pending = append(pending, j)
if len(pending) < batchSize {
return nil
}
b := pending
pending = nil
select {
case jobs <- b:
return nil
case <-ctx.Done():
return errors.New("aborted")
}
}
groups, prodErr := produce(ctx, old, o, st, legacy, send)
if prodErr == nil && len(pending) > 0 {
select {
case jobs <- pending:
case <-ctx.Done():
prodErr = errors.New("aborted")
}
}
close(jobs)
wErr := <-writerDone
errMu.Lock()
fe := firstErr
errMu.Unlock()
if fe != nil {
return nil, fe
}
if prodErr != nil {
return nil, prodErr
}
if wErr != nil {
return nil, wErr
}
st.CIDGroupRows = len(groups)
st.Principals = len(w.pids)
st.ClaimsWritten = int(w.seq)
st.DupClaims = w.dups
if !o.dryRun {
if err = w.finish(groups); err != nil {
return nil, err
}
if _, err = out.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
return nil, err
}
if err = out.Close(); err != nil {
out = nil
return nil, err
}
out = nil
if err = checkTruth(o.truthPath); err != nil {
return nil, fmt.Errorf("re-opening the result with meta-core's storage: %w", err)
}
}
st.Elapsed = time.Since(started)
return st, nil
}
// produce reads the old store and emits every claim; it returns cid_group.
func produce(ctx context.Context, old *sql.DB, o options, st *stats, legacy *identity.Identity, send func(job) error) (map[string]string, error) {
// 1. cid index.
index := map[string]string{}
ridIdx := map[string][]string{}
rows, err := old.QueryContext(ctx, `SELECT k, v FROM kv WHERE k >= 'cid:' AND k < 'cid;'`)
if err != nil {
return nil, err
}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
rows.Close()
return nil, err
}
c := k[len("cid:"):]
if c == "" || v == "" {
continue
}
index[c] = v
ridIdx[v] = append(ridIdx[v], c)
}
if err := rows.Close(); err != nil {
return nil, err
}
o.logf("cid index: %d rows", len(index))
groups := map[string]string{}
// 2. records, one rid at a time (the file: range is contiguous per rid).
rows, err = old.QueryContext(ctx, `SELECT k, v FROM kv WHERE k >= 'file:' AND k < 'file;' ORDER BY k`)
if err != nil {
return nil, err
}
seen := map[string]bool{}
cur := ""
var fields [][2]string
flush := func() error {
if cur == "" {
return nil
}
if seen[cur] {
return fmt.Errorf("file: range for %s is not contiguous", cur)
}
seen[cur] = true
err := emitRecord(o, st, legacy, index, ridIdx, groups, cur, fields, send)
fields = fields[:0]
return err
}
n := 0
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
rows.Close()
return nil, err
}
rid, field, ok := strings.Cut(k[len("file:"):], "/")
if !ok || rid == "" || field == "" {
st.Malformed++
continue
}
if rid != cur {
if err := flush(); err != nil {
rows.Close()
return nil, err
}
cur = rid
}
fields = append(fields, [2]string{field, v})
if n++; n%1_000_000 == 0 {
o.logf(" … %d file: rows, %d records", n, len(seen))
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
if err := flush(); err != nil {
return nil, err
}
for c := range index {
if _, ok := groups[c]; !ok {
st.OrphanCIDRows++
}
}
o.logf("records: %d migrated, %d without a cid dropped", st.Rids, st.RecordsNoCID)
// 3. UDL cells.
if err := emitUDL(ctx, old, o, st, groups, send); err != nil {
return nil, err
}
// 4. everything else: count and drop.
rows, err = old.QueryContext(ctx, `SELECT k FROM kv
WHERE NOT (k >= 'file:' AND k < 'file;') AND NOT (k >= 'cid:' AND k < 'cid;')
AND NOT (k >= 'udl:rec:' AND k < 'udl:rec;')`)
if err != nil {
return nil, err
}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return nil, err
}
classifyOther(st, k)
}
if err := rows.Close(); err != nil {
return nil, err
}
return groups, nil
}
func classifyOther(st *stats, k string) {
for _, p := range []string{"idx:", "ev:", "evhwm:", "dup:", "udl:idx:", "meta-core:", "meta:"} {
if strings.HasPrefix(k, p) {
st.Derived[p+"*"]++
return
}
}
if p, _, ok := strings.Cut(k, ":"); ok && p != "" {
st.Scratch[p+":*"]++
return
}
st.Scratch["(no prefix)"]++
}
// refusedReason mirrors storage.claimKeyAllowed + the write path's length
// checks: keys meta-core's POST /api/claims would refuse.
func refusedReason(key string) string {
switch {
case strings.HasPrefix(key, "cid_"):
return "cid_*"
case key == "midhash256", key == "canonical_cid", key == "__fields__":
return key
case strings.HasPrefix(key, "/"), strings.Contains(key, "\x00"):
return "malformed key"
case len(key) > 4096:
return "key too long"
}
return ""
}
func emitRecord(o options, st *stats, legacy *identity.Identity, index map[string]string,
ridIdx map[string][]string, groups map[string]string, rid string, fields [][2]string, send func(job) error) error {
// Members: cids/* the cid index does not assign elsewhere, plus every cid
// the index assigns here.
member := map[string]bool{}
var members []string
for _, f := range fields {
c, ok := strings.CutPrefix(f[0], storage.CIDsKeyPrefix)
if !ok || c == "" {
continue
}
if r, ok := index[c]; ok && r != rid {
st.CIDConflicts++
continue
}
if !member[c] {
member[c] = true
members = append(members, c)
}
}
for _, c := range ridIdx[rid] {
if !member[c] {
member[c] = true
members = append(members, c)
}
}
var usable []string
for _, c := range members {
if len(c) <= 512 {
usable = append(usable, c)
}
}
if len(usable) == 0 {
st.RecordsNoCID++
return nil
}
// A cid with no cid: index row can sit in two records' cids/* (the same
// content stored twice). The first record keeps it; a record left with none
// folds into the record owning its first shared cid, and its fields become
// claims there (the legacy signer's first claim per (cid, key) wins).
owner, shared := "", ""
var own []string
for _, c := range usable {
if r, ok := groups[c]; ok && r != rid {
st.CIDShared++
if owner == "" {
owner, shared = r, c
}
continue
}
own = append(own, c)
}
var canonical string
if len(own) == 0 {
st.RecordsFolded++
rid = owner
if canonical = st.canon[owner]; canonical == "" {
canonical = shared
}
} else {
canonical = own[0]
for _, c := range own[1:] {
canonical = cid.Better(canonical, c)
}
for _, c := range own {
groups[c] = rid
}
st.canon[rid] = canonical
st.Rids++
}
emitted := 0
mk := func(key, value string) error {
emitted++
return send(job{id: legacy, cl: claim.Claim{
CID: canonical, UID: legacy.UID, Key: key, Value: claim.Text(value), TS: st.ConvTS,
}})
}
for _, f := range fields {
key, val := f[0], f[1]
if c, ok := strings.CutPrefix(key, storage.CIDsKeyPrefix); ok {
if c != canonical && member[c] && len(c) <= 512 && groups[c] == rid {
st.AliasClaims++
if err := mk(key, "true"); err != nil {
return err
}
}
continue
}
if key == "filePath" || key == "mtimeNano" {
st.WatcherFacts[key]++
continue
}
if r := refusedReason(key); r != "" {
st.RefusedFields[r]++
continue
}
st.FieldClaims++
if err := mk(key, val); err != nil {
return err
}
}
if emitted == 0 {
st.RidsNoClaims++
}
return nil
}
// publicUDLKeys are the plaintext tier (meta-watch record/tiers.rs); every
// other key is private and travels as an envelope.
var publicUDLKeys = map[string]bool{"like": true, "userRating": true, "profile:name": true, "profile:avatar": true}
type udlCellJSON struct {
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
}
func decodeB64(s string) ([]byte, error) {
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if b, err := enc.DecodeString(s); err == nil {
return b, nil
}
}
return nil, errors.New("record is not base64")
}
// udlTS converts an MMUR1 ts (seconds; tolerate ms) to MMUR2 milliseconds.
func udlTS(ts int64, fallback uint64) uint64 {
switch {
case ts <= 0:
return fallback
case ts < 100_000_000_000: // seconds (before year 5138)
return uint64(ts) * 1000
default:
return uint64(ts)
}
}
type account struct {
id *identity.Identity
aeadKey []byte
}
func emitUDL(ctx context.Context, old *sql.DB, o options, st *stats, groups map[string]string, send func(job) error) error {
accounts := map[string]*account{}
loadAccount := func(uid string) (*account, error) {
if a, ok := accounts[uid]; ok {
return a, nil
}
var a *account
if o.accountsDir != "" && identity.ValidUID(uid) {
id, err := identity.LoadByUID(o.accountsDir, uid)
if err != nil {
return nil, fmt.Errorf("account %s: %w", uid, err)
}
if id != nil {
if id.UID != uid {
return nil, fmt.Errorf("account file %s.json holds uid %s", uid, id.UID)
}
k, err := identity.DeriveAEADKey(id)
if err != nil {
return nil, err
}
a = &account{id: id, aeadKey: k}
}
}
accounts[uid] = a
return a, nil
}
type cellKey struct{ uid, cid, key string }
best := map[cellKey]job{}
var order []cellKey
drop := func(reason string) { st.UDLDropped[reason]++ }
kinds := map[cellKey]string{}
rows, err := old.QueryContext(ctx, `SELECT k, v FROM kv WHERE k >= 'udl:rec:' AND k < 'udl:rec;' ORDER BY k`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return err
}
parts := strings.SplitN(k[len("udl:rec:"):], "/", 3)
if len(parts) != 3 {
drop("malformed cell key")
continue
}
var cell udlCellJSON
if err := json.Unmarshal([]byte(v), &cell); err != nil || cell.Record == "" {
drop("malformed cell JSON")
continue
}
raw, err := decodeB64(cell.Record)
if err != nil {
drop("record not base64")
continue
}
rec, err := decodeMMUR1(raw)
if err != nil {
drop("record not MMUR1 CBOR")
continue
}
if rec.UID != parts[0] || rec.CID != parts[1] || rec.Key != parts[2] {
drop("record does not match its cell")
continue
}
if !identity.ValidUID(rec.UID) || identity.Verify(rec.UID, rec.Signed, rec.Sig) != nil {
drop("bad MMUR1 signature")
continue
}
acct, err := loadAccount(rec.UID)
if err != nil {
return err
}
if acct == nil {
drop("no account on this box")
continue
}
key := rec.Key
if key == "rating" {
key = "userRating" // §14: a user's rating is userRating
}
if r := refusedReason(key); r != "" || rec.CID == "" || len(rec.CID) > 512 {
drop("key or cid refused by meta-core")
continue
}
ts := udlTS(rec.TS, st.ConvTS)
cl := claim.Claim{CID: rec.CID, UID: rec.UID, Key: key, TS: ts}
kind := ""
switch {
case rec.Value.kind == cNull:
kind = "tombstone"
case publicUDLKeys[key]:
t, ok := rec.Value.wireText()
if !ok {
drop("public key holds bytes")
continue
}
cl.Value, kind = claim.Text(t), "public"
default:
var plain []byte
if rec.Value.kind == cBytes {
plain, err = openEnvelope(acct.aeadKey, rec.Value.bytes, envelopeAAD(rec.CID, rec.UID, rec.Key, rec.Version))
if err != nil {
drop("private envelope does not decrypt")
continue
}
kind = "private (re-encrypted)"
} else {
// A private-tier key stored in plaintext: encrypt it now, its
// plaintext being the CBOR of the typed value, as crypto.rs.
plain = append([]byte(nil), rec.Value.raw...)
kind = "private (plaintext, now encrypted)"
}
nonce := make([]byte, 24)
if _, err := rand.Read(nonce); err != nil {
return err
}
env, err := sealEnvelope(acct.aeadKey, plain, envelopeAAD(rec.CID, rec.UID, key, ts), nonce)
if err != nil {
return err
}
cl.Value = claim.Bytes(env)
}
ck := cellKey{rec.UID, rec.CID, key}
if prev, ok := best[ck]; ok {
drop("superseded (rating + userRating)")
if prev.cl.TS >= ts {
continue
}
} else {
order = append(order, ck)
}
best[ck], kinds[ck] = job{cl: cl, id: acct.id}, kind
}
if err := rows.Err(); err != nil {
return err
}
signers := map[string]bool{}
for _, ck := range order {
j := best[ck]
if _, ok := groups[j.cl.CID]; !ok {
rid, err := storage.NewUUID()
if err != nil {
return err
}
groups[j.cl.CID] = rid
st.GroupsMinted++
}
st.UDLMigrated[kinds[ck]]++
signers[j.cl.UID] = true
if err := send(j); err != nil {
return err
}
}
st.UDLSigners = len(signers)
return nil
}
// writer stores signed claims in arrival order (seq = arrival order).
type writer struct {
db *sql.DB
pids map[string]int64
seq int64
dups int
}
func (w *writer) write(batch []claim.Claim) error {
if w.db == nil { // dry run
for _, cl := range batch {
if _, ok := w.pids[cl.UID]; !ok {
w.pids[cl.UID] = int64(len(w.pids) + 1)
}
}
w.seq += int64(len(batch))
return nil
}
tx, err := w.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
ins, err := tx.Prepare(`INSERT OR IGNORE INTO claim(cid, key, pid, value, ts, sig, seq) VALUES(?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer ins.Close()
for _, cl := range batch {
pid, ok := w.pids[cl.UID]
if !ok {
if err := tx.QueryRow(`INSERT INTO principal(uid) VALUES(?) RETURNING pid`, cl.UID).Scan(&pid); err != nil {
return fmt.Errorf("principal %s: %w", cl.UID, err)
}
w.pids[cl.UID] = pid
}
w.seq++
res, err := ins.Exec(cl.CID, cl.Key, pid, valueArg(cl.Value), int64(cl.TS), cl.Sig, w.seq)
if err != nil {
return fmt.Errorf("claim (%s, %s, %s): %w", cl.CID, cl.Key, cl.UID, err)
}
if n, _ := res.RowsAffected(); n == 0 { // a folded record's duplicate
w.seq--
w.dups++
}
}
return tx.Commit()
}
// finish writes cid_group and last_seq.
func (w *writer) finish(groups map[string]string) error {
tx, err := w.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
ins, err := tx.Prepare(`INSERT INTO cid_group(cid, rid) VALUES(?, ?)`)
if err != nil {
return err
}
defer ins.Close()
keys := make([]string, 0, len(groups))
for c := range groups {
keys = append(keys, c)
}
sort.Strings(keys)
for _, c := range keys {
if _, err := ins.Exec(c, groups[c]); err != nil {
return fmt.Errorf("cid_group %s: %w", c, err)
}
}
if _, err := tx.Exec(`INSERT INTO meta(name, value) VALUES('last_seq', ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value`, fmt.Sprint(w.seq)); err != nil {
return err
}
return tx.Commit()
}
// valueArg is storage's column encoding: TEXT, BLOB or NULL (STRICT ANY keeps it).
func valueArg(v claim.Value) any {
switch v.Kind {
case claim.KindText:
return v.Text
case claim.KindBytes:
if v.Bytes == nil {
return []byte{}
}
return v.Bytes
}
return nil
}
func openOld(path string) (*sql.DB, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("old store: %w", err)
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(10000)")
if err != nil {
return nil, err
}
var name string
if err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`).Scan(&name); err != nil {
db.Close()
return nil, fmt.Errorf("%s has no kv table — not a meta-core v1.x store: %w", path, err)
}
var ver string
err = db.QueryRow(`SELECT v FROM kv WHERE k = 'meta-core:schema-version'`).Scan(&ver)
switch {
case errors.Is(err, sql.ErrNoRows):
// pre-sentinel store: accepted, the key layout is the same
case err != nil:
db.Close()
return nil, err
case ver != "2":
db.Close()
return nil, fmt.Errorf("%s has schema version %q, this tool converts version \"2\"", path, ver)
}
return db, nil
}
// createTruth creates the truth file with meta-core's own schema code
// (storage.Client.Connect + EnsureSchemaVersion stamp schema-version 3), then
// reopens it for the bulk load.
func createTruth(path string) (*sql.DB, error) {
for _, p := range []string{path, path + "-wal", path + "-shm"} {
if _, err := os.Stat(p); err == nil {
return nil, fmt.Errorf("%s already exists; refusing to overwrite", p)
}
}
if fi, err := os.Stat(filepath.Dir(path)); err != nil || !fi.IsDir() {
return nil, fmt.Errorf("output directory %s does not exist", filepath.Dir(path))
}
tmp, err := os.MkdirTemp("", "claims-migrate-cache-")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
c := storage.NewClient()
if err := c.Connect(path, filepath.Join(tmp, "cache.db")); err != nil {
removeDB(path)
return nil, err
}
if err := c.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
c.Close()
removeDB(path)
return nil, err
}
if err := c.Close(); err != nil {
removeDB(path)
return nil, err
}
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=synchronous(OFF)&_pragma=busy_timeout(30000)&_pragma=cache_size(-262144)")
if err != nil {
removeDB(path)
return nil, err
}
db.SetMaxOpenConns(1)
return db, nil
}
// checkTruth reopens the result the way meta-core boots it (schema gate).
func checkTruth(path string) error {
tmp, err := os.MkdirTemp("", "claims-migrate-check-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
c := storage.NewClient()
if err := c.Connect(path, filepath.Join(tmp, "cache.db")); err != nil {
return err
}
defer c.Close()
return c.EnsureSchemaVersion(storage.SchemaVersion)
}
func removeDB(path string) {
for _, p := range []string{path, path + "-wal", path + "-shm"} {
_ = os.Remove(p)
}
}
// printSummary writes the human report.
func printSummary(w io.Writer, st *stats, dryRun bool) {
mode := "written"
if dryRun {
mode = "would be written (dry run: signed + verified, nothing stored)"
}
fmt.Fprintf(w, "\n=== claims-migrate summary ===\n")
fmt.Fprintf(w, "legacy signer uid %s\n", st.LegacyUID)
fmt.Fprintf(w, "conversion ts (ms) %d\n", st.ConvTS)
fmt.Fprintf(w, "records (rids) migrated %d (%d with no claims: only cids / watcher facts)\n", st.Rids, st.RidsNoClaims)
fmt.Fprintf(w, "records dropped (no cid) %d\n", st.RecordsNoCID)
fmt.Fprintf(w, "claims %-17s %d\n", "total", st.ClaimsWritten)
fmt.Fprintf(w, " legacy field claims %d\n", st.FieldClaims)
fmt.Fprintf(w, " legacy alias claims %d (cids/<other> = \"true\")\n", st.AliasClaims)
fmt.Fprintf(w, " UDL claims %d (%d signers)\n", st.UDLMigrated.total(), st.UDLSigners)
printCounts(w, " ", st.UDLMigrated)
fmt.Fprintf(w, " -> %s\n", mode)
fmt.Fprintf(w, "principals %d\n", st.Principals)
fmt.Fprintf(w, "cid_group rows %d (%d minted for UDL-only cids)\n", st.CIDGroupRows, st.GroupsMinted)
fmt.Fprintf(w, "cid conflicts %d (cids/<c> on a rid while cid:<c> names another; index wins)\n", st.CIDConflicts)
fmt.Fprintf(w, "shared cids %d (cids/<c> kept by an earlier record; %d records folded into it)\n", st.CIDShared, st.RecordsFolded)
fmt.Fprintf(w, "duplicate claims skipped %d (folded records' (cid, key) already claimed; not counted in dry run)\n", st.DupClaims)
fmt.Fprintf(w, "orphan cid: rows dropped %d (rid has no file: rows)\n", st.OrphanCIDRows)
fmt.Fprintf(w, "watcher facts skipped %d\n", st.WatcherFacts.total())
printCounts(w, " ", st.WatcherFacts)
fmt.Fprintf(w, "fields refused %d\n", st.RefusedFields.total())
printCounts(w, " ", st.RefusedFields)
fmt.Fprintf(w, "malformed file: keys %d\n", st.Malformed)
fmt.Fprintf(w, "UDL cells dropped %d\n", st.UDLDropped.total())
printCounts(w, " ", st.UDLDropped)
fmt.Fprintf(w, "scratch keys dropped %d (refetched / rebuilt in the cache)\n", st.Scratch.total())
printCounts(w, " ", st.Scratch)
fmt.Fprintf(w, "derived keys skipped %d\n", st.Derived.total())
printCounts(w, " ", st.Derived)
fmt.Fprintf(w, "elapsed %s\n", st.Elapsed.Round(time.Millisecond))
}
func printCounts(w io.Writer, indent string, c counts) {
keys := make([]string, 0, len(c))
for k := range c {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, "%s%-40s %d\n", indent, k, c[k])
}
}
+443
View File
@@ -0,0 +1,443 @@
package main
import (
"bytes"
"database/sql"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
const (
cidIPFS = "bafybeiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // rank 40
cidSHA = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // rank 30
cidB = "baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua"
cidOrph = "baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq"
ridA = "01JKR8XW5T4QMV7AHNJ2DEFGHK"
ridB = "01JKR8XW5T4QMV7AHNJ2DEFGHM"
ridC = "01JKR8XW5T4QMV7AHNJ2DEFGHN"
ridD = "01JKR8XW5T4QMV7AHNJ2DEFGHP"
oldTSsec = 1_700_000_000
)
// --- minimal canonical CBOR encoder for building MMUR1 fixtures ---
func head(major byte, n uint64) []byte {
m := major << 5
switch {
case n < 24:
return []byte{m | byte(n)}
case n <= 0xff:
return []byte{m | 24, byte(n)}
case n <= 0xffff:
return []byte{m | 25, byte(n >> 8), byte(n)}
case n <= 0xffffffff:
return binary.BigEndian.AppendUint32([]byte{m | 26}, uint32(n))
}
return binary.BigEndian.AppendUint64([]byte{m | 27}, n)
}
func encText(s string) []byte { return append(head(3, uint64(len(s))), s...) }
func encBytes(b []byte) []byte { return append(head(2, uint64(len(b))), b...) }
func encUint(n uint64) []byte { return head(0, n) }
func encBool(b bool) []byte {
if b {
return []byte{0xf5}
}
return []byte{0xf4}
}
func encF64(f float64) []byte {
return binary.BigEndian.AppendUint64([]byte{0xfb}, math.Float64bits(f))
}
var encNull = []byte{0xf6}
// mmur1 builds a signed MMUR1 record; badSig signs other bytes.
func mmur1(t *testing.T, id *identity.Identity, cid, key string, value []byte, version uint64, badSig bool) []byte {
t.Helper()
var elems []byte
elems = append(elems, encText(cid)...)
elems = append(elems, encText(id.UID)...)
elems = append(elems, encText(key)...)
elems = append(elems, value...)
elems = append(elems, encUint(version)...)
elems = append(elems, encUint(oldTSsec)...)
signed := append(append([]byte(mmur1Domain), 0x86), elems...)
if badSig {
signed = append(signed, 'x')
}
sig, err := identity.Sign(id, signed)
if err != nil {
t.Fatal(err)
}
return append(append([]byte{0x87}, elems...), encBytes(sig)...)
}
func cell(t *testing.T, rec []byte, version uint64) string {
b, err := json.Marshal(map[string]any{
"record": base64.StdEncoding.EncodeToString(rec), "version": version, "ts": oldTSsec,
})
if err != nil {
t.Fatal(err)
}
return string(b)
}
func importID(t *testing.T, n byte) *identity.Identity {
t.Helper()
id, err := identity.Import(fmt.Sprintf("%02x%062x", n, 7))
if err != nil {
t.Fatal(err)
}
return id
}
type fixture struct {
dir, oldPath, accounts string
user, stranger *identity.Identity
seekPlain, durPlain []byte
}
func buildV1(t *testing.T) fixture {
t.Helper()
dir := t.TempDir()
f := fixture{dir: dir, oldPath: filepath.Join(dir, "v1.db"), accounts: filepath.Join(dir, "accounts")}
f.user, f.stranger = importID(t, 0x11), importID(t, 0x22)
if err := os.MkdirAll(f.accounts, 0o700); err != nil {
t.Fatal(err)
}
if err := identity.SaveAccount(f.accounts, f.user); err != nil {
t.Fatal(err)
}
aead, err := identity.DeriveAEADKey(f.user)
if err != nil {
t.Fatal(err)
}
f.seekPlain = encF64(123.5)
f.durPlain = encF64(5400)
env, err := sealEnvelope(aead, f.seekPlain, envelopeAAD(cidIPFS, f.user.UID, "seek", 3), bytes.Repeat([]byte{9}, 24))
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", "file:"+f.oldPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil {
t.Fatal(err)
}
u := f.user.UID
rows := map[string]string{
"meta-core:schema-version": "2",
// record A: two cids, canonical = the dag-pb one
"file:" + ridA + "/cids/" + cidIPFS: "true",
"file:" + ridA + "/cids/" + cidSHA: "true",
"file:" + ridA + "/title": "Inception",
"file:" + ridA + "/year": "007",
"file:" + ridA + "/sizeByte": "15032385536",
"file:" + ridA + "/genres/Drama": "true",
"file:" + ridA + "/filePath": "/files/watch/Inception.mkv",
"file:" + ridA + "/mtimeNano": "1729872000000000000",
"file:" + ridA + "/cid_sha256": cidSHA,
"file:" + ridA + "/fileinfo/duration": "8878.5",
"cid:" + cidIPFS: ridA,
"cid:" + cidSHA: ridA,
// record B: its cid only in the index; claims cidSHA too (conflict)
"file:" + ridB + "/title": "Other",
"file:" + ridB + "/cids/" + cidSHA: "true",
"cid:" + cidB: ridB,
// record C: no cid at all → dropped
"file:" + ridC + "/title": "Nobody",
// orphan index row
"cid:" + cidOrph: ridD,
// scratch + derived
"btmagnet:abc": "magnet:?xt=urn:btih:abc",
"searchcov:q": "1",
"nzb:x": "y",
"idx:roots/" + ridA: "",
"dup:" + ridA + "/p": "",
"udl:idx:user:" + u + "\x1f": "",
// UDL
"udl:rec:" + u + "/" + cidIPFS + "/like": cell(t, mmur1(t, f.user, cidIPFS, "like", encBool(true), 1, false), 1),
"udl:rec:" + u + "/" + cidIPFS + "/rating": cell(t, mmur1(t, f.user, cidIPFS, "rating", encUint(4), 1, false), 1),
"udl:rec:" + u + "/" + cidIPFS + "/seek": cell(t, mmur1(t, f.user, cidIPFS, "seek", encBytes(env), 3, false), 3),
"udl:rec:" + u + "/" + cidIPFS + "/watched": cell(t, mmur1(t, f.user, cidIPFS, "watched", encNull, 2, false), 2),
"udl:rec:" + u + "/" + cidIPFS + "/duration": cell(t, mmur1(t, f.user, cidIPFS, "duration", f.durPlain, 1, false), 1),
"udl:rec:" + u + "/self/profile:name": cell(t, mmur1(t, f.user, "self", "profile:name", encText("Alice"), 1, false), 1),
"udl:rec:" + u + "/" + cidSHA + "/like": cell(t, mmur1(t, f.user, cidSHA, "like", encBool(true), 1, true), 1),
"udl:rec:" + f.stranger.UID + "/" + cidIPFS + "/like": cell(t, mmur1(t, f.stranger, cidIPFS, "like", encBool(false), 1, false), 1),
}
for k, v := range rows {
if _, err := db.Exec(`INSERT INTO kv(k, v) VALUES(?, ?)`, k, v); err != nil {
t.Fatal(err)
}
}
return f
}
func TestMigrate(t *testing.T) {
f := buildV1(t)
truthPath := filepath.Join(f.dir, "truth.db")
now := time.UnixMilli(1_790_000_000_000)
st, err := migrate(options{oldPath: f.oldPath, truthPath: truthPath, accountsDir: f.accounts, now: now, workers: 3})
if err != nil {
t.Fatalf("migrate: %v", err)
}
var sum bytes.Buffer
printSummary(&sum, st, false)
t.Log(sum.String())
check := func(name string, got, want int) {
t.Helper()
if got != want {
t.Errorf("%s = %d, want %d", name, got, want)
}
}
check("rids", st.Rids, 2)
check("records without cid", st.RecordsNoCID, 1)
check("orphan cid rows", st.OrphanCIDRows, 1)
check("cid conflicts", st.CIDConflicts, 1)
check("alias claims", st.AliasClaims, 1)
check("field claims", st.FieldClaims, 6) // A: title year sizeByte genres/Drama fileinfo/duration; B: title
check("refused cid_*", st.RefusedFields["cid_*"], 1)
check("watcher facts", st.WatcherFacts.total(), 2)
check("udl migrated", st.UDLMigrated.total(), 6)
check("udl no account", st.UDLDropped["no account on this box"], 1)
check("udl bad sig", st.UDLDropped["bad MMUR1 signature"], 1)
check("groups minted", st.GroupsMinted, 1) // "self"
check("cid_group rows", st.CIDGroupRows, 4)
check("scratch", st.Scratch.total(), 3)
check("principals", st.Principals, 2)
check("claims", st.ClaimsWritten, st.FieldClaims+st.AliasClaims+st.UDLMigrated.total())
// Reopen with meta-core's storage package: boot catch-up rebuilds the cache.
c := storage.NewClient()
if err := c.Connect(truthPath, filepath.Join(f.dir, "cache.db")); err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
t.Fatal(err)
}
if err := c.Start(); err != nil {
t.Fatal(err)
}
for cid, want := range map[string]string{cidIPFS: ridA, cidSHA: ridA, cidB: ridB, cidOrph: ""} {
got, err := c.GetByCID(cid)
if err != nil || got != want {
t.Errorf("GetByCID(%s) = %q, %v; want %q", cid, got, err, want)
}
}
a, err := c.GetMetadataFlat(ridA)
if err != nil {
t.Fatal(err)
}
for k, want := range map[string]string{
"title": "Inception", "year": "007", "sizeByte": "15032385536", "genres/Drama": "true",
"fileinfo/duration": "8878.5", "cids/" + cidIPFS: "true", "cids/" + cidSHA: "true",
} {
if a[k] != want {
t.Errorf("record A %s = %q, want %q", k, a[k], want)
}
}
for _, k := range []string{"filePath", "mtimeNano", "cid_sha256"} {
if _, ok := a[k]; ok {
t.Errorf("record A carries %s", k)
}
}
b, err := c.GetMetadataFlat(ridB)
if err != nil || b["title"] != "Other" {
t.Errorf("record B = %v, %v", b, err)
}
ids, err := c.GetAllHashIDs()
if err != nil {
t.Fatal(err)
}
if !slices.Contains(ids, ridA) || !slices.Contains(ids, ridB) || slices.Contains(ids, ridC) || slices.Contains(ids, ridD) {
t.Errorf("roots = %v", ids)
}
if len(ids) != 3 {
t.Errorf("want 3 roots (A, B, self), got %v", ids)
}
// Legacy claims sit on the canonical cid, signed by the printed uid.
cls, err := c.ClaimsForCIDs([]string{cidIPFS}, "title")
if err != nil || len(cls) != 1 || cls[0].UID != st.LegacyUID || cls[0].TS != uint64(now.UnixMilli()) {
t.Fatalf("title claims = %+v, %v", cls, err)
}
if err := cls[0].Verify(); err != nil {
t.Error(err)
}
alias, err := c.ClaimsForCIDs([]string{cidIPFS}, "cids/"+cidSHA)
if err != nil || len(alias) != 1 || alias[0].Value.Text != "true" {
t.Errorf("alias claims = %+v, %v", alias, err)
}
// UDL claims.
u := f.user.UID
wantTS := uint64(oldTSsec) * 1000
get := func(cid, key string) claim.Claim {
t.Helper()
cl, ok, err := c.UDLGetClaim(u, cid, key)
if err != nil || !ok {
t.Fatalf("UDL %s/%s: ok=%v err=%v", cid, key, ok, err)
}
if cl.TS != wantTS {
t.Errorf("UDL %s ts = %d, want %d", key, cl.TS, wantTS)
}
if err := cl.Verify(); err != nil {
t.Errorf("UDL %s: %v", key, err)
}
return cl
}
if v := get(cidIPFS, "like").Value; v.Kind != claim.KindText || v.Text != "true" {
t.Errorf("like = %+v", v)
}
if v := get(cidIPFS, "userRating").Value; v.Kind != claim.KindText || v.Text != "4" {
t.Errorf("userRating = %+v", v)
}
if _, ok, _ := c.UDLGetClaim(u, cidIPFS, "rating"); ok {
t.Error("rating was not renamed")
}
if v := get(cidIPFS, "watched").Value; !v.IsNull() {
t.Errorf("watched tombstone = %+v", v)
}
if v := get("self", "profile:name").Value; v.Text != "Alice" {
t.Errorf("profile:name = %+v", v)
}
aead, _ := identity.DeriveAEADKey(f.user)
for key, plain := range map[string][]byte{"seek": f.seekPlain, "duration": f.durPlain} {
cl := get(cidIPFS, key)
if cl.Value.Kind != claim.KindBytes {
t.Fatalf("%s is not an envelope: %+v", key, cl.Value)
}
pt, err := openEnvelope(aead, cl.Value.Bytes, envelopeAAD(cidIPFS, u, key, cl.TS))
if err != nil || !bytes.Equal(pt, plain) {
t.Errorf("%s decrypts to %x, %v; want %x", key, pt, err, plain)
}
}
if _, ok, _ := c.UDLGetClaim(f.stranger.UID, cidIPFS, "like"); ok {
t.Error("stranger's cell migrated without an account")
}
// A second run refuses to overwrite.
if _, err := migrate(options{oldPath: f.oldPath, truthPath: truthPath, accountsDir: f.accounts}); err == nil {
t.Error("second run overwrote the truth file")
}
}
func TestDryRunWritesNothing(t *testing.T) {
f := buildV1(t)
truthPath := filepath.Join(f.dir, "truth.db")
st, err := migrate(options{oldPath: f.oldPath, truthPath: truthPath, accountsDir: f.accounts, dryRun: true})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(truthPath); !os.IsNotExist(err) {
t.Errorf("dry run created %s", truthPath)
}
if st.ClaimsWritten != st.FieldClaims+st.AliasClaims+st.UDLMigrated.total() || st.ClaimsWritten == 0 {
t.Errorf("dry-run counts: %+v", st)
}
}
func TestNoAccountsDropsUDL(t *testing.T) {
f := buildV1(t)
st, err := migrate(options{oldPath: f.oldPath, dryRun: true})
if err != nil {
t.Fatal(err)
}
if st.UDLMigrated.total() != 0 || st.UDLDropped["no account on this box"] != 7 {
t.Errorf("udl: migrated %v dropped %v", st.UDLMigrated, st.UDLDropped)
}
}
func TestHalfFloat(t *testing.T) {
for h, want := range map[uint16]float64{0x3c00: 1, 0xc000: -2, 0x3e00: 1.5, 0x0000: 0, 0x7bff: 65504} {
if got := halfToFloat(h); got != want {
t.Errorf("half %04x = %v, want %v", h, got, want)
}
}
}
// Two records listing the same cid in cids/* with no cid: index row (the same
// content stored twice): the first keeps it, a record left with no cid of its
// own folds into it, and the duplicate (cid, key) claims are skipped.
func TestSharedUnindexedCIDFolds(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "v1.db")
db, err := sql.Open("sqlite", "file:"+oldPath)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil {
t.Fatal(err)
}
for k, v := range map[string]string{
"meta-core:schema-version": "2",
"file:" + ridA + "/cids/" + cidSHA: "true",
"file:" + ridA + "/title": "A",
"file:" + ridB + "/cids/" + cidSHA: "true",
"file:" + ridB + "/title": "B",
"file:" + ridB + "/genres/Drama": "true",
"file:" + ridC + "/cids/" + cidSHA: "true",
"file:" + ridC + "/cids/" + cidB: "true",
"file:" + ridC + "/title": "C",
} {
if _, err := db.Exec(`INSERT INTO kv(k, v) VALUES(?, ?)`, k, v); err != nil {
t.Fatal(err)
}
}
db.Close()
truthPath := filepath.Join(dir, "truth.db")
st, err := migrate(options{oldPath: oldPath, truthPath: truthPath, workers: 2})
if err != nil {
t.Fatalf("migrate: %v", err)
}
if st.Rids != 2 || st.RecordsFolded != 1 || st.CIDShared != 2 || st.DupClaims != 1 {
t.Errorf("rids %d folded %d shared %d dups %d", st.Rids, st.RecordsFolded, st.CIDShared, st.DupClaims)
}
out, err := sql.Open("sqlite", "file:"+truthPath+"?mode=ro")
if err != nil {
t.Fatal(err)
}
defer out.Close()
groups := map[string]string{}
rows, err := out.Query(`SELECT cid, rid FROM cid_group`)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var c, r string
if err := rows.Scan(&c, &r); err != nil {
t.Fatal(err)
}
groups[c] = r
}
rows.Close()
if groups[cidSHA] != ridA || groups[cidB] != ridC || len(groups) != 2 {
t.Errorf("cid_group %v", groups)
}
var n int
if err := out.QueryRow(`SELECT count(*) FROM claim WHERE cid = ? AND key IN ('title', 'genres/Drama')`, cidSHA).Scan(&n); err != nil || n != 2 {
t.Errorf("claims on the shared cid: %d (%v)", n, err)
}
var title string
if err := out.QueryRow(`SELECT value FROM claim WHERE cid = ? AND key = 'title'`, cidB).Scan(&title); err != nil || title != "C" {
t.Errorf("record C title on its own cid: %q (%v)", title, err)
}
}
+267
View File
@@ -0,0 +1,267 @@
package main
// Decoding of the retired MMUR1 user-data record (meta-watch record/canonical.rs
// at meta-core v1.2.0) and the private-tier envelope (record/crypto.rs).
//
// record = canonical CBOR array(7) [cid, uid, key, value, version, ts, sig]
// bytes_to_sign = "MMUR1\n" || array(6) [cid, uid, key, value, version, ts]
// sig = DER ECDSA-secp256k1 over SHA-256(bytes_to_sign)
//
// value is CBOR null / bool / int / float / text / bytes (bytes = a private-tier
// XChaCha20-Poly1305 envelope whose plaintext is the CBOR of the typed value).
// The signed bytes are rebuilt from the record's own raw element bytes (array
// header 0x87 → 0x86, sig element cut off), so the old signature is checked
// against exactly what was signed, whatever float width the encoder chose.
import (
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"golang.org/x/crypto/chacha20poly1305"
)
const mmur1Domain = "MMUR1\n"
type cborKind uint8
const (
cNull cborKind = iota
cBool
cInt
cFloat
cText
cBytes
)
// cborItem is one decoded scalar plus its raw encoding.
type cborItem struct {
kind cborKind
b bool
i int64
f float64
s string
bytes []byte
raw []byte
}
// wireText is the MMUR2 text form of a typed value — meta-watch's
// Value::to_wire_text: bools "true"/"false", ints in decimal, floats as Rust's
// f64 Display (shortest round-trip, never an exponent), text verbatim.
func (it cborItem) wireText() (string, bool) {
switch it.kind {
case cBool:
if it.b {
return "true", true
}
return "false", true
case cInt:
return strconv.FormatInt(it.i, 10), true
case cFloat:
return strconv.FormatFloat(it.f, 'f', -1, 64), true
case cText:
return it.s, true
}
return "", false
}
type mmur1Record struct {
CID, UID, Key string
Value cborItem
Version uint64
TS int64
Sig []byte
Signed []byte // the exact MMUR1 bytes the signature covers
}
var errCBOR = errors.New("malformed MMUR1 CBOR")
// readHead parses a CBOR initial byte + argument (definite lengths only).
func readHead(b []byte, off int) (major byte, ai byte, n uint64, next int, err error) {
if off >= len(b) {
return 0, 0, 0, 0, errCBOR
}
ib := b[off]
major, ai = ib>>5, ib&31
off++
var size int
switch {
case ai < 24:
return major, ai, uint64(ai), off, nil
case ai == 24:
size = 1
case ai == 25:
size = 2
case ai == 26:
size = 4
case ai == 27:
size = 8
default:
return 0, 0, 0, 0, fmt.Errorf("%w: indefinite or reserved length", errCBOR)
}
if off+size > len(b) {
return 0, 0, 0, 0, errCBOR
}
for _, x := range b[off : off+size] {
n = n<<8 | uint64(x)
}
return major, ai, n, off + size, nil
}
// readItem decodes one scalar item at off.
func readItem(b []byte, off int) (cborItem, int, error) {
start := off
major, ai, n, off, err := readHead(b, off)
if err != nil {
return cborItem{}, 0, err
}
var it cborItem
switch major {
case 0:
if n > math.MaxInt64 {
return it, 0, fmt.Errorf("%w: int out of range", errCBOR)
}
it = cborItem{kind: cInt, i: int64(n)}
case 1:
if n > math.MaxInt64 {
return it, 0, fmt.Errorf("%w: int out of range", errCBOR)
}
it = cborItem{kind: cInt, i: -1 - int64(n)}
case 2, 3:
if n > uint64(len(b)-off) {
return it, 0, errCBOR
}
end := off + int(n)
if major == 2 {
it = cborItem{kind: cBytes, bytes: append([]byte(nil), b[off:end]...)}
} else {
it = cborItem{kind: cText, s: string(b[off:end])}
}
off = end
case 7:
switch ai {
case 20, 21:
it = cborItem{kind: cBool, b: ai == 21}
case 22:
it = cborItem{kind: cNull}
case 25:
it = cborItem{kind: cFloat, f: halfToFloat(uint16(n))}
case 26:
it = cborItem{kind: cFloat, f: float64(math.Float32frombits(uint32(n)))}
case 27:
it = cborItem{kind: cFloat, f: math.Float64frombits(n)}
default:
return it, 0, fmt.Errorf("%w: unsupported simple value %d", errCBOR, ai)
}
default:
return it, 0, fmt.Errorf("%w: unsupported major type %d", errCBOR, major)
}
it.raw = b[start:off]
return it, off, nil
}
func halfToFloat(h uint16) float64 {
sign := 1.0
if h&0x8000 != 0 {
sign = -1
}
exp := int(h>>10) & 0x1f
frac := float64(h & 0x3ff)
switch exp {
case 0:
return sign * math.Ldexp(frac, -24)
case 31:
if frac == 0 {
return math.Inf(int(sign))
}
return math.NaN()
}
return sign * math.Ldexp(frac+1024, exp-25)
}
// decodeMMUR1 parses a stored record and rebuilds its signed bytes.
func decodeMMUR1(b []byte) (*mmur1Record, error) {
if len(b) == 0 || b[0] != 0x87 {
return nil, fmt.Errorf("%w: want array(7)", errCBOR)
}
off := 1
items := make([]cborItem, 7)
sigStart := 0
for i := range items {
if i == 6 {
sigStart = off
}
it, next, err := readItem(b, off)
if err != nil {
return nil, err
}
items[i], off = it, next
}
if off != len(b) {
return nil, fmt.Errorf("%w: trailing bytes", errCBOR)
}
for i, want := range []cborKind{cText, cText, cText} {
if items[i].kind != want {
return nil, fmt.Errorf("%w: cid/uid/key must be text", errCBOR)
}
}
if items[4].kind != cInt || items[4].i < 0 {
return nil, fmt.Errorf("%w: version must be an unsigned int", errCBOR)
}
if items[5].kind != cInt {
return nil, fmt.Errorf("%w: ts must be an int", errCBOR)
}
if items[6].kind != cBytes {
return nil, fmt.Errorf("%w: sig must be bytes", errCBOR)
}
signed := make([]byte, 0, len(mmur1Domain)+sigStart)
signed = append(signed, mmur1Domain...)
signed = append(signed, 0x86)
signed = append(signed, b[1:sigStart]...)
return &mmur1Record{
CID: items[0].s, UID: items[1].s, Key: items[2].s,
Value: items[3],
Version: uint64(items[4].i),
TS: items[5].i,
Sig: items[6].bytes,
Signed: signed,
}, nil
}
// envelopeAAD is cid ‖ 0x1F ‖ uid ‖ 0x1F ‖ key ‖ 0x1F ‖ n_be(8) — n was the
// MMUR1 version and is the MMUR2 ts.
func envelopeAAD(cid, uid, key string, n uint64) []byte {
b := make([]byte, 0, len(cid)+len(uid)+len(key)+11)
b = append(b, cid...)
b = append(b, 0x1f)
b = append(b, uid...)
b = append(b, 0x1f)
b = append(b, key...)
b = append(b, 0x1f)
return binary.BigEndian.AppendUint64(b, n)
}
// openEnvelope decrypts nonce(24) ‖ ciphertext+tag.
func openEnvelope(aeadKey, env, aad []byte) ([]byte, error) {
a, err := chacha20poly1305.NewX(aeadKey)
if err != nil {
return nil, err
}
if len(env) < chacha20poly1305.NonceSizeX+a.Overhead() {
return nil, errors.New("envelope too short")
}
return a.Open(nil, env[:chacha20poly1305.NonceSizeX], env[chacha20poly1305.NonceSizeX:], aad)
}
// sealEnvelope encrypts plain under a fresh random nonce.
func sealEnvelope(aeadKey, plain, aad []byte, nonce []byte) ([]byte, error) {
a, err := chacha20poly1305.NewX(aeadKey)
if err != nil {
return nil, err
}
out := make([]byte, 0, len(nonce)+len(plain)+a.Overhead())
out = append(out, nonce...)
return a.Seal(out, nonce, plain, aad), nil
}
+31
View File
@@ -0,0 +1,31 @@
# search-parity
Regression check for `POST /api/metadata/search` against a running meta-core.
Since v2.0.0 it reads the merged view from the cache database
(`/meta-core/db-cache/cache.db`), which keeps the v1 `file:<rid>/<field>` layout.
- `parity.py <cache.db>` builds ~230 queries from real records (free text,
short tokens, key-set and flat filters, exact/contains, multi-filter). It
computes each query's expected result by brute force: one streaming pass
over every record, using a port of the Go matchers. Then it diffs that
against the live API. Any `lost` result means the SQL candidate superset
dropped a real match.
- `timing.py [cache.db]` times the same query set at limit 100 and 2000.
Run them inside the container. It has python3 and reads the database
read-only:
```bash
docker cp tools/search-parity metacore-app:/tmp/sp
docker exec metacore-app python3 -u /tmp/sp/parity.py /meta-core/db-cache/cache.db
docker exec metacore-app python3 -u /tmp/sp/timing.py
```
The reference pass takes ~10 min at 169k records. Records written while it
runs can show up as `extra` at a limit-2000 boundary; check their UUIDv7 time
before treating a diff as real.
History: these tools gated the 2026-10 Redis → SQLite cut-over. Results:
0/217 diffs on v1.1.0, and 232/234 on v1.2.0, where the 2 diffs were writes
made during the run. See
`docs/project-architecture/metacore-storage-engine.md` in the meta-root.
+182
View File
@@ -0,0 +1,182 @@
"""Search parity check for meta-core search.
parity.py <cache.db>
Since v2.0.0 the searchable records are the MERGED view, kept in the cache
database's kv table under the v1 layout (file:<rid>/<field>, idx:roots/<rid>):
point this at /meta-core/db-cache/cache.db, not the truth database.
Builds a query set from real records, computes the EXPECTED result of each
query by brute force — one streaming pass over every record in the database,
evaluated with a line-by-line port of the Go matchers (api.recordMatchesFilter,
api.matchAllTokens, storage.Haystack) — and diffs it against what the live
POST /api/metadata/search returns. A miss means the SQL candidate superset
dropped a real match; that is the failure this exists to catch.
"""
import json, random, sqlite3, sys, time, urllib.request
BASE = "http://127.0.0.1:9000"
LIMIT = 2000
DB = sys.argv[1]
def post(body):
req = urllib.request.Request(BASE + "/api/metadata/search", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.time()
with urllib.request.urlopen(req, timeout=300) as resp:
data = json.load(resp)
return [r["hashId"] for r in data["results"]], time.time() - t0
SEARCH_FIELDS = ["title", "fileName", "originaltitle", "showtitle", "filePath"]
def haystack(f):
parts = [f[k].lower() for k in SEARCH_FIELDS if f.get(k)]
names = []
for k, v in f.items():
if k.startswith("titles/") and v not in ("", "false"):
rest = k[len("titles/"):]
if "/" in rest:
name = rest.split("/", 1)[1]
if name:
names.append(name.replace("∕", "/"))
parts += [n.lower() for n in sorted(names)]
return "".join(p + "\n" for p in parts)
def truthy(v):
v = (v or "").strip().lower()
return v not in ("", "false", "0")
def match_filter(f, flt):
values = [v.strip() for v in flt.get("values", []) if v.strip()]
prop = flt["property"]
if not prop or not values:
return True
for v in values:
if truthy(f.get(prop + "/" + v)):
return True
wanted = {v.lower() for v in values}
prefix = prop.lower() + "/"
for k, val in f.items():
if len(k) > len(prefix) and k[:len(prefix)].lower() == prefix:
if k[len(prefix):].lower() in wanted and truthy(val):
return True
if prop not in f:
return False
for piece in f[prop].split(","):
piece = piece.strip()
if not piece:
continue
for v in values:
if flt.get("exact"):
if piece.lower() == v.lower():
return True
elif v.lower() in piece.lower():
return True
return False
def match(rid, f, q):
query = q.get("query", "")
if query:
tokens = query.lower().split()
hay = haystack(f)
if not all(t in hay for t in tokens) and query.lower() not in rid.lower():
return False
return all(match_filter(f, flt) for flt in q.get("filters", []))
def records(db):
cur = db.execute("SELECT k, v FROM kv WHERE k >= 'file:' AND k < 'file;' ORDER BY k")
rid, fields = None, {}
for k, v in cur:
r, sep, field = k[5:].partition("/")
if not sep or not field:
continue
if r != rid:
if rid is not None:
yield rid, fields
rid, fields = r, {}
fields[field] = v
if rid is not None:
yield rid, fields
def build_queries(db):
random.seed(7)
roots = [r for (r,) in db.execute("SELECT substr(k, 11) FROM kv WHERE k >= 'idx:roots/' AND k < 'idx:roots0'")]
sample = []
for rid in random.sample(roots, 80):
f = {k[len(rid) + 6:]: v for k, v in db.execute(
"SELECT k, v FROM kv WHERE k >= ? AND k < ?", (f"file:{rid}/", f"file:{rid}0"))}
sample.append(f)
qs = [{}]
for fld in ("fileType", "contentKind", "domain", "workForm", "videoType", "provenance", "quality", "indexer"):
for v in sorted({m[fld] for m in sample if m.get(fld)})[:3]:
qs.append({"filters": [{"property": fld, "values": [v], "exact": True}]})
qs.append({"filters": [{"property": fld, "values": [v.upper()[:3]]}]})
for m in sample[:30]:
for k in sorted(m):
if k.startswith(("genres/", "categories/", "source/", "titles/")) and k.count("/") == 1:
p, v = k.split("/", 1)
qs.append({"filters": [{"property": p, "values": [v.lower()]}]})
break
for idf in ("tmdbid", "imdbid", "season"):
if m.get(idf):
qs.append({"filters": [{"property": idf, "values": [m[idf]], "exact": True}]})
words = (m.get("title") or "").split()
if words:
w = max(words, key=len)
qs.append({"query": w})
qs.append({"query": w[:2]})
qs.append({"query": " ".join(words[:2])})
if m.get("fileType"):
qs.append({"query": w, "filters": [{"property": "fileType", "values": [m["fileType"]]}]})
for m in sample[30:45]:
words = (m.get("title") or "").split()
if words and m.get("contentKind"):
qs.append({"query": max(words, key=len), "filters": [{"property": "contentKind", "values": [m["contentKind"]], "exact": True}]})
qs.append({"query": max(words, key=len), "filters": [{"property": "fileType", "values": ["video"]}, {"property": "contentKind", "values": [m["contentKind"]]}]})
qs += [{"query": "s01e01"}, {"query": "1080p", "filters": [{"property": "fileType", "values": ["video"], "exact": True}]},
{"filters": [{"property": "genres", "values": ["action", "comedy"]}, {"property": "fileType", "values": ["video"], "exact": True}]}]
for q in qs:
q["limit"] = LIMIT
return qs
def main():
db = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
qs = build_queries(db)
print(f"{len(qs)} queries; computing reference over every record...", flush=True)
t0 = time.time()
expected = [[] for _ in qs]
n = 0
for rid, f in records(db):
n += 1
for i, q in enumerate(qs):
if match(rid, f, q):
expected[i].append(rid)
print(f"reference: {n} records in {time.time() - t0:.0f}s", flush=True)
bad = 0
for q, exp in zip(qs, expected):
exp = sorted(exp)[:LIMIT]
got, secs = post(q)
lost, extra = set(exp) - set(got), set(got) - set(exp)
order_ok = got == sorted(got)
status = "OK " if not lost and not extra and order_ok else "DIFF"
bad += status != "OK "
print(f"{status} want={len(exp):5d} got={len(got):5d} lost={len(lost):4d} extra={len(extra):4d} "
f"{secs*1000:7.1f}ms {json.dumps({k: v for k, v in q.items() if k != 'limit'})[:100]}", flush=True)
if lost:
print(" lost e.g.", sorted(lost)[:4])
if extra:
print(" extra e.g.", sorted(extra)[:4])
print(f"\n{bad} of {len(qs)} queries differ")
main()
+24
View File
@@ -0,0 +1,24 @@
import importlib.util, json, sqlite3, sys, time
spec = importlib.util.spec_from_file_location("parity", "/tmp/parity.py")
sys.argv = ["parity", sys.argv[1] if len(sys.argv) > 1 else "/meta-core/db-cache/cache.db"]
src = open("/tmp/parity.py").read().replace("\nmain()\n", "\n")
ns = {}
exec(compile(src, "parity", "exec"), ns)
db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
qs = ns["build_queries"](db)
for limit in (100, 2000):
times = []
worst = []
for q in qs:
q = dict(q, limit=limit)
try:
_, secs = ns["post"](q)
except Exception as e:
print(" ERROR", e, json.dumps(q)[:150], flush=True)
secs = 99
times.append(secs * 1000)
worst.append((secs * 1000, json.dumps({k: v for k, v in q.items() if k != "limit"})[:90]))
times.sort()
print(f"limit={limit}: n={len(times)} p50={times[len(times)//2]:.0f}ms p90={times[int(len(times)*.9)]:.0f}ms max={times[-1]:.0f}ms")
for t, q in sorted(worst, reverse=True)[:4]:
print(f" {t:7.0f}ms {q}")