Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76169920d6 | ||
|
|
df6a2d78f8 | ||
|
|
0ca03ac95a | ||
|
|
1798821c53 |
@@ -0,0 +1,6 @@
|
|||||||
|
# FIX CRLF always when developer has not set
|
||||||
|
# Linux/Mac: git config --global core.autocrlf input
|
||||||
|
# Windows: git config --global core.autocrlf true
|
||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
|
* eol=lf
|
||||||
@@ -138,7 +138,7 @@ Environment variables consumed by `internal/config/config.go`:
|
|||||||
| `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` if set (used for HTTPS perimeter). |
|
| `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` if set (used for HTTPS perimeter). |
|
||||||
| `REDIS_PORT` | `6379` | Local Redis port. |
|
| `REDIS_PORT` | `6379` | Local Redis port. |
|
||||||
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
||||||
| `META_CORE_HTTP_HOST` | `127.0.0.1` | HTTP bind. |
|
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind. |
|
||||||
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
|
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
|
||||||
| `HEARTBEAT_INTERVAL_MS` | `30000` | Service registry heartbeat. |
|
| `HEARTBEAT_INTERVAL_MS` | `30000` | Service registry heartbeat. |
|
||||||
| `STALE_THRESHOLD_MS` | `60000` | Age past which a registry entry is marked stale. |
|
| `STALE_THRESHOLD_MS` | `60000` | Age past which a registry entry is marked stale. |
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ out to be load-bearing).
|
|||||||
|
|
||||||
meta-core was designed as the sidecar that mediates every metadata read and
|
meta-core was designed as the sidecar that mediates every metadata read and
|
||||||
write — a single point that owns the Redis schema, enforces invariants
|
write — a single point that owns the Redis schema, enforces invariants
|
||||||
(reverse index, canonical_cid reconciliation, schema-version sentinel),
|
(reverse index, `cids/` key-set integrity, schema-version sentinel),
|
||||||
and presents a stable HTTP contract. Other services were meant to talk to
|
and presents a stable HTTP contract. Other services were meant to talk to
|
||||||
meta-core, not to Redis.
|
meta-core, not to Redis.
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ specifically with the Kubernetes flavour where streams are mediated too.
|
|||||||
coordinating releases across every other service. The UUID-root
|
coordinating releases across every other service. The UUID-root
|
||||||
migration is the cautionary tale of what happens without this.
|
migration is the cautionary tale of what happens without this.
|
||||||
- **Validation choke point.** Every write passes through one code path
|
- **Validation choke point.** Every write passes through one code path
|
||||||
that can enforce invariants (reverse index, canonical_cid, type
|
that can enforce invariants (reverse index, `cids/` key-set, type
|
||||||
constraints, schema version).
|
constraints, schema version).
|
||||||
- **Observability.** Audit logs, request metrics, slow-query detection
|
- **Observability.** Audit logs, request metrics, slow-query detection
|
||||||
all live at one layer.
|
all live at one layer.
|
||||||
@@ -697,11 +697,11 @@ contract violation."
|
|||||||
**The fix.** Three layers in `internal/storage/cid_resolution.go`:
|
**The fix.** Three layers in `internal/storage/cid_resolution.go`:
|
||||||
|
|
||||||
1. **`ResolveRoot(hash)`** — at the entry of every `/meta/{hash}`
|
1. **`ResolveRoot(hash)`** — at the entry of every `/meta/{hash}`
|
||||||
read/write handler, look up `cid:midhash256:<hash>` in the reverse
|
read/write handler, look up `cid:<hash>` in the reverse index (bare
|
||||||
index. If found, use the UUID as the storage root. Falls through to
|
CID — no algorithm prefix). If found, use the UUID as the storage root.
|
||||||
the bare hash for direct-UUID writes / legacy entries.
|
Falls through to the bare hash for direct-UUID writes / legacy entries.
|
||||||
2. **Self-pointing alias guard in `addAliasLocked`** — refuse to
|
2. **Self-pointing alias guard in `addAliasLocked`** — refuse to
|
||||||
register `cid:<algo>:<v>` → `<v>`. The dual-root bug's chicken-and-
|
register `cid:<v>` → `<v>`. The dual-root bug's chicken-and-
|
||||||
egg is: meta-sort writes first, registers self-pointing alias,
|
egg is: meta-sort writes first, registers self-pointing alias,
|
||||||
watcher then can't recover. Refusing the self-write keeps the
|
watcher then can't recover. Refusing the self-write keeps the
|
||||||
watcher's legitimate alias intact.
|
watcher's legitimate alias intact.
|
||||||
|
|||||||
@@ -221,12 +221,13 @@ operators wipe with `pnpm run clean:all` and restart.
|
|||||||
│ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │
|
│ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │
|
||||||
│ Storage: flat string keys │
|
│ Storage: flat string keys │
|
||||||
│ file:<uuid>/<property> → string value │
|
│ file:<uuid>/<property> → string value │
|
||||||
│ file:<uuid>/cids → SET of CID tokens │
|
│ file:<uuid>/cids/<bareCid> → "true" (key-set) │
|
||||||
│ file:<uuid>/canonical_cid → preferred CID │
|
|
||||||
│ file:<uuid>/duplicates → SET of paths │
|
│ file:<uuid>/duplicates → SET of paths │
|
||||||
│ │
|
│ │
|
||||||
|
│ No stored canonical_cid — derived by rank on read. │
|
||||||
|
│ │
|
||||||
│ Aliases (reverse index, one Redis STRING each): │
|
│ Aliases (reverse index, one Redis STRING each): │
|
||||||
│ cid:<algorithm>:<value> → <uuid> │
|
│ cid:<bareCid> → <uuid> │
|
||||||
│ │
|
│ │
|
||||||
│ Index: file:__index__ → SET of all UUIDs │
|
│ Index: file:__index__ → SET of all UUIDs │
|
||||||
│ │
|
│ │
|
||||||
@@ -515,7 +516,7 @@ via `/health`; metadata I/O and SSE event streams stop.
|
|||||||
| `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). |
|
| `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). |
|
||||||
| `REDIS_PORT` | `6379` | Local Redis port. |
|
| `REDIS_PORT` | `6379` | Local Redis port. |
|
||||||
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
|
||||||
| `META_CORE_HTTP_HOST` | `127.0.0.1` | HTTP bind address. |
|
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind address. |
|
||||||
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
|
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
|
||||||
| `HEARTBEAT_INTERVAL_MS` | `30000` | Service registry heartbeat. |
|
| `HEARTBEAT_INTERVAL_MS` | `30000` | Service registry heartbeat. |
|
||||||
| `STALE_THRESHOLD_MS` | `60000` | Age past which a registry entry is stale. |
|
| `STALE_THRESHOLD_MS` | `60000` | Age past which a registry entry is stale. |
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ For the external-access contract (HTTP-only, SSE for events), see
|
|||||||
`01JKR8XW5T4QMV7AHNJ2DEFGHK`.
|
`01JKR8XW5T4QMV7AHNJ2DEFGHK`.
|
||||||
- **Properties are flat Redis STRING keys**, one per leaf:
|
- **Properties are flat Redis STRING keys**, one per leaf:
|
||||||
`file:<uuid>/<property/path>` → string value. *Not* Redis Hashes.
|
`file:<uuid>/<property/path>` → string value. *Not* Redis Hashes.
|
||||||
- **CIDs are reverse-index aliases**: `cid:<algorithm>:<value>` → `<uuid>`
|
- **CIDs are reverse-index aliases**: `cid:<bareCid>` → `<uuid>`
|
||||||
(a Redis STRING per known CID).
|
(a Redis STRING per known CID; no algorithm prefix — the multicodec
|
||||||
- **Per-root sets**: `file:<uuid>/cids` (every CID), `file:<uuid>/duplicates`
|
already carries it).
|
||||||
|
- **Per-root sets**: `file:<uuid>/cids/<bareCid>` (a *key-set*: one STRING
|
||||||
|
`"true"` per known CID, midhash included), `file:<uuid>/duplicates`
|
||||||
(alternate paths with the same content), plus `file:__index__` (every UUID).
|
(alternate paths with the same content), plus `file:__index__` (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
|
- **A schema-version sentinel** (`meta-core:schema-version`) refuses to
|
||||||
boot against legacy data.
|
boot against legacy data.
|
||||||
|
|
||||||
@@ -59,37 +62,54 @@ file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = "bafkreih...
|
|||||||
|
|
||||||
### Reverse-index aliases (CIDs)
|
### Reverse-index aliases (CIDs)
|
||||||
|
|
||||||
Every CID known for a file is registered as its own Redis STRING:
|
Every CID known for a file is registered as its own Redis STRING, 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):
|
||||||
|
|
||||||
```
|
```
|
||||||
cid:<algorithm>:<value> → <uuid>
|
cid:<bareCid> → <uuid>
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```
|
```
|
||||||
cid:midhash256:bafkreih5kapbjzq... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
cid:bafkreih5kapbjzq... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
||||||
cid:sha256:abc... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
cid:bagacbabaec7v3... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
||||||
cid:ipfs:bafy... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The reverse index makes `GET /api/meta/<cid>` an O(1) lookup for any
|
The reverse index makes `GET /api/meta/<cid>` an O(1) lookup for any
|
||||||
registered CID, independent of which hash algorithm "primary" — there is
|
registered CID, independent of which hash algorithm — there is no primary.
|
||||||
no primary. The auto-alias hooks in `SetProperty` and `SetMetadataFlat`
|
The auto-alias hook (`maybeAddAliasFromFieldLocked` in
|
||||||
(see `internal/storage/cid_resolution.go`) catch any write of a `cid_*`
|
`internal/storage/cid_resolution.go`, called from `SetProperty`,
|
||||||
or `midhash256` field and register the alias automatically, so plugins
|
`SetMetadataFlat` and `MergeMetadataFlat`) registers the alias for any
|
||||||
don't need to know about the reverse index.
|
field whose name starts with `cids/`, 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
|
### Per-root sets
|
||||||
|
|
||||||
```
|
```
|
||||||
file:<uuid>/cids → Redis SET of "<algorithm>:<value>" tokens
|
file:<uuid>/cids/<bareCid> → STRING "true" — one key per known CID (a key-set, not a Redis SET)
|
||||||
file:<uuid>/canonical_cid → STRING — the highest-ranked CID for advertising externally
|
|
||||||
file:<uuid>/duplicates → Redis SET of alternate paths with same content
|
file:<uuid>/duplicates → Redis SET of alternate paths with same content
|
||||||
```
|
```
|
||||||
|
|
||||||
`canonical_cid` is reconciled by `reconcileCanonicalCIDLocked` whenever
|
`cids` is a **key-set**: one flat `file:<uuid>/cids/<cid>` STRING per member,
|
||||||
the `cids` set changes; ranking is done by `internal/cid/rank.go`.
|
which keeps it conflict-free to merge across peers (a SET 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
|
### Global index
|
||||||
|
|
||||||
@@ -141,14 +161,15 @@ The Go API in `internal/storage/client.go`:
|
|||||||
| Method | Behaviour |
|
| Method | Behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GetMetadataFlat(uuid)` | `SCAN` for `file:<uuid>/*`, `MGET` the values, return `map[string]string`. |
|
| `GetMetadataFlat(uuid)` | `SCAN` for `file:<uuid>/*`, `MGET` the values, return `map[string]string`. |
|
||||||
| `SetMetadataFlat(uuid, m)` | Pipeline `SET` per field; `SADD file:__index__`; auto-register any `cid_*` aliases. |
|
| `SetMetadataFlat(uuid, m)` | Pipeline `SET` per field; `SADD file:__index__`; auto-register an alias for every `cids/<cid>` member. |
|
||||||
| `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. |
|
| `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. |
|
||||||
| `GetProperty(uuid, field)` | `GET file:<uuid>/<field>`. |
|
| `GetProperty(uuid, field)` | `GET file:<uuid>/<field>`. |
|
||||||
| `SetProperty(uuid, field, value)` | `SET file:<uuid>/<field>`; auto-register alias if the field is a CID. |
|
| `SetProperty(uuid, field, value)` | `SET file:<uuid>/<field>`; auto-register alias if the field is a CID. |
|
||||||
| `DeleteRoot(uuid)` | `SCAN` + `DEL` for `file:<uuid>/*`, unmap every reverse-index entry. |
|
| `DeleteRoot(uuid)` | `SCAN` + `DEL` for `file:<uuid>/*`, unmap every reverse-index entry. |
|
||||||
| `Mint(filePath, size, mtimeNano)` | `NewUUID()` + write `filePath/sizeByte/mtimeNano` + add to index. |
|
| `Mint(filePath, size, mtimeNano)` | `NewUUID()` + write `filePath/sizeByte/mtimeNano` + add to index. |
|
||||||
| `AddAlias(uuid, "<algo>:<value>")` | Set `cid:<algo>:<value>` → `<uuid>`, add to `file:<uuid>/cids`, reconcile `canonical_cid`. |
|
| `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. |
|
||||||
| `ResolveCID("<algo>:<value>")` | `GET cid:<algo>:<value>` → uuid. |
|
| `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}`. |
|
||||||
|
|
||||||
## Reconstruction (flat → nested)
|
## Reconstruction (flat → nested)
|
||||||
|
|
||||||
@@ -237,10 +258,14 @@ docker exec metacore-app redis-cli GET 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo
|
|||||||
docker exec metacore-app redis-cli TYPE 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath'
|
docker exec metacore-app redis-cli TYPE 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath'
|
||||||
# string
|
# string
|
||||||
|
|
||||||
# Reverse-index lookups
|
# Reverse-index lookup (bare CID — no algorithm prefix)
|
||||||
docker exec metacore-app redis-cli GET 'cid:midhash256:bafkreih...' # → uuid
|
docker exec metacore-app redis-cli GET 'cid:bafkreih...' # → uuid
|
||||||
docker exec metacore-app redis-cli SMEMBERS 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids'
|
|
||||||
docker exec metacore-app redis-cli GET 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/canonical_cid'
|
# The cids key-set (one STRING per member, NOT a Redis SET — SMEMBERS won't work)
|
||||||
|
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/*'
|
||||||
|
|
||||||
|
# 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
|
# Schema-version sentinel
|
||||||
docker exec metacore-app redis-cli GET 'meta-core:schema-version'
|
docker exec metacore-app redis-cli GET 'meta-core:schema-version'
|
||||||
|
|||||||
@@ -4,6 +4,25 @@
|
|||||||
|
|
||||||
**Status:** Implemented (2026 alpha clean-wipe gate landed). Kept for design rationale.
|
**Status:** Implemented (2026 alpha clean-wipe gate landed). Kept for design rationale.
|
||||||
|
|
||||||
|
> ⚠ **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.
|
||||||
|
>
|
||||||
|
> Text below that references either shape describes the *proposal*, not the
|
||||||
|
> code. The rank ordering and everything else did ship as written.
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
Today, every metadata entry in Redis is keyed by a content hash, specifically
|
Today, every metadata entry in Redis is keyed by a content hash, specifically
|
||||||
@@ -79,19 +98,21 @@ today — anything that wrote `file:<midhash>/fileinfo/duration` now writes
|
|||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = /watch/Inception.mkv
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = /watch/Inception.mkv
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/size = 4500000000
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/size = 4500000000
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtime = 1729872000000000000
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtime = 1729872000000000000
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids = SET{midhash256:abc, sha256:def, ipfs:bafy...}
|
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/duplicates = SET{/watch/dup1.mkv, /watch/dup2.mkv}
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/canonical_cid = ipfs:bafy...
|
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = 7200.5
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = 7200.5
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = bafy...
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = bafy...
|
||||||
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/subtitle/eng = ...
|
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/subtitle/eng = ...
|
||||||
|
|
||||||
cid:midhash256:abc → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
cid:bagacbabaec… → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
||||||
cid:sha256:def → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
cid:bafybeigd… → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
||||||
cid:ipfs:bafy... → 01JKR8XW5T4QMV7AHNJ2DEFGHK
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*(As shipped — the proposal originally wrote `cids` as a Redis SET of
|
||||||
|
`<algo>:<value>` tokens plus a `canonical_cid` scalar. See the Status note.)*
|
||||||
|
|
||||||
### UUID format: UUIDv7
|
### UUID format: UUIDv7
|
||||||
|
|
||||||
UUIDv7 chosen over UUIDv4 or 64-bit random:
|
UUIDv7 chosen over UUIDv4 or 64-bit random:
|
||||||
@@ -111,26 +132,33 @@ 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
|
`file:<uuid>/`. This proposal does not change that. It adds three keys whose
|
||||||
sole writer is meta-core itself:
|
sole writer is meta-core itself:
|
||||||
|
|
||||||
| Key | Type | Purpose |
|
| Key | Type (as shipped) | Purpose |
|
||||||
|-----|------|---------|
|
|-----|------|---------|
|
||||||
| `cids` | Redis SET | All CIDs known for this file. Mirror of the `cid:*` reverse index. Source of truth for delete-time GC. |
|
| `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 | 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. |
|
| `duplicates` | Redis SET | 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` | STRING | The CID this entry should be presented as externally (meta-share announce, UI display). Selected by the rank function (below). |
|
| ~~`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`)
|
Existing meta-core-owned fields (`filePath`, `size`, `mtime`, `midhash256`)
|
||||||
keep their names and writers. Plugin-written fields (`tmdb/*`, `fileinfo/*`,
|
keep their names and writers. Plugin-written fields (`tmdb/*`, `fileinfo/*`,
|
||||||
`subtitle/<lang>`, …) are unaffected.
|
`subtitle/<lang>`, …) are unaffected.
|
||||||
|
|
||||||
Redis SET is a departure from the all-strings convention in the current
|
~~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
|
storage doc, but `file:__index__` is already a SET — so the precedent exists
|
||||||
for using sets where they're the right structure.
|
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).
|
||||||
|
|
||||||
### Canonical CID selection
|
### Canonical CID selection
|
||||||
|
|
||||||
`canonical_cid` is a label, not a key. Updating it costs one write — no
|
The canonical CID is a label, not a key — so nothing external breaks when it
|
||||||
rename, no race, no broken external references.
|
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:
|
Selection rule, highest-wins (this part shipped as designed —
|
||||||
|
`internal/cid/rank.go`):
|
||||||
|
|
||||||
1. IPFS CID (any variant)
|
1. IPFS CID (any variant)
|
||||||
2. SHA-256 / SHA-3 family
|
2. SHA-256 / SHA-3 family
|
||||||
@@ -142,8 +170,10 @@ midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are
|
|||||||
externally meaningful for content addressing. btih ranks above midhash because
|
externally meaningful for content addressing. btih ranks above midhash because
|
||||||
it enables swarm interop even if it's weaker as a digest.
|
it enables swarm interop even if it's weaker as a digest.
|
||||||
|
|
||||||
Reconciler runs on the leader, recomputes `canonical_cid` whenever
|
~~Reconciler runs on the leader, recomputes `canonical_cid` whenever
|
||||||
`AddAlias` registers a new CID for an entry.
|
`AddAlias` registers a new CID for an entry.~~ **Not shipped** — there is no
|
||||||
|
reconciler. `GetMetadataDocument` folds the members through `cid.Better` on
|
||||||
|
each read.
|
||||||
|
|
||||||
## Internal operations
|
## Internal operations
|
||||||
|
|
||||||
@@ -157,8 +187,8 @@ CID-based API.
|
|||||||
Mint(filePath string, size int64, mtime int64) (uuid string, err error)
|
Mint(filePath string, size int64, mtime int64) (uuid string, err error)
|
||||||
|
|
||||||
// AddAlias registers a CID as resolving to this UUID. Updates both the
|
// AddAlias registers a CID as resolving to this UUID. Updates both the
|
||||||
// cid:<cid> reverse index and the file:<uuid>/cids set atomically.
|
// cid:<cid> reverse index and the file:<uuid>/cids/<cid> key-set member.
|
||||||
// Triggers canonical_cid reconciliation.
|
// (As shipped: no reconciliation step — canonical is derived on read.)
|
||||||
AddAlias(uuid string, cid string) error
|
AddAlias(uuid string, cid string) error
|
||||||
|
|
||||||
// GetByCID resolves any known CID to its UUID. Returns ("", nil) if unknown.
|
// GetByCID resolves any known CID to its UUID. Returns ("", nil) if unknown.
|
||||||
@@ -199,7 +229,8 @@ DeleteRoot(uuid string) error
|
|||||||
- Plugin reports back: "for CID midhash256:abc..., sha256 is sha256:def...".
|
- Plugin reports back: "for CID midhash256:abc..., sha256 is sha256:def...".
|
||||||
- meta-core resolves midhash256:abc → uuid_Y via GetByCID.
|
- meta-core resolves midhash256:abc → uuid_Y via GetByCID.
|
||||||
- meta-core calls AddAlias(uuid_Y, "sha256:def...").
|
- meta-core calls AddAlias(uuid_Y, "sha256:def...").
|
||||||
- Reconciler updates canonical_cid if sha256 outranks midhash256.
|
- Nothing else to do: the next read of this entry derives a canonical CID
|
||||||
|
of sha2-256, because it outranks the midhash.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lifecycle: file deletion
|
### Lifecycle: file deletion
|
||||||
@@ -250,13 +281,20 @@ so the impact is contained but real.
|
|||||||
client.
|
client.
|
||||||
- **meta-dup**: gains a cleaner data source — duplicates are now explicit in
|
- **meta-dup**: gains a cleaner data source — duplicates are now explicit in
|
||||||
the `duplicates` set, not implicit in colliding writes.
|
the `duplicates` set, not implicit in colliding writes.
|
||||||
- **meta-share**: announces `canonical_cid` rather than midhash. Federated
|
- **meta-search / meta-share**: announce the *derived* canonical CID rather
|
||||||
search responses include all CIDs from the `cids` set so any peer querying
|
than the midhash — each mirrors the Go rank in `ingest::canonical_cid_from_metadata`
|
||||||
by any digest gets a hit.
|
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.
|
- **Plugins**: no change. They speak CIDs only.
|
||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
|
> **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:
|
One-time sweep, idempotent, runs on first boot after upgrade:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -285,7 +323,8 @@ roots and resumes by detecting which step completed (presence of
|
|||||||
| UUID visible externally? | No | Pure meta-core internal. Clients address by CID only. |
|
| 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`. |
|
| 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. |
|
| Canonical CID rank | IPFS > sha-family > btih > midhash | Utility-weighted, not strength-weighted. |
|
||||||
| `cids` and `duplicates` field type | Redis SET | Departure from "all-strings" convention but consistent with the existing `file:__index__` precedent. |
|
| ~~`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. |
|
| Reverse-index format | Flat `cid:<cid>` STRING keys | Simple, scan-friendly, no hot-key contention. |
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
@@ -297,9 +336,10 @@ roots and resumes by detecting which step completed (presence of
|
|||||||
hashing reveals the collision. Worth doing eventually; not required for
|
hashing reveals the collision. Worth doing eventually; not required for
|
||||||
this iteration.
|
this iteration.
|
||||||
|
|
||||||
- **meta-share announce strategy.** Announce only `canonical_cid`, or every
|
- ~~**meta-share announce strategy.**~~ **Settled.** The record carries *every*
|
||||||
entry in `cids`? Maximum discoverability vs. DHT footprint. Decide as part
|
member of `cids` on the wire (`Record.cids`) and addresses itself by the
|
||||||
of the meta-share work, not here.
|
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
|
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` as a
|
||||||
content-streaming endpoint for external IPFS clients? Adjacent to this
|
content-streaming endpoint for external IPFS clients? Adjacent to this
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module github.com/metazla/meta-core
|
|||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alicebob/miniredis/v2 v2.31.1
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
@@ -13,6 +14,8 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
|
||||||
|
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
||||||
|
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
@@ -18,7 +27,10 @@ github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
|||||||
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||||
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
||||||
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
|
||||||
|
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -84,8 +85,45 @@ const (
|
|||||||
ErrSchemaViolation = "schema_violation"
|
ErrSchemaViolation = "schema_violation"
|
||||||
ErrStorageUnavailable = "storage_unavailable"
|
ErrStorageUnavailable = "storage_unavailable"
|
||||||
ErrInternal = "internal"
|
ErrInternal = "internal"
|
||||||
|
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.
|
// defaultSlugFor maps an HTTP status code to the most likely error slug.
|
||||||
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
|
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
|
||||||
// instead of "unknown_root" on 404) call writeErrorSlug directly.
|
// instead of "unknown_root" on 404) call writeErrorSlug directly.
|
||||||
@@ -255,6 +293,10 @@ func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validateFields(w, metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -620,6 +662,10 @@ func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validateFields(w, metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
|
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -697,6 +743,10 @@ func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validateFields(w, map[string]string{key: ""}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
return
|
return
|
||||||
@@ -790,6 +840,10 @@ func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validateFields(w, map[string]string{key: ""}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !s.storage.IsConnected() {
|
if !s.storage.IsConnected() {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same resolution as the PUT below: callers hold a CID, not the root.
|
||||||
|
hashID = s.storage.ResolveRoot(hashID)
|
||||||
|
|
||||||
metadata, err := s.storage.GetMetadataFlat(hashID)
|
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -227,6 +230,17 @@ func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
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
|
var metadata map[string]string
|
||||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
@@ -263,6 +277,12 @@ func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
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)
|
deletedCount, err := s.storage.DeleteMetadata(hashID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -201,6 +201,15 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/api/kv/value", s.handleKVValueDelete).Methods("DELETE")
|
s.router.HandleFunc("/api/kv/value", s.handleKVValueDelete).Methods("DELETE")
|
||||||
s.router.HandleFunc("/api/kv/key/{key:.*}", s.handleKVGetKey).Methods("GET")
|
s.router.HandleFunc("/api/kv/key/{key:.*}", s.handleKVGetKey).Methods("GET")
|
||||||
|
|
||||||
|
// User Data Layer (UDL) routes — per-user signed records (My List,
|
||||||
|
// Continue Watching, ratings, profile). See internal/api/udl.go.
|
||||||
|
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT")
|
||||||
|
s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET")
|
||||||
|
|
||||||
// Metadata operations - base endpoints
|
// Metadata operations - base endpoints
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// User Data Layer (UDL) HTTP surface.
|
||||||
|
//
|
||||||
|
// 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 Redis model.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDLPutResponse is the response of PUT /api/udl/record.
|
||||||
|
type UDLPutResponse struct {
|
||||||
|
Accepted bool `json:"accepted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDLRecordResponse is the response of GET /api/udl/record.
|
||||||
|
type UDLRecordResponse struct {
|
||||||
|
Record string `json:"record"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type UDLAggregate struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Avg *float64 `json:"avg"`
|
||||||
|
Trues int `json:"trues"`
|
||||||
|
SampleSize int `json:"sample_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUDLRecordGet handles GET /api/udl/record?uid&cid&key
|
||||||
|
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
uid, cid, key := q.Get("uid"), q.Get("cid"), q.Get("key")
|
||||||
|
if uid == "" || cid == "" || key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "uid, cid and key are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cell, exists, err := s.storage.UDLGetCell(uid, cid, key)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, UDLRecordResponse{
|
||||||
|
Record: cell.Record,
|
||||||
|
Version: cell.Version,
|
||||||
|
Ts: cell.Ts,
|
||||||
|
Exists: exists,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UDLPutRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
|
||||||
|
// (active_for_user_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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
uid, key := vars["uid"], vars["key"]
|
||||||
|
if uid == "" || 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}
|
||||||
|
// (list_for_user_cid).
|
||||||
|
func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
uid, cid := vars["uid"], vars["cid"]
|
||||||
|
if uid == "" || 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users (uids_for_cid).
|
||||||
|
func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cid := mux.Vars(r)["cid"]
|
||||||
|
if cid == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "cid is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.storage.UDLCidUsers(cid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
func (s *Server) handleUDLAggregate(w http | ||||||