From 0b3620890305c1c11666f24e681dbb68a5c3acd2 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:42:36 +0200 Subject: [PATCH] Add shared admission validation --- .changeset/admission-validation.md | 5 + apps/benchmark/README.md | 26 +- .../calendar-runtime-comparison.json | 60 ++ .../calendar-validation-comparison.json | 83 ++ .../calendar/app/bsky/actor/profile.json | 66 ++ .../calendar/app/bsky/graph/follow.json | 29 + .../calendar/com/atproto/label/defs.json | 159 ++++ .../calendar/com/atproto/repo/strongRef.json | 21 + .../community/lexicon/calendar/event.json | 142 ++++ .../community/lexicon/calendar/rsvp.json | 43 + .../community/lexicon/location/address.json | 40 + .../community/lexicon/location/fsq.json | 28 + .../community/lexicon/location/geo.json | 27 + .../community/lexicon/location/hthree.json | 22 + apps/benchmark/package.json | 4 +- apps/benchmark/src/bench.ts | 96 ++- packages/contrail/README.md | 29 +- packages/contrail/package.json | 6 +- packages/contrail/src/adapters/postgres.ts | 2 + packages/contrail/src/contrail.ts | 8 + packages/contrail/src/core/backfill.ts | 37 + packages/contrail/src/core/constellation.ts | 66 +- packages/contrail/src/core/db/records.ts | 69 +- packages/contrail/src/core/db/schema.ts | 7 +- packages/contrail/src/core/diagnostics.ts | 85 ++ packages/contrail/src/core/ingest.ts | 166 +++- packages/contrail/src/core/router/profiles.ts | 133 +-- packages/contrail/src/core/types.ts | 21 +- packages/contrail/src/core/validation.ts | 152 ++++ packages/contrail/src/index.ts | 3 + .../contrail/tests/backfill-status.test.ts | 76 ++ .../benchmark-validation-fixture.test.ts | 40 + .../tests/constellation-ingest.test.ts | 190 +++++ packages/contrail/tests/ingest.test.ts | 6 + .../tests/postgres-concurrent-init.test.ts | 2 +- packages/contrail/tests/postgres-e2e.test.ts | 2 +- packages/contrail/tests/postgres.test.ts | 2 +- .../contrail/tests/profiles-ingest.test.ts | 198 +++++ packages/contrail/tests/schema.test.ts | 1 + .../contrail/tests/validation-paths.test.ts | 155 ++++ packages/contrail/tests/validation.test.ts | 297 +++++++ pnpm-lock.yaml | 763 +++++++++++++++++- 42 files changed, 3202 insertions(+), 165 deletions(-) create mode 100644 .changeset/admission-validation.md create mode 100644 apps/benchmark/baselines/calendar-runtime-comparison.json create mode 100644 apps/benchmark/baselines/calendar-validation-comparison.json create mode 100644 apps/benchmark/lexicons/calendar/app/bsky/actor/profile.json create mode 100644 apps/benchmark/lexicons/calendar/app/bsky/graph/follow.json create mode 100644 apps/benchmark/lexicons/calendar/com/atproto/label/defs.json create mode 100644 apps/benchmark/lexicons/calendar/com/atproto/repo/strongRef.json create mode 100644 apps/benchmark/lexicons/calendar/community/lexicon/calendar/event.json create mode 100644 apps/benchmark/lexicons/calendar/community/lexicon/calendar/rsvp.json create mode 100644 apps/benchmark/lexicons/calendar/community/lexicon/location/address.json create mode 100644 apps/benchmark/lexicons/calendar/community/lexicon/location/fsq.json create mode 100644 apps/benchmark/lexicons/calendar/community/lexicon/location/geo.json create mode 100644 apps/benchmark/lexicons/calendar/community/lexicon/location/hthree.json create mode 100644 packages/contrail/src/core/diagnostics.ts create mode 100644 packages/contrail/src/core/validation.ts create mode 100644 packages/contrail/tests/benchmark-validation-fixture.test.ts create mode 100644 packages/contrail/tests/constellation-ingest.test.ts create mode 100644 packages/contrail/tests/profiles-ingest.test.ts create mode 100644 packages/contrail/tests/validation-paths.test.ts create mode 100644 packages/contrail/tests/validation.test.ts diff --git a/.changeset/admission-validation.md b/.changeset/admission-validation.md new file mode 100644 index 0000000..06f16c2 --- /dev/null +++ b/.changeset/admission-validation.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +Add opt-in strict runtime Lexicon validation and canonical DAG-CBOR CID verification to the shared ingestion path. Route profile enrichment and Constellation follows through the same admission, source-ordering, projection, and sink behavior; expose bounded aggregate rejection diagnostics; and keep bulk backfill efficient by prefiltering out-of-scope dependencies, treating successful PDS pages as authoritative observations, and flushing diagnostics once per run. diff --git a/apps/benchmark/README.md b/apps/benchmark/README.md index b824aa7..a32244d 100644 --- a/apps/benchmark/README.md +++ b/apps/benchmark/README.md @@ -10,6 +10,21 @@ pnpm bench --config calendar.config.json `calendar.config.json` mirrors the retained indexing shape from `11-atproto/02-atmo-rsvp`: calendar events, RSVPs, profiles, follows/feeds, query indexes, relation counts, and event full-text search. Removed product modules and read-only pipeline handlers are intentionally absent because they do not participate in indexing. External sinks are also omitted so the benchmark measures Contrail and D1 rather than Meilisearch latency. +To run the same full workload with strict runtime Lexicon validation and canonical DAG-CBOR CID verification enabled: + +```bash +pnpm bench --config calendar.config.json \ + --validation-lexicons apps/benchmark/lexicons/calendar +``` + +The validated run uses the same config, scheduling limits, and fresh-D1 lifecycle. Its result filename gains `-validated`, records the Lexicon directory/document count, and includes aggregate rejection diagnostics. Compare validation overhead only against an adjacent run with identical limits; live-network differences still need alternating repetitions before they are treated as stable. + +## Pinned benchmark runtime + +This benchmark app pins Wrangler 4.84.1 and its compatible Workers types. Wrangler 4.118.0/workerd 1.20260730.1 made the matched records-only local-D1 workload roughly 2.4× slower than workerd 1.20260421.1, including HTTP and identity phases that Contrail's source-ordering code does not control. Other example and deployment apps remain on the current Wrangler release. + +Do not update the benchmark runtime as ordinary dependency housekeeping. Rebaseline it deliberately, record both Wrangler and workerd versions, and run old/new Contrail code under the same runtime before attributing a change to Contrail. Every new result includes Node, Wrangler, and workerd versions for this reason. + For a narrow source/storage comparison, `calendar-records-only.config.json` indexes only calendar events and RSVPs. It explicitly disables profiles, follows, feeds, FTS, relation counts, field-query indexes, and Constellation. The retained [`calendar-records-only-comparison.json`](baselines/calendar-records-only-comparison.json) compares this workload with HappyView using matched 100-resolution, 10-PDS, and 3-DID limits; it also records Contrail's validated 20-PDS D1 setting. ## Comparing concurrency @@ -28,7 +43,7 @@ The current defaults are 100 concurrent identity resolutions, 20 active PDS host Before every run the harness recursively deletes its config/concurrency-specific `.cache` directory. It disposes and deletes the local D1 afterward as well; pass `--keep-cache` only for debugging. -Results are written to ignored JSON files under `results/`. Selected reference runs live in `baselines/`: [`calendar-default.json`](baselines/calendar-default.json) is the original 774.49-second global-concurrency run, [`calendar-host-aware.json`](baselines/calendar-host-aware.json) is the 219.74-second host-aware result with set-based derived projection rebuilds, and [`calendar-pipelined.json`](baselines/calendar-pipelined.json) is the comparable 134.98-second result after streaming identity resolution and atomically checkpointing projected pages. +Results are written to ignored JSON files under `results/`. Selected reference runs live in `baselines/`: [`calendar-default.json`](baselines/calendar-default.json) is the original 774.49-second global-concurrency run, [`calendar-host-aware.json`](baselines/calendar-host-aware.json) is the 219.74-second host-aware result with set-based derived projection rebuilds, and [`calendar-pipelined.json`](baselines/calendar-pipelined.json) is the comparable 134.98-second result after streaming identity resolution and atomically checkpointing projected pages. [`calendar-runtime-comparison.json`](baselines/calendar-runtime-comparison.json) isolates the local workerd regression from Contrail changes, while [`calendar-validation-comparison.json`](baselines/calendar-validation-comparison.json) retains the final adjacent validation-disabled/enabled pair. Each result includes: @@ -36,9 +51,16 @@ Each result includes: - accepted and indexed records; - records per second; - peak RSS; -- account and per-collection backfill state; and +- exact Node, Wrangler, and workerd versions; +- account and per-collection backfill state; +- bounded aggregate ingest rejection diagnostics; +- validation/CID settings when enabled; and - the exact resolution, PDS-host, per-PDS account, and attempt settings. +## Validation fixtures + +`lexicons/calendar/` contains the exact record schemas needed by the full calendar workload, including transitive references. The `community.lexicon.*` documents mirror the RSVP application's pulled Lexicons. The `app.bsky.*` and `com.atproto.*` documents come from Atcute's 0BSD-licensed definition packages. They are checked in so validation benchmarks do not depend on a mutable registry during a timed run. + ## Adding configs Put portable JSON `ContrailConfig` files in `configs/`. The harness also accepts absolute paths or paths relative to the current directory. JSON configs cannot contain callbacks, custom query functions, or sinks; those should be omitted or represented by a benchmark-specific code harness when they materially affect ingestion. diff --git a/apps/benchmark/baselines/calendar-runtime-comparison.json b/apps/benchmark/baselines/calendar-runtime-comparison.json new file mode 100644 index 0000000..36a9dd7 --- /dev/null +++ b/apps/benchmark/baselines/calendar-runtime-comparison.json @@ -0,0 +1,60 @@ +{ + "format": "contrail.runtime-comparison-benchmark", + "version": 1, + "observed_at": "2026-08-05T04:18:19Z", + "workload": { + "backend": "wrangler-local-d1", + "config": "configs/calendar-records-only.config.json", + "discovered_accounts": 1633, + "options": { + "identity_concurrency": 100, + "pds_concurrency": 20, + "dids_per_pds": 3, + "max_immediate_attempts": 1 + } + }, + "release_code_pinned_runtime": { + "contrail_commit": "aec1ac8fed1a9a83520cb99764484c7cca9bd2c0", + "wrangler": "4.84.1", + "workerd": "1.20260421.1", + "result": "results/calendar-records-only-r100-h20-d3-2026-08-05T00-08-33-355Z.json", + "backfill_ms": 48559.97, + "full_harness_ms": 50281.28, + "fetched_records": 20878, + "indexed_records": 20878 + }, + "current_code_pinned_runtime": { + "wrangler": "4.84.1", + "workerd": "1.20260421.1", + "result": "results/calendar-records-only-r100-h20-d3-2026-08-05T04-18-18-554Z.json", + "backfill_ms": 50521.06, + "full_harness_ms": 54923.52, + "fetched_records": 20877, + "indexed_records": 20877 + }, + "release_code_new_runtime": { + "contrail_commit": "aec1ac8fed1a9a83520cb99764484c7cca9bd2c0", + "wrangler": "4.118.0", + "workerd": "1.20260730.1", + "result": "results/calendar-records-only-r100-h20-d3-2026-08-05T04-16-54-348Z.json", + "backfill_ms": 107830, + "full_harness_ms": 110540, + "fetched_records": 20876, + "indexed_records": 20876 + }, + "current_code_new_runtime": { + "wrangler": "4.118.0", + "workerd": "1.20260730.1", + "result": "results/calendar-records-only-r100-h20-d3-2026-08-05T04-12-43-631Z.json", + "backfill_ms": 129568.12, + "full_harness_ms": 132081.27, + "fetched_records": 20739, + "indexed_records": 20739 + }, + "notes": [ + "All four rows use the same backend, fixture, and 100x20x3 scheduler limits. Live source totals differ by at most 139 records (0.67%).", + "The inverse runtime/code checks isolate Wrangler/workerd from Contrail source changes: release code took 50.28 seconds on the pinned runtime and 110.54 seconds on the newer runtime; current code took 54.92 and 132.08 seconds respectively.", + "The current durable ordering path adds a modest expected write cost under the pinned runtime. Most of the apparent post-0.13 local-D1 regression came from the benchmark runtime upgrade, not runtime Lexicon validation or source ordering.", + "The benchmark app pins Wrangler 4.84.1/workerd 1.20260421.1 for continuity. Other deployment and example apps remain on current Wrangler releases." + ] +} diff --git a/apps/benchmark/baselines/calendar-validation-comparison.json b/apps/benchmark/baselines/calendar-validation-comparison.json new file mode 100644 index 0000000..9bafe42 --- /dev/null +++ b/apps/benchmark/baselines/calendar-validation-comparison.json @@ -0,0 +1,83 @@ +{ + "format": "contrail.validation-comparison-benchmark", + "version": 1, + "observed_at": "2026-08-05T04:40:22Z", + "workload": { + "backend": "wrangler-local-d1", + "config": "configs/calendar.config.json", + "discovered_accounts": 1633, + "runtime": { + "node": "v26.5.0", + "wrangler": "4.84.1", + "workerd": "1.20260421.1" + }, + "options": { + "identity_concurrency": 100, + "pds_concurrency": 20, + "dids_per_pds": 3, + "max_immediate_attempts": 1 + } + }, + "validation_disabled": { + "result": "results/calendar-r100-h20-d3-2026-08-05T04-26-34-203Z.json", + "backfill_ms": 148081.06, + "full_harness_ms": 153173.06, + "source_requests": 17380, + "fetched_records": 842689, + "accepted_records": 87275, + "indexed_records": 87266, + "accepted_records_per_second": 589.37, + "peak_rss_kib": 502672, + "records": { + "community.lexicon.calendar.event": 14610, + "community.lexicon.calendar.rsvp": 6275, + "app.bsky.actor.profile": 1401, + "app.bsky.graph.follow": 64980 + }, + "accounts": { + "complete": 1588, + "retrying": 45, + "failed": 0 + } + }, + "validated": { + "runtime_lexicons": 10, + "strict": true, + "verify_cid": true, + "result": "results/calendar-validated-r100-h20-d3-2026-08-05T04-40-21-241Z.json", + "backfill_ms": 145360.78, + "full_harness_ms": 148222.14, + "source_requests": 17398, + "fetched_records": 844253, + "accepted_records": 75038, + "indexed_records": 75029, + "accepted_records_per_second": 516.22, + "peak_rss_kib": 588592, + "records": { + "community.lexicon.calendar.event": 4833, + "community.lexicon.calendar.rsvp": 3780, + "app.bsky.actor.profile": 1382, + "app.bsky.graph.follow": 65034 + }, + "rejections": { + "lexicon_validation": 12291, + "cid_mismatch": 0, + "cid_encoding": 0, + "missing_cid": 0, + "unknown_subject": 756922, + "superseded": 2 + }, + "accounts": { + "complete": 1591, + "retrying": 42, + "failed": 0 + } + }, + "notes": [ + "These are fresh-D1 live-network runs using the same built package, pinned runtime, config, and 100x20x3 scheduling limits. A rate-limited intermediate run was discarded before this pair was retained.", + "The source workloads were closely matched: fetched records differed by 1,564 (0.185%) and source requests differed by 18 (0.104%). Neither retained run received HTTP 429 responses.", + "The validated run finished 4.95 seconds sooner, but this is not a validation speedup: it rejected 12,291 records and therefore performed materially fewer canonical and derived writes. A deterministic replay fixture is required to isolate validation CPU cost.", + "Accepted-record throughput was 516.22/s with validation and 589.37/s without it. Peak RSS was 85,920 KiB higher in the validated process; treat one live-run memory sample as directional rather than a stable bound.", + "CID verification found no mismatches, encoding failures, or missing authoritative CIDs." + ] +} diff --git a/apps/benchmark/lexicons/calendar/app/bsky/actor/profile.json b/apps/benchmark/lexicons/calendar/app/bsky/actor/profile.json new file mode 100644 index 0000000..0a63580 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/app/bsky/actor/profile.json @@ -0,0 +1,66 @@ +{ + "id": "app.bsky.actor.profile", + "defs": { + "main": { + "key": "literal:self", + "type": "record", + "record": { + "type": "object", + "properties": { + "avatar": { + "type": "blob", + "accept": ["image/jpeg", "image/png"], + "maxSize": 1000000, + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" + }, + "banner": { + "type": "blob", + "accept": ["image/jpeg", "image/png"], + "maxSize": 1000000, + "description": "Larger horizontal image to display behind profile view." + }, + "labels": { + "refs": ["com.atproto.label.defs#selfLabels"], + "type": "union", + "description": "Self-label values, specific to the Bluesky application, on the overall account." + }, + "website": { + "type": "string", + "format": "uri" + }, + "pronouns": { + "type": "string", + "maxLength": 200, + "description": "Free-form pronouns text.", + "maxGraphemes": 20 + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "pinnedPost": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + }, + "description": { + "type": "string", + "maxLength": 2560, + "description": "Free-form profile description text.", + "maxGraphemes": 256 + }, + "displayName": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + }, + "joinedViaStarterPack": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + } + } + }, + "description": "A declaration of a Bluesky account profile." + } + }, + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/app/bsky/graph/follow.json b/apps/benchmark/lexicons/calendar/app/bsky/graph/follow.json new file mode 100644 index 0000000..6029110 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/app/bsky/graph/follow.json @@ -0,0 +1,29 @@ +{ + "id": "app.bsky.graph.follow", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": ["createdAt", "subject"], + "properties": { + "via": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + }, + "subject": { + "type": "string", + "format": "did" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + }, + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView." + } + }, + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/com/atproto/label/defs.json b/apps/benchmark/lexicons/calendar/com/atproto/label/defs.json new file mode 100644 index 0000000..b68a065 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/com/atproto/label/defs.json @@ -0,0 +1,159 @@ +{ + "id": "com.atproto.label.defs", + "defs": { + "label": { + "type": "object", + "required": ["cts", "src", "uri", "val"], + "properties": { + "cid": { + "type": "string", + "format": "cid", + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." + }, + "cts": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "neg": { + "type": "boolean", + "description": "If true, this is a negation label, overwriting a previous label." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." + }, + "src": { + "type": "string", + "format": "did", + "description": "DID of the actor who created this label." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "AT URI of the record, repository (account), or other resource that this label applies to." + }, + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + }, + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + } + }, + "description": "Metadata tag on an atproto resource (eg, repo or record)." + }, + "selfLabel": { + "type": "object", + "required": ["val"], + "properties": { + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + } + }, + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." + }, + "labelValue": { + "type": "string", + "knownValues": [ + "!hide", + "!no-unauthenticated", + "!warn", + "bot", + "graphic-media", + "nudity", + "porn", + "sexual" + ] + }, + "selfLabels": { + "type": "object", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { + "ref": "#selfLabel", + "type": "ref" + }, + "maxLength": 10 + } + }, + "description": "Metadata tags on an atproto record, published by the author within the record." + }, + "labelValueDefinition": { + "type": "object", + "required": ["blurs", "identifier", "locales", "severity"], + "properties": { + "blurs": { + "type": "string", + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + "knownValues": ["content", "media", "none"] + }, + "locales": { + "type": "array", + "items": { + "ref": "#labelValueDefinitionStrings", + "type": "ref" + } + }, + "severity": { + "type": "string", + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + "knownValues": ["alert", "inform", "none"] + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "identifier": { + "type": "string", + "maxLength": 100, + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + "maxGraphemes": 100 + }, + "defaultSetting": { + "type": "string", + "default": "warn", + "description": "The default setting for this label.", + "knownValues": ["hide", "ignore", "warn"] + } + }, + "description": "Declares a label value and its expected interpretations and behaviors." + }, + "labelValueDefinitionStrings": { + "type": "object", + "required": ["description", "lang", "name"], + "properties": { + "lang": { + "type": "string", + "format": "language", + "description": "The code of the language these strings are written in." + }, + "name": { + "type": "string", + "maxLength": 640, + "description": "A short human-readable name for the label.", + "maxGraphemes": 64 + }, + "description": { + "type": "string", + "maxLength": 100000, + "description": "A longer description of what the label means and why it might be applied.", + "maxGraphemes": 10000 + } + }, + "description": "Strings which describe the label in the UI, localized into a specific language." + } + }, + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/com/atproto/repo/strongRef.json b/apps/benchmark/lexicons/calendar/com/atproto/repo/strongRef.json new file mode 100644 index 0000000..dfb00b9 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/com/atproto/repo/strongRef.json @@ -0,0 +1,21 @@ +{ + "id": "com.atproto.repo.strongRef", + "defs": { + "main": { + "type": "object", + "required": ["cid", "uri"], + "properties": { + "cid": { + "type": "string", + "format": "cid" + }, + "uri": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "lexicon": 1, + "description": "A URI with a content-hash fingerprint." +} diff --git a/apps/benchmark/lexicons/calendar/community/lexicon/calendar/event.json b/apps/benchmark/lexicons/calendar/community/lexicon/calendar/event.json new file mode 100644 index 0000000..96e54cb --- /dev/null +++ b/apps/benchmark/lexicons/calendar/community/lexicon/calendar/event.json @@ -0,0 +1,142 @@ +{ + "id": "community.lexicon.calendar.event", + "defs": { + "uri": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string", + "description": "The display name of the URI." + } + }, + "description": "A URI associated with the event." + }, + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": ["createdAt", "name"], + "properties": { + "mode": { + "ref": "community.lexicon.calendar.event#mode", + "type": "ref", + "description": "The attendance mode of the event." + }, + "name": { + "type": "string", + "description": "The name of the event." + }, + "uris": { + "type": "array", + "items": { + "ref": "community.lexicon.calendar.event#uri", + "type": "ref" + }, + "description": "URIs associated with the event." + }, + "endsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event ends." + }, + "status": { + "ref": "community.lexicon.calendar.event#status", + "type": "ref", + "description": "The status of the event." + }, + "startsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event starts." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event was created." + }, + "locations": { + "type": "array", + "items": { + "refs": [ + "community.lexicon.calendar.event#uri", + "community.lexicon.location.address", + "community.lexicon.location.fsq", + "community.lexicon.location.geo", + "community.lexicon.location.hthree" + ], + "type": "union" + }, + "description": "The locations where the event takes place." + }, + "description": { + "type": "string", + "description": "The description of the event." + } + } + }, + "description": "A calendar event." + }, + "mode": { + "type": "string", + "default": "community.lexicon.calendar.event#inperson", + "description": "The mode of the event.", + "knownValues": [ + "community.lexicon.calendar.event#hybrid", + "community.lexicon.calendar.event#inperson", + "community.lexicon.calendar.event#virtual" + ] + }, + "hybrid": { + "type": "token", + "description": "A hybrid event that takes place both online and offline." + }, + "status": { + "type": "string", + "default": "community.lexicon.calendar.event#scheduled", + "description": "The status of the event.", + "knownValues": [ + "community.lexicon.calendar.event#cancelled", + "community.lexicon.calendar.event#planned", + "community.lexicon.calendar.event#postponed", + "community.lexicon.calendar.event#rescheduled", + "community.lexicon.calendar.event#scheduled" + ] + }, + "planned": { + "type": "token", + "description": "The event has been created, but not finalized." + }, + "virtual": { + "type": "token", + "description": "A virtual event that takes place online." + }, + "inperson": { + "type": "token", + "description": "An in-person event that takes place offline." + }, + "cancelled": { + "type": "token", + "description": "The event has been cancelled." + }, + "postponed": { + "type": "token", + "description": "The event has been postponed and a new start date has not been set." + }, + "scheduled": { + "type": "token", + "description": "The event has been created and scheduled." + }, + "rescheduled": { + "type": "token", + "description": "The event has been rescheduled." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/community/lexicon/calendar/rsvp.json b/apps/benchmark/lexicons/calendar/community/lexicon/calendar/rsvp.json new file mode 100644 index 0000000..ac61fd3 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/community/lexicon/calendar/rsvp.json @@ -0,0 +1,43 @@ +{ + "id": "community.lexicon.calendar.rsvp", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": ["subject", "status"], + "properties": { + "status": { + "type": "string", + "default": "community.lexicon.calendar.rsvp#going", + "knownValues": [ + "community.lexicon.calendar.rsvp#interested", + "community.lexicon.calendar.rsvp#going", + "community.lexicon.calendar.rsvp#notgoing" + ] + }, + "subject": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + } + } + }, + "description": "An RSVP for an event." + }, + "going": { + "type": "token", + "description": "Going to the event" + }, + "notgoing": { + "type": "token", + "description": "Not going to the event" + }, + "interested": { + "type": "token", + "description": "Interested in the event" + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/community/lexicon/location/address.json b/apps/benchmark/lexicons/calendar/community/lexicon/location/address.json new file mode 100644 index 0000000..acfa0fc --- /dev/null +++ b/apps/benchmark/lexicons/calendar/community/lexicon/location/address.json @@ -0,0 +1,40 @@ +{ + "id": "community.lexicon.location.address", + "defs": { + "main": { + "type": "object", + "required": ["country"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "region": { + "type": "string", + "description": "The administrative region of the country. For example, a state in the USA." + }, + "street": { + "type": "string", + "description": "The street address." + }, + "country": { + "type": "string", + "maxLength": 10, + "minLength": 2, + "description": "The ISO 3166 country code. Preferably the 2-letter code." + }, + "locality": { + "type": "string", + "description": "The locality of the region. For example, a city in the USA." + }, + "postalCode": { + "type": "string", + "description": "The postal code of the location." + } + }, + "description": "A physical location in the form of a street address." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/community/lexicon/location/fsq.json b/apps/benchmark/lexicons/calendar/community/lexicon/location/fsq.json new file mode 100644 index 0000000..91faa7a --- /dev/null +++ b/apps/benchmark/lexicons/calendar/community/lexicon/location/fsq.json @@ -0,0 +1,28 @@ +{ + "id": "community.lexicon.location.fsq", + "defs": { + "main": { + "type": "object", + "required": ["fsq_place_id"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + }, + "fsq_place_id": { + "type": "string", + "description": "The unique identifier of a Foursquare POI." + } + }, + "description": "A physical location contained in the Foursquare Open Source Places dataset." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/community/lexicon/location/geo.json b/apps/benchmark/lexicons/calendar/community/lexicon/location/geo.json new file mode 100644 index 0000000..8676479 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/community/lexicon/location/geo.json @@ -0,0 +1,27 @@ +{ + "id": "community.lexicon.location.geo", + "defs": { + "main": { + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "altitude": { + "type": "string" + }, + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + } + }, + "description": "A physical location in the form of a WGS84 coordinate." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/benchmark/lexicons/calendar/community/lexicon/location/hthree.json b/apps/benchmark/lexicons/calendar/community/lexicon/location/hthree.json new file mode 100644 index 0000000..fa6cb99 --- /dev/null +++ b/apps/benchmark/lexicons/calendar/community/lexicon/location/hthree.json @@ -0,0 +1,22 @@ +{ + "id": "community.lexicon.location.hthree", + "defs": { + "main": { + "type": "object", + "required": ["value"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "value": { + "type": "string", + "description": "The h3 encoded location." + } + }, + "description": "A physical location in the form of a H3 encoded location." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 98c4b84..ccc2130 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -11,9 +11,9 @@ "@atmo-dev/contrail": "workspace:*" }, "devDependencies": { - "@cloudflare/workers-types": "^5.20260804.1", + "@cloudflare/workers-types": "4.20260424.1", "@types/node": "^26.1.2", "typescript": "^6.0.3", - "wrangler": "^4.118.0" + "wrangler": "4.84.1" } } diff --git a/apps/benchmark/src/bench.ts b/apps/benchmark/src/bench.ts index b196791..b9debaf 100644 --- a/apps/benchmark/src/bench.ts +++ b/apps/benchmark/src/bench.ts @@ -1,8 +1,14 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { performance } from "node:perf_hooks"; -import { Contrail, type ContrailConfig, type Database } from "@atmo-dev/contrail"; +import { createRequire } from "node:module"; +import { + Contrail, + type ContrailConfig, + type Database, + type LexiconDoc, +} from "@atmo-dev/contrail"; import { getPlatformProxy } from "wrangler"; const APP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -10,6 +16,7 @@ const CONFIG_DIR = resolve(APP_DIR, "configs"); const CACHE_DIR = resolve(APP_DIR, ".cache"); const RESULTS_DIR = resolve(APP_DIR, "results"); const WRANGLER_CONFIG = resolve(APP_DIR, "wrangler.jsonc"); +const require = createRequire(import.meta.url); interface Options { config: string; @@ -19,6 +26,7 @@ interface Options { maxAttempts: number; keepCache: boolean; excludeDids: string[]; + validationLexicons?: string; } function positiveInteger(raw: string | undefined, name: string, fallback: number): number { @@ -37,6 +45,7 @@ function parseArgs(argv: string[]): Options { let didsPerPdsRaw: string | undefined; let maxAttemptsRaw: string | undefined; let keepCache = false; + let validationLexicons: string | undefined; const excludeDids: string[] = []; for (let index = 0; index < argv.length; index++) { @@ -58,6 +67,10 @@ function parseArgs(argv: string[]): Options { } else if (arg === "--exclude-did") excludeDids.push(argv[++index]); else if (arg.startsWith("--exclude-did=")) { excludeDids.push(arg.slice("--exclude-did=".length)); + } else if (arg === "--validation-lexicons") { + validationLexicons = argv[++index]; + } else if (arg.startsWith("--validation-lexicons=")) { + validationLexicons = arg.slice("--validation-lexicons=".length); } else if (arg === "--keep-cache") keepCache = true; else if (arg === "--help" || arg === "-h") { console.log(`Usage: pnpm bench --config [options] @@ -69,6 +82,8 @@ Options: --dids-per-pds Concurrent accounts per PDS (default: 3) --max-attempts Immediate attempts per failed account (default: 1) --exclude-did Skip an actor after discovery (repeatable) + --validation-lexicons + Enable strict Lexicon + CID validation with JSON docs --keep-cache Keep the disposable local D1 after the run `); process.exit(0); @@ -86,6 +101,7 @@ Options: maxAttempts: positiveInteger(maxAttemptsRaw, "--max-attempts", 1), keepCache, excludeDids, + validationLexicons, }; } @@ -104,6 +120,38 @@ async function resolveConfigPath(input: string): Promise { throw new Error(`Config not found: ${input}`); } +async function loadLexiconDirectory(input: string): Promise<{ + path: string; + documents: object[]; +}> { + const path = isAbsolute(input) ? input : resolve(process.cwd(), input); + const entries = await readdir(path, { recursive: true, withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => resolve(entry.parentPath, entry.name)) + .sort(); + if (files.length === 0) throw new Error(`No Lexicon JSON files found: ${path}`); + const documents = await Promise.all( + files.map(async (file) => JSON.parse(await readFile(file, "utf8")) as object), + ); + return { path, documents }; +} + +async function installedPackageVersion( + name: string, + packageRequire: NodeJS.Require = require, +): Promise { + try { + const packagePath = packageRequire.resolve(`${name}/package.json`); + const metadata = JSON.parse(await readFile(packagePath, "utf8")) as { + version?: unknown; + }; + return typeof metadata.version === "string" ? metadata.version : null; + } catch { + return null; + } +} + function elapsed(start: number): number { return Math.round((performance.now() - start) * 100) / 100; } @@ -187,7 +235,26 @@ async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const configPath = await resolveConfigPath(options.config); const config = JSON.parse(await readFile(configPath, "utf8")) as ContrailConfig; - const name = safeName(configPath); + const validation = options.validationLexicons + ? await loadLexiconDirectory(options.validationLexicons) + : null; + if (validation) { + config.validation = { + lexicons: validation.documents as LexiconDoc[], + strict: true, + verifyCid: true, + }; + } + const wranglerPackagePath = require.resolve("wrangler/package.json"); + const runtime = { + node: process.version, + wrangler: await installedPackageVersion("wrangler"), + workerd: await installedPackageVersion( + "workerd", + createRequire(wranglerPackagePath), + ), + }; + const name = `${safeName(configPath)}${validation ? "-validated" : ""}`; const cachePath = resolve( CACHE_DIR, `${name}-r${options.concurrency}-h${options.pdsConcurrency}-d${options.didsPerPds}`, @@ -199,11 +266,16 @@ async function main(): Promise { console.log(`config: ${configPath}`); console.log(`backend: fresh local D1`); + console.log( + `runtime: Wrangler ${runtime.wrangler ?? "unknown"}, ` + + `workerd ${runtime.workerd ?? "unknown"}`, + ); console.log(`resolution: ${options.concurrency}`); console.log(`PDS hosts: ${options.pdsConcurrency}`); console.log(`DIDs / PDS: ${options.didsPerPds}`); console.log(`max attempts: ${options.maxAttempts}`); console.log(`excluded: ${options.excludeDids.length} actors`); + console.log(`validation: ${validation ? `${validation.documents.length} Lexicons + CID` : "disabled"}`); console.log(`cache: reset ${cachePath}`); const startedAt = new Date(); @@ -217,6 +289,7 @@ async function main(): Promise { let acceptedRecords = 0; let backfillMetrics: any; let overview: any; + let diagnostics: any; let fetchInstrumentation: ReturnType | undefined; try { @@ -276,6 +349,7 @@ async function main(): Promise { .fetch(new Request("http://benchmark/status")); if (!response.ok) throw new Error(`Status request failed: ${response.status}`); overview = await response.json(); + diagnostics = await contrail.diagnostics(); } finally { fetchInstrumentation?.restore(); await proxy?.dispose(); @@ -303,12 +377,21 @@ async function main(): Promise { version: 1, config: relativeConfig.startsWith("..") ? configPath : relativeConfig, backend: "wrangler-local-d1", + runtime, options: { concurrency: options.concurrency, pdsConcurrency: options.pdsConcurrency, didsPerPds: options.didsPerPds, maxAttempts: options.maxAttempts, excludedDids: options.excludeDids, + validation: validation + ? { + lexicons: relative(APP_DIR, validation.path), + documents: validation.documents.length, + strict: true, + verifyCid: true, + } + : null, }, started_at: startedAt.toISOString(), completed_at: completedAt.toISOString(), @@ -336,6 +419,7 @@ async function main(): Promise { }, backfill: overview.backfill, collections: overview.collections, + ingest_diagnostics: diagnostics, }; const timestamp = completedAt.toISOString().replace(/[:.]/g, "-"); @@ -359,6 +443,12 @@ async function main(): Promise { `${overview.backfill.accounts.failed} failed`, ); console.log(`network max: ${result.network.max_concurrent} concurrent requests`); + const rejected = (diagnostics ?? []).reduce( + (total: number, diagnostic: { total?: number }) => + total + Number(diagnostic.total ?? 0), + 0, + ); + console.log(`rejections: ${rejected} aggregate admission decisions`); if (backfillMetrics) { console.log( `phases: resolution ${(backfillMetrics.resolution_ms / 1000).toFixed(2)}s, ` + diff --git a/packages/contrail/README.md b/packages/contrail/README.md index fb542f1..f0eac5b 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -70,7 +70,34 @@ await contrail.runPersistent({ After a write to a user's PDS, `contrail.notify(uri)` can fetch the authoritative record immediately. Only an authoritative not-found response deletes local state; rate limits, server errors, timeouts, malformed responses, and network failures leave it unchanged. Authentication and abuse controls for the public HTTP operation remain under design. -Contrail stores source event time, repository revision, source cursor, CID, and local index time separately from record/application time. Durable tombstones reject stale resurrection, and live Jetstream projection commits its exact yielded cursor in the same transaction. Tombstones are retained indefinitely; authoritative rebuild/retention tooling is planned separately. +Contrail stores source event time, repository revision, source cursor, CID, and local index time separately from record/application time. Durable tombstones reject stale resurrection, and live Jetstream projection commits its exact yielded cursor in the same transaction. A successful PDS `listRecords` page is a current authoritative observation, so it supersedes older durable state without a redundant version read; its version writes and page cursor still commit atomically. Tombstones are retained indefinitely; authoritative rebuild/retention tooling is planned separately. + +## Runtime record validation + +Pass the record Lexicons for every configured collection and their transitive references to enable shared strict validation and CID verification: + +```ts +const contrail = new Contrail({ + db, + namespace: "com.example", + collections, + validation: { + lexicons: [eventLexicon, profileLexicon, strongRefLexicon], + strict: true, // default: enforce blob size/MIME constraints too + verifyCid: true, // default: canonical DAG-CBOR CID verification + }, +}); +``` + +Validation is opt-in for compatibility, but once configured it applies identically to Jetstream, PDS backfill, notify, on-demand profiles, Constellation enrichment, and direct `ingestRecords()` calls. Configuration fails early when a collection or referenced Lexicon is missing. Authoritative sources must provide matching CIDs; local and Constellation synthetic records may be CID-less by default. Override `allowCidlessSources` only for explicitly trusted synthetic adapters. + +`createWorker(config, { lexicons })` continues to expose method Lexicons over HTTP; it does not silently enable record validation. Put record schemas in `config.validation.lexicons` deliberately. + +Private aggregate-only rejection counters are available without exposing DIDs, URIs, errors, or record bodies. Concurrent bulk backfill accumulates these bounded counters in memory and flushes once per run, so diagnostics cannot turn into a hot D1 row on every source page: + +```ts +const diagnostics = await contrail.diagnostics(); +``` ## HTTP diff --git a/packages/contrail/package.json b/packages/contrail/package.json index 8302cde..68b071e 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -67,13 +67,17 @@ "dependencies": { "@atcute/atproto": "^4.0.4", "@atcute/cbor": "^2.3.6", + "@atcute/cid": "2.4.2", "@atcute/client": "^5.1.1", "@atcute/identity-resolver": "^2.0.1", "@atcute/jetstream": "^2.0.2", + "@atcute/lexicon-doc": "3.0.2", "@atcute/lexicons": "^2.0.3", + "@atcute/tid": "1.1.4", "cac": "^7.0.0", "hono": "^4.13.0", - "jiti": "^2.7.0" + "jiti": "^2.7.0", + "valibot": "1.4.2" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260804.1", diff --git a/packages/contrail/src/adapters/postgres.ts b/packages/contrail/src/adapters/postgres.ts index 99c07c5..310be5d 100644 --- a/packages/contrail/src/adapters/postgres.ts +++ b/packages/contrail/src/adapters/postgres.ts @@ -13,7 +13,9 @@ const BIGINT_COLUMNS = new Set([ "time_us", "source_time_us", "indexed_at", + "last_seen_at", "resolved_at", + "total", ]); function normalizeRow(row: any): any { diff --git a/packages/contrail/src/contrail.ts b/packages/contrail/src/contrail.ts index c75c1f3..181a80d 100644 --- a/packages/contrail/src/contrail.ts +++ b/packages/contrail/src/contrail.ts @@ -9,6 +9,8 @@ import { validateConfig, } from "./core/types"; import { initSchema } from "./core/db/schema"; +import { prepareRecordValidation } from "./core/validation"; +import { getIngestDiagnostics } from "./core/diagnostics"; import { optimizeDatabase } from "./core/db/optimize"; import { queryRecords, type QueryOptions } from "./core/db/records"; import { @@ -55,6 +57,7 @@ export class Contrail { const { db, ...configInput } = options; this.config = resolveConfig(configInput); validateConfig(this.config); + prepareRecordValidation(this.config); this._db = db; } @@ -78,6 +81,11 @@ export class Contrail { await optimizeDatabase(this.getDb(db), optimizeAnalysisLimit(this.config)); } + /** Read private aggregate ingest rejection counters. */ + async diagnostics(db?: Database) { + return getIngestDiagnostics(this.getDb(db)); + } + /** Query records from a collection. */ async query( collection: string, diff --git a/packages/contrail/src/core/backfill.ts b/packages/contrail/src/core/backfill.ts index 89957e5..a019068 100644 --- a/packages/contrail/src/core/backfill.ts +++ b/packages/contrail/src/core/backfill.ts @@ -12,6 +12,10 @@ import { import { getLastCursor, saveCursor } from "./db"; import { getMeta, setMeta } from "./db/meta"; import { createIngestEvent, ingestRecords, recordTimeUs } from "./ingest"; +import { + ingestDiagnosticsStatement, + type IngestDiagnosticCounts, +} from "./diagnostics"; import { rebuildDerivedProjections } from "./db/records"; import { createPdsClient, getClient, getPDS } from "./client"; import { @@ -200,6 +204,8 @@ export interface BackfillOptions { metrics?: BackfillMetricsAccumulator; /** @internal Cursor already loaded by the bulk scheduler. */ resumeState?: { cursor: string | null }; + /** @internal Bounded diagnostics shared by one bulk run. */ + aggregateDiagnostics?: IngestDiagnosticCounts; } interface BackfillUserAttempt { @@ -389,6 +395,10 @@ async function backfillUserAttempt( skipFeedFanout: true, knownDids: options?.knownDids, skipDerivedProjections: options?.skipDerivedProjections, + // listRecords is an authoritative current-source observation. It wins + // durable older state, so another record_versions read is redundant. + authoritativeSourceObservation: true, + aggregateDiagnostics: options?.aggregateDiagnostics, // Canonical projection and cursor acknowledgement commit atomically. trailingStatements: [checkpoint], // Let sinks bulk-flush differently from live ingestion. @@ -623,6 +633,23 @@ function createStreamingHostScheduler( }; } +async function flushIngestDiagnostics( + db: Database, + counts: IngestDiagnosticCounts, + config: ContrailConfig, +): Promise { + try { + const statement = ingestDiagnosticsStatement(db, counts); + if (statement) await statement.run(); + } catch (error) { + // Private telemetry must never change canonical completion state. + (config.logger ?? console).warn( + "Backfill diagnostics flush failed", + error, + ); + } +} + async function loadKnownBackfillDids(db: Database): Promise> { const rows = await db .prepare("SELECT DISTINCT did FROM backfills") @@ -656,6 +683,7 @@ async function backfillPendingWork( let totalBackfilled = 0; const knownDids = await loadKnownBackfillDids(db); const metrics = emptyBackfillMetrics(); + const aggregateDiagnostics: IngestDiagnosticCounts = {}; // Anchor the jetstream cursor to now if it hasn't been set yet, so records // emitted during backfill are replayed once jetstream starts. @@ -762,6 +790,7 @@ async function backfillPendingWork( skipDerivedProjections: true, metrics, resumeState: work, + aggregateDiagnostics, } ); records += attempt.records; @@ -823,6 +852,11 @@ async function backfillPendingWork( // another host-aware pass without changing the scheduled retry budget. } + // Diagnostics are private aggregate telemetry, not canonical state. Flush + // once after concurrent page work to avoid turning one hot counter row into + // a global D1 write lock on every backfill page. + await flushIngestDiagnostics(db, aggregateDiagnostics, config); + // Canonical records and cursors are durable now. Rebuild expensive derived // projections once with set-based SQL instead of hundreds of statements per // network page. This also repairs a prior interrupted bulk pass. @@ -893,6 +927,7 @@ export async function retryPendingBackfills( let completed = 0; let failed = 0; let records = 0; + const aggregateDiagnostics: IngestDiagnosticCounts = {}; try { if ((await getMeta(db, DERIVED_PROJECTIONS_DIRTY_KEY)) === "1") { @@ -958,6 +993,7 @@ export async function retryPendingBackfills( Math.max(1, deadline - Date.now()) ), exhaustAfterAttempts: maxAttempts, + aggregateDiagnostics, }) ); } @@ -973,6 +1009,7 @@ export async function retryPendingBackfills( await heartbeatBackfillRun(db, runId); } } finally { + await flushIngestDiagnostics(db, aggregateDiagnostics, config); await finishBackfillRun(db, runId); } diff --git a/packages/contrail/src/core/constellation.ts b/packages/contrail/src/core/constellation.ts index 5812685..dcae8f8 100644 --- a/packages/contrail/src/core/constellation.ts +++ b/packages/contrail/src/core/constellation.ts @@ -1,11 +1,12 @@ import { isDid } from "@atcute/lexicons/syntax"; +import { parse as parseTid } from "@atcute/tid"; import type { ContrailConfig, Database, Logger } from "./types"; import { DEFAULT_CONSTELLATION_URL, DEFAULT_FOLLOW_NSID, - recordsTableName, shortNameForNsid, } from "./types"; +import { createIngestEvent, ingestRecords } from "./ingest"; const PAGE_LIMIT = 100; const DID_FILTER_CHUNK = 50; @@ -54,11 +55,11 @@ function parseBacklink(entry: NonNullable[number]): Back return { did: entry.did, rkey: entry.rkey, - uri: entry.uri ?? `at://${entry.did}/${DEFAULT_FOLLOW_NSID}/${entry.rkey}`, + uri: `at://${entry.did}/${DEFAULT_FOLLOW_NSID}/${entry.rkey}`, }; } if (entry.uri) { - const m = /^at:\/\/(did:[^/]+)\/[^/]+\/([^/]+)$/.exec(entry.uri); + const m = /^at:\/\/(did:[^/]+)\/app\.bsky\.graph\.follow\/([^/]+)$/.exec(entry.uri); if (m && isDid(m[1])) { return { did: m[1], rkey: m[2], uri: entry.uri }; } @@ -122,14 +123,6 @@ export async function backfillFollowersFromConstellation( if (!followShort) return 0; const log = getLogger(config); - const followTable = recordsTableName(followShort); - const recordJson = JSON.stringify({ - $type: DEFAULT_FOLLOW_NSID, - subject: subjectDid, - createdAt: new Date().toISOString(), - }); - const nowUs = Date.now() * 1000; - let cursor: string | undefined; let inserted = 0; let pages = 0; @@ -154,17 +147,46 @@ export async function backfillFollowersFromConstellation( rows.map((r) => r.did) ); const survivors = rows.filter((r) => known.has(r.did)); - - for (const r of survivors) { - const result = await db - .prepare( - `INSERT INTO ${followTable} (uri, did, rkey, cid, record, time_us, indexed_at) - VALUES (?, ?, ?, NULL, ?, ?, ?) - ON CONFLICT(uri) DO NOTHING` - ) - .bind(r.uri, r.did, r.rkey, recordJson, nowUs, nowUs) - .run(); - inserted += (result as { changes?: number })?.changes ?? 0; + const nowUs = Date.now() * 1000; + const events = survivors.flatMap((row) => { + try { + const { timestamp } = parseTid(row.rkey); + return [ + createIngestEvent({ + uri: row.uri, + did: row.did, + collection: DEFAULT_FOLLOW_NSID, + rkey: row.rkey, + operation: "create", + cid: null, + value: { + $type: DEFAULT_FOLLOW_NSID, + subject: subjectDid, + createdAt: new Date(timestamp / 1000).toISOString(), + }, + timeUs: timestamp, + indexedAt: nowUs, + // The backlink API has no record CID. TID-derived source metadata + // is stable across repeated acquisitions, keeping sinks/feed fanout + // idempotent while the shared validator treats this as synthetic. + source: { + id: "constellation", + time_us: timestamp, + revision: row.rkey, + cursor: row.rkey, + }, + }), + ]; + } catch { + return []; + } + }); + if (events.length > 0) { + known.add(subjectDid); + const ingest = await ingestRecords(db, events, config, { + knownDids: known, + }); + inserted += ingest.accepted.length; } } diff --git a/packages/contrail/src/core/db/records.ts b/packages/contrail/src/core/db/records.ts index 818bb0e..74e0b94 100644 --- a/packages/contrail/src/core/db/records.ts +++ b/packages/contrail/src/core/db/records.ts @@ -660,13 +660,10 @@ export interface MutationSelection { superseded: number; } -/** Select one newest mutation per URI and reject rows older than durable state. */ -export async function selectCurrentMutations( - db: Database, +function selectMutationWinners( events: IngestEvent[], -): Promise { - if (events.length === 0) return { applied: [], superseded: 0 }; - const durable = await lookupRecordVersions(db, events.map((event) => event.uri)); + durable: ReadonlyMap, +): MutationSelection { const winners = new Map< string, { event: IngestEvent; version: RecordVersionInfo; index: number } @@ -694,6 +691,23 @@ export async function selectCurrentMutations( }; } +/** Deduplicate one authoritative source observation without a durable lookup. */ +export function selectAuthoritativeMutations( + events: IngestEvent[], +): MutationSelection { + return selectMutationWinners(events, new Map()); +} + +/** Select one newest mutation per URI and reject rows older than durable state. */ +export async function selectCurrentMutations( + db: Database, + events: IngestEvent[], +): Promise { + if (events.length === 0) return { applied: [], superseded: 0 }; + const durable = await lookupRecordVersions(db, events.map((event) => event.uri)); + return selectMutationWinners(events, durable); +} + /** * Look up existing records for a set of events, grouped by collection. * Returns a map of uri → { cid, record }. @@ -984,20 +998,35 @@ export async function projectEvents( // record, and isolates failures so a throwing sink never blocks ingestion. const sinks = config.sinks; if (sinks && sinks.length > 0) { - const records: RecordEvent[] = events.map((e) => - e.operation === "delete" - ? { kind: "deleted", uri: e.uri, did: e.did, collection: e.collection, rkey: e.rkey } - : { - kind: "created", - uri: e.uri, - did: e.did, - collection: e.collection, - rkey: e.rkey, - cid: e.cid, - record: e.record ? safeParseJson(e.record) : {}, - time_us: e.time_us, - } - ); + // An absent-row tombstone is canonical ordering state, not an externally + // visible deletion. Publish deletes only when this transaction removed a + // previously visible record. + const records: RecordEvent[] = events + .filter( + (event) => + event.operation !== "delete" || existingMap.has(event.uri), + ) + .map((event) => + event.operation === "delete" + ? { + kind: "deleted", + uri: event.uri, + did: event.did, + collection: event.collection, + rkey: event.rkey, + } + : { + kind: "created", + uri: event.uri, + did: event.did, + collection: event.collection, + rkey: event.rkey, + cid: event.cid, + record: event.record ? safeParseJson(event.record) : {}, + time_us: event.time_us, + }, + ); + if (records.length === 0) return selection; const ctx = { phase: options?.phase ?? "live" } as const; const logger = config.logger ?? console; for (const sink of sinks) { diff --git a/packages/contrail/src/core/db/schema.ts b/packages/contrail/src/core/db/schema.ts index b343e12..d673ff4 100644 --- a/packages/contrail/src/core/db/schema.ts +++ b/packages/contrail/src/core/db/schema.ts @@ -17,7 +17,7 @@ import { getSearchableFields } from "../search"; import { buildLabelsSchema } from "../labels/schema"; import { getMeta, setMeta } from "./meta"; -export const CONTRAIL_SCHEMA_VERSION = 7; +export const CONTRAIL_SCHEMA_VERSION = 8; const SCHEMA_FINGERPRINT_KEY = "schema_fingerprint"; function getResolved(config: ContrailConfig): ResolvedMaps { @@ -90,6 +90,11 @@ CREATE TABLE IF NOT EXISTS record_versions ( CREATE INDEX IF NOT EXISTS idx_record_versions_collection ON record_versions(collection); CREATE INDEX IF NOT EXISTS idx_record_versions_did ON record_versions(did); CREATE INDEX IF NOT EXISTS idx_record_versions_tombstones ON record_versions(operation, indexed_at); +CREATE TABLE IF NOT EXISTS ingest_diagnostics ( + category TEXT PRIMARY KEY, + total ${dialect.bigintType} NOT NULL, + last_seen_at ${dialect.bigintType} NOT NULL +); `; } diff --git a/packages/contrail/src/core/diagnostics.ts b/packages/contrail/src/core/diagnostics.ts new file mode 100644 index 0000000..5880c69 --- /dev/null +++ b/packages/contrail/src/core/diagnostics.ts @@ -0,0 +1,85 @@ +import type { Database, Statement } from "./types"; + +export const INGEST_DIAGNOSTIC_CATEGORIES = [ + "unknown_collection", + "invalid_json", + "lexicon_validation", + "cid_mismatch", + "cid_encoding", + "missing_cid", + "record_filter", + "unknown_actor", + "unknown_subject", + "superseded", +] as const; + +export type IngestDiagnosticCategory = + (typeof INGEST_DIAGNOSTIC_CATEGORIES)[number]; + +export interface IngestDiagnostic { + category: IngestDiagnosticCategory; + total: number; + last_seen_at: number | null; +} + +export type IngestDiagnosticCounts = Partial< + Record +>; + +/** Merge one ingest decision batch into a bounded in-memory aggregate. */ +export function addIngestDiagnosticCounts( + target: IngestDiagnosticCounts, + counts: IngestDiagnosticCounts, +): void { + for (const category of INGEST_DIAGNOSTIC_CATEGORIES) { + const total = counts[category] ?? 0; + if (total > 0) target[category] = (target[category] ?? 0) + total; + } +} + +/** Build one bounded aggregate update for an ingest transaction. */ +export function ingestDiagnosticsStatement( + db: Database, + counts: IngestDiagnosticCounts, + nowUs: number = Date.now() * 1000, +): Statement | null { + const entries = INGEST_DIAGNOSTIC_CATEGORIES.flatMap((category) => { + const total = counts[category] ?? 0; + return total > 0 ? [{ category, total }] : []; + }); + if (entries.length === 0) return null; + const values = entries.map(() => "(?, ?, ?)").join(", "); + return db + .prepare( + `INSERT INTO ingest_diagnostics (category, total, last_seen_at) VALUES ${values} ON CONFLICT(category) DO UPDATE SET total = ingest_diagnostics.total + excluded.total, last_seen_at = excluded.last_seen_at`, + ) + .bind( + ...entries.flatMap(({ category, total }) => [category, total, nowUs]), + ); +} + +/** Private, aggregate-only diagnostics. Never includes DIDs, URIs, or records. */ +export async function getIngestDiagnostics( + db: Database, +): Promise { + const rows = await db + .prepare( + "SELECT category, total, last_seen_at FROM ingest_diagnostics ORDER BY category", + ) + .all<{ + category: IngestDiagnosticCategory; + total: number; + last_seen_at: number; + }>(); + const byCategory = new Map( + (rows.results ?? []).map((row) => [row.category, row]), + ); + return INGEST_DIAGNOSTIC_CATEGORIES.map((category) => { + const row = byCategory.get(category); + return { + category, + total: row?.total ?? 0, + last_seen_at: row?.last_seen_at ?? null, + }; + }); +} diff --git a/packages/contrail/src/core/ingest.ts b/packages/contrail/src/core/ingest.ts index 3a52141..bd7ca67 100644 --- a/packages/contrail/src/core/ingest.ts +++ b/packages/contrail/src/core/ingest.ts @@ -12,9 +12,19 @@ import { } from "./types"; import { projectEvents, + selectAuthoritativeMutations, selectCurrentMutations, type ExistingRecordInfo, } from "./db/records"; +import { + addIngestDiagnosticCounts, + ingestDiagnosticsStatement, + type IngestDiagnosticCounts, +} from "./diagnostics"; +import { + validateCanonicalRecord, + type RecordValidationFailure, +} from "./validation"; export interface RecordEventInput { uri?: string; @@ -99,11 +109,21 @@ export interface IngestRecordsOptions { knownDids?: ReadonlySet; /** Statements committed after projection in the same database batch. */ trailingStatements?: Statement[]; + /** The source response is a current authoritative snapshot, so it supersedes + * durable observations without a redundant version lookup. */ + authoritativeSourceObservation?: boolean; + /** @internal Aggregate private diagnostics for one bulk run. The caller + * flushes this bounded object once after concurrent page processing. */ + aggregateDiagnostics?: IngestDiagnosticCounts; } export interface IngestDropCounts { unknownCollection: number; invalidRecord: number; + lexiconValidation: number; + cidMismatch: number; + cidEncoding: number; + missingCid: number; recordFilter: number; unknownActor: number; unknownSubject: number; @@ -136,12 +156,21 @@ export async function ingestRecords( const dropped: IngestDropCounts = { unknownCollection: 0, invalidRecord: 0, + lexiconValidation: 0, + cidMismatch: 0, + cidEncoding: 0, + missingCid: 0, recordFilter: 0, unknownActor: 0, unknownSubject: 0, superseded: 0, }; const logger = config.logger ?? console; + let candidates: Array<{ + event: IngestEvent; + shortName: string; + record: Record | null; + }> = []; for (const event of events) { const shortName = resolveCollectionKey(config, event.collection); @@ -152,15 +181,66 @@ export async function ingestRecords( ); continue; } + const record = + event.operation === "delete" ? null : parseRecord(event.record); + if (event.operation !== "delete" && !record) { + dropped.invalidRecord++; + logger.warn(`[ingest] drop invalid record: ${event.uri}`); + continue; + } + candidates.push({ event, shortName, record }); + } - if (event.operation !== "delete") { - const record = parseRecord(event.record); - if (!record) { - dropped.invalidRecord++; - logger.warn(`[ingest] drop invalid record: ${event.uri}`); - continue; + // A backfill page contains one collection. When that collection is dependent + // and the caller supplied the complete actor set, reject out-of-scope rows + // before expensive Lexicon/CID work. Mixed live batches keep the two-pass path + // below so a discoverable record can still admit a dependent sibling. + const hasDiscoverableMutation = candidates.some(({ event, shortName }) => { + const collection = config.collections[shortName]; + return event.operation !== "delete" && collection?.discover !== false; + }); + if (options.knownDids && !hasDiscoverableMutation) { + candidates = candidates.filter(({ event, shortName, record }) => { + if (event.operation === "delete") return true; + const collection = config.collections[shortName]; + if (collection?.discover !== false) return true; + if (!options.knownDids!.has(event.did)) { + dropped.unknownActor++; + return false; } + if (!collection.subjectField) return true; + const subject = record + ? getNestedValue(record, collection.subjectField) + : undefined; + if (typeof subject !== "string" || !isDid(subject)) { + dropped.unknownSubject++; + return false; + } + if (!options.knownDids!.has(subject)) { + dropped.unknownSubject++; + return false; + } + return true; + }); + } + + // CID hashing is asynchronous. Validate a bounded ingest batch in parallel, + // while preserving source order when applying admission decisions below. + const validationFailures = await Promise.all( + candidates.map(({ event, record }) => + record ? validateCanonicalRecord(config, event, record) : null, + ), + ); + for (let index = 0; index < candidates.length; index++) { + const { event, shortName, record } = candidates[index]; + const failure = validationFailures[index]; + if (failure) { + incrementValidationDrop(dropped, failure); + continue; + } + + if (record) { const filter = config.collections[shortName]?.recordFilter; if (filter) { let keep = false; @@ -182,9 +262,24 @@ export async function ingestRecords( accepted.push(event); } + const validationDropTotal = + dropped.lexiconValidation + + dropped.cidMismatch + + dropped.cidEncoding + + dropped.missingCid; + if (validationDropTotal > 0 && !options.aggregateDiagnostics) { + logger.warn( + `[ingest] dropped ${validationDropTotal} record(s) during validation ` + + `(lexicon=${dropped.lexiconValidation}, cid_mismatch=${dropped.cidMismatch}, ` + + `cid_encoding=${dropped.cidEncoding}, missing_cid=${dropped.missingCid})`, + ); + } + // Reject duplicate/stale source observations before they can admit dependent // actors in this batch. The winning versions are persisted with projection. - const ordered = await selectCurrentMutations(db, accepted); + const ordered = options.authoritativeSourceObservation + ? selectAuthoritativeMutations(accepted) + : await selectCurrentMutations(db, accepted); dropped.superseded += ordered.superseded; const projectionExclusions = ordered.applied.filter((event) => @@ -222,32 +317,65 @@ export async function ingestRecords( continue; } dropped.unknownActor++; - projectionExclusions.push(asProjectionDelete(event)); } - const subjectResult = await filterUnknownSubjects( + const subjectFiltered = await filterUnknownSubjects( db, config, actorFiltered, effectiveKnownDids, dropped, ); - projectionExclusions.push(...subjectResult.excluded); const projectionEvents = [ - ...subjectResult.accepted, + ...subjectFiltered, ...projectionExclusions, ]; + const diagnosticCounts: IngestDiagnosticCounts = { + unknown_collection: dropped.unknownCollection, + invalid_json: dropped.invalidRecord, + lexicon_validation: dropped.lexiconValidation, + cid_mismatch: dropped.cidMismatch, + cid_encoding: dropped.cidEncoding, + missing_cid: dropped.missingCid, + record_filter: dropped.recordFilter, + unknown_actor: dropped.unknownActor, + unknown_subject: dropped.unknownSubject, + superseded: dropped.superseded, + }; + const diagnostics = options.aggregateDiagnostics + ? null + : ingestDiagnosticsStatement(db, diagnosticCounts); + const trailingStatements = [ + ...(diagnostics ? [diagnostics] : []), + ...(options.trailingStatements ?? []), + ]; if (projectionEvents.length > 0) { await projectEvents(db, projectionEvents, config, { ...options, + trailingStatements, sourceOrderingChecked: true, }); - } else if (options.trailingStatements?.length) { - await db.batch(options.trailingStatements); + } else if (trailingStatements.length > 0) { + await db.batch(trailingStatements); + } + // Aggregate only after the canonical projection/checkpoint transaction + // succeeds, so a rolled-back page cannot inflate private diagnostics. + if (options.aggregateDiagnostics) { + addIngestDiagnosticCounts(options.aggregateDiagnostics, diagnosticCounts); } - return { accepted: subjectResult.accepted, dropped, discoveredDids }; + return { accepted: subjectFiltered, dropped, discoveredDids }; +} + +function incrementValidationDrop( + dropped: IngestDropCounts, + failure: RecordValidationFailure, +): void { + if (failure === "lexicon_validation") dropped.lexiconValidation++; + else if (failure === "cid_mismatch") dropped.cidMismatch++; + else if (failure === "cid_encoding") dropped.cidEncoding++; + else dropped.missingCid++; } function asProjectionDelete(event: IngestEvent): IngestEvent { @@ -267,7 +395,7 @@ async function filterUnknownSubjects( events: IngestEvent[], knownDids: ReadonlySet | undefined, dropped: IngestDropCounts, -): Promise<{ accepted: IngestEvent[]; excluded: IngestEvent[] }> { +): Promise { const subjectsByEvent = new Map(); const subjects = new Set(); @@ -290,13 +418,10 @@ async function filterUnknownSubjects( subjects.add(subject); } - if (subjectsByEvent.size === 0) { - return { accepted: events, excluded: [] }; - } + if (subjectsByEvent.size === 0) return events; const known = knownDids ? new Set(knownDids) : await loadKnownDids(db, subjects); const accepted: IngestEvent[] = []; - const excluded: IngestEvent[] = []; for (const event of events) { const subject = subjectsByEvent.get(event); if (subject === undefined || (subject !== "" && known.has(subject))) { @@ -304,9 +429,8 @@ async function filterUnknownSubjects( continue; } if (subject !== "") dropped.unknownSubject++; - excluded.push(asProjectionDelete(event)); } - return { accepted, excluded }; + return accepted; } async function loadKnownDids( diff --git a/packages/contrail/src/core/router/profiles.ts b/packages/contrail/src/core/router/profiles.ts index 8a95c82..abaa351 100644 --- a/packages/contrail/src/core/router/profiles.ts +++ b/packages/contrail/src/core/router/profiles.ts @@ -2,6 +2,7 @@ import type { Database, ContrailConfig, RecordRow, ProfileConfig } from "../type import { recordsTableName, normalizeProfileConfig } from "../types"; import { resolveIdentities } from "../identity"; import { getPDS } from "../client"; +import { createIngestEvent, ingestRecords, recordTimeUs } from "../ingest"; import type { Did } from "@atcute/lexicons"; import { batchedInQuery } from "./helpers"; @@ -47,6 +48,7 @@ export async function resolveProfiles( const profileConfigs = config.profiles.map(normalizeProfileConfig); const result: Record = {}; + const indexedUris = new Set(); // Batch-lookup profile records for each configured profile collection for (const pc of profileConfigs) { @@ -63,6 +65,7 @@ export async function resolveProfiles( ); for (const row of rows) { + indexedUris.add(row.uri); let value: unknown = null; if (row.record) { try { @@ -87,10 +90,17 @@ export async function resolveProfiles( // Resolve identities for all DIDs const identities = await resolveIdentities(db, dids, config); - // Fetch missing profile records from PDS on demand - const missingDids = dids.filter((d) => !result[d]); - if (missingDids.length > 0 && profileConfigs.length > 0) { - const fetched = await fetchMissingProfiles(db, config, missingDids); + // Fetch each missing (DID, profile collection) independently. One cached + // profile collection must not make another configured collection look done. + const missingProfiles = dids.flatMap((did) => + profileConfigs.flatMap((profile) => { + const rkey = profile.rkey ?? "self"; + const uri = `at://${did}/${profile.collection}/${rkey}`; + return indexedUris.has(uri) ? [] : [{ did, profile }]; + }), + ); + if (missingProfiles.length > 0) { + const fetched = await fetchMissingProfiles(db, config, missingProfiles); for (const [did, entries] of Object.entries(fetched)) { if (!result[did]) result[did] = []; result[did].push(...entries); @@ -122,60 +132,73 @@ export async function resolveProfiles( async function fetchMissingProfiles( db: Database, config: ContrailConfig, - dids: string[] + missing: Array<{ did: string; profile: ProfileConfig }>, ): Promise> { - const result: Record = {}; - const profileConfigs = config.profiles!.map(normalizeProfileConfig); - - await Promise.all( - dids.flatMap((did) => - profileConfigs.map(async (pc) => { - const { collection, rkey: configRkey, shortName } = pc; - const rkey = configRkey ?? "self"; - const table = recordsTableName(shortName ?? collection); - try { - const pds = await getPDS(did as Did, db, config); - if (!pds) return; - - const url = new URL("/xrpc/com.atproto.repo.getRecord", pds); - url.searchParams.set("repo", did); - url.searchParams.set("collection", collection); - url.searchParams.set("rkey", rkey); - - const res = await fetch(url.toString()); - if (!res.ok) return; - - const data = (await res.json()) as { uri?: string; value?: unknown; cid?: string }; - if (!data.value || !data.cid) return; - - const uri = data.uri ?? `at://${did}/${collection}/${rkey}`; - const record = data.value; - const cid = data.cid; - - // Index into D1 for future requests - await db - .prepare( - `INSERT INTO ${table} (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, indexed_at = excluded.indexed_at` - ) - .bind(uri, did, rkey, cid, JSON.stringify(record), Date.now() * 1000, Date.now()) - .run(); - - if (!result[did]) result[did] = []; - result[did].push({ - did, - handle: null, - uri, - collection, - rkey, - cid, - value: record, - }); - } catch { - // Skip failures silently + const fetched = await Promise.all( + missing.map(async ({ did, profile }) => { + const { collection, rkey: configRkey } = profile; + const rkey = configRkey ?? "self"; + const uri = `at://${did}/${collection}/${rkey}`; + try { + const pds = await getPDS(did as Did, db, config); + if (!pds) return null; + + const url = new URL("/xrpc/com.atproto.repo.getRecord", pds); + url.searchParams.set("repo", did); + url.searchParams.set("collection", collection); + url.searchParams.set("rkey", rkey); + + const response = await fetch(url.toString()); + if (!response.ok) return null; + const data = (await response.json()) as { + uri?: string; + value?: unknown; + cid?: string; + }; + if (!data.value || !data.cid || (data.uri && data.uri !== uri)) { + return null; } - }) - ) + + const nowUs = Date.now() * 1000; + return createIngestEvent({ + uri, + did, + collection, + rkey, + operation: "create", + cid: data.cid, + value: data.value, + timeUs: recordTimeUs(data.value, collection, config, nowUs), + indexedAt: nowUs, + source: { + id: "pds-profile", + time_us: nowUs, + revision: null, + cursor: null, + }, + }); + } catch { + return null; + } + }), ); + const events = fetched.filter((event) => event !== null); + if (events.length === 0) return {}; + const { accepted } = await ingestRecords(db, events, config); + const result: Record = {}; + for (const event of accepted) { + if (event.operation === "delete") continue; + if (!result[event.did]) result[event.did] = []; + result[event.did].push({ + did: event.did, + handle: null, + uri: event.uri, + collection: event.collection, + rkey: event.rkey, + cid: event.cid, + value: event.record ? JSON.parse(event.record) : null, + }); + } return result; } diff --git a/packages/contrail/src/core/types.ts b/packages/contrail/src/core/types.ts index b94509e..4819b81 100644 --- a/packages/contrail/src/core/types.ts +++ b/packages/contrail/src/core/types.ts @@ -1,3 +1,4 @@ +import type { LexiconDoc } from "@atcute/lexicon-doc"; import type { SqlDialect } from "./dialect"; // Database interface — D1 implements this natively @@ -143,9 +144,9 @@ export interface CollectionConfig { searchable?: string[] | false; /** XRPC methods to emit. Defaults to ['listRecords', 'getRecord']. */ methods?: CollectionMethod[]; - /** JSON field on the record used as the canonical event time, parsed and - * written into `time_us` during backfill (and clamped to now). Default - * `"createdAt"`. Set to `false` to disable parsing and keep ingest time. */ + /** JSON field used as record/application time across live ingest, backfill, + * notify, and enrichment (clamped to source observation time). Default + * `"createdAt"`. Set to `false` to use source observation time. */ timeField?: string | false; /** JSON field on the record holding a DID that this record points at * (e.g. `"subject"` for follows). When set on a `discover: false` @@ -226,11 +227,25 @@ export interface Logger { error(...args: any[]): void; } +export interface IngestValidationConfig { + /** Lexicon documents for every configured record collection and its refs. */ + lexicons: LexiconDoc[]; + /** Recompute authoritative record CIDs from canonical DAG-CBOR (default true). */ + verifyCid?: boolean; + /** Enforce strict blob size/MIME constraints as well as normal Lexicon rules (default true). */ + strict?: boolean; + /** Sources allowed to emit CID-less creates/updates. Defaults to local/synthetic sources only. */ + allowCidlessSources?: string[]; +} + export interface ContrailConfig { namespace: string; /** Collections to index, keyed by short name. Short names become endpoint URL segments * (`..listRecords`) and table suffixes (`records_`). */ collections: Record; + /** Optional shared runtime Lexicon and CID validation. When configured, + * every create/update from every source passes through it before projection. */ + validation?: IngestValidationConfig; profiles?: (string | ProfileConfig)[]; relays?: string[]; /** Jetstream endpoints to ingest from (defaults to {@link DEFAULT_JETSTREAMS}). diff --git a/packages/contrail/src/core/validation.ts b/packages/contrail/src/core/validation.ts new file mode 100644 index 0000000..e38d464 --- /dev/null +++ b/packages/contrail/src/core/validation.ts @@ -0,0 +1,152 @@ +import { encode } from "@atcute/cbor"; +import { CODEC_DCBOR, create, toString } from "@atcute/cid"; +import { + findExternalReferences, + lexiconDoc, + parseLexiconRef, + type LexiconDoc, +} from "@atcute/lexicon-doc"; +import { RecordValidator } from "@atcute/lexicon-doc/validations"; +import type { Nsid } from "@atcute/lexicons"; +import { isRecordKey } from "@atcute/lexicons/syntax"; +import { parse as parseValibot } from "valibot"; +import type { + ContrailConfig, + IngestEvent, + IngestValidationConfig, +} from "./types"; + +export type RecordValidationFailure = + | "lexicon_validation" + | "cid_mismatch" + | "cid_encoding" + | "missing_cid"; + +interface ValidationContext { + validators: Map; + strict: boolean; + verifyCid: boolean; + allowCidlessSources: ReadonlySet; +} + +const DEFAULT_CIDLESS_SOURCES = ["local", "legacy-caller", "constellation"]; +const contexts = new WeakMap(); + +function documentMap(lexicons: LexiconDoc[]): Record { + const documents: Record = {}; + for (const input of lexicons) { + let document: LexiconDoc; + try { + document = parseValibot(lexiconDoc, input); + } catch { + throw new Error("validation.lexicons contains an invalid Lexicon document"); + } + if (documents[document.id]) { + throw new Error(`duplicate validation Lexicon document: ${document.id}`); + } + documents[document.id] = document; + } + return documents; +} + +function requireReferencedDocuments( + documents: Record, + roots: Iterable, +): void { + const visited = new Set(); + const pending = [...roots]; + while (pending.length > 0) { + const nsid = pending.pop()!; + if (visited.has(nsid)) continue; + visited.add(nsid); + const document = documents[nsid]; + if (!document) { + throw new Error(`missing validation Lexicon document: ${nsid}`); + } + for (const reference of findExternalReferences(document)) { + const parsed = parseLexiconRef(reference, document.id); + const referenced = documents[parsed.nsid]; + if (!referenced) { + throw new Error(`missing validation Lexicon document: ${parsed.nsid}`); + } + if (!referenced.defs[parsed.defId]) { + throw new Error( + `missing validation Lexicon definition: ${parsed.nsid}#${parsed.defId}`, + ); + } + if (!visited.has(parsed.nsid)) pending.push(parsed.nsid); + } + } +} + +/** Build and cache one Atcute RecordValidator per configured collection. */ +export function prepareRecordValidation( + config: ContrailConfig, +): ValidationContext | null { + const validation = config.validation; + if (!validation) return null; + const cached = contexts.get(validation); + if (cached) return cached; + + const documents = documentMap(validation.lexicons); + const collections = new Set( + Object.entries(config.collections).map( + ([shortName, collection]) => collection.collection ?? shortName, + ), + ); + requireReferencedDocuments(documents, collections); + + const validators = new Map(); + for (const collection of collections) { + validators.set( + collection, + new RecordValidator(documents, collection as Nsid), + ); + } + + const context: ValidationContext = { + validators, + strict: validation.strict !== false, + verifyCid: validation.verifyCid !== false, + allowCidlessSources: new Set( + validation.allowCidlessSources ?? DEFAULT_CIDLESS_SOURCES, + ), + }; + contexts.set(validation, context); + return context; +} + +/** Validate one parsed create/update before filters, projections, or sinks. */ +export async function validateCanonicalRecord( + config: ContrailConfig, + event: IngestEvent, + record: Record, +): Promise { + const context = prepareRecordValidation(config); + if (!context) return null; + + const validator = context.validators.get(event.collection); + if (!validator || !isRecordKey(event.rkey)) return "lexicon_validation"; + try { + const result = validator.try( + { key: event.rkey, object: record }, + { strict: context.strict }, + ); + if (!result.ok) return "lexicon_validation"; + } catch { + return "lexicon_validation"; + } + if (!context.verifyCid) return null; + + if (event.cid === null) { + const sourceId = event.source?.id ?? "legacy-caller"; + return context.allowCidlessSources.has(sourceId) ? null : "missing_cid"; + } + + try { + const actual = toString(await create(CODEC_DCBOR, encode(record))); + return actual === event.cid ? null : "cid_mismatch"; + } catch { + return "cid_encoding"; + } +} diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index d8a67b0..c83d456 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -1,4 +1,5 @@ /** Contrail's public API. */ +export type { LexiconDoc } from "@atcute/lexicon-doc"; export { Contrail } from "./contrail"; export type { AppOptions, ContrailOptions } from "./contrail"; @@ -21,6 +22,8 @@ export * from "./core/jetstream"; export * from "./core/persistent"; export * from "./core/backfill"; export * from "./core/status"; +export * from "./core/diagnostics"; +export * from "./core/validation"; export * from "./core/search"; export * from "./core/constellation"; diff --git a/packages/contrail/tests/backfill-status.test.ts b/packages/contrail/tests/backfill-status.test.ts index 22fec29..4ca02d1 100644 --- a/packages/contrail/tests/backfill-status.test.ts +++ b/packages/contrail/tests/backfill-status.test.ts @@ -6,6 +6,7 @@ import { discoverDIDs, finishBackfillRun, getBackfillStatus, + getIngestDiagnostics, initSchema, queryRecords, resolveConfig, @@ -113,6 +114,15 @@ describe("backfill failure state", () => { uri: `at://${actor}/${follow}/one`, cid: "follow-cid", value: { $type: follow, subject, createdAt: "2026-01-01T00:00:00Z" } + }, + { + uri: `at://${actor}/${follow}/outside`, + cid: "outside-cid", + value: { + $type: follow, + subject: "did:plc:not-discovered", + createdAt: "2026-01-01T00:00:00Z" + } } ] }), @@ -129,6 +139,72 @@ describe("backfill failure state", () => { .first<{ source_id: string; source_time_us: number }>(); expect(version?.source_id).toBe("pds-backfill"); expect(version?.source_time_us).toBeTypeOf("number"); + expect( + (await getIngestDiagnostics(db)).find( + (entry) => entry.category === "unknown_subject", + )?.total, + ).toBe(1); + }); + + it("lets an authoritative PDS observation replace a future-skewed tombstone", async () => { + const db = await createTestDbWithSchema(); + const uri = `at://${DID}/${EVENT}/authoritative`; + await db + .prepare("INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)") + .bind(DID, "backfill.test", "https://pds.test", Date.now()) + .run(); + await ingestRecords(db, [ + { + ...makeEvent({ + uri, + did: DID, + collection: EVENT, + rkey: "authoritative", + operation: "delete", + cid: null, + record: null, + }), + source: { + id: "jetstream", + time_us: Date.now() * 1000 + 60_000_000, + revision: null, + cursor: "future-skew", + }, + }, + ]); + + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + records: [ + { + uri, + cid: "current-pds-cid", + value: { + $type: EVENT, + name: "Current PDS record", + startsAt: "2026-01-01T00:00:00Z", + mode: "virtual", + }, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await backfillUser(db, DID, EVENT, Infinity, TEST_CONFIG, { + maxRetries: 0, + }); + + const records = await queryRecords(db, TEST_CONFIG, { collection: "event" }); + expect(records.records.map((record) => record.uri)).toContain(uri); + expect( + await db + .prepare("SELECT source_id FROM record_versions WHERE uri = ?") + .bind(uri) + .first<{ source_id: string }>(), + ).toEqual({ source_id: "pds-backfill" }); }); it("starts the next queued account without waiting for a slow worker", async () => { diff --git a/packages/contrail/tests/benchmark-validation-fixture.test.ts b/packages/contrail/tests/benchmark-validation-fixture.test.ts new file mode 100644 index 0000000..da16c98 --- /dev/null +++ b/packages/contrail/tests/benchmark-validation-fixture.test.ts @@ -0,0 +1,40 @@ +import { readFile, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Contrail, type ContrailConfig, type LexiconDoc } from "../src/index"; + +const BENCHMARK_DIR = resolve(process.cwd(), "../../apps/benchmark"); + +async function loadFixtureLexicons(): Promise { + const root = resolve(BENCHMARK_DIR, "lexicons/calendar"); + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => resolve(entry.parentPath, entry.name)) + .sort() + .map(async (path) => + JSON.parse(await readFile(path, "utf8")) as LexiconDoc, + ), + ); +} + +describe("calendar benchmark validation fixture", () => { + it("contains every configured collection Lexicon and transitive reference", async () => { + const config = JSON.parse( + await readFile( + resolve(BENCHMARK_DIR, "configs/calendar.config.json"), + "utf8", + ), + ) as ContrailConfig; + const lexicons = await loadFixtureLexicons(); + + const contrail = new Contrail({ + ...config, + validation: { lexicons, strict: true, verifyCid: true }, + }); + + expect(lexicons).toHaveLength(10); + expect(contrail.config.validation?.lexicons).toHaveLength(10); + }); +}); diff --git a/packages/contrail/tests/constellation-ingest.test.ts b/packages/contrail/tests/constellation-ingest.test.ts new file mode 100644 index 0000000..134eeef --- /dev/null +++ b/packages/contrail/tests/constellation-ingest.test.ts @@ -0,0 +1,190 @@ +import { encode } from "@atcute/cbor"; +import { CODEC_DCBOR, create, toString } from "@atcute/cid"; +import { create as createTid } from "@atcute/tid"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + backfillFollowersFromConstellation, + createIngestEvent, + ingestRecords, + initSchema, + queryRecords, + resolveConfig, + type RecordEvent, +} from "../src/index"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; + +const EVENT = "com.example.event"; +const FOLLOW = "app.bsky.graph.follow"; +const SUBJECT = "did:plc:subject"; +const FOLLOWER = "did:plc:follower"; +const EVENT_URI = `at://${SUBJECT}/${EVENT}/event-one`; +const TID_TIME = 1_700_000_000_000_000; +const FOLLOW_RKEY = createTid(TID_TIME, 0); +const logger = { log() {}, warn() {}, error() {} }; + +const eventLexicon = { + lexicon: 1 as const, + id: EVENT, + defs: { + main: { + type: "record" as const, + key: "any" as const, + record: { + type: "object" as const, + required: ["name"], + properties: { name: { type: "string" as const } }, + }, + }, + }, +}; +const followLexicon = { + lexicon: 1 as const, + id: FOLLOW, + defs: { + main: { + type: "record" as const, + key: "tid" as const, + record: { + type: "object" as const, + required: ["subject", "createdAt"], + properties: { + subject: { type: "string" as const, format: "did" as const }, + createdAt: { type: "string" as const, format: "datetime" as const }, + }, + }, + }, + }, +}; + +async function cidFor(record: unknown) { + return toString(await create(CODEC_DCBOR, encode(record))); +} + +async function setup(sinkBatches: RecordEvent[][]) { + const config = resolveConfig({ + namespace: "com.example", + profiles: [], + logger, + collections: { + event: { collection: EVENT }, + follow: { + collection: FOLLOW, + discover: false, + subjectField: "subject", + }, + }, + feeds: { + network: { follow: "follow", targets: ["event"] }, + }, + validation: { lexicons: [eventLexicon, followLexicon] }, + constellation: { + url: "https://constellation.example.com", + userAgent: "contrail-test", + }, + sinks: [ + { + async onRecords(records) { + sinkBatches.push(records); + }, + }, + ], + }); + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + for (const did of [SUBJECT, FOLLOWER]) { + await db + .prepare( + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)", + ) + .bind(did, null, "https://pds.example.com", 1) + .run(); + } + const event = { $type: EVENT, name: "Subject event" }; + await ingestRecords( + db, + [ + createIngestEvent({ + uri: EVENT_URI, + did: SUBJECT, + collection: EVENT, + rkey: "event-one", + operation: "create", + cid: await cidFor(event), + value: event, + timeUs: TID_TIME, + indexedAt: TID_TIME, + source: { + id: "pds-backfill", + time_us: TID_TIME, + revision: null, + cursor: null, + }, + }), + ], + config, + ); + sinkBatches.length = 0; + return { config, db }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Constellation enrichment through shared ingestion", () => { + it("uses normal validation/feed/sink projection and is idempotent", async () => { + const sinkBatches: RecordEvent[][] = []; + const { config, db } = await setup(sinkBatches); + const fetchSpy = vi.fn(async () => + new Response( + JSON.stringify({ + links: [ + { did: FOLLOWER, rkey: FOLLOW_RKEY }, + { + uri: `at://${FOLLOWER}/com.example.wrong/${FOLLOW_RKEY}`, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchSpy); + + expect( + await backfillFollowersFromConstellation(db, config, SUBJECT), + ).toBe(1); + expect( + await backfillFollowersFromConstellation(db, config, SUBJECT), + ).toBe(0); + + expect( + (await queryRecords(db, config, { collection: "follow" })).records, + ).toHaveLength(1); + expect( + await db + .prepare( + "SELECT source_id, source_revision, source_time_us FROM record_versions WHERE uri = ?", + ) + .bind(`at://${FOLLOWER}/${FOLLOW}/${FOLLOW_RKEY}`) + .first(), + ).toEqual({ + source_id: "constellation", + source_revision: FOLLOW_RKEY, + source_time_us: TID_TIME, + }); + expect( + await db + .prepare( + "SELECT actor, uri FROM feed_items WHERE actor = ? AND uri = ?", + ) + .bind(FOLLOWER, EVENT_URI) + .first(), + ).toEqual({ actor: FOLLOWER, uri: EVENT_URI }); + expect(sinkBatches).toHaveLength(1); + expect(sinkBatches[0]).toHaveLength(1); + expect(sinkBatches[0][0]).toMatchObject({ + kind: "created", + collection: FOLLOW, + }); + }); +}); diff --git a/packages/contrail/tests/ingest.test.ts b/packages/contrail/tests/ingest.test.ts index bbcb843..eefa59b 100644 --- a/packages/contrail/tests/ingest.test.ts +++ b/packages/contrail/tests/ingest.test.ts @@ -285,6 +285,12 @@ describe("ingestRecords", () => { const result = await ingestRecords(db, records, config); expect(result.accepted.map((record) => record.rkey)).toEqual(["f0"]); expect(result.dropped.unknownSubject).toBe(1); + expect( + await db + .prepare("SELECT uri FROM record_versions WHERE uri = ?") + .bind(records[1].uri) + .first(), + ).toBeNull(); }); it("always admits deletes when another mutation for the same URI is filtered", async () => { diff --git a/packages/contrail/tests/postgres-concurrent-init.test.ts b/packages/contrail/tests/postgres-concurrent-init.test.ts index c24d355..954bbb3 100644 --- a/packages/contrail/tests/postgres-concurrent-init.test.ts +++ b/packages/contrail/tests/postgres-concurrent-init.test.ts @@ -71,7 +71,7 @@ if (!PG_URL) { const tables = await pool.query( `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'records_%' OR tablename LIKE 'fts_%' - OR tablename IN ('backfills', 'discovery', 'cursor', 'identities', 'feed_items', 'feed_backfills'))` + OR tablename IN ('_contrail_meta', 'backfills', 'backfill_state', 'discovery', 'cursor', 'identities', 'record_versions', 'ingest_diagnostics', 'feed_items', 'feed_prune_cursor', 'feed_backfills'))` ); for (const { tablename } of tables.rows) { await pool.query(`DROP TABLE IF EXISTS ${tablename} CASCADE`); diff --git a/packages/contrail/tests/postgres-e2e.test.ts b/packages/contrail/tests/postgres-e2e.test.ts index 5510f02..e5032ea 100644 --- a/packages/contrail/tests/postgres-e2e.test.ts +++ b/packages/contrail/tests/postgres-e2e.test.ts @@ -78,7 +78,7 @@ if (!PG_URL) { const tables = await pool.query( `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'records_%' OR tablename LIKE 'fts_%' - OR tablename IN ('backfills', 'discovery', 'cursor', 'identities', 'feed_items', 'feed_backfills'))` + OR tablename IN ('_contrail_meta', 'backfills', 'backfill_state', 'discovery', 'cursor', 'identities', 'record_versions', 'ingest_diagnostics', 'feed_items', 'feed_prune_cursor', 'feed_backfills'))` ); for (const { tablename } of tables.rows) { await pool.query(`DROP TABLE IF EXISTS ${tablename} CASCADE`); diff --git a/packages/contrail/tests/postgres.test.ts b/packages/contrail/tests/postgres.test.ts index 7bc29cf..2738dc6 100644 --- a/packages/contrail/tests/postgres.test.ts +++ b/packages/contrail/tests/postgres.test.ts @@ -57,7 +57,7 @@ if (!PG_URL) { const tables = await pool.query( `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND (tablename LIKE 'records_%' OR tablename LIKE 'fts_%' - OR tablename IN ('backfills', 'discovery', 'cursor', 'identities', 'feed_items', 'feed_backfills'))` + OR tablename IN ('_contrail_meta', 'backfills', 'backfill_state', 'discovery', 'cursor', 'identities', 'record_versions', 'ingest_diagnostics', 'feed_items', 'feed_prune_cursor', 'feed_backfills'))` ); for (const { tablename } of tables.rows) { await pool.query(`DROP TABLE IF EXISTS ${tablename} CASCADE`); diff --git a/packages/contrail/tests/profiles-ingest.test.ts b/packages/contrail/tests/profiles-ingest.test.ts new file mode 100644 index 0000000..222b3a0 --- /dev/null +++ b/packages/contrail/tests/profiles-ingest.test.ts @@ -0,0 +1,198 @@ +import { encode } from "@atcute/cbor"; +import { CODEC_DCBOR, create, toString } from "@atcute/cid"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createIngestEvent, + ingestRecords, + initSchema, + queryRecords, + resolveConfig, + resolveProfiles, + type Database, + type RecordEvent, +} from "../src/index"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; + +const DID = "did:plc:profile-test"; +const PROFILE_A = "com.example.profileA"; +const PROFILE_B = "com.example.profileB"; +const logger = { log() {}, warn() {}, error() {} }; + +function profileLexicon(id: string) { + return { + lexicon: 1 as const, + id, + defs: { + main: { + type: "record" as const, + key: "literal:self" as const, + record: { + type: "object" as const, + required: ["displayName"], + properties: { + displayName: { type: "string" as const, maxLength: 100 }, + }, + }, + }, + }, + }; +} + +async function cidFor(record: unknown) { + return toString(await create(CODEC_DCBOR, encode(record))); +} + +async function setup(options?: { rejectB?: boolean; sink?: RecordEvent[][] }) { + const config = resolveConfig({ + namespace: "com.example", + logger, + profiles: [ + { collection: PROFILE_A, shortName: "profileA" }, + { collection: PROFILE_B, shortName: "profileB" }, + ], + collections: { + profileA: { collection: PROFILE_A }, + profileB: { + collection: PROFILE_B, + searchable: ["displayName"], + recordFilter: options?.rejectB + ? (record) => record.displayName !== "Rejected" + : undefined, + }, + }, + validation: { + lexicons: [profileLexicon(PROFILE_A), profileLexicon(PROFILE_B)], + }, + sinks: options?.sink + ? [ + { + async onRecords(records) { + options.sink!.push(records); + }, + }, + ] + : undefined, + }); + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + await db + .prepare( + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)", + ) + .bind(DID, "profile.test", "https://pds.example.com", Date.now()) + .run(); + return { config, db }; +} + +async function seedProfileA(db: Database, config: ReturnType) { + const record = { $type: PROFILE_A, displayName: "Cached A" }; + await ingestRecords( + db, + [ + createIngestEvent({ + did: DID, + collection: PROFILE_A, + rkey: "self", + operation: "create", + cid: await cidFor(record), + value: record, + timeUs: 100, + indexedAt: 100, + source: { + id: "pds-backfill", + time_us: 100, + revision: null, + cursor: null, + }, + }), + ], + config, + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("profile enrichment through shared ingestion", () => { + it("checks each profile collection and applies validation, FTS, sinks, and source metadata", async () => { + const sinkBatches: RecordEvent[][] = []; + const { config, db } = await setup({ sink: sinkBatches }); + await seedProfileA(db, config); + sinkBatches.length = 0; + const profileB = { $type: PROFILE_B, displayName: "Fetched B" }; + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.searchParams.get("collection")).toBe(PROFILE_B); + return new Response( + JSON.stringify({ + uri: `at://${DID}/${PROFILE_B}/self`, + cid: await cidFor(profileB), + value: profileB, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchSpy); + + const profiles = await resolveProfiles(db, config, [DID]); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(profiles[DID].map((entry) => entry.collection).sort()).toEqual([ + PROFILE_A, + PROFILE_B, + ]); + expect(sinkBatches).toHaveLength(1); + expect(sinkBatches[0][0].collection).toBe(PROFILE_B); + expect( + await db + .prepare( + "SELECT source_id, indexed_at FROM record_versions WHERE uri = ?", + ) + .bind(`at://${DID}/${PROFILE_B}/self`) + .first(), + ).toMatchObject({ + source_id: "pds-profile", + indexed_at: expect.any(Number), + }); + expect( + ( + await queryRecords(db, config, { + collection: "profileB", + search: "Fetched", + }) + ).records, + ).toHaveLength(1); + }); + + it("does not return or persist a fetched profile rejected by normal admission", async () => { + const sinkBatches: RecordEvent[][] = []; + const { config, db } = await setup({ rejectB: true, sink: sinkBatches }); + await seedProfileA(db, config); + sinkBatches.length = 0; + const rejected = { $type: PROFILE_B, displayName: "Rejected" }; + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + uri: `at://${DID}/${PROFILE_B}/self`, + cid: await cidFor(rejected), + value: rejected, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + + const profiles = await resolveProfiles(db, config, [DID]); + + expect(profiles[DID].map((entry) => entry.collection)).toEqual([PROFILE_A]); + expect( + (await queryRecords(db, config, { collection: "profileB" })).records, + ).toHaveLength(0); + // The exclusion is ordering state, not a visible create/delete, so it does + // not reach extensions when no prior profile existed. + expect(sinkBatches).toHaveLength(0); + }); +}); diff --git a/packages/contrail/tests/schema.test.ts b/packages/contrail/tests/schema.test.ts index 9b53158..15643dc 100644 --- a/packages/contrail/tests/schema.test.ts +++ b/packages/contrail/tests/schema.test.ts @@ -20,6 +20,7 @@ describe("initSchema", () => { expect(names).toContain("cursor"); expect(names).toContain("identities"); expect(names).toContain("record_versions"); + expect(names).toContain("ingest_diagnostics"); const backfillColumns = await db .prepare("PRAGMA table_info(backfills)") diff --git a/packages/contrail/tests/validation-paths.test.ts b/packages/contrail/tests/validation-paths.test.ts new file mode 100644 index 0000000..5b7e3ae --- /dev/null +++ b/packages/contrail/tests/validation-paths.test.ts @@ -0,0 +1,155 @@ +import { encode } from "@atcute/cbor"; +import { CODEC_DCBOR, create, toString } from "@atcute/cid"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + backfillUser, + getIngestDiagnostics, + initSchema, + processNotifyUris, + queryRecords, + resolveConfig, +} from "../src/index"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { __resetPdsCachesForTests } from "../src/core/client"; + +const DID = "did:plc:validation-path"; +const COLLECTION = "com.example.event"; +const URI = `at://${DID}/${COLLECTION}/one`; +const WRONG_CID = + "bafyreihvzsz6wxhv5idsmsjfbx5jdmfrqx3h4oqw2vvxpwzcdpavqzkp4m"; +const record = { + $type: COLLECTION, + name: "Valid", + createdAt: "2026-01-01T00:00:00.000Z", +}; +const logger = { log() {}, warn() {}, error() {} }; +const config = resolveConfig({ + namespace: "com.example", + profiles: [], + logger, + collections: { event: { collection: COLLECTION } }, + validation: { + lexicons: [ + { + lexicon: 1, + id: COLLECTION, + defs: { + main: { + type: "record", + key: "any", + record: { + type: "object", + required: ["name", "createdAt"], + properties: { + name: { type: "string" }, + createdAt: { type: "string", format: "datetime" }, + }, + }, + }, + }, + }, + ], + }, +}); + +async function setup() { + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + await db + .prepare( + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)", + ) + .bind(DID, "validation.test", "https://pds.example.com", Date.now()) + .run(); + return db; +} + +async function validCid() { + return toString(await create(CODEC_DCBOR, encode(record))); +} + +beforeEach(() => { + __resetPdsCachesForTests(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + __resetPdsCachesForTests(); +}); + +describe("validation across acquisition paths", () => { + it.each([ + ["valid", true], + ["mismatched", false], + ])("applies the same CID policy to PDS backfill: %s", async (_name, valid) => { + const db = await setup(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + records: [ + { + uri: URI, + cid: valid ? await validCid() : WRONG_CID, + value: record, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + + const accepted = await backfillUser( + db, + DID, + COLLECTION, + Infinity, + config, + { maxRetries: 0 }, + ); + + expect(accepted).toBe(valid ? 1 : 0); + expect( + (await queryRecords(db, config, { collection: "event" })).records, + ).toHaveLength(valid ? 1 : 0); + expect( + (await getIngestDiagnostics(db)).find( + ({ category }) => category === "cid_mismatch", + )?.total, + ).toBe(valid ? 0 : 1); + }); + + it.each([ + ["valid", true], + ["mismatched", false], + ])("applies the same CID policy to notify/PDS repair: %s", async (_name, valid) => { + const db = await setup(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + uri: URI, + cid: valid ? await validCid() : WRONG_CID, + value: record, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + + const result = await processNotifyUris(db, config, [URI]); + + expect(result.indexed).toBe(valid ? 1 : 0); + expect( + (await queryRecords(db, config, { collection: "event" })).records, + ).toHaveLength(valid ? 1 : 0); + expect( + (await getIngestDiagnostics(db)).find( + ({ category }) => category === "cid_mismatch", + )?.total, + ).toBe(valid ? 0 : 1); + }); +}); diff --git a/packages/contrail/tests/validation.test.ts b/packages/contrail/tests/validation.test.ts new file mode 100644 index 0000000..fea4b2a --- /dev/null +++ b/packages/contrail/tests/validation.test.ts @@ -0,0 +1,297 @@ +import { encode } from "@atcute/cbor"; +import { CODEC_DCBOR, create, toString } from "@atcute/cid"; +import { describe, expect, it } from "vitest"; +import { + Contrail, + createIngestEvent, + getIngestDiagnostics, + ingestRecords, + initSchema, + prepareRecordValidation, + queryRecords, + resolveConfig, + saveCursorStatement, + type ContrailConfig, + type Database, +} from "../src/index"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; + +const COLLECTION = "com.example.event"; +const LEXICON = { + lexicon: 1, + id: COLLECTION, + defs: { + main: { + type: "record", + key: "any", + record: { + type: "object", + required: ["name", "createdAt"], + properties: { + name: { type: "string", maxLength: 10 }, + createdAt: { type: "string", format: "datetime" }, + }, + }, + }, + }, +} as const; +const logger = { log() {}, warn() {}, error() {} }; + +function validatedConfig(overrides: Partial = {}) { + return resolveConfig({ + namespace: "com.example", + profiles: [], + logger, + collections: { + event: { collection: COLLECTION }, + }, + validation: { + lexicons: [LEXICON], + ...overrides.validation, + }, + ...overrides, + }); +} + +async function setup(config = validatedConfig()): Promise { + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + return db; +} + +async function cidFor(record: unknown): Promise { + return toString(await create(CODEC_DCBOR, encode(record))); +} + +async function event(options: { + record?: Record; + cid?: string | null; + rkey?: string; + sourceId?: string; +}) { + const record = options.record ?? { + $type: COLLECTION, + name: "valid", + createdAt: "2026-01-01T00:00:00.000Z", + }; + const cid = + options.cid === undefined ? await cidFor(record) : options.cid; + return createIngestEvent({ + did: "did:plc:alice", + collection: COLLECTION, + rkey: options.rkey ?? "one", + operation: "create", + cid, + value: record, + timeUs: 100, + indexedAt: 200, + source: { + id: options.sourceId ?? "jetstream", + time_us: 100, + revision: "1", + cursor: "100", + }, + }); +} + +describe("runtime record validation", () => { + it("admits a valid Lexicon record with its canonical CID", async () => { + const config = validatedConfig(); + const db = await setup(config); + const result = await ingestRecords(db, [await event({})], config); + + expect(result.accepted).toHaveLength(1); + expect( + (await queryRecords(db, config, { collection: "event" })).records, + ).toHaveLength(1); + }); + + it.each([ + [ + "wrong $type", + { + $type: "com.example.wrong", + name: "valid", + createdAt: "2026-01-01T00:00:00.000Z", + }, + "one", + ], + [ + "missing required field", + { $type: COLLECTION, createdAt: "2026-01-01T00:00:00.000Z" }, + "one", + ], + [ + "maximum length", + { + $type: COLLECTION, + name: "far too long", + createdAt: "2026-01-01T00:00:00.000Z", + }, + "one", + ], + [ + "datetime syntax", + { $type: COLLECTION, name: "valid", createdAt: "yesterday-ish" }, + "one", + ], + [ + "record key syntax", + { + $type: COLLECTION, + name: "valid", + createdAt: "2026-01-01T00:00:00.000Z", + }, + "bad key", + ], + ])("rejects invalid Lexicon input: %s", async (_name, record, rkey) => { + const config = validatedConfig(); + const db = await setup(config); + const result = await ingestRecords( + db, + [await event({ record, rkey })], + config, + ); + + expect(result.accepted).toHaveLength(0); + expect(result.dropped.lexiconValidation).toBe(1); + expect( + (await queryRecords(db, config, { collection: "event" })).records, + ).toHaveLength(0); + }); + + it("rejects a record whose claimed CID does not match canonical DAG-CBOR", async () => { + const config = validatedConfig(); + const db = await setup(config); + const result = await ingestRecords( + db, + [ + await event({ + cid: "bafyreihvzsz6wxhv5idsmsjfbx5jdmfrqx3h4oqw2vvxpwzcdpavqzkp4m", + }), + ], + config, + ); + + expect(result.accepted).toHaveLength(0); + expect(result.dropped.cidMismatch).toBe(1); + }); + + it("requires authoritative sources to provide a CID", async () => { + const config = validatedConfig(); + const db = await setup(config); + const result = await ingestRecords( + db, + [await event({ cid: null, sourceId: "pds-backfill" })], + config, + ); + + expect(result.accepted).toHaveLength(0); + expect(result.dropped.missingCid).toBe(1); + }); + + it("allows configured local/synthetic sources to omit a CID", async () => { + const config = validatedConfig(); + const db = await setup(config); + const result = await ingestRecords( + db, + [await event({ cid: null, sourceId: "local" })], + config, + ); + + expect(result.accepted).toHaveLength(1); + }); + + it("caches one validator set per validation config", () => { + const config = validatedConfig(); + expect(prepareRecordValidation(config)).toBe(prepareRecordValidation(config)); + }); + + it("fails configuration early when a collection Lexicon is missing", () => { + expect( + () => + new Contrail({ + namespace: "com.example", + profiles: [], + collections: { event: { collection: COLLECTION } }, + validation: { lexicons: [] }, + }), + ).toThrow(`missing validation Lexicon document: ${COLLECTION}`); + }); + + it("rolls diagnostics and a source cursor back together", async () => { + const config = validatedConfig(); + const db = await setup(config); + await db.prepare("CREATE TABLE diagnostic_failure (value TEXT UNIQUE)").run(); + await db + .prepare("INSERT INTO diagnostic_failure (value) VALUES ('duplicate')") + .run(); + + await expect( + ingestRecords( + db, + [ + await event({ + record: { + $type: COLLECTION, + name: "far too long", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }), + ], + config, + { + trailingStatements: [ + saveCursorStatement(db, 100), + db.prepare( + "INSERT INTO diagnostic_failure (value) VALUES ('duplicate')", + ), + ], + }, + ), + ).rejects.toThrow(); + + expect( + (await getIngestDiagnostics(db)).find( + ({ category }) => category === "lexicon_validation", + )?.total, + ).toBe(0); + expect(await db.prepare("SELECT time_us FROM cursor").first()).toBeNull(); + }); + + it("persists bounded aggregate diagnostics without record identifiers", async () => { + const config = validatedConfig(); + const db = await setup(config); + await ingestRecords( + db, + [ + await event({ + record: { + $type: COLLECTION, + name: "far too long", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }), + await event({ cid: null, sourceId: "jetstream" }), + ], + config, + ); + + const diagnostics = await getIngestDiagnostics(db); + expect( + diagnostics.find(({ category }) => category === "lexicon_validation") + ?.total, + ).toBe(1); + expect( + diagnostics.find(({ category }) => category === "missing_cid")?.total, + ).toBe(1); + const columns = await db + .prepare("PRAGMA table_info(ingest_diagnostics)") + .all<{ name: string }>(); + expect(columns.results.map(({ name }) => name)).toEqual([ + "category", + "total", + "last_seen_at", + ]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcb4895..229e75e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: version: link:../../packages/contrail devDependencies: '@cloudflare/workers-types': - specifier: ^5.20260804.1 - version: 5.20260804.1 + specifier: 4.20260424.1 + version: 4.20260424.1 '@types/node': specifier: ^26.1.2 version: 26.1.2 @@ -34,8 +34,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 wrangler: - specifier: ^4.118.0 - version: 4.118.0(@cloudflare/workers-types@5.20260804.1) + specifier: 4.84.1 + version: 4.84.1(@cloudflare/workers-types@4.20260424.1) apps/cloudflare-workers: dependencies: @@ -45,7 +45,7 @@ importers: devDependencies: '@atcute/lex-cli': specifier: ^3.2.1 - version: 3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(prettier@3.9.6)(typescript@6.0.3) + version: 3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.6)(typescript@6.0.3) '@cloudflare/workers-types': specifier: ^5.20260804.1 version: 5.20260804.1 @@ -110,7 +110,7 @@ importers: version: 2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/lex-cli': specifier: ^3.2.1 - version: 3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(prettier@3.9.6)(typescript@6.0.3) + version: 3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.6)(typescript@6.0.3) '@atcute/lexicon-doc': specifier: ^3.0.2 version: 3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) @@ -203,7 +203,10 @@ importers: version: 4.0.4(@atcute/lexicons@2.0.3) '@atcute/cbor': specifier: ^2.3.6 - version: 2.3.6(@atcute/cid@2.4.1) + version: 2.3.6(@atcute/cid@2.4.2) + '@atcute/cid': + specifier: 2.4.2 + version: 2.4.2 '@atcute/client': specifier: ^5.1.1 version: 5.1.1(@atcute/lexicons@2.0.3)(typescript@6.0.3) @@ -213,9 +216,15 @@ importers: '@atcute/jetstream': specifier: ^2.0.2 version: 2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/lexicon-doc': + specifier: 3.0.2 + version: 3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/lexicons': specifier: ^2.0.3 version: 2.0.3 + '@atcute/tid': + specifier: 1.1.4 + version: 1.1.4 cac: specifier: ^7.0.0 version: 7.0.0 @@ -225,6 +234,9 @@ importers: jiti: specifier: ^2.7.0 version: 2.7.0 + valibot: + specifier: 1.4.2 + version: 1.4.2(typescript@6.0.3) devDependencies: '@cloudflare/workers-types': specifier: ^5.20260804.1 @@ -287,8 +299,8 @@ packages: peerDependencies: '@atcute/cid': ^2.0.0 - '@atcute/cid@2.4.1': - resolution: {integrity: sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ==} + '@atcute/cid@2.4.2': + resolution: {integrity: sha512-Uy48yfyo/hPQXF+XMXWIGomF6v8IvX5ErjozXMV7rlfU3EV7PSVX3J7plJXV6MRC3iI1z3PgTZTS7V0drCQVVw==} '@atcute/client@5.1.1': resolution: {integrity: sha512-cn5/Zi/qo37WtQG6gzIC7JPs0RDzX9Z4eaceX45SpKgLZoc3fCFDJcE7C8xsbxBNfjry2T6PmUxWA8obebZsEQ==} @@ -447,10 +459,23 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} + engines: {node: '>=18.0.0'} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} + '@cloudflare/unenv-preset@2.16.0': + resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0 + peerDependenciesMeta: + workerd: + optional: true + '@cloudflare/unenv-preset@2.16.1': resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} peerDependencies: @@ -460,38 +485,68 @@ packages: workerd: optional: true + '@cloudflare/workerd-darwin-64@1.20260421.1': + resolution: {integrity: sha512-DLU5ZTZ1VHeZZnj0PuVJEMHKGisfLe2XShyImP5P/PPj/m/t7CLEJmPiI7FMxvT7ynArkckJl7m+Z5x7u4Kkdw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260730.1': resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260421.1': + resolution: {integrity: sha512-Trotq3xRAkIcpC505WoxM8+kIH4JIvOJCNuRatyHcz9uF5S+ukgiVUFUlM+GIjw1uCM/Bda2St+vSniX1RZdpw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260730.1': resolution: {integrity: sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-linux-64@1.20260421.1': + resolution: {integrity: sha512-938QjUv0z+QqK6BAvgwX/lCIZ2b224ZXoXtGTbhyNVMhB+mt4Dj24cj9qca4ekNXjVM7uTKp1yOHZO97fVSacw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-64@1.20260730.1': resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260421.1': + resolution: {integrity: sha512-YI4+mLfwnJcKJ+iPyxzx+tp2Jy4o29BxBPSQGZxl/AZyvZ9eTKsmNZmtjEiT4i3O/M0tdO/B/d9ESDHbRCs2rQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260730.1': resolution: {integrity: sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-windows-64@1.20260421.1': + resolution: {integrity: sha512-q1SFgwlNH9lFmw74vh7EJbJtduo92Nx51mNOfd3/u6pux6AldcwRviYzKEEv3FEbtv6OBB7J8D5f8vtZj7Z6Sg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workerd-windows-64@1.20260730.1': resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260702.1': - resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + '@cloudflare/workers-types@4.20260424.1': + resolution: {integrity: sha512-0DLJ9yEk1KKzPbqop80Gw/P1wkKKzawmipULiJWdBXIBCoMvE0OVWms3IrL/Q/G7tfmPop9yF4XlZ69k9JLYng==} '@cloudflare/workers-types@5.20260804.1': resolution: {integrity: sha512-B1dwxpN6e5RZXZkE5zpZj+ooNeNZ1mwavLIyHDYe10ojhlGTwDfe8sAl7R1mMXc1cyIsbr+jKVdvmMEyVcdTdg==} @@ -503,6 +558,12 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -515,6 +576,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} @@ -527,6 +594,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} @@ -539,6 +612,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} @@ -551,6 +630,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} @@ -563,6 +648,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} @@ -575,6 +666,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} @@ -587,6 +684,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} @@ -599,6 +702,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} @@ -611,6 +720,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} @@ -623,6 +738,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} @@ -635,6 +756,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} @@ -647,6 +774,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} @@ -659,6 +792,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} @@ -671,6 +810,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} @@ -683,6 +828,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} @@ -695,6 +846,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -707,6 +864,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} @@ -719,6 +882,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -731,6 +900,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} @@ -743,6 +918,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -755,6 +936,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} @@ -767,6 +954,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} @@ -779,6 +972,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} @@ -791,6 +990,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} @@ -803,6 +1008,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -926,12 +1137,24 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-arm64@0.35.2': resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + '@img/sharp-darwin-x64@0.35.2': resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} engines: {node: '>=20.9.0'} @@ -943,64 +1166,129 @@ packages: engines: {node: '>=20.9.0'} os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.1': resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.1': resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.1': resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.1': resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.1': resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.1': resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.1': resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.1': resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.1': resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm64@0.35.2': resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} engines: {node: '>=20.9.0'} @@ -1008,6 +1296,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.35.2': resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} engines: {node: '>=20.9.0'} @@ -1015,6 +1310,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.35.2': resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} engines: {node: '>=20.9.0'} @@ -1022,6 +1324,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.35.2': resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} engines: {node: '>=20.9.0'} @@ -1029,6 +1338,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.35.2': resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} engines: {node: '>=20.9.0'} @@ -1036,6 +1352,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.35.2': resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} engines: {node: '>=20.9.0'} @@ -1043,6 +1366,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.2': resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} engines: {node: '>=20.9.0'} @@ -1050,6 +1380,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.2': resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} engines: {node: '>=20.9.0'} @@ -1057,6 +1394,11 @@ packages: os: [linux] libc: [musl] + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + '@img/sharp-wasm32@0.35.2': resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} engines: {node: '>=20.9.0'} @@ -1066,18 +1408,36 @@ packages: engines: {node: '>=20.9.0'} cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-arm64@0.35.2': resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-ia32@0.35.2': resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@img/sharp-win32-x64@0.35.2': resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} engines: {node: '>=20.9.0'} @@ -2109,6 +2469,11 @@ packages: es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -2624,6 +2989,11 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + miniflare@4.20260421.0: + resolution: {integrity: sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw==} + engines: {node: '>=18.0.0'} + hasBin: true + miniflare@5.20260730.0-alpha: resolution: {integrity: sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==} engines: {node: '>=22.0.0'} @@ -3073,6 +3443,10 @@ packages: set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.2: resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} engines: {node: '>=20.9.0'} @@ -3324,6 +3698,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -3481,6 +3859,11 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20260421.1: + resolution: {integrity: sha512-zTYD+xFR4d7TUCxsyl7FTPth9a8CDgk8pM7xUWbJxo0SGUx+2e5C7Q5LrramBZwmuAErtzXmOjlQ15PtkPAhZA==} + engines: {node: '>=16'} + hasBin: true + workerd@1.20260730.1: resolution: {integrity: sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==} engines: {node: '>=16'} @@ -3500,6 +3883,28 @@ packages: '@cloudflare/workers-types': optional: true + wrangler@4.84.1: + resolution: {integrity: sha512-Xe1S/Bik7pNdtdJ+asHsEZC2dX9k3WxYn2BbxFtOrrLVxN/LKi750zsrjX41jSAk00M/O1l7jzyQV4sQqw8ftg==} + engines: {node: '>=20.3.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260421.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -3559,20 +3964,20 @@ snapshots: '@atcute/atproto': 4.0.4(@atcute/lexicons@2.0.3) '@atcute/lexicons': 2.0.3 - '@atcute/car@6.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)': + '@atcute/car@6.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)': dependencies: - '@atcute/cbor': 2.3.6(@atcute/cid@2.4.1) - '@atcute/cid': 2.4.1 + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) + '@atcute/cid': 2.4.2 '@atcute/uint8array': 1.1.5 '@atcute/varint': 2.0.2 - '@atcute/cbor@2.3.6(@atcute/cid@2.4.1)': + '@atcute/cbor@2.3.6(@atcute/cid@2.4.2)': dependencies: - '@atcute/cid': 2.4.1 + '@atcute/cid': 2.4.2 '@atcute/multibase': 1.2.5 '@atcute/uint8array': 1.1.5 - '@atcute/cid@2.4.1': + '@atcute/cid@2.4.2': dependencies: '@atcute/multibase': 1.2.5 '@atcute/uint8array': 1.1.5 @@ -3618,12 +4023,12 @@ snapshots: - react - typescript - '@atcute/lex-cli@3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(prettier@3.9.6)(typescript@6.0.3)': + '@atcute/lex-cli@3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.6)(typescript@6.0.3)': dependencies: '@atcute/identity': 2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/identity-resolver': 2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/lexicon-doc': 3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) - '@atcute/lexicon-resolver': 1.0.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/lexicon-resolver': 1.0.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/lexicons': 2.0.3 '@oomfware/kempt': 0.1.4 '@optique/core': 1.2.0 @@ -3647,14 +4052,14 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/lexicon-resolver@1.0.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3)': + '@atcute/lexicon-resolver@1.0.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3)': dependencies: '@atcute/crypto': 2.4.4 '@atcute/identity': 2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/identity-resolver': 2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/lexicon-doc': 3.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/lexicons': 2.0.3 - '@atcute/repo': 1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.3) + '@atcute/repo': 1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.3) '@atcute/util-fetch': 2.0.2(typescript@6.0.3) valibot: 1.4.2(typescript@6.0.3) transitivePeerDependencies: @@ -3669,10 +4074,10 @@ snapshots: '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 - '@atcute/mst@1.0.3(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)': + '@atcute/mst@1.0.3(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)': dependencies: - '@atcute/cbor': 2.3.6(@atcute/cid@2.4.1) - '@atcute/cid': 2.4.1 + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) + '@atcute/cid': 2.4.2 '@atcute/uint8array': 1.1.5 '@atcute/multibase@1.2.5': @@ -3718,14 +4123,14 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/repo@1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.3)': + '@atcute/repo@1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.3)': dependencies: - '@atcute/car': 6.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1) - '@atcute/cbor': 2.3.6(@atcute/cid@2.4.1) - '@atcute/cid': 2.4.1 + '@atcute/car': 6.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2) + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) + '@atcute/cid': 2.4.2 '@atcute/crypto': 2.4.4 '@atcute/lexicons': 2.0.3 - '@atcute/mst': 1.0.3(@atcute/cbor@2.3.6(@atcute/cid@2.4.1))(@atcute/cid@2.4.1) + '@atcute/mst': 1.0.3(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2) '@atcute/uint8array': 1.1.5 '@atcute/tid@1.1.4': @@ -3893,30 +4298,53 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@cloudflare/kv-asset-handler@0.4.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} + '@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260421.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260421.1 + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: workerd: 1.20260730.1 + '@cloudflare/workerd-darwin-64@1.20260421.1': + optional: true + '@cloudflare/workerd-darwin-64@1.20260730.1': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260421.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20260730.1': optional: true + '@cloudflare/workerd-linux-64@1.20260421.1': + optional: true + '@cloudflare/workerd-linux-64@1.20260730.1': optional: true + '@cloudflare/workerd-linux-arm64@1.20260421.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20260730.1': optional: true + '@cloudflare/workerd-windows-64@1.20260421.1': + optional: true + '@cloudflare/workerd-windows-64@1.20260730.1': optional: true - '@cloudflare/workers-types@4.20260702.1': {} + '@cloudflare/workers-types@4.20260424.1': {} '@cloudflare/workers-types@5.20260804.1': {} @@ -3929,156 +4357,234 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-arm@0.27.7': optional: true '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/android-x64@0.27.7': optional: true '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -4261,11 +4767,21 @@ snapshots: '@img/colour@1.1.0': {} + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + '@img/sharp-darwin-arm64@0.35.2': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + '@img/sharp-darwin-x64@0.35.2': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.3.1 @@ -4276,76 +4792,151 @@ snapshots: '@img/sharp-wasm32': 0.35.2 optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + '@img/sharp-libvips-darwin-x64@1.3.1': optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm64@1.3.1': optional: true + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm@1.3.1': optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + '@img/sharp-libvips-linux-s390x@1.3.1': optional: true + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + '@img/sharp-libvips-linux-x64@1.3.1': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + '@img/sharp-linux-arm64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + '@img/sharp-linux-arm@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.3.1 optional: true + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + '@img/sharp-linux-ppc64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + '@img/sharp-linux-riscv64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + '@img/sharp-linux-s390x@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + '@img/sharp-linux-x64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.3.1 optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-arm64@0.35.2': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-x64@0.35.2': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.3.1 optional: true + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + '@img/sharp-wasm32@0.35.2': dependencies: '@emnapi/runtime': 1.11.3 @@ -4356,12 +4947,21 @@ snapshots: '@img/sharp-wasm32': 0.35.2 optional: true + '@img/sharp-win32-arm64@0.34.5': + optional: true + '@img/sharp-win32-arm64@0.35.2': optional: true + '@img/sharp-win32-ia32@0.34.5': + optional: true + '@img/sharp-win32-ia32@0.35.2': optional: true + '@img/sharp-win32-x64@0.34.5': + optional: true + '@img/sharp-win32-x64@0.35.2': optional: true @@ -4608,7 +5208,7 @@ snapshots: '@sveltejs/adapter-cloudflare@7.2.9(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(wrangler@4.118.0(@cloudflare/workers-types@5.20260804.1))': dependencies: - '@cloudflare/workers-types': 4.20260702.1 + '@cloudflare/workers-types': 4.20260424.1 '@sveltejs/kit': 2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)) worktop: 0.8.0-next.18 wrangler: 4.118.0(@cloudflare/workers-types@5.20260804.1) @@ -5251,6 +5851,35 @@ snapshots: es-module-lexer@2.3.1: {} + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -5746,6 +6375,18 @@ snapshots: mini-svg-data-uri@1.4.4: {} + miniflare@4.20260421.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260421.1 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + miniflare@5.20260730.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -6165,6 +6806,37 @@ snapshots: set-cookie-parser@3.1.2: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + sharp@0.35.2: dependencies: '@img/colour': 1.1.0 @@ -6448,6 +7120,8 @@ snapshots: undici-types@8.3.0: {} + undici@7.24.8: {} + undici@7.28.0: {} unenv@2.0.0-rc.24: @@ -6546,6 +7220,14 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20260421.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260421.1 + '@cloudflare/workerd-darwin-arm64': 1.20260421.1 + '@cloudflare/workerd-linux-64': 1.20260421.1 + '@cloudflare/workerd-linux-arm64': 1.20260421.1 + '@cloudflare/workerd-windows-64': 1.20260421.1 + workerd@1.20260730.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260730.1 @@ -6576,6 +7258,25 @@ snapshots: - bufferutil - utf-8-validate + wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260421.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260421.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260421.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260424.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + ws@8.21.0: {} xtend@4.0.2: {} -- 2.51.2