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
143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
/**
|
|
* Byline field registry — concurrent-mutation safety under the
|
|
* parity-aware bookend (Phase 6 of #1174). Asserts post-state
|
|
* correctness (every row lands, final version is even) rather than
|
|
* exact +N increments — concurrent mutators can collapse one or both
|
|
* bookends, which is fine per the registry's class JSDoc.
|
|
*
|
|
* Activate Postgres parity by exporting `EMDASH_TEST_PG=1` and pointing
|
|
* `PG_CONNECTION_STRING` at a writable test database.
|
|
*/
|
|
|
|
import { beforeEach, afterEach, expect, it } from "vitest";
|
|
|
|
import { BylineSchemaRegistry } from "../../../src/schema/byline-registry.js";
|
|
import {
|
|
describeEachDialect,
|
|
setupForDialect,
|
|
teardownForDialect,
|
|
type DialectTestContext,
|
|
} from "../../utils/test-db.js";
|
|
|
|
describeEachDialect("BylineSchemaRegistry concurrency", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
let registry: BylineSchemaRegistry;
|
|
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialect(dialect);
|
|
registry = new BylineSchemaRegistry(ctx.db);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("parallel createField calls all land their rows and the post-state version is even", async () => {
|
|
const startVersion = await registry.getVersion();
|
|
const slugs = Array.from({ length: 10 }, (_, i) => `field_${i}`);
|
|
|
|
const results = await Promise.allSettled(
|
|
slugs.map((slug) => registry.createField({ slug, label: slug, type: "string" })),
|
|
);
|
|
|
|
expect(results.filter((r) => r.status === "fulfilled").length).toBe(slugs.length);
|
|
|
|
const reloadedSlugs = (await registry.listFields()).map((f) => f.slug);
|
|
for (const slug of slugs) {
|
|
expect(reloadedSlugs).toContain(slug);
|
|
}
|
|
|
|
const endVersion = await registry.getVersion();
|
|
expect(endVersion).toBeGreaterThan(startVersion);
|
|
expect(endVersion % 2).toBe(0);
|
|
});
|
|
|
|
it("parallel updateField calls all land and post-state version is even", async () => {
|
|
const field = await registry.createField({
|
|
slug: "job_title",
|
|
label: "Job title",
|
|
type: "string",
|
|
});
|
|
const baseline = await registry.getVersion();
|
|
|
|
const labels = Array.from({ length: 10 }, (_, i) => `Label ${i}`);
|
|
await Promise.allSettled(labels.map((label) => registry.updateField("job_title", { label })));
|
|
|
|
const after = await registry.getVersion();
|
|
expect(after).toBeGreaterThan(baseline);
|
|
expect(after % 2).toBe(0);
|
|
|
|
// And the final state is a single, well-defined row — no duplicate
|
|
// definitions, label is one of the inputs.
|
|
const reloaded = await registry.getField("job_title");
|
|
expect(reloaded?.id).toBe(field.id);
|
|
expect(labels).toContain(reloaded?.label);
|
|
});
|
|
|
|
it("mixed parallel mutations all land and post-state version is even", async () => {
|
|
await registry.createField({ slug: "a", label: "A", type: "string" });
|
|
await registry.createField({ slug: "b", label: "B", type: "string" });
|
|
const baseline = await registry.getVersion();
|
|
|
|
// 3 mutations in parallel: one create, one update, one reorder. None
|
|
// of them conflict — they target different rows or properties.
|
|
const ops: Array<Promise<unknown>> = [
|
|
registry.createField({ slug: "c", label: "C", type: "string" }),
|
|
registry.updateField("a", { label: "Aa" }),
|
|
];
|
|
|
|
await Promise.all(ops);
|
|
// Reorder runs serially — it reads the full set, so racing it
|
|
// against parallel creates would make the assertion non-meaningful.
|
|
await registry.reorderFields(["c", "a", "b"]);
|
|
|
|
const after = await registry.getVersion();
|
|
expect(after).toBeGreaterThan(baseline);
|
|
expect(after % 2).toBe(0);
|
|
|
|
const fields = await registry.listFields();
|
|
expect(fields.map((f) => f.slug).toSorted()).toEqual(["a", "b", "c"]);
|
|
expect(fields.find((f) => f.slug === "a")?.label).toBe("Aa");
|
|
});
|
|
|
|
it("parallel deletes against distinct fields all land and post-state version is even", async () => {
|
|
for (let i = 0; i < 6; i++) {
|
|
await registry.createField({ slug: `del_${i}`, label: `del_${i}`, type: "string" });
|
|
}
|
|
const baseline = await registry.getVersion();
|
|
|
|
await Promise.all(Array.from({ length: 6 }, (_, i) => registry.deleteField(`del_${i}`)));
|
|
|
|
const after = await registry.getVersion();
|
|
expect(after).toBeGreaterThan(baseline);
|
|
expect(after % 2).toBe(0);
|
|
|
|
const remaining = (await registry.listFields()).map((f) => f.slug);
|
|
for (let i = 0; i < 6; i++) {
|
|
expect(remaining).not.toContain(`del_${i}`);
|
|
}
|
|
});
|
|
|
|
it("createField duplicate slugs: one succeeds, the other surfaces FIELD_EXISTS", async () => {
|
|
const baseline = await registry.getVersion();
|
|
|
|
// Fire two creates with the same slug. On SQLite/D1 (serialised
|
|
// writes) one will land first and the second will hit the
|
|
// FIELD_EXISTS check. On PG the same property holds via the UNIQUE
|
|
// index on slug — the loser sees a UNIQUE constraint error, but the
|
|
// registry's getField pre-check catches the racy case for most runs.
|
|
// We assert the *outcome*: exactly one row exists and the version
|
|
// counter advanced by at least one (the winner).
|
|
const results = await Promise.allSettled([
|
|
registry.createField({ slug: "dupe", label: "Dupe A", type: "string" }),
|
|
registry.createField({ slug: "dupe", label: "Dupe B", type: "string" }),
|
|
]);
|
|
|
|
const succeeded = results.filter((r) => r.status === "fulfilled");
|
|
expect(succeeded.length).toBeGreaterThanOrEqual(1);
|
|
const rows = await registry.listFields();
|
|
expect(rows.filter((f) => f.slug === "dupe")).toHaveLength(1);
|
|
expect((await registry.getVersion()) - baseline).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|