History

refactor(v2): simplify SQL persistence to direct repositories

Replace proxy/layered row operations with native Drizzle backends and scoped row queries so control-plane services follow straightforward database flows.

Rhys Sullivan committed 8 months ago

Commit f50778e1 parent a5d22a226 files · +1508 −2152Browse files

Unified · Split

26 files
  1. apps/pm/src/actor.ts +29 −24
  2. apps/pm/src/approvals-service.ts +39 −65
  3. apps/pm/src/control-plane-row-helpers.ts +0 −18
  4. apps/pm/src/credential-resolver.ts +72 −40
  5. apps/pm/src/credentials-service.ts +126 −91
  6. apps/pm/src/execute.e2e.test.ts +24 −3
  7. apps/pm/src/main.ts +14 −14
  8. apps/pm/src/organizations-service.ts +26 −25
  9. apps/pm/src/persistent-approval-policy.test.ts +23 −0
  10. apps/pm/src/policies-service.ts +23 −24
  11. apps/pm/src/storage-service.test.ts +23 −0
  12. apps/pm/src/storage-service.ts +49 −35
  13. apps/pm/src/tool-call-handler.test.ts +6 −5
  14. apps/pm/src/workspaces-service.ts +9 −9
  15. apps/web/lib/control-plane/server.ts +10 −13
  16. packages/persistence-sql/src/control-plane-rows.ts +865 −0
  17. packages/persistence-sql/src/index.ts +18 −184
  18. packages/persistence-sql/src/row-operations-auth.ts +0 −404
  19. packages/persistence-sql/src/row-operations-core.ts +0 −515
  20. packages/persistence-sql/src/row-operations.ts +0 −21
  21. packages/persistence-sql/src/rows-effect-api-auth.ts +0 −116
  22. packages/persistence-sql/src/rows-effect-api-core.ts +0 −157
  23. packages/persistence-sql/src/rows-effect-api.ts +0 −12
  24. packages/persistence-sql/src/rows-effect-helpers.ts +0 −55
  25. packages/persistence-sql/src/source-artifact-stores.ts +82 −106
  26. packages/persistence-sql/src/sql-internals.ts +70 −216
apps/pm/src/actor.ts+29−24
@@ -37,14 +37,6 @@ const localAdminFallbackEnabled = (() => { · 36 unmodified lines
37 return isTruthy(configured);37 return isTruthy(configured);
38})();38})();
39 39
40const organizationMembershipsForAccount = (
41 memberships: ReadonlyArray<OrganizationMembership>,
42 accountId: OrganizationMembership["accountId"],
43): ReadonlyArray<OrganizationMembership> =>
44 memberships.filter(
45 (membership) => membership.accountId === accountId,
46 );
47
48const workspaceMembershipsForAccount = (40const workspaceMembershipsForAccount = (
49 workspaces: ReadonlyArray<Workspace>,41 workspaces: ReadonlyArray<Workspace>,
50 accountId: OrganizationMembership["accountId"],42 accountId: OrganizationMembership["accountId"],
@@ -66,10 +58,20 @@ const resolveActorFromSnapshot = ( · 15 unmodified lines
66 Effect.gen(function* () {58 Effect.gen(function* () {
67 const principal = yield* requirePrincipalFromHeaders(headers);59 const principal = yield* requirePrincipalFromHeaders(headers);
68 60
69 const [memberships, workspaces] = yield* Effect.all([61 const memberships = yield* rows.organizationMemberships
70 rows.organizationMemberships.list(),62 .listByAccountId(principal.accountId)
71 rows.workspaces.list(),63 .pipe(
72 ]).pipe(64 Effect.mapError(
65 (error) =>
66 new ActorUnauthenticatedError({
67 message: `Unable to read local auth state (${error.operation})`,
68 }),
69 ),
70 );
71
72 const organizationIds = memberships.map((membership) => membership.organizationId);
73
74 const workspaces = yield* rows.workspaces.listByOrganizationIds(organizationIds).pipe(
73 Effect.mapError(75 Effect.mapError(
74 (error) =>76 (error) =>
75 new ActorUnauthenticatedError({77 new ActorUnauthenticatedError({
@@ -82,10 +84,7 @@ const resolveActorFromSnapshot = ( · 6 unmodified lines
82 return makeAllowAllActor(principal);84 return makeAllowAllActor(principal);
83 }85 }
84 86
85 const organizationMemberships = organizationMembershipsForAccount(87 const organizationMemberships = memberships;
86 memberships,
87 principal.accountId,
88 );
89 const workspaceMemberships = workspaceMembershipsForAccount(88 const workspaceMemberships = workspaceMembershipsForAccount(
90 workspaces,89 workspaces,
91 principal.accountId,90 principal.accountId,
@@ -106,10 +105,19 @@ export const PmActorLive = (rows: ActorRows) => · 14 unmodified lines
106 Effect.gen(function* () {105 Effect.gen(function* () {
107 const principal = yield* requirePrincipalFromHeaders(input.headers);106 const principal = yield* requirePrincipalFromHeaders(input.headers);
108 107
109 const [memberships, workspaces] = yield* Effect.all([108 const memberships = yield* rows.organizationMemberships
110 rows.organizationMemberships.list(),109 .listByAccountId(principal.accountId)
111 rows.workspaces.list(),110 .pipe(
112 ]).pipe(111 Effect.mapError(
112 (error) =>
113 new ActorUnauthenticatedError({
114 message: `Unable to read local auth state (${error.operation})`,
115 }),
116 ),
117 );
118
119 const organizationIds = memberships.map((membership) => membership.organizationId);
120 const workspaces = yield* rows.workspaces.listByOrganizationIds(organizationIds).pipe(
113 Effect.mapError(121 Effect.mapError(
114 (error) =>122 (error) =>
115 new ActorUnauthenticatedError({123 new ActorUnauthenticatedError({
@@ -122,10 +130,7 @@ export const PmActorLive = (rows: ActorRows) => · 6 unmodified lines
122 return makeAllowAllActor(principal);130 return makeAllowAllActor(principal);
123 }131 }
124 132
125 const organizationMemberships = organizationMembershipsForAccount(133 const organizationMemberships = memberships;
126 memberships,
127 principal.accountId,
128 );
129 134
130 const workspace = workspaces.find((item) => item.id === input.workspaceId) ?? null;135 const workspace = workspaces.find((item) => item.id === input.workspaceId) ?? null;
131 136
apps/pm/src/approvals-service.ts+39−65
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12} from "@executor-v2/management-api";12} from "@executor-v2/management-api";
13import { type Approval } from "@executor-v2/schema";13import { type Approval } from "@executor-v2/schema";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
15 16
16import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";17import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";
17 18
@@ -19,18 +20,6 @@ type ApprovalRows = Pick<SqlControlPlanePersistence["rows"], "approvals">; · 1 unmodified line
19 20
20const sourceStoreError = createSqlSourceStoreErrorMapper("approvals");21const sourceStoreError = createSqlSourceStoreErrorMapper("approvals");
21 22
22const findApprovalIndex = (
23 approvals: ReadonlyArray<Approval>,
24 workspaceId: string,
25 approvalId: string,
26): number =>
27 approvals.findIndex(
28 (approval) => approval.workspaceId === workspaceId && approval.id === approvalId,
29 );
30
31const sortApprovals = (approvals: ReadonlyArray<Approval>): Array<Approval> =>
32 [...approvals].sort((left, right) => right.requestedAt - left.requestedAt);
33
34const toPersistentApprovalStoreError = (23const toPersistentApprovalStoreError = (
35 operation: string,24 operation: string,
36 message: string,25 message: string,
@@ -69,50 +58,45 @@ export const createPmPersistentToolApprovalPolicy = ( · 32 unmodified lines
69): ToolApprovalPolicy => {58): ToolApprovalPolicy => {
70 const store: PersistentToolApprovalStore = {59 const store: PersistentToolApprovalStore = {
71 findByRunAndCall: (input) =>60 findByRunAndCall: (input) =>
72 rows.approvals.list().pipe(61 rows.approvals
62 .findByRunAndCall(
63 input.workspaceId as Approval["workspaceId"],
64 input.runId as Approval["taskRunId"],
65 input.callId,
66 )
67 .pipe(
73 Effect.mapError((error) =>68 Effect.mapError((error) =>
74 toPersistentApprovalStoreErrorFromRowStore("approvals.read", error),69 toPersistentApprovalStoreErrorFromRowStore("approvals.read", error),
75 ),70 ),
76 Effect.flatMap((approvals) => {71 Effect.flatMap((approvalOption) => {
77 const approval =72 const approval = Option.getOrNull(approvalOption);
78 approvals.find(73 return Effect.succeed(approval !== null ? toPersistentApprovalRecord(approval) : null);
79 (candidate) =>
80 candidate.workspaceId === input.workspaceId &&
81 candidate.taskRunId === input.runId &&
82 candidate.callId === input.callId,
83 ) ?? null;
84
85 return Effect.succeed(approval ? toPersistentApprovalRecord(approval) : null);
86 }),74 }),
87 ),75 ),
88 76
89 createPending: (input) =>77 createPending: (input) =>
90 rows.approvals.list().pipe(78 Effect.gen(function* () {
91 Effect.mapError((error) =>79 const pendingApproval: Approval = {
92 toPersistentApprovalStoreErrorFromRowStore("approvals.read", error),80 id: `apr_${crypto.randomUUID()}` as Approval["id"],
93 ),81 workspaceId: input.workspaceId as Approval["workspaceId"],
94 Effect.flatMap(() => {82 taskRunId: input.runId as Approval["taskRunId"],
95 const pendingApproval = {83 callId: input.callId,
96 id: `apr_${crypto.randomUUID()}`,84 toolPath: input.toolPath,
97 workspaceId: input.workspaceId,85 status: "pending",
98 taskRunId: input.runId,86 inputPreviewJson: input.inputPreviewJson,
99 callId: input.callId,87 reason: null,
100 toolPath: input.toolPath,88 requestedAt: Date.now(),
101 status: "pending",89 resolvedAt: null,
102 inputPreviewJson: input.inputPreviewJson,90 };
103 reason: null,91
104 requestedAt: Date.now(),92 yield* rows.approvals.upsert(pendingApproval).pipe(
105 resolvedAt: null,93 Effect.mapError((error) =>
106 } as Approval;94 toPersistentApprovalStoreErrorFromRowStore("approvals.write", error),
107 95 ),
108 return rows.approvals.upsert(pendingApproval).pipe(96 );
109 Effect.mapError((error) =>97
110 toPersistentApprovalStoreErrorFromRowStore("approvals.write", error),98 return toPersistentApprovalRecord(pendingApproval);
111 ),99 }),
112 Effect.as(toPersistentApprovalRecord(pendingApproval)),
113 );
114 }),
115 ),
116 };100 };
117 101
118 return createPersistentToolApprovalPolicy({102 return createPersistentToolApprovalPolicy({
@@ -128,34 +112,25 @@ export const createPmApprovalsService = ( · 9 unmodified lines
128 makeControlPlaneApprovalsService({112 makeControlPlaneApprovalsService({
129 listApprovals: (workspaceId) =>113 listApprovals: (workspaceId) =>
130 Effect.gen(function* () {114 Effect.gen(function* () {
131 const approvals = yield* rows.approvals.list().pipe(115 const approvals = yield* rows.approvals.listByWorkspaceId(workspaceId).pipe(
132 Effect.mapError((error) =>116 Effect.mapError((error) =>
133 sourceStoreError.fromRowStore("approvals.list", error),117 sourceStoreError.fromRowStore("approvals.list", error),
134 ),118 ),
135 );119 );
136 120
137 const scopedApprovals = approvals.filter(121 return approvals;
138 (approval) => approval.workspaceId === workspaceId,
139 );
140
141 return sortApprovals(scopedApprovals);
142 }),122 }),
143 123
144 resolveApproval: (input) =>124 resolveApproval: (input) =>
145 Effect.gen(function* () {125 Effect.gen(function* () {
146 const approvals = yield* rows.approvals.list().pipe(126 const approvalOption = yield* rows.approvals.getById(input.approvalId).pipe(
147 Effect.mapError((error) =>127 Effect.mapError((error) =>
148 sourceStoreError.fromRowStore("approvals.resolve", error),128 sourceStoreError.fromRowStore("approvals.get_by_id", error),
149 ),129 ),
150 );130 );
151 131
152 const index = findApprovalIndex(132 const approval = Option.getOrNull(approvalOption);
153 approvals,133 if (approval === null || approval.workspaceId !== input.workspaceId) {
154 input.workspaceId,
155 input.approvalId,
156 );
157
158 if (index < 0) {
159 return yield* sourceStoreError.fromMessage(134 return yield* sourceStoreError.fromMessage(
160 "approvals.resolve",135 "approvals.resolve",
161 "Approval not found",136 "Approval not found",
@@ -163,7 +138,6 @@ export const createPmApprovalsService = ( · 1 unmodified line
163 );138 );
164 }139 }
165 140
166 const approval = approvals[index];
167 if (approval.status !== "pending") {141 if (approval.status !== "pending") {
168 return yield* sourceStoreError.fromMessage(142 return yield* sourceStoreError.fromMessage(
169 "approvals.resolve",143 "approvals.resolve",
apps/pm/src/control-plane-row-helpers.ts+0−18
@@ -1,9 +1,4 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 type OrganizationId,
4 type Workspace,
5 type WorkspaceId,
6} from "@executor-v2/schema";
7 2
8type RowStoreLikeError = {3type RowStoreLikeError = {
9 message: string;4 message: string;
@@ -57,16 +52,3 @@ export const createSqlSourceStoreErrorMapper = ( · 47 unmodified lines
57 ),52 ),
58 };53 };
59};54};
60
61export const resolveWorkspaceOrganizationId = (
62 workspaces: ReadonlyArray<Workspace>,
63 workspaceId: WorkspaceId,
64): OrganizationId => {
65 const workspace = workspaces.find((item) => item.id === workspaceId);
66
67 if (!workspace) {
68 throw new Error(`Workspace not found: ${workspaceId}`);
69 }
70
71 return workspace.organizationId;
72};
apps/pm/src/credential-resolver.ts+72−40
@@ -5,26 +5,19 @@ import { · 4 unmodified lines
5 sourceIdFromSourceKey,5 sourceIdFromSourceKey,
6 type ResolveToolCredentials,6 type ResolveToolCredentials,
7} from "@executor-v2/engine";7} from "@executor-v2/engine";
8import { type RowStoreError } from "@executor-v2/persistence-ports";8import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
9import {9import { type OrganizationId, type WorkspaceId } from "@executor-v2/schema";
10 type AuthConnection,
11 type AuthMaterial,
12 type OAuthState,
13 type SourceAuthBinding,
14 type Workspace,
15} from "@executor-v2/schema";
16import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
17 11
18type RowReader<A> = {
19 list: () => Effect.Effect<ReadonlyArray<A>, RowStoreError>;
20};
21
22type CredentialResolverRows = {12type CredentialResolverRows = {
23 workspaces: RowReader<Workspace>;13 workspaces: Pick<SqlControlPlanePersistence["rows"]["workspaces"], "getById">;
24 sourceAuthBindings: RowReader<SourceAuthBinding>;14 sourceAuthBindings: Pick<
25 authConnections: RowReader<AuthConnection>;15 SqlControlPlanePersistence["rows"]["sourceAuthBindings"],
26 authMaterials: RowReader<AuthMaterial>;16 "listByWorkspaceScope"
27 oauthStates: RowReader<OAuthState>;17 >;
18 authConnections: Pick<SqlControlPlanePersistence["rows"]["authConnections"], "getById">;
19 authMaterials: Pick<SqlControlPlanePersistence["rows"]["authMaterials"], "getByConnectionId">;
20 oauthStates: Pick<SqlControlPlanePersistence["rows"]["oauthStates"], "getByConnectionId">;
28};21};
29 22
30const toCredentialResolverError = (23const toCredentialResolverError = (
@@ -180,33 +173,44 @@ export const createPmResolveToolCredentials = ( · 149 unmodified lines
180 };173 };
181 }174 }
182 175
183 const [workspaces, bindings, connections, oauthStates, materials] = yield* Effect.all([176 const sourceId = sourceIdFromSourceKey(context.sourceKey);
184 rows.workspaces.list(),177 if (!sourceId) {
185 rows.sourceAuthBindings.list(),178 return {
186 rows.authConnections.list(),179 headers: {},
187 rows.oauthStates.list(),180 };
188 rows.authMaterials.list(),181 }
189 ]).pipe(182
183 const workspaceId = context.workspaceId as WorkspaceId;
184
185 const workspaceOption = yield* rows.workspaces.getById(workspaceId).pipe(
190 Effect.mapError((error) =>186 Effect.mapError((error) =>
191 toCredentialResolverError(187 toCredentialResolverError(
192 "read_credential_rows",188 "read_workspace",
193 "Failed reading credential rows while resolving credentials",189 "Failed reading workspace while resolving credentials",
194 error.details ?? error.message,190 error.details ?? error.message,
195 ),191 ),
196 ),192 ),
197 );193 );
198 194
199 const sourceId = sourceIdFromSourceKey(context.sourceKey);195 const workspace = workspaceOption._tag === "Some" ? workspaceOption.value : null;
200 if (!sourceId) {196 const organizationId = context.organizationId ?? workspace?.organizationId ?? null;
197 if (organizationId === null) {
201 return {198 return {
202 headers: {},199 headers: {},
203 };200 };
204 }201 }
205 202
206 const workspace = workspaces.find(203 const bindings = yield* rows.sourceAuthBindings
207 (item) => item.id === context.workspaceId,204 .listByWorkspaceScope(workspaceId, organizationId as OrganizationId)
208 );205 .pipe(
209 const organizationId = context.organizationId ?? workspace?.organizationId ?? null;206 Effect.mapError((error) =>
207 toCredentialResolverError(
208 "read_bindings",
209 "Failed reading credential bindings",
210 error.details ?? error.message,
211 ),
212 ),
213 );
210 214
211 const binding = bindings215 const binding = bindings
212 .filter((candidate) => candidate.sourceId === sourceId)216 .filter((candidate) => candidate.sourceId === sourceId)
@@ -247,9 +251,19 @@ export const createPmResolveToolCredentials = ( · 34 unmodified lines
247 };251 };
248 }252 }
249 253
250 const connection = connections.find(254 const connectionOption = yield* rows.authConnections.getById(binding.connectionId).pipe(
251 (candidate) => candidate.id === binding.connectionId,255 Effect.mapError((error) =>
256 toCredentialResolverError(
257 "read_connection",
258 "Failed reading auth connection",
259 error.details ?? error.message,
260 ),
261 ),
252 );262 );
263
264 const connection = connectionOption._tag === "Some"
265 ? connectionOption.value
266 : null;
253 if (!connection || connection.status !== "active") {267 if (!connection || connection.status !== "active") {
254 return {268 return {
255 headers: {},269 headers: {},
@@ -261,9 +275,18 @@ export const createPmResolveToolCredentials = ( · 5 unmodified lines
261 );275 );
262 276
263 if (connection.strategy === "oauth2") {277 if (connection.strategy === "oauth2") {
264 const oauthState = oauthStates.find(278 const oauthStateOption = yield* rows.oauthStates
265 (candidate) => candidate.connectionId === connection.id,279 .getByConnectionId(connection.id)
266 );280 .pipe(
281 Effect.mapError((error) =>
282 toCredentialResolverError(
283 "read_oauth_state",
284 "Failed reading oauth state",
285 error.details ?? error.message,
286 ),
287 ),
288 );
289 const oauthState = oauthStateOption._tag === "Some" ? oauthStateOption.value : null;
267 290
268 const accessToken = normalizeString(oauthState?.accessTokenCiphertext);291 const accessToken = normalizeString(oauthState?.accessTokenCiphertext);
269 const oauthHeaders = accessToken292 const oauthHeaders = accessToken
@@ -275,9 +298,18 @@ export const createPmResolveToolCredentials = ( · 5 unmodified lines
275 };298 };
276 }299 }
277 300
278 const material = materials.find(301 const materialOption = yield* rows.authMaterials
279 (candidate) => candidate.connectionId === connection.id,302 .getByConnectionId(connection.id)
280 );303 .pipe(
304 Effect.mapError((error) =>
305 toCredentialResolverError(
306 "read_auth_material",
307 "Failed reading auth material",
308 error.details ?? error.message,
309 ),
310 ),
311 );
312 const material = materialOption._tag === "Some" ? materialOption.value : null;
281 313
282 if (!material) {314 if (!material) {
283 return {315 return {
apps/pm/src/credentials-service.ts+126−91
@@ -9,14 +9,11 @@ import { · 8 unmodified lines
9 type OAuthState,9 type OAuthState,
10 type SourceAuthBinding,10 type SourceAuthBinding,
11 type SourceCredentialBinding,11 type SourceCredentialBinding,
12 type WorkspaceId,
13} from "@executor-v2/schema";12} from "@executor-v2/schema";
14import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15 15
16import {16import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";
17 createSqlSourceStoreErrorMapper,
18 resolveWorkspaceOrganizationId,
19} from "./control-plane-row-helpers";
20import {17import {
21 buildOAuthRefreshConfigFromPayload,18 buildOAuthRefreshConfigFromPayload,
22 encodeOAuthRefreshConfig,19 encodeOAuthRefreshConfig,
@@ -46,38 +43,42 @@ export const createPmCredentialsService = ( · 23 unmodified lines
46 makeControlPlaneCredentialsService({43 makeControlPlaneCredentialsService({
47 listCredentialBindings: (workspaceId) =>44 listCredentialBindings: (workspaceId) =>
48 Effect.gen(function* () {45 Effect.gen(function* () {
49 const [bindings, connections, workspaces] = yield* Effect.all([46 const workspaceOption = yield* rows.workspaces.getById(workspaceId).pipe(
50 rows.sourceAuthBindings.list().pipe(47 Effect.mapError((error) =>
51 Effect.mapError((error) =>48 sourceStoreError.fromRowStore("credentials.workspaces.get_by_id", error),
52 sourceStoreError.fromRowStore("credentials.bindings.list", error),
53 ),
54 ),49 ),
55 rows.authConnections.list().pipe(50 );
56 Effect.mapError((error) =>51
57 sourceStoreError.fromRowStore("credentials.connections.list", error),52 const workspace = Option.getOrNull(workspaceOption);
53 if (workspace === null) {
54 return yield* sourceStoreError.fromMessage(
55 "credentials.list",
56 "Workspace not found",
57 `workspace=${workspaceId}`,
58 );
59 }
60
61 const [bindings, connections] = yield* Effect.all([
62 rows.sourceAuthBindings
63 .listByWorkspaceScope(workspaceId, workspace.organizationId)
64 .pipe(
65 Effect.mapError((error) =>
66 sourceStoreError.fromRowStore("credentials.bindings.list", error),
67 ),
58 ),68 ),
59 ),69 rows.authConnections.listByOrganizationId(workspace.organizationId).pipe(
60 rows.workspaces.list().pipe(
61 Effect.mapError((error) =>70 Effect.mapError((error) =>
62 sourceStoreError.fromRowStore("credentials.workspaces.list", error),71 sourceStoreError.fromRowStore("credentials.connections.list", error),
63 ),72 ),
64 ),73 ),
65 ]);74 ]);
66 75
67 const organizationId = resolveWorkspaceOrganizationId(workspaces, workspaceId);76 const connectionById = new Map(connections.map((connection) => [connection.id, connection]));
68
69 const scopedBindings = bindings.filter(
70 (binding) =>
71 binding.workspaceId === workspaceId
72 || (binding.workspaceId === null && binding.organizationId === organizationId),
73 );
74 77
75 const compatBindings: Array<SourceCredentialBinding> = [];78 const compatBindings: Array<SourceCredentialBinding> = [];
76 79
77 for (const binding of scopedBindings) {80 for (const binding of bindings) {
78 const connection = connections.find(81 const connection = connectionById.get(binding.connectionId);
79 (candidate) => candidate.id === binding.connectionId,
80 );
81 82
82 if (!connection) {83 if (!connection) {
83 continue;84 continue;
@@ -91,33 +92,22 @@ export const createPmCredentialsService = ( · 7 unmodified lines
91 92
92 upsertCredentialBinding: (input) =>93 upsertCredentialBinding: (input) =>
93 Effect.gen(function* () {94 Effect.gen(function* () {
94 const [bindings, connections, materials, oauthStates, workspaces] = yield* Effect.all([95 const workspaceOption = yield* rows.workspaces.getById(input.workspaceId).pipe(
95 rows.sourceAuthBindings.list().pipe(96 Effect.mapError((error) =>
96 Effect.mapError((error) =>97 sourceStoreError.fromRowStore("credentials.workspaces.get_by_id", error),
97 sourceStoreError.fromRowStore("credentials.bindings.list", error),
98 ),
99 ),
100 rows.authConnections.list().pipe(
101 Effect.mapError((error) =>
102 sourceStoreError.fromRowStore("credentials.connections.list", error),
103 ),
104 ),
105 rows.authMaterials.list().pipe(
106 Effect.mapError((error) =>
107 sourceStoreError.fromRowStore("credentials.materials.list", error),
108 ),
109 ),
110 rows.oauthStates.list().pipe(
111 Effect.mapError((error) =>
112 sourceStoreError.fromRowStore("credentials.oauth_states.list", error),
113 ),
114 ),
115 rows.workspaces.list().pipe(
116 Effect.mapError((error) =>
117 sourceStoreError.fromRowStore("credentials.workspaces.list", error),
118 ),
119 ),98 ),
120 ]);99 );
100
101 const workspace = Option.getOrNull(workspaceOption);
102 if (workspace === null) {
103 return yield* sourceStoreError.fromMessage(
104 "credentials.upsert",
105 "Workspace not found",
106 `workspace=${input.workspaceId}`,
107 );
108 }
109
110 const organizationId = workspace.organizationId;
121 111
122 if (input.payload.scopeType === "account" && input.payload.accountId === null) {112 if (input.payload.scopeType === "account" && input.payload.accountId === null) {
123 return yield* sourceStoreError.fromMessage(113 return yield* sourceStoreError.fromMessage(
@@ -140,11 +130,29 @@ export const createPmCredentialsService = ( · 16 unmodified lines
140 const requestedId = input.payload.id;130 const requestedId = input.payload.id;
141 const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined;131 const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined;
142 132
143 const existingBinding = requestedBindingId133 const existingBindingOption = requestedBindingId
144 ? bindings.find((binding) => binding.id === requestedBindingId) ?? null134 ? yield* rows.sourceAuthBindings.getById(requestedBindingId).pipe(
145 : null;135 Effect.mapError((error) =>
146 136 sourceStoreError.fromRowStore("credentials.binding.get_by_id", error),
147 const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);137 ),
138 )
139 : Option.none<SourceAuthBinding>();
140
141 const existingBinding = Option.getOrNull(existingBindingOption);
142 if (
143 existingBinding !== null
144 && existingBinding.workspaceId !== input.workspaceId
145 && (
146 existingBinding.workspaceId !== null
147 || existingBinding.organizationId !== organizationId
148 )
149 ) {
150 return yield* sourceStoreError.fromMessage(
151 "credentials.upsert",
152 "Credential binding is outside workspace scope",
153 `workspace=${input.workspaceId} binding=${requestedBindingId}`,
154 );
155 }
148 156
149 const scopeWorkspaceId =157 const scopeWorkspaceId =
150 input.payload.scopeType === "workspace" ? input.workspaceId : null;158 input.payload.scopeType === "workspace" ? input.workspaceId : null;
@@ -163,9 +171,15 @@ export const createPmCredentialsService = ( · 12 unmodified lines
163 ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"])171 ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"])
164 ) as AuthConnection["id"];172 ) as AuthConnection["id"];
165 173
166 const existingConnection = connections.find(174 const existingConnectionOption = yield* rows.authConnections
167 (connection) => connection.id === requestedConnectionId,175 .getById(requestedConnectionId)
168 ) ?? null;176 .pipe(
177 Effect.mapError((error) =>
178 sourceStoreError.fromRowStore("credentials.connection.get_by_id", error),
179 ),
180 );
181
182 const existingConnection = Option.getOrNull(existingConnectionOption);
169 183
170 if (existingConnection && existingConnection.organizationId !== organizationId) {184 if (existingConnection && existingConnection.organizationId !== organizationId) {
171 return yield* sourceStoreError.fromMessage(185 return yield* sourceStoreError.fromMessage(
@@ -228,9 +242,15 @@ export const createPmCredentialsService = ( · 56 unmodified lines
228 );242 );
229 243
230 if (nextConnection.strategy === "oauth2") {244 if (nextConnection.strategy === "oauth2") {
231 const existingOAuth = oauthStates.find(245 const existingOAuthOption = yield* rows.oauthStates
232 (state) => state.connectionId === requestedConnectionId,246 .getByConnectionId(requestedConnectionId)
233 ) ?? null;247 .pipe(
248 Effect.mapError((error) =>
249 sourceStoreError.fromRowStore("credentials.oauth_states.get_by_connection", error),
250 ),
251 );
252 const existingOAuth = Option.getOrNull(existingOAuthOption);
253
234 const refreshConfig = buildOAuthRefreshConfigFromPayload(254 const refreshConfig = buildOAuthRefreshConfigFromPayload(
235 input.payload,255 input.payload,
236 parseOAuthRefreshConfig(existingOAuth?.refreshConfigJson ?? null),256 parseOAuthRefreshConfig(existingOAuth?.refreshConfigJson ?? null),
@@ -282,9 +302,14 @@ export const createPmCredentialsService = ( · 45 unmodified lines
282 ),302 ),
283 );303 );
284 } else {304 } else {
285 const existingMaterial = materials.find(305 const existingMaterialOption = yield* rows.authMaterials
286 (material) => material.connectionId === requestedConnectionId,306 .getByConnectionId(requestedConnectionId)
287 ) ?? null;307 .pipe(
308 Effect.mapError((error) =>
309 sourceStoreError.fromRowStore("credentials.materials.get_by_connection", error),
310 ),
311 );
312 const existingMaterial = Option.getOrNull(existingMaterialOption);
288 313
289 const material: AuthMaterial = {314 const material: AuthMaterial = {
290 id:315 id:
@@ -312,30 +337,35 @@ export const createPmCredentialsService = ( · 21 unmodified lines
312 337
313 removeCredentialBinding: (input) =>338 removeCredentialBinding: (input) =>
314 Effect.gen(function* () {339 Effect.gen(function* () {
315 const [bindings, workspaces] = yield* Effect.all([340 const workspaceOption = yield* rows.workspaces.getById(input.workspaceId).pipe(
316 rows.sourceAuthBindings.list().pipe(341 Effect.mapError((error) =>
317 Effect.mapError((error) =>342 sourceStoreError.fromRowStore("credentials.workspaces.get_by_id", error),
318 sourceStoreError.fromRowStore("credentials.bindings.list", error),
319 ),
320 ),
321 rows.workspaces.list().pipe(
322 Effect.mapError((error) =>
323 sourceStoreError.fromRowStore("credentials.workspaces.list", error),
324 ),
325 ),343 ),
326 ]);344 );
345
346 const workspace = Option.getOrNull(workspaceOption);
347 if (workspace === null) {
348 return {
349 removed: false,
350 };
351 }
327 352
328 const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);353 const bindingOption = yield* rows.sourceAuthBindings
329 const binding = bindings.find(354 .getById(input.credentialBindingId)
330 (item) =>355 .pipe(
331 item.id === input.credentialBindingId356 Effect.mapError((error) =>
332 && (357 sourceStoreError.fromRowStore("credentials.binding.get_by_id", error),
333 item.workspaceId === input.workspaceId
334 || (item.workspaceId === null && item.organizationId === organizationId)
335 ),358 ),
336 );359 );
337 360
338 if (!binding) {361 const binding = Option.getOrNull(bindingOption);
362 if (
363 binding === null
364 || (
365 binding.workspaceId !== input.workspaceId
366 && (binding.workspaceId !== null || binding.organizationId !== workspace.organizationId)
367 )
368 ) {
339 return {369 return {
340 removed: false,370 removed: false,
341 };371 };
@@ -355,10 +385,15 @@ export const createPmCredentialsService = ( · 13 unmodified lines
355 };385 };
356 }386 }
357 387
358 const hasRemainingBindings = bindings.some(388 const remainingBindings = yield* rows.sourceAuthBindings
359 (candidate) =>389 .listByConnectionId(binding.connectionId)
360 candidate.id !== binding.id && candidate.connectionId === binding.connectionId,390 .pipe(
361 );391 Effect.mapError((error) =>
392 sourceStoreError.fromRowStore("credentials.bindings.list_by_connection", error),
393 ),
394 );
395
396 const hasRemainingBindings = remainingBindings.some((candidate) => candidate.id !== binding.id);
362 397
363 if (!hasRemainingBindings) {398 if (!hasRemainingBindings) {
364 yield* Effect.all([399 yield* Effect.all([
apps/pm/src/execute.e2e.test.ts+24−3
@@ -109,10 +109,31 @@ const withPmProcess = (port: number, localDataDir: string) => · 108 unmodified lines
109 }),109 }),
110 ),110 ),
111 (child) =>111 (child) =>
112 Effect.sync(() => {112 Effect.promise(async () => {
113 if (!child.killed) {113 if (child.exitCode !== null || child.killed) {
114 child.kill("SIGTERM");114 return;
115 }115 }
116
117 child.kill("SIGTERM");
118
119 await new Promise<void>((resolve) => {
120 const timeout = setTimeout(() => {
121 if (child.exitCode === null && !child.killed) {
122 child.kill("SIGKILL");
123 }
124 resolve();
125 }, 2_000);
126
127 child.once("exit", () => {
128 clearTimeout(timeout);
129 resolve();
130 });
131
132 child.once("error", () => {
133 clearTimeout(timeout);
134 resolve();
135 });
136 });
116 }),137 }),
117 );138 );
118 139
apps/pm/src/main.ts+14−14
@@ -59,7 +59,7 @@ const pmStateRootDir = process.env.PM_STATE_ROOT_DIR ?? ".executor-v2/pm-state"; · 58 unmodified lines
59 59
60const parsePort = (value: string | undefined): number => {60const parsePort = (value: string | undefined): number => {
61 const parsed = Number.parseInt(value ?? "", 10);61 const parsed = Number.parseInt(value ?? "", 10);
62 return Number.isFinite(parsed) && parsed > 0 ? parsed : 8787;62 return Number.isFinite(parsed) && parsed > 0 ? parsed : 8788;
63};63};
64 64
65const readConfiguredRuntimeKind = (value: string | undefined): string | undefined => {65const readConfiguredRuntimeKind = (value: string | undefined): string | undefined => {
@@ -102,17 +102,21 @@ const ensurePmBootstrap = ( · 36 unmodified lines
102) =>102) =>
103 Effect.gen(function* () {103 Effect.gen(function* () {
104 const now = Date.now();104 const now = Date.now();
105 const [organizations, memberships, workspaces, profileOption] = yield* Effect.all([
106 persistence.rows.organizations.list(),
107 persistence.rows.organizationMemberships.list(),
108 persistence.rows.workspaces.list(),
109 persistence.rows.profile.get(),
110 ]);
111 105
112 const organizationId = "org_local" as OrganizationId;106 const organizationId = "org_local" as OrganizationId;
113 const accountId = "acct_local" as AccountId;107 const accountId = "acct_local" as AccountId;
114 108
115 if (organizations.find((item) => item.id === organizationId) === undefined) {109 const [organizationOption, membershipOption, workspaceOption, profileOption] = yield* Effect.all([
110 persistence.rows.organizations.getById(organizationId),
111 persistence.rows.organizationMemberships.getByOrganizationAndAccount(
112 organizationId,
113 accountId,
114 ),
115 persistence.rows.workspaces.getById(workspaceId),
116 persistence.rows.profile.get(),
117 ]);
118
119 if (organizationOption._tag === "None") {
116 yield* persistence.rows.organizations.upsert({120 yield* persistence.rows.organizations.upsert({
117 id: organizationId,121 id: organizationId,
118 slug: organizationId,122 slug: organizationId,
@@ -124,11 +128,7 @@ const ensurePmBootstrap = ( · 5 unmodified lines
124 });128 });
125 }129 }
126 130
127 if (131 if (membershipOption._tag === "None") {
128 memberships.find(
129 (item) => item.organizationId === organizationId && item.accountId === accountId,
130 ) === undefined
131 ) {
132 yield* persistence.rows.organizationMemberships.upsert({132 yield* persistence.rows.organizationMemberships.upsert({
133 id: "org_member_local" as OrganizationMemberId,133 id: "org_member_local" as OrganizationMemberId,
134 organizationId,134 organizationId,
@@ -143,7 +143,7 @@ const ensurePmBootstrap = ( · 8 unmodified lines
143 });143 });
144 }144 }
145 145
146 if (workspaces.find((item) => item.id === workspaceId) === undefined) {146 if (workspaceOption._tag === "None") {
147 yield* persistence.rows.workspaces.upsert({147 yield* persistence.rows.workspaces.upsert({
148 id: workspaceId,148 id: workspaceId,
149 organizationId,149 organizationId,
apps/pm/src/organizations-service.ts+26−25
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 type OrganizationMembership,8 type OrganizationMembership,
9} from "@executor-v2/schema";9} from "@executor-v2/schema";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
11 12
12import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";13import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";
13 14
@@ -49,28 +50,20 @@ export const createPmOrganizationsService = ( · 35 unmodified lines
49 50
50 upsertOrganization: (input) =>51 upsertOrganization: (input) =>
51 Effect.gen(function* () {52 Effect.gen(function* () {
52 const organizations = yield* rows.organizations.list().pipe(53 const existingOption = input.payload.id
53 Effect.mapError((error) =>54 ? yield* rows.organizations.getById(input.payload.id).pipe(
54 sourceStoreError.fromRowStore("organizations.upsert", error),55 Effect.mapError((error) =>
55 ),56 sourceStoreError.fromRowStore("organizations.get_by_id", error),
56 );
57
58 const memberships = yield* rows.organizationMemberships.list().pipe(
59 Effect.mapError((error) =>
60 sourceStoreError.fromRowStore(
61 "organizations.memberships.list",
62 error,
63 ),57 ),
64 ),58 )
65 );59 : yield* rows.organizations.getBySlug(input.payload.slug).pipe(
60 Effect.mapError((error) =>
61 sourceStoreError.fromRowStore("organizations.get_by_slug", error),
62 ),
63 );
66 64
67 const now = Date.now();65 const now = Date.now();
68 const existingIndex = input.payload.id66 const existing = Option.getOrNull(existingOption);
69 ? organizations.findIndex((organization) => organization.id === input.payload.id)
70 : organizations.findIndex(
71 (organization) => organization.slug === input.payload.slug,
72 );
73 const existing = existingIndex >= 0 ? organizations[existingIndex] : null;
74 67
75 const nextOrganization: Organization = {68 const nextOrganization: Organization = {
76 id:69 id:
@@ -94,13 +87,21 @@ export const createPmOrganizationsService = ( · 17 unmodified lines
94 );87 );
95 88
96 if (existing === null && nextOrganization.createdByAccountId !== null) {89 if (existing === null && nextOrganization.createdByAccountId !== null) {
97 const existingMembership = memberships.find(90 const existingMembership = yield* rows.organizationMemberships
98 (membership) =>91 .getByOrganizationAndAccount(
99 membership.organizationId === nextOrganization.id92 nextOrganization.id,
100 && membership.accountId === nextOrganization.createdByAccountId,93 nextOrganization.createdByAccountId,
101 );94 )
95 .pipe(
96 Effect.mapError((error) =>
97 sourceStoreError.fromRowStore(
98 "organizations.membership_get",
99 error,
100 ),
101 ),
102 );
102 103
103 if (!existingMembership) {104 if (Option.isNone(existingMembership)) {
104 const membership: OrganizationMembership = {105 const membership: OrganizationMembership = {
105 id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],106 id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],
106 organizationId: nextOrganization.id,107 organizationId: nextOrganization.id,
apps/pm/src/persistent-approval-policy.test.ts+23−0
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
3import { type Approval, type WorkspaceId } from "@executor-v2/schema";4import { type Approval, type WorkspaceId } from "@executor-v2/schema";
4 5
5import {6import {
@@ -15,6 +16,28 @@ describe("PM persistent approval policy", () => { · 9 unmodified lines
15 const approvalRows = {16 const approvalRows = {
16 approvals: {17 approvals: {
17 list: () => Effect.succeed(approvals),18 list: () => Effect.succeed(approvals),
19 listByWorkspaceId: (workspaceId: WorkspaceId) =>
20 Effect.succeed(approvals.filter((approval) => approval.workspaceId === workspaceId)),
21 getById: (approvalId: Approval["id"]) =>
22 Effect.succeed(
23 Option.fromNullable(
24 approvals.find((approval) => approval.id === approvalId) ?? null,
25 ),
26 ),
27 findByRunAndCall: (
28 workspaceId: Approval["workspaceId"],
29 taskRunId: Approval["taskRunId"],
30 callId: Approval["callId"],
31 ) =>
32 Effect.succeed(
33 Option.fromNullable(
34 approvals.find((approval) =>
35 approval.workspaceId === workspaceId
36 && approval.taskRunId === taskRunId
37 && approval.callId === callId
38 ) ?? null,
39 ),
40 ),
18 upsert: (approval: Approval) =>41 upsert: (approval: Approval) =>
19 Effect.sync(() => {42 Effect.sync(() => {
20 const index = approvals.findIndex((item) => item.id === approval.id);43 const index = approvals.findIndex((item) => item.id === approval.id);
apps/pm/src/policies-service.ts+23−24
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5} from "@executor-v2/management-api";5} from "@executor-v2/management-api";
6import { type Policy } from "@executor-v2/schema";6import { type Policy } from "@executor-v2/schema";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
8 9
9import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";10import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";
10 11
@@ -29,36 +30,36 @@ export const createPmPoliciesService = ( · 18 unmodified lines
29 makeControlPlanePoliciesService({30 makeControlPlanePoliciesService({
30 listPolicies: (workspaceId) =>31 listPolicies: (workspaceId) =>
31 Effect.gen(function* () {32 Effect.gen(function* () {
32 const policies = yield* rows.policies.list().pipe(33 const policies = yield* rows.policies.listByWorkspaceId(workspaceId).pipe(
33 Effect.mapError((error) =>34 Effect.mapError((error) =>
34 sourceStoreError.fromRowStore("policies.list", error),35 sourceStoreError.fromRowStore("policies.list", error),
35 ),36 ),
36 );37 );
37 38
38 return sortPolicies(39 return sortPolicies(policies);
39 policies.filter((policy) => policy.workspaceId === workspaceId),
40 );
41 }),40 }),
42 41
43 upsertPolicy: (input) =>42 upsertPolicy: (input) =>
44 Effect.gen(function* () {43 Effect.gen(function* () {
45 const policies = yield* rows.policies.list().pipe(
46 Effect.mapError((error) =>
47 sourceStoreError.fromRowStore("policies.upsert", error),
48 ),
49 );
50
51 const now = Date.now();44 const now = Date.now();
52 const requestedId = input.payload.id;45 const requestedId = input.payload.id;
53 46
54 const existingIndex = requestedId47 const existingOption = requestedId
55 ? policies.findIndex(48 ? yield* rows.policies.getById(requestedId).pipe(
56 (policy) =>49 Effect.mapError((error) =>
57 policy.workspaceId === input.workspaceId && policy.id === requestedId,50 sourceStoreError.fromRowStore("policies.get_by_id", error),
58 )51 ),
59 : -1;52 )
60 53 : Option.none<Policy>();
61 const existing = existingIndex >= 0 ? policies[existingIndex] : null;54
55 const existing = Option.getOrNull(existingOption);
56 if (existing !== null && existing.workspaceId !== input.workspaceId) {
57 return yield* sourceStoreError.fromMessage(
58 "policies.upsert",
59 "Policy belongs to another workspace",
60 `workspace=${input.workspaceId} policy=${requestedId}`,
61 );
62 }
62 63
63 const nextPolicy: Policy = {64 const nextPolicy: Policy = {
64 id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])),65 id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])),
@@ -80,17 +81,15 @@ export const createPmPoliciesService = ( · 15 unmodified lines
80 81
81 removePolicy: (input) =>82 removePolicy: (input) =>
82 Effect.gen(function* () {83 Effect.gen(function* () {
83 const policies = yield* rows.policies.list().pipe(84 const existingOption = yield* rows.policies.getById(input.policyId).pipe(
84 Effect.mapError((error) =>85 Effect.mapError((error) =>
85 sourceStoreError.fromRowStore("policies.remove", error),86 sourceStoreError.fromRowStore("policies.get_by_id", error),
86 ),87 ),
87 );88 );
88 89
89 const existing = policies.find(90 const existing = Option.getOrNull(existingOption);
90 (policy) => policy.workspaceId === input.workspaceId && policy.id === input.policyId,
91 );
92 91
93 if (!existing) {92 if (!existing || existing.workspaceId !== input.workspaceId) {
94 return {93 return {
95 removed: false,94 removed: false,
96 };95 };
apps/pm/src/storage-service.test.ts+23−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 type WorkspaceId,6 type WorkspaceId,
7} from "@executor-v2/schema";7} from "@executor-v2/schema";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
9import { mkdtemp, rm, writeFile } from "node:fs/promises";10import { mkdtemp, rm, writeFile } from "node:fs/promises";
10import { tmpdir } from "node:os";11import { tmpdir } from "node:os";
11import * as path from "node:path";12import * as path from "node:path";
@@ -39,10 +40,32 @@ describe("PM storage service", () => { · 27 unmodified lines
39 const rows: StorageRows = {40 const rows: StorageRows = {
40 workspaces: {41 workspaces: {
41 list: () => Effect.succeed([workspace]),42 list: () => Effect.succeed([workspace]),
43 getById: (id) =>
44 Effect.succeed(id === workspace.id ? Option.some(workspace) : Option.none()),
45 listByOrganizationIds: (organizationIds) =>
46 Effect.succeed(
47 organizationIds.includes(workspace.organizationId) ? [workspace] : [],
48 ),
42 upsert: () => Effect.void,49 upsert: () => Effect.void,
43 },50 },
44 storageInstances: {51 storageInstances: {
45 list: () => Effect.succeed(storageInstances),52 list: () => Effect.succeed(storageInstances),
53 getById: (storageInstanceId) =>
54 Effect.succeed(
55 Option.fromNullable(
56 storageInstances.find((item) => item.id === storageInstanceId) ?? null,
57 ),
58 ),
59 listByWorkspaceScope: (scopeWorkspaceId, organizationId) =>
60 Effect.succeed(
61 storageInstances.filter((instance) =>
62 instance.workspaceId === scopeWorkspaceId
63 || (
64 instance.workspaceId === null
65 && instance.organizationId === organizationId
66 )
67 ),
68 ),
46 upsert: (storageInstance) =>69 upsert: (storageInstance) =>
47 Effect.sync(() => {70 Effect.sync(() => {
48 const index = storageInstances.findIndex((item) => item.id === storageInstance.id);71 const index = storageInstances.findIndex((item) => item.id === storageInstance.id);
apps/pm/src/storage-service.ts+49−35
@@ -10,11 +10,9 @@ import { · 9 unmodified lines
10 type WorkspaceId,10 type WorkspaceId,
11} from "@executor-v2/schema";11} from "@executor-v2/schema";
12import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
13 14
14import {15import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";
15 createSqlSourceStoreErrorMapper,
16 resolveWorkspaceOrganizationId,
17} from "./control-plane-row-helpers";
18import {16import {
19 DEFAULT_EPHEMERAL_TTL_HOURS,17 DEFAULT_EPHEMERAL_TTL_HOURS,
20 initializeStorageInstanceFiles,18 initializeStorageInstanceFiles,
@@ -100,7 +98,7 @@ const loadStorageScope = ( · 79 unmodified lines
100 input: {98 input: {
101 workspaceId: WorkspaceId;99 workspaceId: WorkspaceId;
102 instancesOperation: string;100 instancesOperation: string;
103 workspacesOperation: string;101 workspaceOperation: string;
104 },102 },
105): Effect.Effect<103): Effect.Effect<
106 {104 {
@@ -109,22 +107,33 @@ const loadStorageScope = ( · 2 unmodified lines
109 },107 },
110 SourceStoreError108 SourceStoreError
111> =>109> =>
112 Effect.all([110 rows.workspaces.getById(input.workspaceId).pipe(
113 rows.storageInstances.list().pipe(111 Effect.mapError((error) =>
114 Effect.mapError((error) =>112 sourceStoreError.fromRowStore(input.workspaceOperation, error),
115 sourceStoreError.fromRowStore(input.instancesOperation, error),
116 ),
117 ),
118 rows.workspaces.list().pipe(
119 Effect.mapError((error) =>
120 sourceStoreError.fromRowStore(input.workspacesOperation, error),
121 ),
122 ),113 ),
123 ]).pipe(114 Effect.flatMap((workspaceOption) => {
124 Effect.map(([storageInstances, workspaces]) => ({115 const workspace = Option.getOrNull(workspaceOption);
125 storageInstances,116 if (workspace === null) {
126 organizationId: resolveWorkspaceOrganizationId(workspaces, input.workspaceId),117 return sourceStoreError.fromMessage(
127 })),118 input.workspaceOperation,
119 "Workspace not found",
120 `workspace=${input.workspaceId}`,
121 );
122 }
123
124 return rows.storageInstances.listByWorkspaceScope(
125 input.workspaceId,
126 workspace.organizationId,
127 ).pipe(
128 Effect.mapError((error) =>
129 sourceStoreError.fromRowStore(input.instancesOperation, error),
130 ),
131 Effect.map((storageInstances) => ({
132 storageInstances,
133 organizationId: workspace.organizationId,
134 })),
135 );
136 }),
128 );137 );
129 138
130export const createPmStorageService = (139export const createPmStorageService = (
@@ -139,14 +148,10 @@ export const createPmStorageService = ( · 8 unmodified lines
139 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {148 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
140 workspaceId,149 workspaceId,
141 instancesOperation: "storage.list_instances",150 instancesOperation: "storage.list_instances",
142 workspacesOperation: "storage.list_instances.workspaces",151 workspaceOperation: "storage.list_instances.workspace",
143 });152 });
144 153
145 return sortStorageInstances(154 return sortStorageInstances(storageInstances);
146 storageInstances.filter((instance) =>
147 canAccessStorageInstance(instance, workspaceId, organizationId)
148 ),
149 );
150 }),155 }),
151 156
152 openStorageInstance: (input) =>157 openStorageInstance: (input) =>
@@ -159,14 +164,23 @@ export const createPmStorageService = ( · 6 unmodified lines
159 );164 );
160 }165 }
161 166
162 const workspaces = yield* rows.workspaces.list().pipe(167 const workspaceOption = yield* rows.workspaces.getById(input.workspaceId).pipe(
163 Effect.mapError((error) =>168 Effect.mapError((error) =>
164 sourceStoreError.fromRowStore("storage.open.workspaces", error),169 sourceStoreError.fromRowStore("storage.open.workspace", error),
165 ),170 ),
166 );171 );
167 172
173 const workspace = Option.getOrNull(workspaceOption);
174 if (workspace === null) {
175 return yield* sourceStoreError.fromMessage(
176 "storage.open",
177 "Workspace not found",
178 `workspace=${input.workspaceId}`,
179 );
180 }
181
168 const now = Date.now();182 const now = Date.now();
169 const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);183 const organizationId = workspace.organizationId;
170 const storageInstanceId =184 const storageInstanceId =
171 `storage_${crypto.randomUUID()}` as StorageInstance["id"];185 `storage_${crypto.randomUUID()}` as StorageInstance["id"];
172 const ttlHours =186 const ttlHours =
@@ -231,7 +245,7 @@ export const createPmStorageService = ( · 58 unmodified lines
231 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {245 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
232 workspaceId: input.workspaceId,246 workspaceId: input.workspaceId,
233 instancesOperation: "storage.close.instances",247 instancesOperation: "storage.close.instances",
234 workspacesOperation: "storage.close.workspaces",248 workspaceOperation: "storage.close.workspace",
235 });249 });
236 250
237 const found = findStorageInstance(storageInstances, organizationId, {251 const found = findStorageInstance(storageInstances, organizationId, {
@@ -270,7 +284,7 @@ export const createPmStorageService = ( · 32 unmodified lines
270 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {284 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
271 workspaceId: input.workspaceId,285 workspaceId: input.workspaceId,
272 instancesOperation: "storage.remove.instances",286 instancesOperation: "storage.remove.instances",
273 workspacesOperation: "storage.remove.workspaces",287 workspaceOperation: "storage.remove.workspace",
274 });288 });
275 289
276 const found = findStorageInstance(storageInstances, organizationId, {290 const found = findStorageInstance(storageInstances, organizationId, {
@@ -311,7 +325,7 @@ export const createPmStorageService = ( · 34 unmodified lines
311 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {325 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
312 workspaceId: input.workspaceId,326 workspaceId: input.workspaceId,
313 instancesOperation: "storage.list_directory.instances",327 instancesOperation: "storage.list_directory.instances",
314 workspacesOperation: "storage.list_directory.workspaces",328 workspaceOperation: "storage.list_directory.workspace",
315 });329 });
316 330
317 const found = findStorageInstance(storageInstances, organizationId, {331 const found = findStorageInstance(storageInstances, organizationId, {
@@ -356,7 +370,7 @@ export const createPmStorageService = ( · 38 unmodified lines
356 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {370 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
357 workspaceId: input.workspaceId,371 workspaceId: input.workspaceId,
358 instancesOperation: "storage.read_file.instances",372 instancesOperation: "storage.read_file.instances",
359 workspacesOperation: "storage.read_file.workspaces",373 workspaceOperation: "storage.read_file.workspace",
360 });374 });
361 375
362 const found = findStorageInstance(storageInstances, organizationId, {376 const found = findStorageInstance(storageInstances, organizationId, {
@@ -397,7 +411,7 @@ export const createPmStorageService = ( · 34 unmodified lines
397 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {411 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
398 workspaceId: input.workspaceId,412 workspaceId: input.workspaceId,
399 instancesOperation: "storage.list_kv.instances",413 instancesOperation: "storage.list_kv.instances",
400 workspacesOperation: "storage.list_kv.workspaces",414 workspaceOperation: "storage.list_kv.workspace",
401 });415 });
402 416
403 const found = findStorageInstance(storageInstances, organizationId, {417 const found = findStorageInstance(storageInstances, organizationId, {
@@ -438,7 +452,7 @@ export const createPmStorageService = ( · 34 unmodified lines
438 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {452 const { storageInstances, organizationId } = yield* loadStorageScope(rows, {
439 workspaceId: input.workspaceId,453 workspaceId: input.workspaceId,
440 instancesOperation: "storage.query_sql.instances",454 instancesOperation: "storage.query_sql.instances",
441 workspacesOperation: "storage.query_sql.workspaces",455 workspaceOperation: "storage.query_sql.workspace",
442 });456 });
443 457
444 const found = findStorageInstance(storageInstances, organizationId, {458 const found = findStorageInstance(storageInstances, organizationId, {
apps/pm/src/tool-call-handler.test.ts+6−5
@@ -4,24 +4,25 @@ import { · 3 unmodified lines
4} from "@executor-v2/engine";4} from "@executor-v2/engine";
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
7 8
8import { createPmResolveToolCredentials } from "./credential-resolver";9import { createPmResolveToolCredentials } from "./credential-resolver";
9 10
10const emptyCredentialRows = {11const emptyCredentialRows = {
11 workspaces: {12 workspaces: {
12 list: () => Effect.succeed([]),13 getById: () => Effect.succeed(Option.none()),
13 },14 },
14 sourceAuthBindings: {15 sourceAuthBindings: {
15 list: () => Effect.succeed([]),16 listByWorkspaceScope: () => Effect.succeed([]),
16 },17 },
17 authConnections: {18 authConnections: {
18 list: () => Effect.succeed([]),19 getById: () => Effect.succeed(Option.none()),
19 },20 },
20 authMaterials: {21 authMaterials: {
21 list: () => Effect.succeed([]),22 getByConnectionId: () => Effect.succeed(Option.none()),
22 },23 },
23 oauthStates: {24 oauthStates: {
24 list: () => Effect.succeed([]),25 getByConnectionId: () => Effect.succeed(Option.none()),
25 },26 },
26};27};
27 28
apps/pm/src/workspaces-service.ts+9−9
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5} from "@executor-v2/management-api";5} from "@executor-v2/management-api";
6import { type Workspace } from "@executor-v2/schema";6import { type Workspace } from "@executor-v2/schema";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
8 9
9import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";10import { createSqlSourceStoreErrorMapper } from "./control-plane-row-helpers";
10 11
@@ -41,17 +42,16 @@ export const createPmWorkspacesService = ( · 30 unmodified lines
41 42
42 upsertWorkspace: (input) =>43 upsertWorkspace: (input) =>
43 Effect.gen(function* () {44 Effect.gen(function* () {
44 const workspaces = yield* rows.workspaces.list().pipe(45 const existingOption = input.payload.id
45 Effect.mapError((error) =>46 ? yield* rows.workspaces.getById(input.payload.id).pipe(
46 sourceStoreError.fromRowStore("workspaces.upsert", error),47 Effect.mapError((error) =>
47 ),48 sourceStoreError.fromRowStore("workspaces.get_by_id", error),
48 );49 ),
50 )
51 : Option.none<Workspace>();
49 52
50 const now = Date.now();53 const now = Date.now();
51 const existingIndex = input.payload.id54 const existing = Option.getOrNull(existingOption);
52 ? workspaces.findIndex((workspace) => workspace.id === input.payload.id)
53 : -1;
54 const existing = existingIndex >= 0 ? workspaces[existingIndex] : null;
55 55
56 const nextWorkspace: Workspace = {56 const nextWorkspace: Workspace = {
57 id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])),57 id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])),
apps/web/lib/control-plane/server.ts+10−13
@@ -103,14 +103,17 @@ const ensurePrincipalProvisioned = ( · 102 unmodified lines
103) =>103) =>
104 Effect.gen(function* () {104 Effect.gen(function* () {
105 const now = Date.now();105 const now = Date.now();
106 const [organizations, memberships, workspaces, profileOption] = yield* Effect.all([106 const [organizationOption, membershipOption, workspaceOption, profileOption] = yield* Effect.all([
107 persistence.rows.organizations.list(),107 persistence.rows.organizations.getById(principal.organizationId as Organization["id"]),
108 persistence.rows.organizationMemberships.list(),108 persistence.rows.organizationMemberships.getByOrganizationAndAccount(
109 persistence.rows.workspaces.list(),109 principal.organizationId as OrganizationMembership["organizationId"],
110 principal.accountId as OrganizationMembership["accountId"],
111 ),
112 persistence.rows.workspaces.getById(principal.workspaceId as Workspace["id"]),
110 persistence.rows.profile.get(),113 persistence.rows.profile.get(),
111 ]);114 ]);
112 115
113 if (organizations.find((organization) => organization.id === principal.organizationId) === undefined) {116 if (organizationOption._tag === "None") {
114 yield* persistence.rows.organizations.upsert({117 yield* persistence.rows.organizations.upsert({
115 id: principal.organizationId as Organization["id"],118 id: principal.organizationId as Organization["id"],
116 slug: principal.organizationId,119 slug: principal.organizationId,
@@ -124,13 +127,7 @@ const ensurePrincipalProvisioned = ( · 7 unmodified lines
124 });127 });
125 }128 }
126 129
127 if (130 if (membershipOption._tag === "None") {
128 memberships.find(
129 (membership) =>
130 membership.organizationId === principal.organizationId
131 && membership.accountId === principal.accountId,
132 ) === undefined
133 ) {
134 yield* persistence.rows.organizationMemberships.upsert({131 yield* persistence.rows.organizationMemberships.upsert({
135 id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],132 id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],
136 organizationId: principal.organizationId as OrganizationMembership["organizationId"],133 organizationId: principal.organizationId as OrganizationMembership["organizationId"],
@@ -145,7 +142,7 @@ const ensurePrincipalProvisioned = ( · 8 unmodified lines
145 });142 });
146 }143 }
147 144
148 if (workspaces.find((workspace) => workspace.id === principal.workspaceId) === undefined) {145 if (workspaceOption._tag === "None") {
149 yield* persistence.rows.workspaces.upsert({146 yield* persistence.rows.workspaces.upsert({
150 id: principal.workspaceId as Workspace["id"],147 id: principal.workspaceId as Workspace["id"],
151 organizationId: principal.organizationId as Workspace["organizationId"],148 organizationId: principal.organizationId as Workspace["organizationId"],
packages/persistence-sql/src/control-plane-rows.ts+865−0
@@ -0,0 +1,865 @@
1import {
2 type Approval,
3 type AuthConnection,
4 type AuthMaterial,
5 type OAuthState,
6 type Organization,
7 type OrganizationMembership,
8 type Policy,
9 type Profile,
10 type SourceAuthBinding,
11 type StorageInstance,
12 type Workspace,
13} from "@executor-v2/schema";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
17
18import { toRowStoreError } from "./persistence-errors";
19import { tableNames } from "./schema";
20import {
21 type DrizzleDb,
22 type DrizzleTables,
23 type SqlBackend,
24} from "./sql-internals";
25
26type CreateControlPlaneRowsInput = {
27 backend: SqlBackend;
28 db: DrizzleDb;
29 tables: DrizzleTables;
30};
31
32const asDomain = <A>(value: unknown): A => value as A;
33const asDomainArray = <A>(value: ReadonlyArray<unknown>): Array<A> => value as Array<A>;
34
35const rowEffect = <A>(
36 backend: SqlBackend,
37 operation: string,
38 location: string,
39 run: () => Promise<A>,
40) =>
41 Effect.tryPromise({
42 try: run,
43 catch: (cause) => toRowStoreError(backend, operation, location, cause),
44 });
45
46export const createControlPlaneRows = ({
47 backend,
48 db,
49 tables,
50}: CreateControlPlaneRowsInput) => ({
51 profile: {
52 get: () =>
53 rowEffect(backend, "rows.profile.get", tableNames.profile, async () => {
54 const row = await db
55 .select()
56 .from(tables.profileTable)
57 .orderBy(desc(tables.profileTable.updatedAt), asc(tables.profileTable.id))
58 .limit(1);
59
60 return row[0] ? Option.some(asDomain<Profile>(row[0])) : Option.none<Profile>();
61 }),
62
63 upsert: (profile: Profile) =>
64 rowEffect(backend, "rows.profile.upsert", tableNames.profile, async () => {
65 await db
66 .insert(tables.profileTable)
67 .values(profile)
68 .onConflictDoUpdate({
69 target: tables.profileTable.id,
70 set: profile,
71 });
72 }),
73 },
74
75 organizations: {
76 list: () =>
77 rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => {
78 const rows = await db
79 .select()
80 .from(tables.organizationsTable)
81 .orderBy(asc(tables.organizationsTable.updatedAt), asc(tables.organizationsTable.id));
82
83 return asDomainArray<Organization>(rows);
84 }),
85
86 getById: (organizationId: Organization["id"]) =>
87 rowEffect(backend, "rows.organizations.get_by_id", tableNames.organizations, async () => {
88 const row = await db
89 .select()
90 .from(tables.organizationsTable)
91 .where(eq(tables.organizationsTable.id, organizationId))
92 .limit(1);
93
94 return row[0]
95 ? Option.some(asDomain<Organization>(row[0]))
96 : Option.none<Organization>();
97 }),
98
99 getBySlug: (slug: Organization["slug"]) =>
100 rowEffect(
101 backend,
102 "rows.organizations.get_by_slug",
103 tableNames.organizations,
104 async () => {
105 const row = await db
106 .select()
107 .from(tables.organizationsTable)
108 .where(eq(tables.organizationsTable.slug, slug))
109 .limit(1);
110
111 return row[0]
112 ? Option.some(asDomain<Organization>(row[0]))
113 : Option.none<Organization>();
114 },
115 ),
116
117 upsert: (organization: Organization) =>
118 rowEffect(backend, "rows.organizations.upsert", tableNames.organizations, async () => {
119 await db
120 .insert(tables.organizationsTable)
121 .values(organization)
122 .onConflictDoUpdate({
123 target: tables.organizationsTable.id,
124 set: organization,
125 });
126 }),
127 },
128
129 organizationMemberships: {
130 list: () =>
131 rowEffect(
132 backend,
133 "rows.organization_memberships.list",
134 tableNames.organizationMemberships,
135 async () => {
136 const rows = await db
137 .select()
138 .from(tables.organizationMembershipsTable)
139 .orderBy(
140 asc(tables.organizationMembershipsTable.updatedAt),
141 asc(tables.organizationMembershipsTable.id),
142 );
143
144 return asDomainArray<OrganizationMembership>(rows);
145 },
146 ),
147
148 listByOrganizationId: (organizationId: OrganizationMembership["organizationId"]) =>
149 rowEffect(
150 backend,
151 "rows.organization_memberships.list_by_organization",
152 tableNames.organizationMemberships,
153 async () => {
154 const rows = await db
155 .select()
156 .from(tables.organizationMembershipsTable)
157 .where(eq(tables.organizationMembershipsTable.organizationId, organizationId))
158 .orderBy(
159 asc(tables.organizationMembershipsTable.updatedAt),
160 asc(tables.organizationMembershipsTable.id),
161 );
162
163 return asDomainArray<OrganizationMembership>(rows);
164 },
165 ),
166
167 listByAccountId: (accountId: OrganizationMembership["accountId"]) =>
168 rowEffect(
169 backend,
170 "rows.organization_memberships.list_by_account",
171 tableNames.organizationMemberships,
172 async () => {
173 const rows = await db
174 .select()
175 .from(tables.organizationMembershipsTable)
176 .where(eq(tables.organizationMembershipsTable.accountId, accountId))
177 .orderBy(
178 asc(tables.organizationMembershipsTable.updatedAt),
179 asc(tables.organizationMembershipsTable.id),
180 );
181
182 return asDomainArray<OrganizationMembership>(rows);
183 },
184 ),
185
186 getByOrganizationAndAccount: (
187 organizationId: OrganizationMembership["organizationId"],
188 accountId: OrganizationMembership["accountId"],
189 ) =>
190 rowEffect(
191 backend,
192 "rows.organization_memberships.get_by_organization_and_account",
193 tableNames.organizationMemberships,
194 async () => {
195 const row = await db
196 .select()
197 .from(tables.organizationMembershipsTable)
198 .where(
199 and(
200 eq(tables.organizationMembershipsTable.organizationId, organizationId),
201 eq(tables.organizationMembershipsTable.accountId, accountId),
202 ),
203 )
204 .limit(1);
205
206 return row[0]
207 ? Option.some(asDomain<OrganizationMembership>(row[0]))
208 : Option.none<OrganizationMembership>();
209 },
210 ),
211
212 upsert: (membership: OrganizationMembership) =>
213 rowEffect(
214 backend,
215 "rows.organization_memberships.upsert",
216 tableNames.organizationMemberships,
217 async () => {
218 await db
219 .insert(tables.organizationMembershipsTable)
220 .values(membership)
221 .onConflictDoUpdate({
222 target: tables.organizationMembershipsTable.id,
223 set: membership,
224 });
225 },
226 ),
227 },
228
229 workspaces: {
230 list: () =>
231 rowEffect(backend, "rows.workspaces.list", tableNames.workspaces, async () => {
232 const rows = await db
233 .select()
234 .from(tables.workspacesTable)
235 .orderBy(asc(tables.workspacesTable.updatedAt), asc(tables.workspacesTable.id));
236
237 return asDomainArray<Workspace>(rows);
238 }),
239
240 getById: (workspaceId: Workspace["id"]) =>
241 rowEffect(backend, "rows.workspaces.get_by_id", tableNames.workspaces, async () => {
242 const row = await db
243 .select()
244 .from(tables.workspacesTable)
245 .where(eq(tables.workspacesTable.id, workspaceId))
246 .limit(1);
247
248 return row[0] ? Option.some(asDomain<Workspace>(row[0])) : Option.none<Workspace>();
249 }),
250
251 listByOrganizationIds: (
252 organizationIds: ReadonlyArray<Workspace["organizationId"]>,
253 ) =>
254 rowEffect(
255 backend,
256 "rows.workspaces.list_by_organization_ids",
257 tableNames.workspaces,
258 async () => {
259 if (organizationIds.length === 0) {
260 return [] as Array<Workspace>;
261 }
262
263 const rows = await db
264 .select()
265 .from(tables.workspacesTable)
266 .where(inArray(tables.workspacesTable.organizationId, [...organizationIds]))
267 .orderBy(asc(tables.workspacesTable.updatedAt), asc(tables.workspacesTable.id));
268
269 return asDomainArray<Workspace>(rows);
270 },
271 ),
272
273 upsert: (workspace: Workspace) =>
274 rowEffect(backend, "rows.workspaces.upsert", tableNames.workspaces, async () => {
275 await db
276 .insert(tables.workspacesTable)
277 .values(workspace)
278 .onConflictDoUpdate({
279 target: tables.workspacesTable.id,
280 set: workspace,
281 });
282 }),
283 },
284
285 authConnections: {
286 list: () =>
287 rowEffect(
288 backend,
289 "rows.auth_connections.list",
290 tableNames.authConnections,
291 async () => {
292 const rows = await db
293 .select()
294 .from(tables.authConnectionsTable)
295 .orderBy(asc(tables.authConnectionsTable.updatedAt), asc(tables.authConnectionsTable.id));
296
297 return asDomainArray<AuthConnection>(rows);
298 },
299 ),
300
301 getById: (connectionId: AuthConnection["id"]) =>
302 rowEffect(
303 backend,
304 "rows.auth_connections.get_by_id",
305 tableNames.authConnections,
306 async () => {
307 const row = await db
308 .select()
309 .from(tables.authConnectionsTable)
310 .where(eq(tables.authConnectionsTable.id, connectionId))
311 .limit(1);
312
313 return row[0]
314 ? Option.some(asDomain<AuthConnection>(row[0]))
315 : Option.none<AuthConnection>();
316 },
317 ),
318
319 listByOrganizationId: (organizationId: AuthConnection["organizationId"]) =>
320 rowEffect(
321 backend,
322 "rows.auth_connections.list_by_organization",
323 tableNames.authConnections,
324 async () => {
325 const rows = await db
326 .select()
327 .from(tables.authConnectionsTable)
328 .where(eq(tables.authConnectionsTable.organizationId, organizationId))
329 .orderBy(asc(tables.authConnectionsTable.updatedAt), asc(tables.authConnectionsTable.id));
330
331 return asDomainArray<AuthConnection>(rows);
332 },
333 ),
334
335 upsert: (connection: AuthConnection) =>
336 rowEffect(
337 backend,
338 "rows.auth_connections.upsert",
339 tableNames.authConnections,
340 async () => {
341 await db
342 .insert(tables.authConnectionsTable)
343 .values(connection)
344 .onConflictDoUpdate({
345 target: tables.authConnectionsTable.id,
346 set: connection,
347 });
348 },
349 ),
350
351 removeById: (connectionId: AuthConnection["id"]) =>
352 rowEffect(
353 backend,
354 "rows.auth_connections.remove",
355 tableNames.authConnections,
356 async () => {
357 const row = await db
358 .select({ id: tables.authConnectionsTable.id })
359 .from(tables.authConnectionsTable)
360 .where(eq(tables.authConnectionsTable.id, connectionId))
361 .limit(1);
362
363 if (row.length === 0) {
364 return false;
365 }
366
367 await db
368 .delete(tables.authConnectionsTable)
369 .where(eq(tables.authConnectionsTable.id, connectionId));
370
371 return true;
372 },
373 ),
374 },
375
376 sourceAuthBindings: {
377 list: () =>
378 rowEffect(
379 backend,
380 "rows.source_auth_bindings.list",
381 tableNames.sourceAuthBindings,
382 async () => {
383 const rows = await db
384 .select()
385 .from(tables.sourceAuthBindingsTable)
386 .orderBy(
387 asc(tables.sourceAuthBindingsTable.updatedAt),
388 asc(tables.sourceAuthBindingsTable.id),
389 );
390
391 return asDomainArray<SourceAuthBinding>(rows);
392 },
393 ),
394
395 getById: (bindingId: SourceAuthBinding["id"]) =>
396 rowEffect(
397 backend,
398 "rows.source_auth_bindings.get_by_id",
399 tableNames.sourceAuthBindings,
400 async () => {
401 const row = await db
402 .select()
403 .from(tables.sourceAuthBindingsTable)
404 .where(eq(tables.sourceAuthBindingsTable.id, bindingId))
405 .limit(1);
406
407 return row[0]
408 ? Option.some(asDomain<SourceAuthBinding>(row[0]))
409 : Option.none<SourceAuthBinding>();
410 },
411 ),
412
413 listByConnectionId: (connectionId: SourceAuthBinding["connectionId"]) =>
414 rowEffect(
415 backend,
416 "rows.source_auth_bindings.list_by_connection",
417 tableNames.sourceAuthBindings,
418 async () => {
419 const rows = await db
420 .select()
421 .from(tables.sourceAuthBindingsTable)
422 .where(eq(tables.sourceAuthBindingsTable.connectionId, connectionId))
423 .orderBy(
424 asc(tables.sourceAuthBindingsTable.updatedAt),
425 asc(tables.sourceAuthBindingsTable.id),
426 );
427
428 return asDomainArray<SourceAuthBinding>(rows);
429 },
430 ),
431
432 listByWorkspaceScope: (
433 workspaceId: Workspace["id"],
434 organizationId: Organization["id"],
435 ) =>
436 rowEffect(
437 backend,
438 "rows.source_auth_bindings.list_by_workspace_scope",
439 tableNames.sourceAuthBindings,
440 async () => {
441 const rows = await db
442 .select()
443 .from(tables.sourceAuthBindingsTable)
444 .where(
445 or(
446 eq(tables.sourceAuthBindingsTable.workspaceId, workspaceId),
447 and(
448 isNull(tables.sourceAuthBindingsTable.workspaceId),
449 eq(tables.sourceAuthBindingsTable.organizationId, organizationId),
450 ),
451 ),
452 )
453 .orderBy(
454 desc(tables.sourceAuthBindingsTable.updatedAt),
455 desc(tables.sourceAuthBindingsTable.createdAt),
456 );
457
458 return asDomainArray<SourceAuthBinding>(rows);
459 },
460 ),
461
462 upsert: (binding: SourceAuthBinding) =>
463 rowEffect(
464 backend,
465 "rows.source_auth_bindings.upsert",
466 tableNames.sourceAuthBindings,
467 async () => {
468 await db
469 .insert(tables.sourceAuthBindingsTable)
470 .values(binding)
471 .onConflictDoUpdate({
472 target: tables.sourceAuthBindingsTable.id,
473 set: binding,
474 });
475 },
476 ),
477
478 removeById: (bindingId: SourceAuthBinding["id"]) =>
479 rowEffect(
480 backend,
481 "rows.source_auth_bindings.remove",
482 tableNames.sourceAuthBindings,
483 async () => {
484 const row = await db
485 .select({ id: tables.sourceAuthBindingsTable.id })
486 .from(tables.sourceAuthBindingsTable)
487 .where(eq(tables.sourceAuthBindingsTable.id, bindingId))
488 .limit(1);
489
490 if (row.length === 0) {
491 return false;
492 }
493
494 await db
495 .delete(tables.sourceAuthBindingsTable)
496 .where(eq(tables.sourceAuthBindingsTable.id, bindingId));
497
498 return true;
499 },
500 ),
501 },
502
503 authMaterials: {
504 list: () =>
505 rowEffect(backend, "rows.auth_materials.list", tableNames.authMaterials, async () => {
506 const rows = await db
507 .select()
508 .from(tables.authMaterialsTable)
509 .orderBy(asc(tables.authMaterialsTable.updatedAt), asc(tables.authMaterialsTable.id));
510
511 return asDomainArray<AuthMaterial>(rows);
512 }),
513
514 getByConnectionId: (connectionId: AuthMaterial["connectionId"]) =>
515 rowEffect(
516 backend,
517 "rows.auth_materials.get_by_connection",
518 tableNames.authMaterials,
519 async () => {
520 const row = await db
521 .select()
522 .from(tables.authMaterialsTable)
523 .where(eq(tables.authMaterialsTable.connectionId, connectionId))
524 .limit(1);
525
526 return row[0]
527 ? Option.some(asDomain<AuthMaterial>(row[0]))
528 : Option.none<AuthMaterial>();
529 },
530 ),
531
532 upsert: (material: AuthMaterial) =>
533 rowEffect(
534 backend,
535 "rows.auth_materials.upsert",
536 tableNames.authMaterials,
537 async () => {
538 await db
539 .insert(tables.authMaterialsTable)
540 .values(material)
541 .onConflictDoUpdate({
542 target: tables.authMaterialsTable.id,
543 set: material,
544 });
545 },
546 ),
547
548 removeByConnectionId: (connectionId: AuthMaterial["connectionId"]) =>
549 rowEffect(
550 backend,
551 "rows.auth_materials.remove_by_connection",
552 tableNames.authMaterials,
553 async () => {
554 await db
555 .delete(tables.authMaterialsTable)
556 .where(eq(tables.authMaterialsTable.connectionId, connectionId));
557 },
558 ),
559 },
560
561 oauthStates: {
562 list: () =>
563 rowEffect(backend, "rows.oauth_states.list", tableNames.oauthStates, async () => {
564 const rows = await db
565 .select()
566 .from(tables.oauthStatesTable)
567 .orderBy(asc(tables.oauthStatesTable.updatedAt), asc(tables.oauthStatesTable.id));
568
569 return asDomainArray<OAuthState>(rows);
570 }),
571
572 getByConnectionId: (connectionId: OAuthState["connectionId"]) =>
573 rowEffect(
574 backend,
575 "rows.oauth_states.get_by_connection",
576 tableNames.oauthStates,
577 async () => {
578 const row = await db
579 .select()
580 .from(tables.oauthStatesTable)
581 .where(eq(tables.oauthStatesTable.connectionId, connectionId))
582 .limit(1);
583
584 return row[0]
585 ? Option.some(asDomain<OAuthState>(row[0]))
586 : Option.none<OAuthState>();
587 },
588 ),
589
590 upsert: (state: OAuthState) =>
591 rowEffect(backend, "rows.oauth_states.upsert", tableNames.oauthStates, async () => {
592 await db
593 .insert(tables.oauthStatesTable)
594 .values(state)
595 .onConflictDoUpdate({
596 target: tables.oauthStatesTable.id,
597 set: state,
598 });
599 }),
600
601 removeByConnectionId: (connectionId: OAuthState["connectionId"]) =>
602 rowEffect(
603 backend,
604 "rows.oauth_states.remove_by_connection",
605 tableNames.oauthStates,
606 async () => {
607 await db
608 .delete(tables.oauthStatesTable)
609 .where(eq(tables.oauthStatesTable.connectionId, connectionId));
610 },
611 ),
612 },
613
614 storageInstances: {
615 list: () =>
616 rowEffect(
617 backend,
618 "rows.storage_instances.list",
619 tableNames.storageInstances,
620 async () => {
621 const rows = await db
622 .select()
623 .from(tables.storageInstancesTable)
624 .orderBy(asc(tables.storageInstancesTable.updatedAt), asc(tables.storageInstancesTable.id));
625
626 return asDomainArray<StorageInstance>(rows);
627 },
628 ),
629
630 getById: (storageInstanceId: StorageInstance["id"]) =>
631 rowEffect(
632 backend,
633 "rows.storage_instances.get_by_id",
634 tableNames.storageInstances,
635 async () => {
636 const row = await db
637 .select()
638 .from(tables.storageInstancesTable)
639 .where(eq(tables.storageInstancesTable.id, storageInstanceId))
640 .limit(1);
641
642 return row[0]
643 ? Option.some(asDomain<StorageInstance>(row[0]))
644 : Option.none<StorageInstance>();
645 },
646 ),
647
648 listByWorkspaceScope: (
649 workspaceId: Workspace["id"],
650 organizationId: Organization["id"],
651 ) =>
652 rowEffect(
653 backend,
654 "rows.storage_instances.list_by_workspace_scope",
655 tableNames.storageInstances,
656 async () => {
657 const rows = await db
658 .select()
659 .from(tables.storageInstancesTable)
660 .where(
661 or(
662 eq(tables.storageInstancesTable.workspaceId, workspaceId),
663 and(
664 isNull(tables.storageInstancesTable.workspaceId),
665 eq(tables.storageInstancesTable.organizationId, organizationId),
666 ),
667 ),
668 )
669 .orderBy(
670 desc(tables.storageInstancesTable.updatedAt),
671 asc(tables.storageInstancesTable.id),
672 );
673
674 return asDomainArray<StorageInstance>(rows);
675 },
676 ),
677
678 upsert: (storageInstance: StorageInstance) =>
679 rowEffect(
680 backend,
681 "rows.storage_instances.upsert",
682 tableNames.storageInstances,
683 async () => {
684 await db
685 .insert(tables.storageInstancesTable)
686 .values(storageInstance)
687 .onConflictDoUpdate({
688 target: tables.storageInstancesTable.id,
689 set: storageInstance,
690 });
691 },
692 ),
693
694 removeById: (storageInstanceId: StorageInstance["id"]) =>
695 rowEffect(
696 backend,
697 "rows.storage_instances.remove",
698 tableNames.storageInstances,
699 async () => {
700 const row = await db
701 .select({ id: tables.storageInstancesTable.id })
702 .from(tables.storageInstancesTable)
703 .where(eq(tables.storageInstancesTable.id, storageInstanceId))
704 .limit(1);
705
706 if (row.length === 0) {
707 return false;
708 }
709
710 await db
711 .delete(tables.storageInstancesTable)
712 .where(eq(tables.storageInstancesTable.id, storageInstanceId));
713
714 return true;
715 },
716 ),
717 },
718
719 policies: {
720 list: () =>
721 rowEffect(backend, "rows.policies.list", tableNames.policies, async () => {
722 const rows = await db
723 .select()
724 .from(tables.policiesTable)
725 .orderBy(asc(tables.policiesTable.updatedAt), asc(tables.policiesTable.id));
726
727 return asDomainArray<Policy>(rows);
728 }),
729
730 listByWorkspaceId: (workspaceId: Policy["workspaceId"]) =>
731 rowEffect(
732 backend,
733 "rows.policies.list_by_workspace",
734 tableNames.policies,
735 async () => {
736 const rows = await db
737 .select()
738 .from(tables.policiesTable)
739 .where(eq(tables.policiesTable.workspaceId, workspaceId))
740 .orderBy(asc(tables.policiesTable.updatedAt), asc(tables.policiesTable.id));
741
742 return asDomainArray<Policy>(rows);
743 },
744 ),
745
746 getById: (policyId: Policy["id"]) =>
747 rowEffect(backend, "rows.policies.get_by_id", tableNames.policies, async () => {
748 const row = await db
749 .select()
750 .from(tables.policiesTable)
751 .where(eq(tables.policiesTable.id, policyId))
752 .limit(1);
753
754 return row[0] ? Option.some(asDomain<Policy>(row[0])) : Option.none<Policy>();
755 }),
756
757 upsert: (policy: Policy) =>
758 rowEffect(backend, "rows.policies.upsert", tableNames.policies, async () => {
759 await db
760 .insert(tables.policiesTable)
761 .values(policy)
762 .onConflictDoUpdate({
763 target: tables.policiesTable.id,
764 set: policy,
765 });
766 }),
767
768 removeById: (policyId: Policy["id"]) =>
769 rowEffect(backend, "rows.policies.remove", tableNames.policies, async () => {
770 const row = await db
771 .select({ id: tables.policiesTable.id })
772 .from(tables.policiesTable)
773 .where(eq(tables.policiesTable.id, policyId))
774 .limit(1);
775
776 if (row.length === 0) {
777 return false;
778 }
779
780 await db.delete(tables.policiesTable).where(eq(tables.policiesTable.id, policyId));
781 return true;
782 }),
783 },
784
785 approvals: {
786 list: () =>
787 rowEffect(backend, "rows.approvals.list", tableNames.approvals, async () => {
788 const rows = await db
789 .select()
790 .from(tables.approvalsTable)
791 .orderBy(desc(tables.approvalsTable.requestedAt), desc(tables.approvalsTable.id));
792
793 return asDomainArray<Approval>(rows);
794 }),
795
796 listByWorkspaceId: (workspaceId: Approval["workspaceId"]) =>
797 rowEffect(
798 backend,
799 "rows.approvals.list_by_workspace",
800 tableNames.approvals,
801 async () => {
802 const rows = await db
803 .select()
804 .from(tables.approvalsTable)
805 .where(eq(tables.approvalsTable.workspaceId, workspaceId))
806 .orderBy(desc(tables.approvalsTable.requestedAt), desc(tables.approvalsTable.id));
807
808 return asDomainArray<Approval>(rows);
809 },
810 ),
811
812 getById: (approvalId: Approval["id"]) =>
813 rowEffect(backend, "rows.approvals.get_by_id", tableNames.approvals, async () => {
814 const row = await db
815 .select()
816 .from(tables.approvalsTable)
817 .where(eq(tables.approvalsTable.id, approvalId))
818 .limit(1);
819
820 return row[0]
821 ? Option.some(asDomain<Approval>(row[0]))
822 : Option.none<Approval>();
823 }),
824
825 findByRunAndCall: (
826 workspaceId: Approval["workspaceId"],
827 taskRunId: Approval["taskRunId"],
828 callId: Approval["callId"],
829 ) =>
830 rowEffect(
831 backend,
832 "rows.approvals.find_by_run_and_call",
833 tableNames.approvals,
834 async () => {
835 const row = await db
836 .select()
837 .from(tables.approvalsTable)
838 .where(
839 and(
840 eq(tables.approvalsTable.workspaceId, workspaceId),
841 eq(tables.approvalsTable.taskRunId, taskRunId),
842 eq(tables.approvalsTable.callId, callId),
843 ),
844 )
845 .orderBy(desc(tables.approvalsTable.requestedAt))
846 .limit(1);
847
848 return row[0]
849 ? Option.some(asDomain<Approval>(row[0]))
850 : Option.none<Approval>();
851 },
852 ),
853
854 upsert: (approval: Approval) =>
855 rowEffect(backend, "rows.approvals.upsert", tableNames.approvals, async () => {
856 await db
857 .insert(tables.approvalsTable)
858 .values(approval)
859 .onConflictDoUpdate({
860 target: tables.approvalsTable.id,
861 set: approval,
862 });
863 }),
864 },
865});
packages/persistence-sql/src/index.ts+18−184
@@ -1,32 +1,15 @@
1import {1import {
2 type RowStoreError,
3 type SourceStore,2 type SourceStore,
4 type ToolArtifactStore,3 type ToolArtifactStore,
5} from "@executor-v2/persistence-ports";4} from "@executor-v2/persistence-ports";
6import {
7 type Approval,
8 type AuthConnection,
9 type AuthMaterial,
10 type OAuthState,
11 type Organization,
12 type OrganizationMembership,
13 type Policy,
14 type Profile,
15 type SourceAuthBinding,
16 type StorageInstance,
17 type Workspace,
18} from "@executor-v2/schema";
19import * as Data from "effect/Data";5import * as Data from "effect/Data";
20import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as path from "node:path";7import * as path from "node:path";
23 8
24import { createRowOperations } from "./row-operations";9import { createControlPlaneRows } from "./control-plane-rows";
25import { createRowsEffectApi } from "./rows-effect-api";
26import {10import {
11 createSqlRuntime,
27 createDrizzleContext,12 createDrizzleContext,
28 createPGliteAdapter,
29 createPostgresAdapter,
30 runMigrations,13 runMigrations,
31 type SqlBackend,14 type SqlBackend,
32} from "./sql-internals";15} from "./sql-internals";
@@ -38,76 +21,13 @@ export type SqlControlPlanePersistenceOptions = { · 5 unmodified lines
38 postgresApplicationName?: string;21 postgresApplicationName?: string;
39};22};
40 23
24export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>;
25
41export type SqlControlPlanePersistence = {26export type SqlControlPlanePersistence = {
42 backend: SqlBackend;27 backend: SqlBackend;
43 sourceStore: SourceStore;28 sourceStore: SourceStore;
44 toolArtifactStore: ToolArtifactStore;29 toolArtifactStore: ToolArtifactStore;
45 rows: {30 rows: SqlControlPlaneRows;
46 profile: {
47 get: () => Effect.Effect<Option.Option<Profile>, RowStoreError>;
48 upsert: (profile: Profile) => Effect.Effect<void, RowStoreError>;
49 };
50 organizations: {
51 list: () => Effect.Effect<ReadonlyArray<Organization>, RowStoreError>;
52 upsert: (organization: Organization) => Effect.Effect<void, RowStoreError>;
53 };
54 organizationMemberships: {
55 list: () => Effect.Effect<ReadonlyArray<OrganizationMembership>, RowStoreError>;
56 upsert: (
57 membership: OrganizationMembership,
58 ) => Effect.Effect<void, RowStoreError>;
59 };
60 workspaces: {
61 list: () => Effect.Effect<ReadonlyArray<Workspace>, RowStoreError>;
62 upsert: (workspace: Workspace) => Effect.Effect<void, RowStoreError>;
63 };
64 authConnections: {
65 list: () => Effect.Effect<ReadonlyArray<AuthConnection>, RowStoreError>;
66 upsert: (connection: AuthConnection) => Effect.Effect<void, RowStoreError>;
67 removeById: (
68 connectionId: AuthConnection["id"],
69 ) => Effect.Effect<boolean, RowStoreError>;
70 };
71 sourceAuthBindings: {
72 list: () => Effect.Effect<ReadonlyArray<SourceAuthBinding>, RowStoreError>;
73 upsert: (binding: SourceAuthBinding) => Effect.Effect<void, RowStoreError>;
74 removeById: (
75 bindingId: SourceAuthBinding["id"],
76 ) => Effect.Effect<boolean, RowStoreError>;
77 };
78 authMaterials: {
79 list: () => Effect.Effect<ReadonlyArray<AuthMaterial>, RowStoreError>;
80 upsert: (material: AuthMaterial) => Effect.Effect<void, RowStoreError>;
81 removeByConnectionId: (
82 connectionId: AuthMaterial["connectionId"],
83 ) => Effect.Effect<void, RowStoreError>;
84 };
85 oauthStates: {
86 list: () => Effect.Effect<ReadonlyArray<OAuthState>, RowStoreError>;
87 upsert: (state: OAuthState) => Effect.Effect<void, RowStoreError>;
88 removeByConnectionId: (
89 connectionId: OAuthState["connectionId"],
90 ) => Effect.Effect<void, RowStoreError>;
91 };
92 storageInstances: {
93 list: () => Effect.Effect<ReadonlyArray<StorageInstance>, RowStoreError>;
94 upsert: (instance: StorageInstance) => Effect.Effect<void, RowStoreError>;
95 removeById: (
96 storageInstanceId: StorageInstance["id"],
97 ) => Effect.Effect<boolean, RowStoreError>;
98 };
99 policies: {
100 list: () => Effect.Effect<ReadonlyArray<Policy>, RowStoreError>;
101 upsert: (policy: Policy) => Effect.Effect<void, RowStoreError>;
102 removeById: (
103 policyId: Policy["id"],
104 ) => Effect.Effect<boolean, RowStoreError>;
105 };
106 approvals: {
107 list: () => Effect.Effect<ReadonlyArray<Approval>, RowStoreError>;
108 upsert: (approval: Approval) => Effect.Effect<void, RowStoreError>;
109 };
110 };
111 close: () => Promise<void>;31 close: () => Promise<void>;
112};32};
113 33
@@ -123,129 +43,43 @@ const trim = (value: string | undefined): string | undefined => { · 9 unmodified lines
123 return candidate && candidate.length > 0 ? candidate : undefined;43 return candidate && candidate.length > 0 ? candidate : undefined;
124};44};
125 45
126const withWriteLock = <A>(
127 queueRef: { current: Promise<void> },
128 run: () => Promise<A>,
129): Promise<A> => {
130 const next = queueRef.current.then(run, run);
131 queueRef.current = next.then(
132 () => undefined,
133 () => undefined,
134 );
135 return next;
136};
137
138export const makeSqlControlPlanePersistence = (46export const makeSqlControlPlanePersistence = (
139 options: SqlControlPlanePersistenceOptions,47 options: SqlControlPlanePersistenceOptions,
140): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> =>48): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> =>
141 Effect.tryPromise({49 Effect.tryPromise({
142 try: async () => {50 try: async () => {
143 const databaseUrl = trim(options.databaseUrl);
144 const localDataDir = path.resolve(51 const localDataDir = path.resolve(
145 options.localDataDir ?? ".executor-v2/control-plane-pgdata",52 options.localDataDir ?? ".executor-v2/control-plane-pgdata",
146 );53 );
147 const backend: SqlBackend =54 const runtime = await createSqlRuntime({
148 databaseUrl && (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://"))55 databaseUrl: trim(options.databaseUrl),
149 ? "postgres"56 localDataDir,
150 : "pglite";57 postgresApplicationName: trim(options.postgresApplicationName),
58 });
151 59
152 const adapter =60 await runMigrations(runtime);
153 backend === "postgres"
154 ? await createPostgresAdapter(databaseUrl!, trim(options.postgresApplicationName))
155 : await createPGliteAdapter(localDataDir);
156 61
157 await runMigrations(adapter);62 const drizzleContext = createDrizzleContext(runtime.db);
158 const drizzleContext = createDrizzleContext(adapter);
159 const { db, tables } = drizzleContext;63 const { db, tables } = drizzleContext;
160 64
161 const writeQueueRef = {65 const { sourceStore, toolArtifactStore } = createSourceAndArtifactStores({
162 current: Promise.resolve<void>(undefined),66 backend: runtime.backend,
163 };
164
165 const {
166 listOrganizationsRows,
167 upsertOrganizationRow,
168 listOrganizationMembershipRows,
169 upsertOrganizationMembershipRow,
170 listWorkspaceRows,
171 upsertWorkspaceRow,
172 listAuthConnectionRows,
173 upsertAuthConnectionRow,
174 removeAuthConnectionRowById,
175 listSourceAuthBindingRows,
176 upsertSourceAuthBindingRow,
177 removeSourceAuthBindingRowById,
178 listAuthMaterialRows,
179 upsertAuthMaterialRow,
180 removeAuthMaterialRowsByConnectionId,
181 listOAuthStateRows,
182 upsertOAuthStateRow,
183 removeOAuthStateRowsByConnectionId,
184 listStorageInstanceRows,
185 upsertStorageInstanceRow,
186 removeStorageInstanceRowById,
187 listPolicyRows,
188 upsertPolicyRow,
189 removePolicyRowById,
190 listApprovalRows,
191 upsertApprovalRow,
192 getProfileRow,
193 upsertProfileRow,
194 } = createRowOperations({
195 adapter,
196 db,67 db,
197 tables,68 tables,
198 writeLocked: <A>(run: () => Promise<A>) => withWriteLock(writeQueueRef, run),
199 });69 });
200 70
201 const { sourceStore, toolArtifactStore } = createSourceAndArtifactStores({71 const rows = createControlPlaneRows({
202 backend,72 backend: runtime.backend,
203 adapter,
204 db,73 db,
205 tables,74 tables,
206 writeLocked: <A>(run: () => Promise<A>) => withWriteLock(writeQueueRef, run),
207 });75 });
208 76
209 const rows: SqlControlPlanePersistence["rows"] = createRowsEffectApi(
210 backend,
211 {
212 listOrganizationsRows,
213 upsertOrganizationRow,
214 listOrganizationMembershipRows,
215 upsertOrganizationMembershipRow,
216 listWorkspaceRows,
217 upsertWorkspaceRow,
218 listAuthConnectionRows,
219 upsertAuthConnectionRow,
220 removeAuthConnectionRowById,
221 listSourceAuthBindingRows,
222 upsertSourceAuthBindingRow,
223 removeSourceAuthBindingRowById,
224 listAuthMaterialRows,
225 upsertAuthMaterialRow,
226 removeAuthMaterialRowsByConnectionId,
227 listOAuthStateRows,
228 upsertOAuthStateRow,
229 removeOAuthStateRowsByConnectionId,
230 listStorageInstanceRows,
231 upsertStorageInstanceRow,
232 removeStorageInstanceRowById,
233 listPolicyRows,
234 upsertPolicyRow,
235 removePolicyRowById,
236 listApprovalRows,
237 upsertApprovalRow,
238 getProfileRow,
239 upsertProfileRow,
240 },
241 );
242
243 return {77 return {
244 backend,78 backend: runtime.backend,
245 sourceStore,79 sourceStore,
246 toolArtifactStore,80 toolArtifactStore,
247 rows,81 rows,
248 close: () => adapter.close(),82 close: () => runtime.close(),
249 };83 };
250 },84 },
251 catch: (cause) => {85 catch: (cause) => {
packages/persistence-sql/src/row-operations-auth.ts+0−404
@@ -1,404 +0,0 @@
1import {
2 type AuthConnection,
3 type AuthMaterial,
4 type OAuthState,
5 type SourceAuthBinding,
6} from "@executor-v2/schema";
7import { asc, eq } from "drizzle-orm";
8
9import {
10 createDrizzleContext,
11 type DrizzleDb,
12 type DrizzleTables,
13 type SqlAdapter,
14} from "./sql-internals";
15
16type WriteLocked = <A>(run: () => Promise<A>) => Promise<A>;
17
18type AuthOperationsInput = {
19 adapter: SqlAdapter;
20 db: DrizzleDb;
21 tables: DrizzleTables;
22 writeLocked: WriteLocked;
23};
24
25const toAuthConnection = (
26 row: DrizzleTables["authConnectionsTable"]["$inferSelect"],
27): AuthConnection => ({
28 id: row.id as AuthConnection["id"],
29 organizationId: row.organizationId as AuthConnection["organizationId"],
30 workspaceId: row.workspaceId as AuthConnection["workspaceId"],
31 accountId: row.accountId as AuthConnection["accountId"],
32 ownerType: row.ownerType as AuthConnection["ownerType"],
33 strategy: row.strategy as AuthConnection["strategy"],
34 displayName: row.displayName,
35 status: row.status as AuthConnection["status"],
36 statusReason: row.statusReason,
37 lastAuthErrorClass: row.lastAuthErrorClass,
38 metadataJson: row.metadataJson,
39 additionalHeadersJson: row.additionalHeadersJson,
40 createdByAccountId: row.createdByAccountId as AuthConnection["createdByAccountId"],
41 createdAt: row.createdAt,
42 updatedAt: row.updatedAt,
43 lastUsedAt: row.lastUsedAt,
44});
45
46const toSourceAuthBinding = (
47 row: DrizzleTables["sourceAuthBindingsTable"]["$inferSelect"],
48): SourceAuthBinding => ({
49 id: row.id as SourceAuthBinding["id"],
50 sourceId: row.sourceId as SourceAuthBinding["sourceId"],
51 connectionId: row.connectionId as SourceAuthBinding["connectionId"],
52 organizationId: row.organizationId as SourceAuthBinding["organizationId"],
53 workspaceId: row.workspaceId as SourceAuthBinding["workspaceId"],
54 accountId: row.accountId as SourceAuthBinding["accountId"],
55 scopeType: row.scopeType as SourceAuthBinding["scopeType"],
56 selector: row.selector,
57 enabled: row.enabled,
58 createdAt: row.createdAt,
59 updatedAt: row.updatedAt,
60});
61
62const toAuthMaterial = (
63 row: DrizzleTables["authMaterialsTable"]["$inferSelect"],
64): AuthMaterial => ({
65 id: row.id as AuthMaterial["id"],
66 connectionId: row.connectionId as AuthMaterial["connectionId"],
67 ciphertext: row.ciphertext,
68 keyVersion: row.keyVersion,
69 createdAt: row.createdAt,
70 updatedAt: row.updatedAt,
71});
72
73const toOAuthState = (
74 row: DrizzleTables["oauthStatesTable"]["$inferSelect"],
75): OAuthState => ({
76 id: row.id as OAuthState["id"],
77 connectionId: row.connectionId as OAuthState["connectionId"],
78 accessTokenCiphertext: row.accessTokenCiphertext,
79 refreshTokenCiphertext: row.refreshTokenCiphertext,
80 keyVersion: row.keyVersion,
81 expiresAt: row.expiresAt,
82 scope: row.scope,
83 tokenType: row.tokenType,
84 issuer: row.issuer,
85 refreshConfigJson: row.refreshConfigJson,
86 tokenVersion: row.tokenVersion,
87 leaseHolder: row.leaseHolder,
88 leaseExpiresAt: row.leaseExpiresAt,
89 leaseFence: row.leaseFence,
90 lastRefreshAt: row.lastRefreshAt,
91 lastRefreshErrorClass: row.lastRefreshErrorClass,
92 lastRefreshError: row.lastRefreshError,
93 reauthRequiredAt: row.reauthRequiredAt,
94 createdAt: row.createdAt,
95 updatedAt: row.updatedAt,
96});
97
98export const createAuthRowOperations = ({
99 adapter,
100 db,
101 tables,
102 writeLocked,
103}: AuthOperationsInput) => {
104 const listAuthConnectionRows = async (): Promise<Array<AuthConnection>> => {
105 const rows = await db.select().from(tables.authConnectionsTable).orderBy(
106 asc(tables.authConnectionsTable.updatedAt),
107 asc(tables.authConnectionsTable.id),
108 );
109
110 return rows.map(toAuthConnection);
111 };
112
113 const upsertAuthConnectionRow = async (connection: AuthConnection): Promise<void> => {
114 await writeLocked(async () => {
115 await adapter.transaction(async (transaction) => {
116 const transactionContext = createDrizzleContext(transaction);
117
118 await transactionContext.db
119 .insert(transactionContext.tables.authConnectionsTable)
120 .values({
121 id: connection.id,
122 organizationId: connection.organizationId,
123 workspaceId: connection.workspaceId,
124 accountId: connection.accountId,
125 ownerType: connection.ownerType,
126 strategy: connection.strategy,
127 displayName: connection.displayName,
128 status: connection.status,
129 statusReason: connection.statusReason,
130 lastAuthErrorClass: connection.lastAuthErrorClass,
131 metadataJson: connection.metadataJson,
132 additionalHeadersJson: connection.additionalHeadersJson,
133 createdByAccountId: connection.createdByAccountId,
134 createdAt: connection.createdAt,
135 updatedAt: connection.updatedAt,
136 lastUsedAt: connection.lastUsedAt,
137 })
138 .onConflictDoUpdate({
139 target: transactionContext.tables.authConnectionsTable.id,
140 set: {
141 organizationId: connection.organizationId,
142 workspaceId: connection.workspaceId,
143 accountId: connection.accountId,
144 ownerType: connection.ownerType,
145 strategy: connection.strategy,
146 displayName: connection.displayName,
147 status: connection.status,
148 statusReason: connection.statusReason,
149 lastAuthErrorClass: connection.lastAuthErrorClass,
150 metadataJson: connection.metadataJson,
151 additionalHeadersJson: connection.additionalHeadersJson,
152 createdByAccountId: connection.createdByAccountId,
153 createdAt: connection.createdAt,
154 updatedAt: connection.updatedAt,
155 lastUsedAt: connection.lastUsedAt,
156 },
157 });
158 });
159 });
160 };
161
162 const removeAuthConnectionRowById = async (
163 connectionId: AuthConnection["id"],
164 ): Promise<boolean> =>
165 writeLocked(async () =>
166 adapter.transaction(async (transaction) => {
167 const transactionContext = createDrizzleContext(transaction);
168 const existing = await transactionContext.db
169 .select({ id: transactionContext.tables.authConnectionsTable.id })
170 .from(transactionContext.tables.authConnectionsTable)
171 .where(eq(transactionContext.tables.authConnectionsTable.id, connectionId))
172 .limit(1);
173
174 if (existing.length === 0) {
175 return false;
176 }
177
178 await transactionContext.db
179 .delete(transactionContext.tables.authConnectionsTable)
180 .where(eq(transactionContext.tables.authConnectionsTable.id, connectionId));
181
182 return true;
183 })
184 );
185
186 const listSourceAuthBindingRows = async (): Promise<Array<SourceAuthBinding>> => {
187 const rows = await db.select().from(tables.sourceAuthBindingsTable).orderBy(
188 asc(tables.sourceAuthBindingsTable.updatedAt),
189 asc(tables.sourceAuthBindingsTable.id),
190 );
191
192 return rows.map(toSourceAuthBinding);
193 };
194
195 const upsertSourceAuthBindingRow = async (
196 binding: SourceAuthBinding,
197 ): Promise<void> => {
198 await writeLocked(async () => {
199 await adapter.transaction(async (transaction) => {
200 const transactionContext = createDrizzleContext(transaction);
201
202 await transactionContext.db
203 .insert(transactionContext.tables.sourceAuthBindingsTable)
204 .values({
205 id: binding.id,
206 sourceId: binding.sourceId,
207 connectionId: binding.connectionId,
208 organizationId: binding.organizationId,
209 workspaceId: binding.workspaceId,
210 accountId: binding.accountId,
211 scopeType: binding.scopeType,
212 selector: binding.selector,
213 enabled: binding.enabled,
214 createdAt: binding.createdAt,
215 updatedAt: binding.updatedAt,
216 })
217 .onConflictDoUpdate({
218 target: transactionContext.tables.sourceAuthBindingsTable.id,
219 set: {
220 sourceId: binding.sourceId,
221 connectionId: binding.connectionId,
222 organizationId: binding.organizationId,
223 workspaceId: binding.workspaceId,
224 accountId: binding.accountId,
225 scopeType: binding.scopeType,
226 selector: binding.selector,
227 enabled: binding.enabled,
228 createdAt: binding.createdAt,
229 updatedAt: binding.updatedAt,
230 },
231 });
232 });
233 });
234 };
235
236 const removeSourceAuthBindingRowById = async (
237 bindingId: SourceAuthBinding["id"],
238 ): Promise<boolean> =>
239 writeLocked(async () =>
240 adapter.transaction(async (transaction) => {
241 const transactionContext = createDrizzleContext(transaction);
242 const existing = await transactionContext.db
243 .select({ id: transactionContext.tables.sourceAuthBindingsTable.id })
244 .from(transactionContext.tables.sourceAuthBindingsTable)
245 .where(eq(transactionContext.tables.sourceAuthBindingsTable.id, bindingId))
246 .limit(1);
247
248 if (existing.length === 0) {
249 return false;
250 }
251
252 await transactionContext.db
253 .delete(transactionContext.tables.sourceAuthBindingsTable)
254 .where(eq(transactionContext.tables.sourceAuthBindingsTable.id, bindingId));
255
256 return true;
257 })
258 );
259
260 const listAuthMaterialRows = async (): Promise<Array<AuthMaterial>> => {
261 const rows = await db.select().from(tables.authMaterialsTable).orderBy(
262 asc(tables.authMaterialsTable.updatedAt),
263 asc(tables.authMaterialsTable.id),
264 );
265
266 return rows.map(toAuthMaterial);
267 };
268
269 const upsertAuthMaterialRow = async (material: AuthMaterial): Promise<void> => {
270 await writeLocked(async () => {
271 await adapter.transaction(async (transaction) => {
272 const transactionContext = createDrizzleContext(transaction);
273
274 await transactionContext.db
275 .insert(transactionContext.tables.authMaterialsTable)
276 .values({
277 id: material.id,
278 connectionId: material.connectionId,
279 ciphertext: material.ciphertext,
280 keyVersion: material.keyVersion,
281 createdAt: material.createdAt,
282 updatedAt: material.updatedAt,
283 })
284 .onConflictDoUpdate({
285 target: transactionContext.tables.authMaterialsTable.id,
286 set: {
287 connectionId: material.connectionId,
288 ciphertext: material.ciphertext,
289 keyVersion: material.keyVersion,
290 createdAt: material.createdAt,
291 updatedAt: material.updatedAt,
292 },
293 });
294 });
295 });
296 };
297
298 const removeAuthMaterialRowsByConnectionId = async (
299 connectionId: AuthMaterial["connectionId"],
300 ): Promise<void> => {
301 await writeLocked(async () => {
302 await adapter.transaction(async (transaction) => {
303 const transactionContext = createDrizzleContext(transaction);
304 await transactionContext.db
305 .delete(transactionContext.tables.authMaterialsTable)
306 .where(eq(transactionContext.tables.authMaterialsTable.connectionId, connectionId));
307 });
308 });
309 };
310
311 const listOAuthStateRows = async (): Promise<Array<OAuthState>> => {
312 const rows = await db.select().from(tables.oauthStatesTable).orderBy(
313 asc(tables.oauthStatesTable.updatedAt),
314 asc(tables.oauthStatesTable.id),
315 );
316
317 return rows.map(toOAuthState);
318 };
319
320 const upsertOAuthStateRow = async (state: OAuthState): Promise<void> => {
321 await writeLocked(async () => {
322 await adapter.transaction(async (transaction) => {
323 const transactionContext = createDrizzleContext(transaction);
324
325 await transactionContext.db
326 .insert(transactionContext.tables.oauthStatesTable)
327 .values({
328 id: state.id,
329 connectionId: state.connectionId,
330 accessTokenCiphertext: state.accessTokenCiphertext,
331 refreshTokenCiphertext: state.refreshTokenCiphertext,
332 keyVersion: state.keyVersion,
333 expiresAt: state.expiresAt,
334 scope: state.scope,
335 tokenType: state.tokenType,
336 issuer: state.issuer,
337 refreshConfigJson: state.refreshConfigJson,
338 tokenVersion: state.tokenVersion,
339 leaseHolder: state.leaseHolder,
340 leaseExpiresAt: state.leaseExpiresAt,
341 leaseFence: state.leaseFence,
342 lastRefreshAt: state.lastRefreshAt,
343 lastRefreshErrorClass: state.lastRefreshErrorClass,
344 lastRefreshError: state.lastRefreshError,
345 reauthRequiredAt: state.reauthRequiredAt,
346 createdAt: state.createdAt,
347 updatedAt: state.updatedAt,
348 })
349 .onConflictDoUpdate({
350 target: transactionContext.tables.oauthStatesTable.id,
351 set: {
352 connectionId: state.connectionId,
353 accessTokenCiphertext: state.accessTokenCiphertext,
354 refreshTokenCiphertext: state.refreshTokenCiphertext,
355 keyVersion: state.keyVersion,
356 expiresAt: state.expiresAt,
357 scope: state.scope,
358 tokenType: state.tokenType,
359 issuer: state.issuer,
360 refreshConfigJson: state.refreshConfigJson,
361 tokenVersion: state.tokenVersion,
362 leaseHolder: state.leaseHolder,
363 leaseExpiresAt: state.leaseExpiresAt,
364 leaseFence: state.leaseFence,
365 lastRefreshAt: state.lastRefreshAt,
366 lastRefreshErrorClass: state.lastRefreshErrorClass,
367 lastRefreshError: state.lastRefreshError,
368 reauthRequiredAt: state.reauthRequiredAt,
369 createdAt: state.createdAt,
370 updatedAt: state.updatedAt,
371 },
372 });
373 });
374 });
375 };
376
377 const removeOAuthStateRowsByConnectionId = async (
378 connectionId: OAuthState["connectionId"],
379 ): Promise<void> => {
380 await writeLocked(async () => {
381 await adapter.transaction(async (transaction) => {
382 const transactionContext = createDrizzleContext(transaction);
383 await transactionContext.db
384 .delete(transactionContext.tables.oauthStatesTable)
385 .where(eq(transactionContext.tables.oauthStatesTable.connectionId, connectionId));
386 });
387 });
388 };
389
390 return {
391 listAuthConnectionRows,
392 upsertAuthConnectionRow,
393 removeAuthConnectionRowById,
394 listSourceAuthBindingRows,
395 upsertSourceAuthBindingRow,
396 removeSourceAuthBindingRowById,
397 listAuthMaterialRows,
398 upsertAuthMaterialRow,
399 removeAuthMaterialRowsByConnectionId,
400 listOAuthStateRows,
401 upsertOAuthStateRow,
402 removeOAuthStateRowsByConnectionId,
403 };
404};
packages/persistence-sql/src/row-operations-core.ts+0−515
@@ -1,515 +0,0 @@
1import {
2 type Approval,
3 type Organization,
4 type OrganizationMembership,
5 type Policy,
6 type Profile,
7 type StorageInstance,
8 type Workspace,
9} from "@executor-v2/schema";
10import { asc, desc, eq } from "drizzle-orm";
11
12import {
13 createDrizzleContext,
14 type DrizzleDb,
15 type DrizzleTables,
16 type SqlAdapter,
17} from "./sql-internals";
18
19type WriteLocked = <A>(run: () => Promise<A>) => Promise<A>;
20
21type CoreOperationsInput = {
22 adapter: SqlAdapter;
23 db: DrizzleDb;
24 tables: DrizzleTables;
25 writeLocked: WriteLocked;
26};
27
28const toOrganization = (
29 row: DrizzleTables["organizationsTable"]["$inferSelect"],
30): Organization => ({
31 id: row.id as Organization["id"],
32 slug: row.slug,
33 name: row.name,
34 status: row.status as Organization["status"],
35 createdByAccountId: row.createdByAccountId as Organization["createdByAccountId"],
36 createdAt: row.createdAt,
37 updatedAt: row.updatedAt,
38});
39
40const toOrganizationMembership = (
41 row: DrizzleTables["organizationMembershipsTable"]["$inferSelect"],
42): OrganizationMembership => ({
43 id: row.id as OrganizationMembership["id"],
44 organizationId: row.organizationId as OrganizationMembership["organizationId"],
45 accountId: row.accountId as OrganizationMembership["accountId"],
46 role: row.role as OrganizationMembership["role"],
47 status: row.status as OrganizationMembership["status"],
48 billable: row.billable,
49 invitedByAccountId:
50 row.invitedByAccountId as OrganizationMembership["invitedByAccountId"],
51 joinedAt: row.joinedAt,
52 createdAt: row.createdAt,
53 updatedAt: row.updatedAt,
54});
55
56const toWorkspace = (
57 row: DrizzleTables["workspacesTable"]["$inferSelect"],
58): Workspace => ({
59 id: row.id as Workspace["id"],
60 organizationId: row.organizationId as Workspace["organizationId"],
61 name: row.name,
62 createdByAccountId: row.createdByAccountId as Workspace["createdByAccountId"],
63 createdAt: row.createdAt,
64 updatedAt: row.updatedAt,
65});
66
67const toStorageInstance = (
68 row: DrizzleTables["storageInstancesTable"]["$inferSelect"],
69): StorageInstance => ({
70 id: row.id as StorageInstance["id"],
71 scopeType: row.scopeType as StorageInstance["scopeType"],
72 durability: row.durability as StorageInstance["durability"],
73 status: row.status as StorageInstance["status"],
74 provider: row.provider as StorageInstance["provider"],
75 backendKey: row.backendKey,
76 organizationId: row.organizationId as StorageInstance["organizationId"],
77 workspaceId: row.workspaceId as StorageInstance["workspaceId"],
78 accountId: row.accountId as StorageInstance["accountId"],
79 createdByAccountId: row.createdByAccountId as StorageInstance["createdByAccountId"],
80 purpose: row.purpose,
81 sizeBytes: row.sizeBytes,
82 fileCount: row.fileCount,
83 createdAt: row.createdAt,
84 updatedAt: row.updatedAt,
85 lastSeenAt: row.lastSeenAt,
86 closedAt: row.closedAt,
87 expiresAt: row.expiresAt,
88});
89
90const toPolicy = (
91 row: DrizzleTables["policiesTable"]["$inferSelect"],
92): Policy => ({
93 id: row.id as Policy["id"],
94 workspaceId: row.workspaceId as Policy["workspaceId"],
95 toolPathPattern: row.toolPathPattern,
96 decision: row.decision as Policy["decision"],
97 createdAt: row.createdAt,
98 updatedAt: row.updatedAt,
99});
100
101const toApproval = (
102 row: DrizzleTables["approvalsTable"]["$inferSelect"],
103): Approval => ({
104 id: row.id as Approval["id"],
105 workspaceId: row.workspaceId as Approval["workspaceId"],
106 taskRunId: row.taskRunId as Approval["taskRunId"],
107 callId: row.callId,
108 toolPath: row.toolPath,
109 status: row.status as Approval["status"],
110 inputPreviewJson: row.inputPreviewJson,
111 reason: row.reason,
112 requestedAt: row.requestedAt,
113 resolvedAt: row.resolvedAt,
114});
115
116const toProfile = (row: DrizzleTables["profileTable"]["$inferSelect"]): Profile => ({
117 id: row.id as Profile["id"],
118 defaultWorkspaceId: row.defaultWorkspaceId as Profile["defaultWorkspaceId"],
119 displayName: row.displayName,
120 runtimeMode: row.runtimeMode as Profile["runtimeMode"],
121 createdAt: row.createdAt,
122 updatedAt: row.updatedAt,
123});
124
125export const createCoreRowOperations = ({
126 adapter,
127 db,
128 tables,
129 writeLocked,
130}: CoreOperationsInput) => {
131 const listOrganizationsRows = async (): Promise<Array<Organization>> => {
132 const rows = await db.select().from(tables.organizationsTable).orderBy(
133 asc(tables.organizationsTable.updatedAt),
134 asc(tables.organizationsTable.id),
135 );
136
137 return rows.map(toOrganization);
138 };
139
140 const upsertOrganizationRow = async (organization: Organization): Promise<void> => {
141 await writeLocked(async () => {
142 await adapter.transaction(async (transaction) => {
143 const transactionContext = createDrizzleContext(transaction);
144
145 await transactionContext.db
146 .insert(transactionContext.tables.organizationsTable)
147 .values({
148 id: organization.id,
149 slug: organization.slug,
150 name: organization.name,
151 status: organization.status,
152 createdByAccountId: organization.createdByAccountId,
153 createdAt: organization.createdAt,
154 updatedAt: organization.updatedAt,
155 })
156 .onConflictDoUpdate({
157 target: transactionContext.tables.organizationsTable.id,
158 set: {
159 slug: organization.slug,
160 name: organization.name,
161 status: organization.status,
162 createdByAccountId: organization.createdByAccountId,
163 createdAt: organization.createdAt,
164 updatedAt: organization.updatedAt,
165 },
166 });
167 });
168 });
169 };
170
171 const listOrganizationMembershipRows = async (): Promise<
172 Array<OrganizationMembership>
173 > => {
174 const rows = await db.select().from(tables.organizationMembershipsTable).orderBy(
175 asc(tables.organizationMembershipsTable.updatedAt),
176 asc(tables.organizationMembershipsTable.id),
177 );
178
179 return rows.map(toOrganizationMembership);
180 };
181
182 const upsertOrganizationMembershipRow = async (
183 membership: OrganizationMembership,
184 ): Promise<void> => {
185 await writeLocked(async () => {
186 await adapter.transaction(async (transaction) => {
187 const transactionContext = createDrizzleContext(transaction);
188
189 await transactionContext.db
190 .insert(transactionContext.tables.organizationMembershipsTable)
191 .values({
192 id: membership.id,
193 organizationId: membership.organizationId,
194 accountId: membership.accountId,
195 role: membership.role,
196 status: membership.status,
197 billable: membership.billable,
198 invitedByAccountId: membership.invitedByAccountId,
199 joinedAt: membership.joinedAt,
200 createdAt: membership.createdAt,
201 updatedAt: membership.updatedAt,
202 })
203 .onConflictDoUpdate({
204 target: transactionContext.tables.organizationMembershipsTable.id,
205 set: {
206 organizationId: membership.organizationId,
207 accountId: membership.accountId,
208 role: membership.role,
209 status: membership.status,
210 billable: membership.billable,
211 invitedByAccountId: membership.invitedByAccountId,
212 joinedAt: membership.joinedAt,
213 createdAt: membership.createdAt,
214 updatedAt: membership.updatedAt,
215 },
216 });
217 });
218 });
219 };
220
221 const listWorkspaceRows = async (): Promise<Array<Workspace>> => {
222 const rows = await db.select().from(tables.workspacesTable).orderBy(
223 asc(tables.workspacesTable.updatedAt),
224 asc(tables.workspacesTable.id),
225 );
226
227 return rows.map(toWorkspace);
228 };
229
230 const upsertWorkspaceRow = async (workspace: Workspace): Promise<void> => {
231 await writeLocked(async () => {
232 await adapter.transaction(async (transaction) => {
233 const transactionContext = createDrizzleContext(transaction);
234
235 await transactionContext.db
236 .insert(transactionContext.tables.workspacesTable)
237 .values({
238 id: workspace.id,
239 organizationId: workspace.organizationId,
240 name: workspace.name,
241 createdByAccountId: workspace.createdByAccountId,
242 createdAt: workspace.createdAt,
243 updatedAt: workspace.updatedAt,
244 })
245 .onConflictDoUpdate({
246 target: transactionContext.tables.workspacesTable.id,
247 set: {
248 organizationId: workspace.organizationId,
249 name: workspace.name,
250 createdByAccountId: workspace.createdByAccountId,
251 createdAt: workspace.createdAt,
252 updatedAt: workspace.updatedAt,
253 },
254 });
255 });
256 });
257 };
258
259 const listStorageInstanceRows = async (): Promise<Array<StorageInstance>> => {
260 const rows = await db.select().from(tables.storageInstancesTable).orderBy(
261 asc(tables.storageInstancesTable.updatedAt),
262 asc(tables.storageInstancesTable.id),
263 );
264
265 return rows.map(toStorageInstance);
266 };
267
268 const upsertStorageInstanceRow = async (
269 storageInstance: StorageInstance,
270 ): Promise<void> => {
271 await writeLocked(async () => {
272 await adapter.transaction(async (transaction) => {
273 const transactionContext = createDrizzleContext(transaction);
274
275 await transactionContext.db
276 .insert(transactionContext.tables.storageInstancesTable)
277 .values({
278 id: storageInstance.id,
279 scopeType: storageInstance.scopeType,
280 durability: storageInstance.durability,
281 status: storageInstance.status,
282 provider: storageInstance.provider,
283 backendKey: storageInstance.backendKey,
284 organizationId: storageInstance.organizationId,
285 workspaceId: storageInstance.workspaceId,
286 accountId: storageInstance.accountId,
287 createdByAccountId: storageInstance.createdByAccountId,
288 purpose: storageInstance.purpose,
289 sizeBytes: storageInstance.sizeBytes,
290 fileCount: storageInstance.fileCount,
291 createdAt: storageInstance.createdAt,
292 updatedAt: storageInstance.updatedAt,
293 lastSeenAt: storageInstance.lastSeenAt,
294 closedAt: storageInstance.closedAt,
295 expiresAt: storageInstance.expiresAt,
296 })
297 .onConflictDoUpdate({
298 target: transactionContext.tables.storageInstancesTable.id,
299 set: {
300 scopeType: storageInstance.scopeType,
301 durability: storageInstance.durability,
302 status: storageInstance.status,
303 provider: storageInstance.provider,
304 backendKey: storageInstance.backendKey,
305 organizationId: storageInstance.organizationId,
306 workspaceId: storageInstance.workspaceId,
307 accountId: storageInstance.accountId,
308 createdByAccountId: storageInstance.createdByAccountId,
309 purpose: storageInstance.purpose,
310 sizeBytes: storageInstance.sizeBytes,
311 fileCount: storageInstance.fileCount,
312 createdAt: storageInstance.createdAt,
313 updatedAt: storageInstance.updatedAt,
314 lastSeenAt: storageInstance.lastSeenAt,
315 closedAt: storageInstance.closedAt,
316 expiresAt: storageInstance.expiresAt,
317 },
318 });
319 });
320 });
321 };
322
323 const removeStorageInstanceRowById = async (
324 storageInstanceId: StorageInstance["id"],
325 ): Promise<boolean> =>
326 writeLocked(async () =>
327 adapter.transaction(async (transaction) => {
328 const transactionContext = createDrizzleContext(transaction);
329 const existing = await transactionContext.db
330 .select({ id: transactionContext.tables.storageInstancesTable.id })
331 .from(transactionContext.tables.storageInstancesTable)
332 .where(eq(transactionContext.tables.storageInstancesTable.id, storageInstanceId))
333 .limit(1);
334
335 if (existing.length === 0) {
336 return false;
337 }
338
339 await transactionContext.db
340 .delete(transactionContext.tables.storageInstancesTable)
341 .where(eq(transactionContext.tables.storageInstancesTable.id, storageInstanceId));
342
343 return true;
344 })
345 );
346
347 const listPolicyRows = async (): Promise<Array<Policy>> => {
348 const rows = await db.select().from(tables.policiesTable).orderBy(
349 asc(tables.policiesTable.updatedAt),
350 asc(tables.policiesTable.id),
351 );
352
353 return rows.map(toPolicy);
354 };
355
356 const upsertPolicyRow = async (policy: Policy): Promise<void> => {
357 await writeLocked(async () => {
358 await adapter.transaction(async (transaction) => {
359 const transactionContext = createDrizzleContext(transaction);
360
361 await transactionContext.db
362 .insert(transactionContext.tables.policiesTable)
363 .values({
364 id: policy.id,
365 workspaceId: policy.workspaceId,
366 toolPathPattern: policy.toolPathPattern,
367 decision: policy.decision,
368 createdAt: policy.createdAt,
369 updatedAt: policy.updatedAt,
370 })
371 .onConflictDoUpdate({
372 target: transactionContext.tables.policiesTable.id,
373 set: {
374 workspaceId: policy.workspaceId,
375 toolPathPattern: policy.toolPathPattern,
376 decision: policy.decision,
377 createdAt: policy.createdAt,
378 updatedAt: policy.updatedAt,
379 },
380 });
381 });
382 });
383 };
384
385 const removePolicyRowById = async (policyId: Policy["id"]): Promise<boolean> =>
386 writeLocked(async () =>
387 adapter.transaction(async (transaction) => {
388 const transactionContext = createDrizzleContext(transaction);
389
390 const existing = await transactionContext.db
391 .select({ id: transactionContext.tables.policiesTable.id })
392 .from(transactionContext.tables.policiesTable)
393 .where(eq(transactionContext.tables.policiesTable.id, policyId))
394 .limit(1);
395
396 if (existing.length === 0) {
397 return false;
398 }
399
400 await transactionContext.db
401 .delete(transactionContext.tables.policiesTable)
402 .where(eq(transactionContext.tables.policiesTable.id, policyId));
403
404 return true;
405 })
406 );
407
408 const listApprovalRows = async (): Promise<Array<Approval>> => {
409 const rows = await db.select().from(tables.approvalsTable).orderBy(
410 asc(tables.approvalsTable.requestedAt),
411 asc(tables.approvalsTable.id),
412 );
413
414 return rows.map(toApproval);
415 };
416
417 const upsertApprovalRow = async (approval: Approval): Promise<void> => {
418 await writeLocked(async () => {
419 await adapter.transaction(async (transaction) => {
420 const transactionContext = createDrizzleContext(transaction);
421
422 await transactionContext.db
423 .insert(transactionContext.tables.approvalsTable)
424 .values({
425 id: approval.id,
426 workspaceId: approval.workspaceId,
427 taskRunId: approval.taskRunId,
428 callId: approval.callId,
429 toolPath: approval.toolPath,
430 status: approval.status,
431 inputPreviewJson: approval.inputPreviewJson,
432 reason: approval.reason,
433 requestedAt: approval.requestedAt,
434 resolvedAt: approval.resolvedAt,
435 })
436 .onConflictDoUpdate({
437 target: transactionContext.tables.approvalsTable.id,
438 set: {
439 workspaceId: approval.workspaceId,
440 taskRunId: approval.taskRunId,
441 callId: approval.callId,
442 toolPath: approval.toolPath,
443 status: approval.status,
444 inputPreviewJson: approval.inputPreviewJson,
445 reason: approval.reason,
446 requestedAt: approval.requestedAt,
447 resolvedAt: approval.resolvedAt,
448 },
449 });
450 });
451 });
452 };
453
454 const getProfileRow = async (): Promise<Profile | null> => {
455 const rows = await db.select().from(tables.profileTable).orderBy(
456 desc(tables.profileTable.updatedAt),
457 asc(tables.profileTable.id),
458 ).limit(1);
459
460 const row = rows[0];
461 if (!row) {
462 return null;
463 }
464
465 return toProfile(row);
466 };
467
468 const upsertProfileRow = async (profile: Profile): Promise<void> => {
469 await writeLocked(async () => {
470 await adapter.transaction(async (transaction) => {
471 const transactionContext = createDrizzleContext(transaction);
472
473 await transactionContext.db
474 .insert(transactionContext.tables.profileTable)
475 .values({
476 id: profile.id,
477 defaultWorkspaceId: profile.defaultWorkspaceId,
478 displayName: profile.displayName,
479 runtimeMode: profile.runtimeMode,
480 createdAt: profile.createdAt,
481 updatedAt: profile.updatedAt,
482 })
483 .onConflictDoUpdate({
484 target: transactionContext.tables.profileTable.id,
485 set: {
486 defaultWorkspaceId: profile.defaultWorkspaceId,
487 displayName: profile.displayName,
488 runtimeMode: profile.runtimeMode,
489 createdAt: profile.createdAt,
490 updatedAt: profile.updatedAt,
491 },
492 });
493 });
494 });
495 };
496
497 return {
498 listOrganizationsRows,
499 upsertOrganizationRow,
500 listOrganizationMembershipRows,
501 upsertOrganizationMembershipRow,
502 listWorkspaceRows,
503 upsertWorkspaceRow,
504 listStorageInstanceRows,
505 upsertStorageInstanceRow,
506 removeStorageInstanceRowById,
507 listPolicyRows,
508 upsertPolicyRow,
509 removePolicyRowById,
510 listApprovalRows,
511 upsertApprovalRow,
512 getProfileRow,
513 upsertProfileRow,
514 };
515};
packages/persistence-sql/src/row-operations.ts+0−21
@@ -1,21 +0,0 @@
1import { createAuthRowOperations } from "./row-operations-auth";
2import { createCoreRowOperations } from "./row-operations-core";
3import {
4 type DrizzleDb,
5 type DrizzleTables,
6 type SqlAdapter,
7} from "./sql-internals";
8
9type WriteLocked = <A>(run: () => Promise<A>) => Promise<A>;
10
11type CreateRowOperationsInput = {
12 adapter: SqlAdapter;
13 db: DrizzleDb;
14 tables: DrizzleTables;
15 writeLocked: WriteLocked;
16};
17
18export const createRowOperations = (input: CreateRowOperationsInput) => ({
19 ...createCoreRowOperations(input),
20 ...createAuthRowOperations(input),
21});
packages/persistence-sql/src/rows-effect-api-auth.ts+0−116
@@ -1,116 +0,0 @@
1import {
2 type AuthConnection,
3 type AuthMaterial,
4 type OAuthState,
5 type SourceAuthBinding,
6} from "@executor-v2/schema";
7
8import { tableNames } from "./schema";
9import { type SqlBackend } from "./sql-internals";
10import {
11 type RowOperations,
12 toBooleanEffect,
13 toListEffect,
14 toVoidEffect,
15} from "./rows-effect-helpers";
16
17export const createAuthRowsEffectApi = (
18 backend: SqlBackend,
19 operations: RowOperations,
20) => ({
21 authConnections: {
22 list: () =>
23 toListEffect<AuthConnection>(
24 backend,
25 "rows.authConnections.list",
26 tableNames.authConnections,
27 operations.listAuthConnectionRows,
28 ),
29 upsert: (connection: AuthConnection) =>
30 toVoidEffect(
31 backend,
32 "rows.authConnections.upsert",
33 tableNames.authConnections,
34 () => operations.upsertAuthConnectionRow(connection),
35 ),
36 removeById: (connectionId: AuthConnection["id"]) =>
37 toBooleanEffect(
38 backend,
39 "rows.authConnections.remove",
40 tableNames.authConnections,
41 () => operations.removeAuthConnectionRowById(connectionId),
42 ),
43 },
44
45 sourceAuthBindings: {
46 list: () =>
47 toListEffect<SourceAuthBinding>(
48 backend,
49 "rows.sourceAuthBindings.list",
50 tableNames.sourceAuthBindings,
51 operations.listSourceAuthBindingRows,
52 ),
53 upsert: (binding: SourceAuthBinding) =>
54 toVoidEffect(
55 backend,
56 "rows.sourceAuthBindings.upsert",
57 tableNames.sourceAuthBindings,
58 () => operations.upsertSourceAuthBindingRow(binding),
59 ),
60 removeById: (bindingId: SourceAuthBinding["id"]) =>
61 toBooleanEffect(
62 backend,
63 "rows.sourceAuthBindings.remove",
64 tableNames.sourceAuthBindings,
65 () => operations.removeSourceAuthBindingRowById(bindingId),
66 ),
67 },
68
69 authMaterials: {
70 list: () =>
71 toListEffect<AuthMaterial>(
72 backend,
73 "rows.authMaterials.list",
74 tableNames.authMaterials,
75 operations.listAuthMaterialRows,
76 ),
77 upsert: (material: AuthMaterial) =>
78 toVoidEffect(
79 backend,
80 "rows.authMaterials.upsert",
81 tableNames.authMaterials,
82 () => operations.upsertAuthMaterialRow(material),
83 ),
84 removeByConnectionId: (connectionId: AuthMaterial["connectionId"]) =>
85 toVoidEffect(
86 backend,
87 "rows.authMaterials.remove_by_connection",
88 tableNames.authMaterials,
89 () => operations.removeAuthMaterialRowsByConnectionId(connectionId),
90 ),
91 },
92
93 oauthStates: {
94 list: () =>
95 toListEffect<OAuthState>(
96 backend,
97 "rows.oauthStates.list",
98 tableNames.oauthStates,
99 operations.listOAuthStateRows,
100 ),
101 upsert: (state: OAuthState) =>
102 toVoidEffect(
103 backend,
104 "rows.oauthStates.upsert",
105 tableNames.oauthStates,
106 () => operations.upsertOAuthStateRow(state),
107 ),
108 removeByConnectionId: (connectionId: OAuthState["connectionId"]) =>
109 toVoidEffect(
110 backend,
111 "rows.oauthStates.remove_by_connection",
112 tableNames.oauthStates,
113 () => operations.removeOAuthStateRowsByConnectionId(connectionId),
114 ),
115 },
116});
packages/persistence-sql/src/rows-effect-api-core.ts+0−157
@@ -1,157 +0,0 @@
1import {
2 type Approval,
3 type Organization,
4 type OrganizationMembership,
5 type Policy,
6 type Profile,
7 type StorageInstance,
8 type Workspace,
9} from "@executor-v2/schema";
10
11import { tableNames } from "./schema";
12import { type SqlBackend } from "./sql-internals";
13import {
14 type RowOperations,
15 toBooleanEffect,
16 toListEffect,
17 toOptionEffect,
18 toVoidEffect,
19} from "./rows-effect-helpers";
20
21export const createCoreRowsEffectApi = (
22 backend: SqlBackend,
23 operations: RowOperations,
24) => ({
25 profile: {
26 get: () =>
27 toOptionEffect<Profile>(
28 backend,
29 "rows.profile.get",
30 tableNames.profile,
31 operations.getProfileRow,
32 ),
33 upsert: (profile: Profile) =>
34 toVoidEffect(
35 backend,
36 "rows.profile.upsert",
37 tableNames.profile,
38 () => operations.upsertProfileRow(profile),
39 ),
40 },
41
42 organizations: {
43 list: () =>
44 toListEffect<Organization>(
45 backend,
46 "rows.organizations.list",
47 tableNames.organizations,
48 operations.listOrganizationsRows,
49 ),
50 upsert: (organization: Organization) =>
51 toVoidEffect(
52 backend,
53 "rows.organizations.upsert",
54 tableNames.organizations,
55 () => operations.upsertOrganizationRow(organization),
56 ),
57 },
58
59 organizationMemberships: {
60 list: () =>
61 toListEffect<OrganizationMembership>(
62 backend,
63 "rows.organizationMemberships.list",
64 tableNames.organizationMemberships,
65 operations.listOrganizationMembershipRows,
66 ),
67 upsert: (membership: OrganizationMembership) =>
68 toVoidEffect(
69 backend,
70 "rows.organizationMemberships.upsert",
71 tableNames.organizationMemberships,
72 () => operations.upsertOrganizationMembershipRow(membership),
73 ),
74 },
75
76 workspaces: {
77 list: () =>
78 toListEffect<Workspace>(
79 backend,
80 "rows.workspaces.list",
81 tableNames.workspaces,
82 operations.listWorkspaceRows,
83 ),
84 upsert: (workspace: Workspace) =>
85 toVoidEffect(
86 backend,
87 "rows.workspaces.upsert",
88 tableNames.workspaces,
89 () => operations.upsertWorkspaceRow(workspace),
90 ),
91 },
92
93 storageInstances: {
94 list: () =>
95 toListEffect<StorageInstance>(
96 backend,
97 "rows.storageInstances.list",
98 tableNames.storageInstances,
99 operations.listStorageInstanceRows,
100 ),
101 upsert: (storageInstance: StorageInstance) =>
102 toVoidEffect(
103 backend,
104 "rows.storageInstances.upsert",
105 tableNames.storageInstances,
106 () => operations.upsertStorageInstanceRow(storageInstance),
107 ),
108 removeById: (storageInstanceId: StorageInstance["id"]) =>
109 toBooleanEffect(
110 backend,
111 "rows.storageInstances.remove",
112 tableNames.storageInstances,
113 () => operations.removeStorageInstanceRowById(storageInstanceId),
114 ),
115 },
116
117 policies: {
118 list: () =>
119 toListEffect<Policy>(
120 backend,
121 "rows.policies.list",
122 tableNames.policies,
123 operations.listPolicyRows,
124 ),
125 upsert: (policy: Policy) =>
126 toVoidEffect(
127 backend,
128 "rows.policies.upsert",
129 tableNames.policies,
130 () => operations.upsertPolicyRow(policy),
131 ),
132 removeById: (policyId: Policy["id"]) =>
133 toBooleanEffect(
134 backend,
135 "rows.policies.remove",
136 tableNames.policies,
137 () => operations.removePolicyRowById(policyId),
138 ),
139 },
140
141 approvals: {
142 list: () =>
143 toListEffect<Approval>(
144 backend,
145 "rows.approvals.list",
146 tableNames.approvals,
147 operations.listApprovalRows,
148 ),
149 upsert: (approval: Approval) =>
150 toVoidEffect(
151 backend,
152 "rows.approvals.upsert",
153 tableNames.approvals,
154 () => operations.upsertApprovalRow(approval),
155 ),
156 },
157});
packages/persistence-sql/src/rows-effect-api.ts+0−12
@@ -1,12 +0,0 @@
1import { createAuthRowsEffectApi } from "./rows-effect-api-auth";
2import { createCoreRowsEffectApi } from "./rows-effect-api-core";
3import { type RowOperations } from "./rows-effect-helpers";
4import { type SqlBackend } from "./sql-internals";
5
6export const createRowsEffectApi = (
7 backend: SqlBackend,
8 operations: RowOperations,
9) => ({
10 ...createCoreRowsEffectApi(backend, operations),
11 ...createAuthRowsEffectApi(backend, operations),
12});
packages/persistence-sql/src/rows-effect-helpers.ts+0−55
@@ -1,55 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3
4import { toRowStoreError } from "./persistence-errors";
5import { createRowOperations } from "./row-operations";
6import { type SqlBackend } from "./sql-internals";
7
8export type RowOperations = ReturnType<typeof createRowOperations>;
9
10export const toListEffect = <A>(
11 backend: SqlBackend,
12 operation: string,
13 location: string,
14 run: () => Promise<ReadonlyArray<A>>,
15) =>
16 Effect.tryPromise({
17 try: run,
18 catch: (cause) => toRowStoreError(backend, operation, location, cause),
19 });
20
21export const toVoidEffect = (
22 backend: SqlBackend,
23 operation: string,
24 location: string,
25 run: () => Promise<void>,
26) =>
27 Effect.tryPromise({
28 try: run,
29 catch: (cause) => toRowStoreError(backend, operation, location, cause),
30 });
31
32export const toBooleanEffect = (
33 backend: SqlBackend,
34 operation: string,
35 location: string,
36 run: () => Promise<boolean>,
37) =>
38 Effect.tryPromise({
39 try: run,
40 catch: (cause) => toRowStoreError(backend, operation, location, cause),
41 });
42
43export const toOptionEffect = <A>(
44 backend: SqlBackend,
45 operation: string,
46 location: string,
47 run: () => Promise<A | null>,
48) =>
49 Effect.tryPromise({
50 try: async () => {
51 const value = await run();
52 return value === null ? Option.none<A>() : Option.some(value);
53 },
54 catch: (cause) => toRowStoreError(backend, operation, location, cause),
55 });
packages/persistence-sql/src/source-artifact-stores.ts+82−106
@@ -15,21 +15,15 @@ import { · 14 unmodified lines
15} from "./persistence-errors";15} from "./persistence-errors";
16import { tableNames } from "./schema";16import { tableNames } from "./schema";
17import {17import {
18 createDrizzleContext,
19 type DrizzleDb,18 type DrizzleDb,
20 type DrizzleTables,19 type DrizzleTables,
21 type SqlAdapter,
22 type SqlBackend,20 type SqlBackend,
23} from "./sql-internals";21} from "./sql-internals";
24 22
25type WriteLocked = <A>(run: () => Promise<A>) => Promise<A>;
26
27type CreateStoresInput = {23type CreateStoresInput = {
28 backend: SqlBackend;24 backend: SqlBackend;
29 adapter: SqlAdapter;
30 db: DrizzleDb;25 db: DrizzleDb;
31 tables: DrizzleTables;26 tables: DrizzleTables;
32 writeLocked: WriteLocked;
33};27};
34 28
35const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;29const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;
@@ -76,10 +70,8 @@ const toToolArtifact = ( · 40 unmodified lines
76 70
77export const createSourceAndArtifactStores = ({71export const createSourceAndArtifactStores = ({
78 backend,72 backend,
79 adapter,
80 db,73 db,
81 tables,74 tables,
82 writeLocked,
83}: CreateStoresInput): {75}: CreateStoresInput): {
84 sourceStore: SourceStore;76 sourceStore: SourceStore;
85 toolArtifactStore: ToolArtifactStore;77 toolArtifactStore: ToolArtifactStore;
@@ -124,46 +116,40 @@ export const createSourceAndArtifactStores = ({ · 38 unmodified lines
124 upsert: (source: Source) =>116 upsert: (source: Source) =>
125 Effect.tryPromise({117 Effect.tryPromise({
126 try: async () => {118 try: async () => {
127 await writeLocked(async () => {119 await db
128 await adapter.transaction(async (transaction) => {120 .insert(tables.sourcesTable)
129 const transactionContext = createDrizzleContext(transaction);121 .values({
130 122 workspaceId: source.workspaceId,
131 await transactionContext.db123 sourceId: source.id,
132 .insert(transactionContext.tables.sourcesTable)124 name: source.name,
133 .values({125 kind: source.kind,
134 workspaceId: source.workspaceId,126 endpoint: source.endpoint,
135 sourceId: source.id,127 status: source.status,
136 name: source.name,128 enabled: source.enabled,
137 kind: source.kind,129 configJson: source.configJson,
138 endpoint: source.endpoint,130 sourceHash: source.sourceHash,
139 status: source.status,131 lastError: source.lastError,
140 enabled: source.enabled,132 createdAt: source.createdAt,
141 configJson: source.configJson,133 updatedAt: source.updatedAt,
142 sourceHash: source.sourceHash,134 })
143 lastError: source.lastError,135 .onConflictDoUpdate({
144 createdAt: source.createdAt,136 target: [
145 updatedAt: source.updatedAt,137 tables.sourcesTable.workspaceId,
146 })138 tables.sourcesTable.sourceId,
147 .onConflictDoUpdate({139 ],
148 target: [140 set: {
149 transactionContext.tables.sourcesTable.workspaceId,141 name: source.name,
150 transactionContext.tables.sourcesTable.sourceId,142 kind: source.kind,
151 ],143 endpoint: source.endpoint,
152 set: {144 status: source.status,
153 name: source.name,145 enabled: source.enabled,
154 kind: source.kind,146 configJson: source.configJson,
155 endpoint: source.endpoint,147 sourceHash: source.sourceHash,
156 status: source.status,148 lastError: source.lastError,
157 enabled: source.enabled,149 createdAt: source.createdAt,
158 configJson: source.configJson,150 updatedAt: source.updatedAt,
159 sourceHash: source.sourceHash,151 },
160 lastError: source.lastError,
161 createdAt: source.createdAt,
162 updatedAt: source.updatedAt,
163 },
164 });
165 });152 });
166 });
167 },153 },
168 catch: (cause) =>154 catch: (cause) =>
169 toSourceStoreError(backend, "upsert", tableNames.sources, cause),155 toSourceStoreError(backend, "upsert", tableNames.sources, cause),
@@ -171,37 +157,33 @@ export const createSourceAndArtifactStores = ({ · 1 unmodified line
171 157
172 removeById: (workspaceId: WorkspaceId, sourceId: SourceId) =>158 removeById: (workspaceId: WorkspaceId, sourceId: SourceId) =>
173 Effect.tryPromise({159 Effect.tryPromise({
174 try: async () =>160 try: async () => {
175 writeLocked(async () =>161 const existing = await db
176 adapter.transaction(async (transaction) => {162 .select({ sourceId: tables.sourcesTable.sourceId })
177 const transactionContext = createDrizzleContext(transaction);163 .from(tables.sourcesTable)
178 const existing = await transactionContext.db164 .where(
179 .select({ sourceId: transactionContext.tables.sourcesTable.sourceId })165 and(
180 .from(transactionContext.tables.sourcesTable)166 eq(tables.sourcesTable.workspaceId, workspaceId),
181 .where(167 eq(tables.sourcesTable.sourceId, sourceId),
182 and(168 ),
183 eq(transactionContext.tables.sourcesTable.workspaceId, workspaceId),169 )
184 eq(transactionContext.tables.sourcesTable.sourceId, sourceId),170 .limit(1);
185 ),
186 )
187 .limit(1);
188 171
189 if (existing.length === 0) {172 if (existing.length === 0) {
190 return false;173 return false;
191 }174 }
192 175
193 await transactionContext.db176 await db
194 .delete(transactionContext.tables.sourcesTable)177 .delete(tables.sourcesTable)
195 .where(178 .where(
196 and(179 and(
197 eq(transactionContext.tables.sourcesTable.workspaceId, workspaceId),180 eq(tables.sourcesTable.workspaceId, workspaceId),
198 eq(transactionContext.tables.sourcesTable.sourceId, sourceId),181 eq(tables.sourcesTable.sourceId, sourceId),
199 ),182 ),
200 );183 );
201 184
202 return true;185 return true;
203 })186 },
204 ),
205 catch: (cause) =>187 catch: (cause) =>
206 toSourceStoreError(backend, "remove_by_id", tableNames.sources, cause),188 toSourceStoreError(backend, "remove_by_id", tableNames.sources, cause),
207 }),189 }),
@@ -241,38 +223,32 @@ export const createSourceAndArtifactStores = ({ · 33 unmodified lines
241 upsert: (artifact: ToolArtifact) =>223 upsert: (artifact: ToolArtifact) =>
242 Effect.tryPromise({224 Effect.tryPromise({
243 try: async () => {225 try: async () => {
244 await writeLocked(async () => {226 await db
245 await adapter.transaction(async (transaction) => {227 .insert(tables.toolArtifactsTable)
246 const transactionContext = createDrizzleContext(transaction);228 .values({
247 229 id: artifact.id,
248 await transactionContext.db230 workspaceId: artifact.workspaceId,
249 .insert(transactionContext.tables.toolArtifactsTable)231 sourceId: artifact.sourceId,
250 .values({232 sourceHash: artifact.sourceHash,
251 id: artifact.id,233 toolCount: artifact.toolCount,
252 workspaceId: artifact.workspaceId,234 manifestJson: artifact.manifestJson,
253 sourceId: artifact.sourceId,235 createdAt: artifact.createdAt,
254 sourceHash: artifact.sourceHash,236 updatedAt: artifact.updatedAt,
255 toolCount: artifact.toolCount,237 })
256 manifestJson: artifact.manifestJson,238 .onConflictDoUpdate({
257 createdAt: artifact.createdAt,239 target: [
258 updatedAt: artifact.updatedAt,240 tables.toolArtifactsTable.workspaceId,
259 })241 tables.toolArtifactsTable.sourceId,
260 .onConflictDoUpdate({242 ],
261 target: [243 set: {
262 transactionContext.tables.toolArtifactsTable.workspaceId,244 id: artifact.id,
263 transactionContext.tables.toolArtifactsTable.sourceId,245 sourceHash: artifact.sourceHash,
264 ],246 toolCount: artifact.toolCount,
265 set: {247 manifestJson: artifact.manifestJson,
266 id: artifact.id,248 createdAt: artifact.createdAt,
267 sourceHash: artifact.sourceHash,249 updatedAt: artifact.updatedAt,
268 toolCount: artifact.toolCount,250 },
269 manifestJson: artifact.manifestJson,
270 createdAt: artifact.createdAt,
271 updatedAt: artifact.updatedAt,
272 },
273 });
274 });251 });
275 });
276 },252 },
277 catch: (cause) =>253 catch: (cause) =>
278 toToolArtifactStoreError(backend, "upsert", tableNames.toolArtifacts, cause),254 toToolArtifactStoreError(backend, "upsert", tableNames.toolArtifacts, cause),
packages/persistence-sql/src/sql-internals.ts+70−216
@@ -1,11 +1,13 @@
1import { PGlite } from "@electric-sql/pglite";1import { PGlite } from "@electric-sql/pglite";
2import { drizzle as drizzlePgProxy } from "drizzle-orm/pg-proxy";2import { drizzle as drizzlePGlite } from "drizzle-orm/pglite";
3import { migrate as migratePgProxy } from "drizzle-orm/pg-proxy/migrator";3import { migrate as migratePGlite } from "drizzle-orm/pglite/migrator";
4import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js";
5import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator";
4import { existsSync } from "node:fs";6import { existsSync } from "node:fs";
5import { mkdir } from "node:fs/promises";7import { mkdir } from "node:fs/promises";
6import * as path from "node:path";8import * as path from "node:path";
7import { fileURLToPath } from "node:url";9import { fileURLToPath } from "node:url";
8import postgres, { type Sql } from "postgres";10import postgres from "postgres";
9 11
10import {12import {
11 approvalsTable,13 approvalsTable,
@@ -26,160 +28,80 @@ import { · 14 unmodified lines
26} from "./schema";28} from "./schema";
27 29
28export type SqlBackend = "pglite" | "postgres";30export type SqlBackend = "pglite" | "postgres";
29type SqlRow = Record<string, unknown>;31type CreateSqlRuntimeOptions = {
30 32 databaseUrl?: string;
31export type SqlAdapter = {33 localDataDir: string;
32 readonly backend: SqlBackend;34 postgresApplicationName?: string;
33 query: <TRow extends SqlRow = SqlRow>(
34 statement: string,
35 args?: ReadonlyArray<unknown>,
36 ) => Promise<Array<TRow>>;
37 execute: (statement: string, args?: ReadonlyArray<unknown>) => Promise<void>;
38 transaction: <A>(run: (transaction: SqlAdapter) => Promise<A>) => Promise<A>;
39 close: () => Promise<void>;
40};35};
41 36
42const withPostgresPlaceholders = (statement: string): string => {37const drizzleSchema = {
43 let index = 0;38 profileTable,
44 return statement.replace(/\?/g, () => {39 organizationsTable,
45 index += 1;40 organizationMembershipsTable,
46 return `$${index}`;41 workspacesTable,
47 });42 sourcesTable,
43 toolArtifactsTable,
44 authConnectionsTable,
45 sourceAuthBindingsTable,
46 authMaterialsTable,
47 oauthStatesTable,
48 policiesTable,
49 approvalsTable,
50 taskRunsTable,
51 storageInstancesTable,
52 syncStatesTable,
48};53};
49 54
50const toPostgresStatement = (statement: string): string => withPostgresPlaceholders(statement);55const createPGliteDb = (client: PGlite) => drizzlePGlite(client, { schema: drizzleSchema });
51 56const createPostgresDb = (client: postgres.Sql) => drizzlePostgres(client, { schema: drizzleSchema });
52const makePGliteTransaction = (
53 execute: (statement: string, args?: ReadonlyArray<unknown>) => Promise<void>,
54 query: <TRow extends SqlRow = SqlRow>(
55 statement: string,
56 args?: ReadonlyArray<unknown>,
57 ) => Promise<Array<TRow>>,
58): SqlAdapter["transaction"] =>
59 async <A>(run: (transactionAdapter: SqlAdapter) => Promise<A>): Promise<A> => {
60 await execute("BEGIN");
61 57
62 try {58type PGliteDb = ReturnType<typeof createPGliteDb>;
63 const adapter: SqlAdapter = {59type PostgresDb = ReturnType<typeof createPostgresDb>;
64 backend: "pglite",
65 query,
66 execute,
67 transaction: async (nestedRun) => nestedRun(adapter),
68 close: async () => {},
69 };
70 60
71 const result = await run(adapter);61export type DrizzleDb = PGliteDb | PostgresDb;
72 await execute("COMMIT");62export type DrizzleTables = typeof drizzleSchema;
73 return result;
74 } catch (error) {
75 try {
76 await execute("ROLLBACK");
77 } catch {
78 // ignore rollback failure after original error
79 }
80 63
81 throw error;64type SqlRuntime = {
82 }65 backend: SqlBackend;
83 };66 db: DrizzleDb;
67 close: () => Promise<void>;
68};
84 69
85export const createPGliteAdapter = async (localDataDir: string): Promise<SqlAdapter> => {70const createPGliteRuntime = async (localDataDir: string): Promise<SqlRuntime> => {
86 const resolvedDataDir = path.resolve(localDataDir);71 const resolvedDataDir = path.resolve(localDataDir);
87 await mkdir(path.dirname(resolvedDataDir), { recursive: true });72 await mkdir(resolvedDataDir, { recursive: true });
88 73
89 const db = new PGlite(resolvedDataDir);74 const client = new PGlite(resolvedDataDir);
90 75 const db = createPGliteDb(client);
91 const query = async <TRow extends SqlRow = SqlRow>(
92 statement: string,
93 args: ReadonlyArray<unknown> = [],
94 ): Promise<Array<TRow>> => {
95 const result = await db.query(toPostgresStatement(statement), [...args]);
96 return result.rows as Array<TRow>;
97 };
98
99 const execute = async (
100 statement: string,
101 args: ReadonlyArray<unknown> = [],
102 ): Promise<void> => {
103 await db.query(toPostgresStatement(statement), [...args]);
104 };
105 76
106 return {77 return {
107 backend: "pglite",78 backend: "pglite",
108 query,79 db,
109 execute,
110 transaction: makePGliteTransaction(execute, query),
111 close: async () => {80 close: async () => {
112 await db.close();81 await client.close();
113 },82 },
114 };83 };
115};84};
116 85
117export const createPostgresAdapter = async (86const createPostgresRuntime = async (
118 databaseUrl: string,87 databaseUrl: string,
119 applicationName: string | undefined,88 applicationName: string | undefined,
120): Promise<SqlAdapter> => {89): Promise<SqlRuntime> => {
121 const client = postgres(databaseUrl, {90 const client = postgres(databaseUrl, {
122 prepare: false,91 prepare: false,
123 max: 10,92 max: 10,
124 ...(applicationName ? { connection: { application_name: applicationName } } : {}),93 ...(applicationName ? { connection: { application_name: applicationName } } : {}),
125 });94 });
126 95
127 type UnsafeRunner = {96 const db = createPostgresDb(client);
128 unsafe: Sql["unsafe"];
129 };
130
131 const toPostgresParams = (
132 args: ReadonlyArray<unknown>,
133 ): Array<postgres.ParameterOrJSON<never>> =>
134 args as unknown as Array<postgres.ParameterOrJSON<never>>;
135 97
136 const queryWith = async <TRow extends SqlRow = SqlRow>(98 return {
137 runner: UnsafeRunner,
138 statement: string,
139 args: ReadonlyArray<unknown> = [],
140 ): Promise<Array<TRow>> =>
141 (await runner.unsafe(
142 toPostgresStatement(statement),
143 toPostgresParams(args),
144 )) as unknown as Array<TRow>;
145
146 const executeWith = async (
147 runner: UnsafeRunner,
148 statement: string,
149 args: ReadonlyArray<unknown> = [],
150 ): Promise<void> => {
151 await runner.unsafe(
152 toPostgresStatement(statement),
153 toPostgresParams(args),
154 );
155 };
156
157 const adapter: SqlAdapter = {
158 backend: "postgres",99 backend: "postgres",
159 query: (statement, args = []) => queryWith(client, statement, args),100 db,
160 execute: (statement, args = []) => executeWith(client, statement, args),
161 transaction: async <A>(run: (transaction: SqlAdapter) => Promise<A>) => {
162 const result = await client.begin(async (transactionClient) => {
163 const runner: UnsafeRunner = transactionClient;
164 const transactionAdapter: SqlAdapter = {
165 backend: "postgres",
166 query: (statement, args = []) => queryWith(runner, statement, args),
167 execute: (statement, args = []) => executeWith(runner, statement, args),
168 transaction: async (nestedRun) => nestedRun(transactionAdapter),
169 close: async () => {},
170 };
171
172 return run(transactionAdapter);
173 });
174
175 return result as A;
176 },
177 close: async () => {101 close: async () => {
178 await client.end({ timeout: 5 });102 await client.end({ timeout: 5 });
179 },103 },
180 };104 };
181
182 return adapter;
183};105};
184 106
185const resolveDrizzleMigrationsFolder = (): string => {107const resolveDrizzleMigrationsFolder = (): string => {
@@ -202,105 +124,37 @@ const resolveDrizzleMigrationsFolder = (): string => { · 16 unmodified lines
202 throw new Error("Unable to resolve drizzle migrations folder");124 throw new Error("Unable to resolve drizzle migrations folder");
203};125};
204 126
205const runMigrationQueries = async (
206 adapter: SqlAdapter,
207 queries: ReadonlyArray<string>,
208): Promise<void> => {
209 for (const query of queries) {
210 const statement = query.trim();
211 if (statement.length === 0) {
212 continue;
213 }
214
215 await adapter.execute(statement);
216 }
217};
218
219const toProxyRow = (row: unknown): unknown => {
220 if (Array.isArray(row) || row === null || row === undefined) {
221 return row;
222 }
223
224 if (typeof row === "object") {
225 return Object.values(row as Record<string, unknown>);
226 }
227
228 return row;
229};
230
231const normalizeProxyRows = (
232 method: "execute" | "all" | "values" | "get",
233 rows: ReadonlyArray<unknown>,
234): Array<unknown> => {
235 if (method === "get") {
236 const first = rows[0];
237 return first === undefined ? [] : [toProxyRow(first)];
238 }
239
240 return rows.map(toProxyRow);
241};
242
243const drizzleSchema = {
244 profileTable,
245 organizationsTable,
246 organizationMembershipsTable,
247 workspacesTable,
248 sourcesTable,
249 toolArtifactsTable,
250 authConnectionsTable,
251 sourceAuthBindingsTable,
252 authMaterialsTable,
253 oauthStatesTable,
254 policiesTable,
255 approvalsTable,
256 taskRunsTable,
257 storageInstancesTable,
258 syncStatesTable,
259};
260
261type DrizzleSchema = typeof drizzleSchema;
262type PostgresDrizzleDb = ReturnType<typeof drizzlePgProxy<DrizzleSchema>>;
263
264export type DrizzleDb = PostgresDrizzleDb;
265export type DrizzleTables = DrizzleSchema;
266
267export type DrizzleContext = {127export type DrizzleContext = {
268 db: DrizzleDb;128 db: DrizzleDb;
269 tables: DrizzleTables;129 tables: DrizzleTables;
270};130};
271 131
272const createPostgresProxyDb = (adapter: SqlAdapter): PostgresDrizzleDb =>132export const createDrizzleContext = (db: DrizzleDb): DrizzleContext => ({
273 drizzlePgProxy(133 db,
274 async (statement, params, method) => {
275 if (method === "execute") {
276 await adapter.execute(statement, params);
277 return { rows: [] };
278 }
279
280 const rows = await adapter.query(statement, params);
281 return {
282 rows: normalizeProxyRows(method, rows),
283 };
284 },
285 {
286 schema: drizzleSchema,
287 },
288 );
289
290export const createDrizzleContext = (adapter: SqlAdapter): DrizzleContext => ({
291 db: createPostgresProxyDb(adapter),
292 tables: drizzleSchema,134 tables: drizzleSchema,
293});135});
294 136
295export const runMigrations = async (adapter: SqlAdapter): Promise<void> => {137export const createSqlRuntime = async (
296 const migrationDb = createPostgresProxyDb(adapter);138 options: CreateSqlRuntimeOptions,
139): Promise<SqlRuntime> => {
140 const databaseUrl = options.databaseUrl?.trim();
141 if (
142 databaseUrl
143 && (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://"))
144 ) {
145 return createPostgresRuntime(databaseUrl, options.postgresApplicationName?.trim());
146 }
147
148 return createPGliteRuntime(options.localDataDir);
149};
150
151export const runMigrations = async (runtime: SqlRuntime): Promise<void> => {
297 const migrationsFolder = resolveDrizzleMigrationsFolder();152 const migrationsFolder = resolveDrizzleMigrationsFolder();
298 153
299 await migratePgProxy(154 if (runtime.backend === "pglite") {
300 migrationDb,155 await migratePGlite(runtime.db as PGliteDb, { migrationsFolder });
301 async (queries) => runMigrationQueries(adapter, queries),156 return;
302 {157 }
303 migrationsFolder,158
304 },159 await migratePostgres(runtime.db as PostgresDb, { migrationsFolder });
305 );
306};160};

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t