Skip to content

Commit 0d2190a

Browse files
authored
Auto-archive oldest projects and threads at capacity (#83)
- Add shared active-entity helpers and capacity constants - Auto-delete oldest projects or threads when creating new ones
1 parent 4f90229 commit 0d2190a

3 files changed

Lines changed: 117 additions & 2 deletions

File tree

apps/server/src/orchestration/commandInvariants.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
ProjectId,
77
ThreadId,
88
} from "@okcode/contracts";
9+
import { MAX_PROJECTS, MAX_THREADS_PER_PROJECT } from "@okcode/contracts";
910
import { Effect } from "effect";
1011

1112
import { OrchestrationCommandInvariantError } from "./Errors.ts";
@@ -119,3 +120,54 @@ export function requireNonNegativeInteger(input: {
119120
),
120121
);
121122
}
123+
124+
// ── Active entity helpers ────────────────────────────────────────────
125+
126+
export function listActiveProjects(
127+
readModel: OrchestrationReadModel,
128+
): ReadonlyArray<OrchestrationProject> {
129+
return readModel.projects.filter((project) => project.deletedAt === null);
130+
}
131+
132+
export function listActiveThreadsByProjectId(
133+
readModel: OrchestrationReadModel,
134+
projectId: ProjectId,
135+
): ReadonlyArray<OrchestrationThread> {
136+
return readModel.threads.filter(
137+
(thread) => thread.projectId === projectId && thread.deletedAt === null,
138+
);
139+
}
140+
141+
/**
142+
* Returns the oldest active projects that must be archived to stay within
143+
* MAX_PROJECTS when a new project is about to be created.
144+
* Sorted by updatedAt ascending (oldest first).
145+
*/
146+
export function getProjectsToArchive(
147+
readModel: OrchestrationReadModel,
148+
): ReadonlyArray<OrchestrationProject> {
149+
const active = listActiveProjects(readModel);
150+
if (active.length < MAX_PROJECTS) return [];
151+
const overflow = active.length - MAX_PROJECTS + 1;
152+
return [...active]
153+
.toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt) || a.id.localeCompare(b.id))
154+
.slice(0, overflow);
155+
}
156+
157+
/**
158+
* Returns the oldest active threads in the given project that must be
159+
* archived to stay within MAX_THREADS_PER_PROJECT when a new thread is
160+
* about to be created.
161+
* Sorted by updatedAt ascending (oldest first).
162+
*/
163+
export function getThreadsToArchive(
164+
readModel: OrchestrationReadModel,
165+
projectId: ProjectId,
166+
): ReadonlyArray<OrchestrationThread> {
167+
const active = listActiveThreadsByProjectId(readModel, projectId);
168+
if (active.length < MAX_THREADS_PER_PROJECT) return [];
169+
const overflow = active.length - MAX_THREADS_PER_PROJECT + 1;
170+
return [...active]
171+
.toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt) || a.id.localeCompare(b.id))
172+
.slice(0, overflow);
173+
}

apps/server/src/orchestration/decider.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Effect } from "effect";
77

88
import { OrchestrationCommandInvariantError } from "./Errors.ts";
99
import {
10+
getProjectsToArchive,
11+
getThreadsToArchive,
1012
requireProject,
1113
requireProjectAbsent,
1214
requireThread,
@@ -65,7 +67,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
6567
projectId: command.projectId,
6668
});
6769

68-
return {
70+
const projectCreatedEvent: Omit<OrchestrationEvent, "sequence"> = {
6971
...withEventBase({
7072
aggregateKind: "project",
7173
aggregateId: command.projectId,
@@ -83,6 +85,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
8385
updatedAt: command.createdAt,
8486
},
8587
};
88+
89+
// Auto-archive oldest projects when limit is reached
90+
const projectsToArchive = getProjectsToArchive(readModel);
91+
if (projectsToArchive.length === 0) {
92+
return projectCreatedEvent;
93+
}
94+
95+
const archiveEvents: Omit<OrchestrationEvent, "sequence">[] = projectsToArchive.map(
96+
(project) => ({
97+
...withEventBase({
98+
aggregateKind: "project" as const,
99+
aggregateId: project.id,
100+
occurredAt: command.createdAt,
101+
commandId: command.commandId,
102+
}),
103+
type: "project.deleted" as const,
104+
payload: {
105+
projectId: project.id,
106+
deletedAt: command.createdAt,
107+
},
108+
}),
109+
);
110+
return [...archiveEvents, projectCreatedEvent];
86111
}
87112

88113
case "project.meta.update": {
@@ -144,7 +169,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
144169
command,
145170
threadId: command.threadId,
146171
});
147-
return {
172+
173+
const threadCreatedEvent: Omit<OrchestrationEvent, "sequence"> = {
148174
...withEventBase({
149175
aggregateKind: "thread",
150176
aggregateId: command.threadId,
@@ -165,6 +191,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
165191
updatedAt: command.createdAt,
166192
},
167193
};
194+
195+
// Auto-archive oldest threads in the project when limit is reached
196+
const threadsToArchive = getThreadsToArchive(readModel, command.projectId);
197+
if (threadsToArchive.length === 0) {
198+
return threadCreatedEvent;
199+
}
200+
201+
const archiveEvents: Omit<OrchestrationEvent, "sequence">[] = threadsToArchive.map(
202+
(thread) => ({
203+
...withEventBase({
204+
aggregateKind: "thread" as const,
205+
aggregateId: thread.id,
206+
occurredAt: command.createdAt,
207+
commandId: command.commandId,
208+
}),
209+
type: "thread.deleted" as const,
210+
payload: {
211+
threadId: thread.id,
212+
deletedAt: command.createdAt,
213+
},
214+
}),
215+
);
216+
return [...archiveEvents, threadCreatedEvent];
168217
}
169218

170219
case "thread.delete": {

packages/contracts/src/orchestration.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
9191
export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
9292
export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
9393

94+
/**
95+
* Maximum number of active (non-deleted) projects allowed.
96+
* When this limit is reached, the oldest project is automatically archived
97+
* (soft-deleted) to make room for the new one.
98+
*/
99+
export const MAX_PROJECTS = 50;
100+
101+
/**
102+
* Maximum number of active (non-deleted) threads allowed per project.
103+
* When this limit is reached, the oldest thread in the project is
104+
* automatically archived (soft-deleted) to make room for the new one.
105+
*/
106+
export const MAX_THREADS_PER_PROJECT = 100;
107+
94108
export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
95109
export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
96110
export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;

0 commit comments

Comments
 (0)