first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import {
ActivityReadModel,
CommentReadModel,
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
/** Every realtime message says which event caused it and the resulting version. */
export interface Change {
/** Event class name, e.g. `IssueStatusChangedEvent`. */
action: string;
version: number;
actorId: string;
}
export interface IssueChange extends Change {
issue: IssueReadModel;
}
export interface CommentChange extends Change {
issueId: string;
commentId: string;
/** Null when the comment was deleted. */
comment: CommentReadModel | null;
}
export interface ReleaseChange extends Change {
release: ReleaseReadModel;
}
export interface ProjectChange extends Change {
project: ProjectReadModel;
}
/**
* Live stream of read-model changes: event handlers publish once the read
* model is up to date, GraphQL subscriptions watch.
*/
export abstract class ChangeFeed {
abstract publishIssue(change: IssueChange): Promise<void>;
abstract publishComment(change: CommentChange): Promise<void>;
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
abstract publishRelease(change: ReleaseChange): Promise<void>;
/** @param notifyUserIds users whose project list changed (joined / left). */
abstract publishProject(
change: ProjectChange,
notifyUserIds?: string[],
): Promise<void>;
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
abstract activity(
projectId: string,
): AsyncIterableIterator<ActivityReadModel>;
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
}

View File

@@ -0,0 +1,11 @@
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindActivityRepository {
abstract find(params: {
projectId: string;
issueId?: string;
limit: number;
/** ISO date cursor: only activity strictly older than this. */
before?: string;
}): Promise<ActivityReadModel[]>;
}

View File

@@ -0,0 +1,12 @@
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { Page } from './find-issues.repository';
export abstract class FindCommentsRepository {
abstract findById(id: string): Promise<CommentReadModel | null>;
abstract findByIssue(
issueId: string,
limit: number,
offset: number,
): Promise<Page<CommentReadModel>>;
abstract countByIssue(issueId: string): Promise<number>;
}

View File

@@ -0,0 +1,40 @@
import {
IssueArea,
IssuePriority,
IssueReadModel,
IssueStatus,
IssueType,
ProjectStatsReadModel,
ReleaseProgressReadModel,
} from '@task-stream/api/tracker/domain';
export interface IssueFilter {
projectId: string;
statuses?: IssueStatus[];
types?: IssueType[];
areas?: IssueArea[];
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
labels?: string[];
search?: string;
limit: number;
offset: number;
}
export interface Page<T> {
items: T[];
total: number;
}
export abstract class FindIssuesRepository {
abstract findById(id: string): Promise<IssueReadModel | null>;
abstract findByKey(key: string): Promise<IssueReadModel | null>;
abstract findByIds(ids: string[]): Promise<IssueReadModel[]>;
abstract search(filter: IssueFilter): Promise<Page<IssueReadModel>>;
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;
}

View File

@@ -0,0 +1,11 @@
import { ProjectReadModel, ProjectRole } from '@task-stream/api/tracker/domain';
export abstract class FindProjectsRepository {
abstract findById(id: string): Promise<ProjectReadModel | null>;
abstract findByKey(key: string): Promise<ProjectReadModel | null>;
abstract findByMember(userId: string): Promise<ProjectReadModel[]>;
abstract findRole(
projectId: string,
userId: string,
): Promise<ProjectRole | null>;
}

View File

@@ -0,0 +1,6 @@
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindReleasesRepository {
abstract findById(id: string): Promise<ReleaseReadModel | null>;
abstract findByProject(projectId: string): Promise<ReleaseReadModel[]>;
}

View File

@@ -0,0 +1,16 @@
export * from './project-keys.repository';
export * from './issue-numbers.repository';
export * from './release-names.repository';
export * from './find-projects.repository';
export * from './find-issues.repository';
export * from './find-comments.repository';
export * from './find-releases.repository';
export * from './find-activity.repository';
export * from './upsert-materialized-project.repository';
export * from './upsert-materialized-issue.repository';
export * from './upsert-materialized-comment.repository';
export * from './upsert-materialized-release.repository';
export * from './upsert-materialized-activity.repository';
export * from './change-feed';
export * from './user-directory';
export * from './release-issues.repository';

View File

@@ -0,0 +1,5 @@
/** Per-project ticket counter (TS-1, TS-2...). */
export abstract class IssueNumbersRepository {
/** Atomically reserves the next number of the project. */
abstract next(projectId: string): Promise<number>;
}

View File

@@ -0,0 +1,5 @@
/** Write-side uniqueness of project keys ("TS", "SHOP"...). */
export abstract class ProjectKeysRepository {
/** Throws BusinessRuleError if the key is already taken. */
abstract reserve(key: string, projectId: string): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
/**
* Write-side lookup over the event store: tickets that were ever put in a
* release. Rules about a release (e.g. "no unfinished tickets") must not
* depend on the eventually-consistent read model.
*/
export abstract class ReleaseIssuesRepository {
abstract findIssueIds(releaseId: string): Promise<string[]>;
}

View File

@@ -0,0 +1,10 @@
/** Write-side uniqueness of release names inside a project. */
export abstract class ReleaseNamesRepository {
/** Throws BusinessRuleError if the name is already used in the project. */
abstract reserve(
projectId: string,
name: string,
releaseId: string,
): Promise<void>;
abstract free(projectId: string, name: string): Promise<void>;
}

View File

@@ -0,0 +1,6 @@
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedActivityRepository {
/** Inserts the entry unless it already exists. Returns true when inserted. */
abstract insert(activity: ActivityReadModel): Promise<boolean>;
}

View File

@@ -0,0 +1,10 @@
import { CommentReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedCommentRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
comment: Pick<CommentReadModel, 'id' | 'version'> &
Partial<CommentReadModel>,
): Promise<void>;
abstract remove(id: string): Promise<void>;
}

View File

@@ -0,0 +1,24 @@
import {
IssueLinkReadModel,
IssueReadModel,
} from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
issue: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount'>>,
): Promise<void>;
/** Adds a relation to an issue (idempotent). */
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
abstract removeLink(
issueId: string,
link: Omit<IssueLinkReadModel, 'issueKey'>,
): Promise<void>;
abstract setCommentCount(
issueId: string,
commentCount: number,
): Promise<void>;
/** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedProjectRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
project: Pick<ProjectReadModel, 'id' | 'version'> &
Partial<ProjectReadModel>,
): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedReleaseRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
release: Pick<ReleaseReadModel, 'id' | 'version'> &
Partial<ReleaseReadModel>,
): Promise<void>;
}

View File

@@ -0,0 +1,28 @@
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
export interface DirectoryUser {
id: string;
name: string;
isActive: boolean;
}
/** Outbound port to the identity context. */
export abstract class UserDirectory {
/**
* @param accessToken caller's token, lets the identity server be asked for
* users that never used task-stream yet.
*/
abstract findById(
userId: string,
accessToken?: string,
): Promise<DirectoryUser | null>;
/** Resolves user ids to {id, name} for read models, in one lookup. */
abstract refs(userIds: (string | null | undefined)[]): Promise<UserRefs>;
}
export interface UserRefs {
/** Unknown users keep their id as name. */
of(userId: string): UserRefReadModel;
ofNullable(userId: string | null | undefined): UserRefReadModel | null;
}