1import {
2 type ApplicationProjectEvent,
3 IsoDateTime,
4 ModelSelection,
5 type OrchestrationProjectShell,
6 StoredProjectIcon,
7 ProjectId,
8 ProjectScript,
9 ThreadEnvMode,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import * as SqlClient from "effect/sql/SqlClient";
17import * as SqlSchema from "effect/sql/SqlSchema";
18
19export class ProjectStoreV2Error extends Schema.TaggedError<ProjectStoreV2Error>()(
20 "ProjectStoreV2Error",
21 {
22 operation: Schema.String,
23 cause: Schema.Defect(),
24 },
25) {
26 override get message(): string {
27 return `Project store operation '${this.operation}' failed.`;
28 }
29}
30
31/** One row of `projection_projects`, the durable project read model. */
32export const ProjectRow = Schema.Struct({
33 projectId: ProjectId,
34 title: Schema.String,
35 workspaceRoot: Schema.String,
36 defaultModelSelection: Schema.NullOr(ModelSelection),
37 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
38 autoPull: Schema.Boolean,
39 faviconPath: Schema.NullOr(Schema.String),
40 projectIcon: Schema.NullOr(StoredProjectIcon),
41 scripts: Schema.Array(ProjectScript),
42 createdAt: IsoDateTime,
43 updatedAt: IsoDateTime,
44 deletedAt: Schema.NullOr(IsoDateTime),
45});
46export type ProjectRow = typeof ProjectRow.Type;
47
48const ProjectDbRow = Schema.Struct({
49 ...ProjectRow.fields,
50 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
51 autoPull: Schema.BooleanFromBit,
52 projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)),
53 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
54});
55
56/** Shell fields without workspace-derived enrichment such as repository identity. */
57function toShell(row: ProjectRow): OrchestrationProjectShell {
58 return {
59 id: row.projectId,
60 title: row.title,
61 workspaceRoot: row.workspaceRoot,
62 repositoryIdentity: null,
63 defaultModelSelection: row.defaultModelSelection,
64 defaultThreadEnvMode: row.defaultThreadEnvMode,
65 autoPull: row.autoPull,
66 faviconPath: row.faviconPath,
67 projectIcon: row.projectIcon,
68 scripts: row.scripts,
69 createdAt: row.createdAt,
70 updatedAt: row.updatedAt,
71 };
72}
73
74export class ProjectStoreV2 extends Context.Service<
75 ProjectStoreV2,
76 {
77 /** Fold one committed project event into its row. Call inside the commit transaction. */
78 readonly apply: (event: ApplicationProjectEvent) => Effect.Effect<void, ProjectStoreV2Error>;
79 readonly get: (
80 projectId: ProjectId,
81 options?: { readonly includeDeleted?: boolean },
82 ) => Effect.Effect<Option.Option<ProjectRow>, ProjectStoreV2Error>;
83 readonly list: (options?: {
84 readonly projectIds?: ReadonlyArray<ProjectId>;
85 readonly includeDeleted?: boolean;
86 }) => Effect.Effect<ReadonlyArray<ProjectRow>, ProjectStoreV2Error>;
87 /** Workspace roots match by exact string; callers normalize before asking. */
88 readonly findActiveByWorkspaceRoot: (
89 workspaceRoot: string,
90 ) => Effect.Effect<Option.Option<ProjectRow>, ProjectStoreV2Error>;
91 /** An active project's shell without enrichment such as repository identity. */
92 readonly getShell: (
93 projectId: ProjectId,
94 ) => Effect.Effect<Option.Option<OrchestrationProjectShell>, ProjectStoreV2Error>;
95 /** Active project shells in creation order, without enrichment. */
96 readonly listShells: (options?: {
97 readonly projectIds?: ReadonlyArray<ProjectId>;
98 }) => Effect.Effect<ReadonlyArray<OrchestrationProjectShell>, ProjectStoreV2Error>;
99 }
100>()("t3/orchestration-v2/ProjectStore/ProjectStoreV2") {}
101
102/** @public Service construction is part of the canonical Effect module API. */
103export const make = Effect.gen(function* () {
104 const sql = yield* SqlClient.SqlClient;
105 const encodeRow = Schema.encodeEffect(ProjectDbRow);
106
107 const selectRows = SqlSchema.findAll({
108 Request: Schema.Struct({
109 projectId: Schema.optional(ProjectId),
110 projectIds: Schema.optional(Schema.Array(ProjectId)),
111 workspaceRoot: Schema.optional(Schema.String),
112 includeDeleted: Schema.Boolean,
113 }),
114 Result: ProjectDbRow,
115 execute: (request) => sql`
116 SELECT
117 project_id AS "projectId",
118 title,
119 workspace_root AS "workspaceRoot",
120 default_model_selection_json AS "defaultModelSelection",
121 default_thread_env_mode AS "defaultThreadEnvMode",
122 auto_pull AS "autoPull",
123 favicon_path AS "faviconPath",
124 project_icon_json AS "projectIcon",
125 scripts_json AS "scripts",
126 created_at AS "createdAt",
127 updated_at AS "updatedAt",
128 deleted_at AS "deletedAt"
129 FROM projection_projects
130 WHERE ${sql.and([
131 ...(request.includeDeleted ? [] : [sql`deleted_at IS NULL`]),
132 ...(request.projectId === undefined ? [] : [sql`project_id = ${request.projectId}`]),
133 ...(request.projectIds === undefined ? [] : [sql.in("project_id", request.projectIds)]),
134 ...(request.workspaceRoot === undefined
135 ? []
136 : [sql`workspace_root = ${request.workspaceRoot}`]),
137 ])}
138 ORDER BY created_at ASC, project_id ASC
139 `,
140 });
141
142 const upsertRow = (row: ProjectRow) =>
143 encodeRow(row).pipe(
144 Effect.flatMap(
145 (encoded) => sql`
146 INSERT INTO projection_projects (
147 project_id,
148 title,
149 workspace_root,
150 default_model_selection_json,
151 default_thread_env_mode,
152 auto_pull,
153 favicon_path,
154 project_icon_json,
155 scripts_json,
156 created_at,
157 updated_at,
158 deleted_at
159 )
160 VALUES (
161 ${encoded.projectId},
162 ${encoded.title},
163 ${encoded.workspaceRoot},
164 ${encoded.defaultModelSelection},
165 ${encoded.defaultThreadEnvMode},
166 ${encoded.autoPull},
167 ${encoded.faviconPath},
168 ${encoded.projectIcon},
169 ${encoded.scripts},
170 ${encoded.createdAt},
171 ${encoded.updatedAt},
172 ${encoded.deletedAt}
173 )
174 ON CONFLICT (project_id)
175 DO UPDATE SET
176 title = excluded.title,
177 workspace_root = excluded.workspace_root,
178 default_model_selection_json = excluded.default_model_selection_json,
179 default_thread_env_mode = excluded.default_thread_env_mode,
180 auto_pull = excluded.auto_pull,
181 favicon_path = excluded.favicon_path,
182 project_icon_json = excluded.project_icon_json,
183 scripts_json = excluded.scripts_json,
184 created_at = excluded.created_at,
185 updated_at = excluded.updated_at,
186 deleted_at = excluded.deleted_at
187 `,
188 ),
189 );
190
191 const mapError =
192 (operation: string) =>
193 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
194 effect.pipe(Effect.mapError((cause) => new ProjectStoreV2Error({ operation, cause })));
195
196 const get: ProjectStoreV2["Service"]["get"] = (projectId, options) =>
197 selectRows({ projectId, includeDeleted: options?.includeDeleted === true }).pipe(
198 Effect.map((rows) => Option.fromUndefinedOr(rows[0])),
199 mapError("get"),
200 );
201
202 const list: ProjectStoreV2["Service"]["list"] = (options) =>
203 selectRows({
204 ...(options?.projectIds === undefined ? {} : { projectIds: options.projectIds }),
205 includeDeleted: options?.includeDeleted === true,
206 }).pipe(mapError("list"));
207
208 const findActiveByWorkspaceRoot: ProjectStoreV2["Service"]["findActiveByWorkspaceRoot"] = (
209 workspaceRoot,
210 ) =>
211 selectRows({ workspaceRoot, includeDeleted: false }).pipe(
212 Effect.map((rows) => Option.fromUndefinedOr(rows[0])),
213 mapError("findActiveByWorkspaceRoot"),
214 );
215
216 const apply: ProjectStoreV2["Service"]["apply"] = Effect.fn("ProjectStoreV2.apply")(
217 function* (event) {
218 if (event.type === "project.created") {
219 const payload = event.payload;
220 return yield* upsertRow({
221 projectId: payload.projectId,
222 title: payload.title,
223 workspaceRoot: payload.workspaceRoot,
224 defaultModelSelection: payload.defaultModelSelection,
225 defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null,
226 autoPull: false,
227 faviconPath: payload.faviconPath ?? null,
228 projectIcon: payload.projectIcon ?? null,
229 scripts: payload.scripts,
230 createdAt: payload.createdAt,
231 updatedAt: payload.updatedAt,
232 deletedAt: null,
233 }).pipe(mapError("apply"));
234 }
235 const existing = yield* get(event.payload.projectId, { includeDeleted: true });
236 if (Option.isNone(existing)) return;
237 const row = existing.value;
238 if (event.type === "project.deleted") {
239 return yield* upsertRow({
240 ...row,
241 deletedAt: event.payload.deletedAt,
242 updatedAt: event.payload.deletedAt,
243 }).pipe(mapError("apply"));
244 }
245 const payload = event.payload;
246 yield* upsertRow({
247 ...row,
248 ...(payload.title === undefined ? {} : { title: payload.title }),
249 ...(payload.workspaceRoot === undefined ? {} : { workspaceRoot: payload.workspaceRoot }),
250 ...(payload.defaultModelSelection === undefined
251 ? {}
252 : { defaultModelSelection: payload.defaultModelSelection }),
253 ...(payload.defaultThreadEnvMode === undefined
254 ? {}
255 : { defaultThreadEnvMode: payload.defaultThreadEnvMode }),
256 ...(payload.autoPull === undefined ? {} : { autoPull: payload.autoPull }),
257 ...(payload.faviconPath === undefined ? {} : { faviconPath: payload.faviconPath }),
258 ...(payload.projectIcon === undefined ? {} : { projectIcon: payload.projectIcon }),
259 ...(payload.scripts === undefined ? {} : { scripts: payload.scripts }),
260 updatedAt: payload.updatedAt,
261 }).pipe(mapError("apply"));
262 },
263 );
264
265 return ProjectStoreV2.of({
266 apply,
267 get,
268 list,
269 findActiveByWorkspaceRoot,
270 getShell: (projectId) => get(projectId).pipe(Effect.map(Option.map(toShell))),
271 listShells: (options) =>
272 list(options?.projectIds === undefined ? undefined : { projectIds: options.projectIds }).pipe(
273 Effect.map((rows) => rows.map(toShell)),
274 ),
275 });
276});
277
278export const layer = Layer.effect(ProjectStoreV2, make);