Bridge Training

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.

LayerImport 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.

MethodSignatureDescription
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 }.

editor-boards.ts
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).

MethodSignatureDescription
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 statusFields
persistedreport: { rotated, rotationCount }
gate_rejectedrejections: EditorBoardWriteRejection[], each { reason: "cards_incomplete" | "play_order_invalid"; detail? }
persistence_faileddetail?: 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).

editor-boards.ts
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.

okFields
truecode, persistable: PersistableEditorBoardWrite, report: EditorBoardWriteReport
falsecode, rejections: EditorBoardWriteRejection[]
editor-boards.ts
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.

FunctionSignatureBehaviour
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> | nullSame 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 }.

editor-boards.ts
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); // => null

Query 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>.

MethodEffect
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

FactoryResult typeContents
entity()EditorBoardRecord{ code, data }, the shape every claim, scan and compare path consumes
listItem()EditorBoardListItemFlattened admin row with booleans derived from the blob, without shipping it

Prop

Type

EditorBoardAggregations

FactoryResult typeContents
count(criteria?)numberRow 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.

editor-boards.ts
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 }.

MethodSignatureDescription
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 / updateManyFrom WriteRepositoryPatch 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.

editor-boards.ts
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.