Skip to content
Source
Sign inSign up

ProjectStore.ts

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