Commits
# Conflicts:
# pnpm-lock.yaml
add e2e tests for spaces auth path and privacy invariants
fix: resolve config defensively in raw runPersistent export
fix: flush buffered events while Jetstream is idle
Three new tests in apps/contrail-e2e, each running in-process against a
real devnet PDS + PLC + Postgres with spaces enabled:
- spaces-auth.test.ts: real service-auth JWT minted via
com.atproto.server.getServiceAuth is accepted; wrong aud and wrong
lxm binding are rejected. Exercises the full verifier path (PDS
signs with the user's PLC-published key, Contrail resolves the key
from devnet PLC, @atcute/xrpc-server verifies the signature) — the
existing package-level spaces tests all use a fakeAuth header and
never touch this path.
- spaces-firehose-invisibility.test.ts: records written via
{ns}.space.putRecord do not appear on the ATProto firehose. Uses a
direct-to-PDS createRecord as a positive control so a silently
broken subscriber can't make the negative assertion vacuous.
- spaces-table-isolation.test.ts: a space putRecord lands in
spaces_records_<collection> with the public records_<collection>
table empty for the same caller — proves the store-level separation
that the spaces privacy model depends on.
helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid-
DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds
@atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as
direct devDependencies.
verifier.verify returns a structured {error, description} AuthError on
failure, but the middleware stringified it as `String(result.error)`,
producing "[object Object]" in the 401 body. Real rejection reasons
("jwt lexicon method does not match", "invalid signature",
"could not resolve did document", etc.) were invisible to callers.
Surface the description (or the error name as fallback) so 401 bodies
carry a human-readable reason.
The exported runPersistent(db, config, options) from @atmo-dev/contrail
silently skipped grouped relation count updates when passed an
unresolved ContrailConfig. Internals (applyEvents' buildBatchCountStatements,
query planning, etc.) read `config._resolved?.relations[...]?.groups` —
if `_resolved` was missing, the grouped-counts loop never ran, so columns
like count_rsvp_going stayed at 0 while total count_rsvp updated normally.
The Contrail class constructor resolves the config, so callers going
through `new Contrail(...).runPersistent()` were fine. Only the raw
export was affected — a real footgun for library consumers.
Fix: mirror what Contrail's constructor does and defensively call
resolveConfig when `_resolved` is absent. resolveConfig is idempotent,
so this is a no-op for already-resolved configs.
Regression test in packages/contrail/tests/persistent.test.ts passes a
raw ContrailConfig with a grouped relation and asserts
count_rsvp_going = 1. Confirmed to fail without this patch.
Reverts the Contrail.runPersistent() workaround in the postgres-devnet
e2e tests — they now call the raw runPersistent export directly, same
as any external library consumer would.
New app under apps/contrail-e2e that brings up a full sealed local
ATProto network (PDS, PLC, Jetstream, TAP, Postgres, maildev) via
docker-compose and runs three end-to-end test files against it:
- health.test.ts — service health checks
- ingest-roundtrip.test.ts — publish → index roundtrip, verifying
a calendar event and an RSVP land in the DB and that the grouped
rsvpsGoingCount is populated via an in-process XRPC handler
- cursor-resume.test.ts — regression for runPersistent's durable
cursor. Publishes A, stops the ingester, publishes B + updates B
+ deletes A, restarts, and asserts the saved cursor replays the
gap. The basic roundtrip can't catch a broken cursor because
events always arrive while the ingester is live.
Each test stands up its own runPersistent and XRPC handler
in-process against an isolated pg schema (search_path pinned per
test), so the suite works with just pnpm stack:up and doesn't
fight a dogfooding ingester writing to public.
streamAndFlush only re-checked the flush timer inside the main loop
after iterator.next() resolved, so a trickle of events followed by a
quiet Jetstream would strand the buffer until the next event or abort.
The final flush in the finally block masked the bug in tests that
abort.
Replace the in-loop flush race with a setInterval-driven periodic
flush. The main loop now only races iterator.next() against abort,
and flushes are decoupled from loop tempo. Timer-flush errors log
and retry next interval rather than propagate — a transient DB
hiccup shouldn't force a Jetstream reconnect.
Adds a regression test that forces the timer path by setting
batchSize well above the event count and asserting records are
present BEFORE the controller aborts.
# Conflicts:
# pnpm-lock.yaml
Three new tests in apps/contrail-e2e, each running in-process against a
real devnet PDS + PLC + Postgres with spaces enabled:
- spaces-auth.test.ts: real service-auth JWT minted via
com.atproto.server.getServiceAuth is accepted; wrong aud and wrong
lxm binding are rejected. Exercises the full verifier path (PDS
signs with the user's PLC-published key, Contrail resolves the key
from devnet PLC, @atcute/xrpc-server verifies the signature) — the
existing package-level spaces tests all use a fakeAuth header and
never touch this path.
- spaces-firehose-invisibility.test.ts: records written via
{ns}.space.putRecord do not appear on the ATProto firehose. Uses a
direct-to-PDS createRecord as a positive control so a silently
broken subscriber can't make the negative assertion vacuous.
- spaces-table-isolation.test.ts: a space putRecord lands in
spaces_records_<collection> with the public records_<collection>
table empty for the same caller — proves the store-level separation
that the spaces privacy model depends on.
helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid-
DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds
@atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as
direct devDependencies.
verifier.verify returns a structured {error, description} AuthError on
failure, but the middleware stringified it as `String(result.error)`,
producing "[object Object]" in the 401 body. Real rejection reasons
("jwt lexicon method does not match", "invalid signature",
"could not resolve did document", etc.) were invisible to callers.
Surface the description (or the error name as fallback) so 401 bodies
carry a human-readable reason.
The exported runPersistent(db, config, options) from @atmo-dev/contrail
silently skipped grouped relation count updates when passed an
unresolved ContrailConfig. Internals (applyEvents' buildBatchCountStatements,
query planning, etc.) read `config._resolved?.relations[...]?.groups` —
if `_resolved` was missing, the grouped-counts loop never ran, so columns
like count_rsvp_going stayed at 0 while total count_rsvp updated normally.
The Contrail class constructor resolves the config, so callers going
through `new Contrail(...).runPersistent()` were fine. Only the raw
export was affected — a real footgun for library consumers.
Fix: mirror what Contrail's constructor does and defensively call
resolveConfig when `_resolved` is absent. resolveConfig is idempotent,
so this is a no-op for already-resolved configs.
Regression test in packages/contrail/tests/persistent.test.ts passes a
raw ContrailConfig with a grouped relation and asserts
count_rsvp_going = 1. Confirmed to fail without this patch.
Reverts the Contrail.runPersistent() workaround in the postgres-devnet
e2e tests — they now call the raw runPersistent export directly, same
as any external library consumer would.
New app under apps/contrail-e2e that brings up a full sealed local
ATProto network (PDS, PLC, Jetstream, TAP, Postgres, maildev) via
docker-compose and runs three end-to-end test files against it:
- health.test.ts — service health checks
- ingest-roundtrip.test.ts — publish → index roundtrip, verifying
a calendar event and an RSVP land in the DB and that the grouped
rsvpsGoingCount is populated via an in-process XRPC handler
- cursor-resume.test.ts — regression for runPersistent's durable
cursor. Publishes A, stops the ingester, publishes B + updates B
+ deletes A, restarts, and asserts the saved cursor replays the
gap. The basic roundtrip can't catch a broken cursor because
events always arrive while the ingester is live.
Each test stands up its own runPersistent and XRPC handler
in-process against an isolated pg schema (search_path pinned per
test), so the suite works with just pnpm stack:up and doesn't
fight a dogfooding ingester writing to public.
streamAndFlush only re-checked the flush timer inside the main loop
after iterator.next() resolved, so a trickle of events followed by a
quiet Jetstream would strand the buffer until the next event or abort.
The final flush in the finally block masked the bug in tests that
abort.
Replace the in-loop flush race with a setInterval-driven periodic
flush. The main loop now only races iterator.next() against abort,
and flushes are decoupled from loop tempo. Timer-flush errors log
and retry next interval rather than propagate — a transient DB
hiccup shouldn't force a Jetstream reconnect.
Adds a regression test that forces the timer path by setting
batchSize well above the event count and asserting records are
present BEFORE the controller aborts.