Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions factory/api/src/accounts/conflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { AccountUnavailableError } from "../errors.ts";
import { ConditionFailedError } from "./port.ts";

// 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」)
const VERSION_RETRIES = 3;

// 読み直しと判定の 1 回分の結果。書かずに終わる(done)か、書いて終わる(write が成功したら result)
type ConflictStep<T> = { done: T } | { write: () => Promise<void>; result: T };

// 版の競合(条件の失敗)なら step から読み直して上限の回数まで書き直す。step の中で投げた誤りはそのまま届く。
// 条件以外の書き込みの失敗は onOther の結果を投げる(既定は AccountUnavailableError)
export async function retryOnConflict<T>(
step: () => Promise<ConflictStep<T>>,
onOther: (err: unknown) => unknown = () => new AccountUnavailableError(),
): Promise<T> {
for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) {
const next = await step();
if ("done" in next) return next.done;
try {
await next.write();
return next.result;
} catch (err) {
if (!(err instanceof ConditionFailedError)) throw onOther(err);
}
}
throw new AccountUnavailableError();
}

// 再試行しない書き込み。成功なら undefined、条件の失敗なら onCondition の結果を返す(onCondition は投げてもよい)。
// 条件以外の失敗は AccountUnavailableError
export async function writeOr<T>(write: () => Promise<void>, onCondition: () => T | Promise<T>): Promise<T | undefined> {
try {
await write();
return undefined;
} catch (err) {
if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError();
}
return onCondition();
}
10 changes: 2 additions & 8 deletions factory/api/src/accounts/memory-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts";
import { ConditionFailedError, type AccountStore, type AccountUpdate } from "./port.ts";
import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts";
import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";

Expand Down Expand Up @@ -109,13 +109,7 @@ export class MemoryAccountStore implements AccountStore {
await this.write("removeSeat", () => this.seatUnchanged(expected), () => this.seats.delete(key), history);
}

async updateAccount(
accountId: string,
expectedVersion: number,
patch: AccountPatch,
updatedAt: string,
history: HistoryRecord[],
) {
async updateAccount({ accountId, expectedVersion, patch, updatedAt, history }: AccountUpdate) {
await this.write(
"updateAccount",
() => this.accounts.get(accountId)?.version === expectedVersion,
Expand Down
50 changes: 50 additions & 0 deletions factory/api/src/accounts/port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// アカウントの保存の契約(AccountStore)と、その引数・失敗の型。DynamoDB とメモリの実装が満たす。AWS SDK を読まない
import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts";
import type { IdempotencyClaim, IdempotencyRef } from "../idempotency.ts";

// 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える
export class ConditionFailedError extends Error {
constructor() {
super("condition failed");
this.name = "ConditionFailedError";
}
}

export interface AccountPatch {
plan?: AccountRecord["plan"];
executionMode?: AccountRecord["executionMode"];
}

// アカウントの更新の 1 回分。version が expectedVersion と等しいときだけ patch を当て、version を 1 増やす
export interface AccountUpdate {
accountId: string;
expectedVersion: number;
patch: AccountPatch;
updatedAt: string;
history: HistoryRecord[];
}

// アカウントの表への読み書き。どの書き込みも履歴の行を同じトランザクションで足す(I20)
export interface AccountStore {
getUser(userId: string): Promise<UserRecord | null>;
// 利用者の行が無いときだけ、利用者・個人のアカウント・履歴を書く(I12)
createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]): Promise<void>;
getAccount(accountId: string): Promise<AccountRecord | null>;
getSeat(accountId: string, userId: string): Promise<SeatRecord | null>;
listSeats(accountId: string): Promise<SeatRecord[]>;
// 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、
// 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う
listMemberships(userId: string): Promise<string[]>;
// idempotency があれば作成の冪等の鍵の行も同じトランザクションで書く。期限の中の同じ鍵があれば ConditionFailedError(#2384 の I14)
createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim): Promise<void>;
// 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う
getIdempotent(ref: IdempotencyRef, now: string): Promise<string | null>;
// 席が無いときだけ足す(I15)
addSeat(seat: SeatRecord, history: HistoryRecord[]): Promise<void>;
// 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ変える
updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]): Promise<void>;
// 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ外す
removeSeat(expected: SeatRecord, history: HistoryRecord[]): Promise<void>;
// version が expectedVersion と等しいときだけ変え、version を 1 増やす
updateAccount(update: AccountUpdate): Promise<void>;
}
90 changes: 90 additions & 0 deletions factory/api/src/accounts/seat-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ConflictError, ForbiddenError, NotFoundError, UnprocessableError } from "../errors.ts";
import type { AccountContext, HistoryRecord, SeatRecord, SeatType } from "./model.ts";
import type { AccountStore } from "./port.ts";
import { retryOnConflict, writeOr } from "./conflict.ts";

// 席の履歴の行。null の側は席が無いことを表す
export function seatHistory(
type: "seat.added" | "seat.changed" | "seat.removed",
accountId: string,
at: string,
actor: string,
userId: string,
before: SeatType | null,
after: SeatType | null,
): HistoryRecord {
return {
accountId,
type,
at,
actor,
before: before === null ? null : { user_id: userId, seat_type: before },
after: after === null ? null : { user_id: userId, seat_type: after },
};
}

function seatNotFound(): NotFoundError {
return new NotFoundError("seat_not_found", "その席はありません");
}

// チームの席の足し・変え・外し(I17)
export class SeatService {
constructor(
private readonly store: AccountStore,
private readonly now: () => Date,
) {}

private stamp(): string {
return this.now().toISOString();
}

// 席の操作は持ち主だけ(I17)。持ち主でなければ席の有無にかかわらず先に 403
private requireTeamOwner(ctx: AccountContext): void {
if (ctx.role !== "owner") throw new ForbiddenError();
if (ctx.kind !== "team") throw new ConflictError("not_a_team", "チームのアカウントではありません");
}

async addSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise<SeatRecord> {
this.requireTeamOwner(ctx);
if (!(await this.store.getUser(userId))) {
throw new UnprocessableError("user_not_found", "その利用者はまだログインしていません");
}
const at = this.stamp();
const seat: SeatRecord = { accountId: ctx.accountId, userId, seatType, addedAt: at };
await writeOr(
() => this.store.addSeat(seat, [seatHistory("seat.added", ctx.accountId, at, ctx.userId, userId, null, seatType)]),
() => {
throw new ConflictError("seat_exists", "その利用者は既にこのチームの席を持っています");
},
);
return seat;
}

async changeSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise<SeatRecord> {
this.requireTeamOwner(ctx);
// 読んだ席のままのときだけ書く。競合したら読み直し、履歴の before を作り直す
return retryOnConflict<SeatRecord>(async () => {
const current = await this.store.getSeat(ctx.accountId, userId);
if (!current) throw seatNotFound();
const at = this.stamp();
const history = seatHistory("seat.changed", ctx.accountId, at, ctx.userId, userId, current.seatType, seatType);
return { write: () => this.store.updateSeat(current, seatType, [history]), result: { ...current, seatType } };
});
}

async removeSeat(ctx: AccountContext, userId: string): Promise<void> {
this.requireTeamOwner(ctx);
const account = await this.store.getAccount(ctx.accountId);
if (account?.ownerUserId === userId) {
throw new ConflictError("owner_seat_protected", "チームの持ち主の席は外せません");
}
// 読んだ席のままのときだけ外す。外して足し直した新しい席を古い要求で消さない
await retryOnConflict<void>(async () => {
const current = await this.store.getSeat(ctx.accountId, userId);
if (!current) throw seatNotFound();
const at = this.stamp();
const history = seatHistory("seat.removed", ctx.accountId, at, ctx.userId, userId, current.seatType, null);
return { write: () => this.store.removeSeat(current, [history]), result: undefined };
});
}
}
Loading
Loading