Editor boards
The anonymous public-editor store of deals, its read and write services, the minimal write pipeline, the rotation projection, the query object family and the ports.
An editor board is a deal authored in the public editor by nobody in particular: a flat EditorBoardData blob keyed by its short code, with plain RichText comments and no language, owner, themes or lifecycle. The module has no domain/ folder; its entity is the EditorBoardData / EditorBoardRecord pair declared on the port, and the public player reads it through the source: "editor" branch of DealView (see Public). The admin lists drafts, the ingest jobs write them, and the claim flows of Boards promote a draft into an owned board, where comments become LocalizedField<RichText> and derived fields are computed.
| Layer | Import path |
|---|---|
| Application | @workspace/training/application/editor-boards |
Entity
EditorBoardData
The persisted blob. Comments on auction and play moves are non-localized RichText; review is the board-level comment, [] when empty.
Prop
Type
EditorBoardRecord is { code: string; data: EditorBoardData }. EditorBoardWriteData loosens cards, auction and play to plain strings so external sources (legacy dumps) can be written before their values are narrowed to the branded bridge types; EditorBoardWriteRecord pairs it with a code.
Application
EditorBoardsService
Read side. createEditorBoardsService(deps: { editorBoardsRead: EditorBoardsReadRepository }). Apps use this service and never the repository, query objects or projections directly.
| Method | Signature | Description |
|---|---|---|
getByCode | (code: string) => Promise<EditorBoardRecord | null> | Single lookup, null when the code is unknown. |
getManyByCodes | (codes: readonly string[]) => Promise<EditorBoardRecord[]> | Unknown codes are absent from the result; an empty input skips the read. |
listExistingCodes | (codes?: readonly string[]) => Promise<Set<string>> | Subset of codes present in the store, or every stored code when omitted. |
listPage | ({ page, pageSize }) => Promise<EditorBoardsListPage> | One admin table page, createdAt descending, listItem() projection. |
EditorBoardsListPage is { items: EditorBoardListItem[]; pagination: PaginationMeta }.
export async function lookupDraft(
editorBoardsRead: EditorBoardsReadRepository,
code: string
): Promise<EditorBoardRecord | null> {
const editorBoards = createEditorBoardsService({ editorBoardsRead });
const record = await editorBoards.getByCode(code); // null when unknown
const existing = await editorBoards.listExistingCodes([code, "ZZZZZZZZ"]);
existing.has(code); // => true when the draft exists
const page = await editorBoards.listPage({ page: 1, pageSize: 50 });
page.items[0]?.hasAuction; // flattened admin row, newest first
return record;
}EditorBoardsWriteService
Write side. createEditorBoardsWriteService(deps: { editorBoards: EditorBoardsRepository }). Writes go through a minimal inline pipeline: no deal write gate, no rich-text conversion, no derived fields, no gate version. The only validation is structural card integrity and chronological play legality, run by a BridgeEngine built for the row's teaching mode (default mode when it does not parse).
| Method | Signature | Description |
|---|---|---|
writeOne | (input: EditorBoardWriteInput) => Promise<EditorBoardWriteOutcome> | Prepares, then inserts. A repository throw becomes persistence_failed with the message as detail. |
writeMany | (inputs: EditorBoardWriteInput[]) => Promise<EditorBoardWriteOutcome[]> | Only prepared inputs reach editorBoards.writeMany. Codes in failedCodes or missing from insertedCodes become persistence_failed (detail: "not_inserted" for the latter). |
overwriteMany | (inputs: EditorBoardWriteInput[]) => Promise<EditorBoardWriteOutcome[]> | Same pipeline, then editorBoards.overwriteMany; a code missing from updatedCodes becomes persistence_failed with detail: "not_updated". |
EditorBoardWriteInput is { code: string; data: EditorBoardWriteData }.
Outcome status | Fields |
|---|---|
persisted | report: { rotated, rotationCount } |
gate_rejected | rejections: EditorBoardWriteRejection[], each { reason: "cards_incomplete" | "play_order_invalid"; detail? } |
persistence_failed | detail?: string |
cards_incomplete carries detail: "malformed_hands" or invalid_total_<n>: a deal must hold 52 cards, or a multiple of 4 between 4 and 52 with every seat holding the same count. play_order_invalid carries card_not_owned, revoke or invalid_play from the engine's trick replay. A row without a declarer is accepted (no-auction teaching modes).
export async function ingestDraft(
editorBoards: EditorBoardsRepository,
input: EditorBoardWriteInput
) {
const writer = createEditorBoardsWriteService({ editorBoards });
const outcome = await writer.writeOne(input);
switch (outcome.status) {
case "persisted":
return outcome.report; // { rotated, rotationCount }
case "gate_rejected":
return outcome.rejections; // [{ reason: "cards_incomplete" | "play_order_invalid", detail? }]
case "persistence_failed":
return outcome.detail;
}
}prepareEditorBoardWrite
prepareEditorBoardWrite(input: EditorBoardWriteInput, engine: BridgeEngine): PreparedEditorBoardWrite
Pure dry-run of the pipeline, exposed so a preview (the ingest payload dialog) renders exactly what would be stored. It resolves the declarer from the auction (a complete auction overrides a declared value that lies), rotates the blob so the declarer sits South, then validates.
ok | Fields |
|---|---|
true | code, persistable: PersistableEditorBoardWrite, report: EditorBoardWriteReport |
false | code, rejections: EditorBoardWriteRejection[] |
export function previewDraft(input: EditorBoardWriteInput) {
const engine = createBridgeEngine(
createBridgeContext({ teachingMode: "auction" })
);
const prepared = prepareEditorBoardWrite(input, engine); // pure, no repository
if (!prepared.ok) {
return prepared.rejections;
}
return prepared.persistable.data; // exactly what writeOne would store
}Rotation projection
rotation-projection.ts holds the helpers that put the declarer at South. Two are public.
| Function | Signature | Behaviour |
|---|---|---|
rotateEditorBoardDataToSouth | <A, P>(data: RotatableBoardData<A, P>) => EditorBoardRotationResult<A, P> | No-op when declarer is null or "S". Otherwise rotates cards, auction.first, play.first, dealer, player, flips vuln between NS and EW on odd counts, sets declarer to "S", and rotates player names inside every text leaf of review. Contract strings are not rotated. Generic over the move-comment type, so it serves both editor blobs (RichText) and the boards write gate (LocalizedField<RichText>). |
rotateLocalizedReview | (field: LocalizedField<RichText> | null, count: number) => LocalizedField<RichText> | null | Same text-leaf rotation, applied to every locale of a persisted localized comment. Returns the input when null or count === 0. |
EditorBoardRotationResult is { rotated: boolean; rotationCount: number; originalDeclarer: Player | null; data }.
export function toSouth(data: EditorBoardData) {
const rotation = rotateEditorBoardDataToSouth(data);
rotation.rotated; // false when declarer is null or already "S"
rotation.rotationCount; // clockwise seats moved; vulnerability flips on odd counts
rotation.data.declarer; // => "S" after rotation
return rotation;
}
rotateLocalizedReview(null, 2); // => nullQuery objects
Generics are on Queries. The store is keyed by code and has no id, soft delete or viewer perspective, so the family is small.
EditorBoardQuery: EditorBoardQueryParams = QueryParams<EditorBoardWhere, EditorBoardSort>.
| Method | Effect |
|---|---|
EditorBoardQuery.empty() | Every editor board |
.byCode(code) / .byCodes(codes) | code equality / membership |
.forScan({ limit, offset }) | Code-ascending order plus an offset window: the deterministic ordering makes batched drains resumable across pages |
.withPagination(p) / .withSorting(s) | Pagination and EditorBoardSort |
.withFilters(where) | Raw EditorBoardWhere merge |
.toParams() | The frozen params |
EditorBoardCriteria: byCode, byCodes, where, and. There is no byId. An empty criteria throws.
EditorBoardWhere: { code?: Equality<string> }.
EditorBoardSort: { column: "code" \| "createdAt"; desc: boolean }, whitelist EDITOR_BOARD_SORT_COLUMNS.
EditorBoardPatch: { data: EditorBoardData }, the blob being the only mutable column.
EditorBoardProjections
| Factory | Result type | Contents |
|---|---|---|
entity() | EditorBoardRecord | { code, data }, the shape every claim, scan and compare path consumes |
listItem() | EditorBoardListItem | Flattened admin row with booleans derived from the blob, without shipping it |
Prop
Type
EditorBoardAggregations
| Factory | Result type | Contents |
|---|---|---|
count(criteria?) | number | Row count under the criteria, or every row |
codeSet(codes?) | Set<string> | With codes, the subset present in the store (one IN query); without, every stored code |
EditorBoardAggregation is the type the repository switches on, carrying the optional criteria and codes.
export async function scanAll(
editorBoardsRead: EditorBoardsReadRepository,
onPage: (records: EditorBoardRecord[]) => void
): Promise<void> {
const total = await editorBoardsRead.aggregate(
EditorBoardAggregations.count()
);
const limit = 500;
for (let offset = 0; offset < total; offset += limit) {
const page = await editorBoardsRead.findMany(
EditorBoardQuery.empty().forScan({ limit, offset }), // code ascending
EditorBoardProjections.entity()
);
onPage(page);
}
}
export function replaceDraft(
editorBoards: EditorBoardsRepository,
code: string,
data: EditorBoardData
) {
return editorBoards.update(EditorBoardCriteria.byCode(code), { data });
}Ports
EditorBoardsReadRepository is ReadRepository<EditorBoardQuery>: getById(code, projection), findMany, findManyPaginated, aggregate. getById takes the code since there is no id column.
EditorBoardsRepository is WriteRepository<EditorBoardCriteria, EditorBoardPatch, PersistableEditorBoardWrite, undefined, EditorBoardUpdateResult, EditorBoardDeleteResult> plus three inserts. Results are keyed by code: { updatedCodes } and { deletedCodes }.
| Method | Signature | Description |
|---|---|---|
writeOne | (input: PersistableEditorBoardWrite) => Promise<void> | Single insert, ON CONFLICT (code) DO NOTHING, idempotent. |
writeMany | (inputs: PersistableEditorBoardWrite[]) => Promise<EditorBoardsWriteManyResult> | Bulk insert; conflicting codes land in failedCodes. |
overwriteMany | (rows: PersistableEditorBoardWrite[]) => Promise<EditorBoardsOverwriteManyResult> | One UPDATE ... FROM (VALUES ...) per call, atomic, sized for tens of thousands of rows. failedCodes holds codes absent from the RETURNING set, or every code when the statement throws. |
update / updateMany | From WriteRepository | Patch the blob under a criteria. |
delete | (criteria: EditorBoardCriteria) => Promise<{ deletedCodes }> | Filtered delete. |
PersistableEditorBoardWrite is { code: string; data: EditorBoardData }; EditorBoardsWriteManyResult is { insertedCodes, failedCodes }; EditorBoardsOverwriteManyResult is { updatedCodes, failedCodes }.
Testing
makeEditorBoardsRead(records?) from @workspace/training/testing is an in-memory EditorBoardsReadRepository seeded with records. findMany honours code equality and membership, code ordering and pagination; aggregate supports codeSet and count; getById and findManyPaginated are bare mocks. makePersistableEditorBoardWrite(faker, overrides) from @workspace/training/factories builds a record.
export function seededRead(faker: Faker): EditorBoardsReadRepository {
const draft = makePersistableEditorBoardWrite(faker, { code: "ABCD2345" });
return makeEditorBoardsRead([draft]); // findMany honours code filters, ordering, pagination
}Errors
The module throws no AppError. Rejections are returned as data in EditorBoardWriteOutcome and PreparedEditorBoardWrite; repository failures are folded into persistence_failed.
Boards
Boards, a deal with teaching metadata: the entity family, codes and enums, the write gate, claiming editor boards, the query object family and filters, comments, analytics, bookmarks and votes, the ports and the errors.
Themes
Board themes, system and user-defined, their label resolution, the ThemesService, and the theme query object family and ports.