b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
218 lines
7.3 KiB
TypeScript
218 lines
7.3 KiB
TypeScript
/**
|
|
* `wrapAtcuteSubscription` regression test.
|
|
*
|
|
* The wrapper's `close()` MUST cancel a pending `for await` even when the
|
|
* underlying subscription is quiescent (no events arriving). MockJetstream
|
|
* actively resolves pending awaiters on close, so it can't catch a
|
|
* misbehaving production wrapper — this test pairs the wrapper with a stub
|
|
* whose `next()` never resolves, and asserts the for-await terminates within
|
|
* a small grace window after `close()`.
|
|
*
|
|
* Without the fix, the wrapper's `close()` flipped a flag the iterator only
|
|
* checked AFTER `inner.next()` resolved, so a quiescent stream would hang
|
|
* `stop()` indefinitely. This test failing means we've regressed there.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { wrapAtcuteSubscription, type RawJetstreamSubscription } from "../src/jetstream-client.js";
|
|
|
|
interface QuiescentEvent {
|
|
kind: string;
|
|
}
|
|
|
|
/**
|
|
* Subscription stub whose `next()` returns a Promise that NEVER resolves,
|
|
* even after `return()` is called. This mirrors `@mary-ext/event-iterator`'s
|
|
* actual behaviour: `EventIterator.return()` drops its resolver reference
|
|
* without invoking it (`lib/index.ts:55-67`), so a pending `next()` Promise
|
|
* is orphaned. If the wrapper relies on `inner.return()` resolving the
|
|
* pending await (the C1 mistake), this stub catches it — the for-await
|
|
* never wakes from `inner.return()` alone, only the closed-signal race in
|
|
* the wrapper can unblock it.
|
|
*/
|
|
function quiescentSubscription(): RawJetstreamSubscription<QuiescentEvent> {
|
|
let returned = false;
|
|
const innerIter: AsyncIterator<QuiescentEvent> = {
|
|
next() {
|
|
if (returned) return Promise.resolve({ value: undefined, done: true });
|
|
// Return a Promise that never settles. Mirrors EventIterator's
|
|
// behaviour: it stashes the resolver in a private field and
|
|
// drops it on return() without ever calling it.
|
|
return new Promise<IteratorResult<QuiescentEvent>>(() => {});
|
|
},
|
|
async return() {
|
|
returned = true;
|
|
return { value: undefined, done: true };
|
|
},
|
|
};
|
|
return {
|
|
cursor: 0,
|
|
[Symbol.asyncIterator]: () => innerIter,
|
|
};
|
|
}
|
|
|
|
describe("wrapAtcuteSubscription", () => {
|
|
it("close() unblocks a for-await waiting on a quiescent subscription", async () => {
|
|
const sub = quiescentSubscription();
|
|
const handle = wrapAtcuteSubscription(sub);
|
|
|
|
// Start consuming on a background promise. The for-await will block
|
|
// on the first iter.next() because the underlying inner iterator
|
|
// only resolves when return() is called.
|
|
const consumed: QuiescentEvent[] = [];
|
|
const consumePromise = (async () => {
|
|
for await (const event of handle) {
|
|
consumed.push(event);
|
|
}
|
|
})();
|
|
|
|
// Yield a few microtasks so the consumer reaches the awaiting state.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await new Promise<void>((r) => setTimeout(r, 0));
|
|
|
|
// The consumer should be wedged, not done. Use Promise.race to
|
|
// detect — if it's wedged, the timeout wins.
|
|
const beforeClose = await Promise.race([
|
|
consumePromise.then(() => "done" as const),
|
|
new Promise<"pending">((r) => setTimeout(r, 10, "pending")),
|
|
]);
|
|
expect(beforeClose).toBe("pending");
|
|
|
|
// close() must cancel the pending await.
|
|
handle.close();
|
|
|
|
// Now the consumer should resolve quickly.
|
|
await expect(
|
|
Promise.race([
|
|
consumePromise.then(() => "done" as const),
|
|
new Promise<"timeout">((r) => setTimeout(r, 100, "timeout")),
|
|
]),
|
|
).resolves.toBe("done");
|
|
|
|
expect(consumed).toEqual([]);
|
|
});
|
|
|
|
it("filters non-commit events", async () => {
|
|
// `isCommitEvent` requires the full commit shape (collection + rkey +
|
|
// operation) — a `{kind: "commit"}` envelope without a structurally
|
|
// valid `commit` object is correctly rejected as "malformed", so the
|
|
// stub must mirror what production producers emit.
|
|
const events: Array<{
|
|
kind: string;
|
|
commit?: { collection: string; rkey: string; operation: string; cid?: string };
|
|
}> = [
|
|
{ kind: "identity" },
|
|
{
|
|
kind: "commit",
|
|
commit: { collection: "x", rkey: "r1", operation: "create", cid: "bafyc1" },
|
|
},
|
|
{ kind: "account" },
|
|
{
|
|
kind: "commit",
|
|
commit: { collection: "y", rkey: "r2", operation: "create", cid: "bafyc2" },
|
|
},
|
|
];
|
|
let i = 0;
|
|
const sub: RawJetstreamSubscription<(typeof events)[number]> = {
|
|
cursor: 0,
|
|
[Symbol.asyncIterator]: () => ({
|
|
async next() {
|
|
if (i >= events.length) return { value: undefined, done: true };
|
|
const value = events[i++];
|
|
return { value: value as (typeof events)[number], done: false };
|
|
},
|
|
}),
|
|
};
|
|
const handle = wrapAtcuteSubscription(sub);
|
|
const out: unknown[] = [];
|
|
for await (const event of handle) out.push(event);
|
|
expect(out).toHaveLength(2);
|
|
expect(out.every((e) => (e as { kind: string }).kind === "commit")).toBe(true);
|
|
});
|
|
|
|
it("rejects commits with missing cid on non-delete operations", async () => {
|
|
// `create`/`update` events without a `cid` would produce a RecordsJob
|
|
// with `cid: undefined`, breaking the consumer's verification step.
|
|
// Predicate must drop them at the source.
|
|
const events = [
|
|
{ kind: "commit", commit: { collection: "x", rkey: "r1", operation: "create" } },
|
|
{
|
|
kind: "commit",
|
|
commit: { collection: "x", rkey: "r2", operation: "update" },
|
|
},
|
|
];
|
|
let i = 0;
|
|
const sub: RawJetstreamSubscription<(typeof events)[number]> = {
|
|
cursor: 0,
|
|
[Symbol.asyncIterator]: () => ({
|
|
async next() {
|
|
if (i >= events.length) return { value: undefined, done: true };
|
|
const value = events[i++];
|
|
return { value: value as (typeof events)[number], done: false };
|
|
},
|
|
}),
|
|
};
|
|
const handle = wrapAtcuteSubscription(sub);
|
|
const out: unknown[] = [];
|
|
for await (const event of handle) out.push(event);
|
|
expect(out).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects commits whose operation isn't one of create/update/delete", async () => {
|
|
// A producer emitting an unknown operation would otherwise produce a
|
|
// RecordsJob the consumer can't handle, ending up as
|
|
// UNEXPECTED_ERROR in dead_letters. Better to drop at the source.
|
|
const events = [
|
|
{
|
|
kind: "commit",
|
|
commit: {
|
|
collection: "x",
|
|
rkey: "r1",
|
|
operation: "rebase", // not a real atproto op
|
|
cid: "bafyc1",
|
|
},
|
|
},
|
|
];
|
|
let i = 0;
|
|
const sub: RawJetstreamSubscription<(typeof events)[number]> = {
|
|
cursor: 0,
|
|
[Symbol.asyncIterator]: () => ({
|
|
async next() {
|
|
if (i >= events.length) return { value: undefined, done: true };
|
|
const value = events[i++];
|
|
return { value: value as (typeof events)[number], done: false };
|
|
},
|
|
}),
|
|
};
|
|
const handle = wrapAtcuteSubscription(sub);
|
|
const out: unknown[] = [];
|
|
for await (const event of handle) out.push(event);
|
|
expect(out).toHaveLength(0);
|
|
});
|
|
|
|
it("accepts delete commits without cid", async () => {
|
|
// Delete events legitimately have no cid; predicate must let them
|
|
// through.
|
|
const events = [
|
|
{ kind: "commit", commit: { collection: "x", rkey: "r1", operation: "delete" } },
|
|
];
|
|
let i = 0;
|
|
const sub: RawJetstreamSubscription<(typeof events)[number]> = {
|
|
cursor: 0,
|
|
[Symbol.asyncIterator]: () => ({
|
|
async next() {
|
|
if (i >= events.length) return { value: undefined, done: true };
|
|
const value = events[i++];
|
|
return { value: value as (typeof events)[number], done: false };
|
|
},
|
|
}),
|
|
};
|
|
const handle = wrapAtcuteSubscription(sub);
|
|
const out: unknown[] = [];
|
|
for await (const event of handle) out.push(event);
|
|
expect(out).toHaveLength(1);
|
|
});
|
|
});
|