Skip to content
Source
Sign inSign up

ProjectStore.ts

308 lines11.3 KB

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