Skip to content

Commit 31ce057

Browse files
authored
Feature/plan import improvements (#109)
* utilize bulk query transactions * cleanup tags on failure * be more explicit as to what new tags are created
1 parent 850685e commit 31ce057

File tree

2 files changed

+170
-118
lines changed

2 files changed

+170
-118
lines changed

src/packages/plan/gql.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
export default {
2-
CREATE_ACTIVITY_DIRECTIVE: `#graphql
3-
mutation CreateActivityDirective($activityDirectiveInsertInput: activity_directive_insert_input!) {
4-
insert_activity_directive_one(object: $activityDirectiveInsertInput) {
5-
id
6-
type
2+
CREATE_ACTIVITY_DIRECTIVES: `#graphql
3+
mutation CreateActivityDirectives($activityDirectivesInsertInput: [activity_directive_insert_input!]!) {
4+
insert_activity_directive(objects: $activityDirectivesInsertInput) {
5+
returning {
6+
id
7+
type
8+
}
79
}
810
}
911
`,
@@ -37,11 +39,7 @@ export default {
3739
`,
3840
CREATE_TAGS: `#graphql
3941
mutation CreateTags($tags: [tags_insert_input!]!) {
40-
insert_tags(objects: $tags, on_conflict: {
41-
constraint: tags_name_key,
42-
update_columns: []
43-
}) {
44-
affected_rows
42+
insert_tags(objects: $tags) {
4543
returning {
4644
color
4745
created_at
@@ -59,6 +57,17 @@ export default {
5957
}
6058
}
6159
`,
60+
DELETE_TAGS: `#graphql
61+
mutation DeleteTags($tagIds: [Int!]! = []) {
62+
delete_tags(
63+
where: {
64+
id: { _in: $tagIds }
65+
}
66+
) {
67+
affected_rows
68+
}
69+
}
70+
`,
6271
GET_TAGS: `#graphql
6372
query GetTags {
6473
tags(order_by: { name: desc }) {
@@ -70,13 +79,12 @@ export default {
7079
}
7180
}
7281
`,
73-
UPDATE_ACTIVITY_DIRECTIVE: `#graphql
74-
mutation UpdateActivityDirective($id: Int!, $plan_id: Int!, $activityDirectiveSetInput: activity_directive_set_input!) {
75-
update_activity_directive_by_pk(
76-
pk_columns: { id: $id, plan_id: $plan_id }, _set: $activityDirectiveSetInput
82+
UPDATE_ACTIVITY_DIRECTIVES: `#graphql
83+
mutation UpdateActivityDirective($updates: [activity_directive_updates!]!) {
84+
update_activity_directive_many(
85+
updates: $updates
7786
) {
78-
anchor_id
79-
id
87+
affected_rows
8088
}
8189
}
8290
`,

src/packages/plan/plan.ts

Lines changed: 146 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { auth } from '../auth/middleware.js';
88
import type {
99
ActivityDirective,
1010
ActivityDirectiveInsertInput,
11-
ActivityDirectiveSetInput,
1211
ImportPlanPayload,
1312
PlanInsertInput,
1413
PlanSchema,
@@ -53,6 +52,7 @@ export async function importPlan(req: Request, res: Response) {
5352
};
5453

5554
let createdPlan: PlanSchema | null = null;
55+
let createdTags: Tag[] = [];
5656

5757
try {
5858
const { activities, simulation_arguments }: PlanTransfer = await new Promise(resolve => {
@@ -123,36 +123,6 @@ export async function importPlan(req: Request, res: Response) {
123123
// insert all the imported activities into the plan
124124
logger.info(`POST /importPlan: Importing activities: ${name}`);
125125

126-
const activityTags = activities.reduce(
127-
(prevActivitiesTagsMap: Record<string, Pick<Tag, 'color' | 'name'>>, { tags }) => {
128-
const tagsMap =
129-
tags?.reduce((prevTagsMap: Record<string, Pick<Tag, 'color' | 'name'>>, { tag }) => {
130-
return {
131-
...prevTagsMap,
132-
[tag.name]: {
133-
color: tag.color,
134-
name: tag.name,
135-
},
136-
};
137-
}, {}) ?? {};
138-
139-
return {
140-
...prevActivitiesTagsMap,
141-
...tagsMap,
142-
};
143-
},
144-
{},
145-
);
146-
147-
await fetch(GQL_API_URL, {
148-
body: JSON.stringify({
149-
query: gql.CREATE_TAGS,
150-
variables: { tags: Object.values(activityTags) },
151-
}),
152-
headers,
153-
method: 'POST',
154-
});
155-
156126
const tagsResponse = await fetch(GQL_API_URL, {
157127
body: JSON.stringify({
158128
query: gql.GET_TAGS,
@@ -180,88 +150,153 @@ export async function importPlan(req: Request, res: Response) {
180150
}, {});
181151
}
182152

153+
// derive a map of uniquely named tags from the list of activities that doesn't already exist in the database
154+
const activityTags = activities.reduce(
155+
(prevActivitiesTagsMap: Record<string, Pick<Tag, 'color' | 'name'>>, { tags }) => {
156+
const currentTagsMap =
157+
tags?.reduce(
158+
(prevTagsMap: Record<string, Pick<Tag, 'color' | 'name'>>, { tag: { name: tagName, color } }) => {
159+
// If the tag doesn't exist already, add it
160+
if (tagsMap[tagName] === undefined) {
161+
return {
162+
...prevTagsMap,
163+
[tagName]: {
164+
color,
165+
name: tagName,
166+
},
167+
};
168+
}
169+
return prevTagsMap;
170+
},
171+
{},
172+
) ?? {};
173+
174+
return {
175+
...prevActivitiesTagsMap,
176+
...currentTagsMap,
177+
};
178+
},
179+
{},
180+
);
181+
182+
const createdTagsResponse = await fetch(GQL_API_URL, {
183+
body: JSON.stringify({
184+
query: gql.CREATE_TAGS,
185+
variables: { tags: Object.values(activityTags) },
186+
}),
187+
headers,
188+
method: 'POST',
189+
});
190+
191+
const { data } = (await createdTagsResponse.json()) as {
192+
data: {
193+
insert_tags: { returning: Tag[] };
194+
};
195+
};
196+
197+
if (data && data.insert_tags && data.insert_tags.returning.length) {
198+
// track the newly created tags for cleanup if an error occurs during plan import
199+
createdTags = data.insert_tags.returning;
200+
}
201+
202+
// add the newly created tags to the `tagsMap`
203+
tagsMap = createdTags.reduce(
204+
(prevTagsMap: Record<string, Tag>, tag) => ({
205+
...prevTagsMap,
206+
[tag.name]: tag,
207+
}),
208+
tagsMap,
209+
);
210+
183211
const activityRemap: Record<number, number> = {};
184-
await Promise.all(
185-
activities.map(
186-
async ({
212+
const activityDirectivesInsertInput = activities.map(
213+
({
214+
anchored_to_start: anchoredToStart,
215+
arguments: activityArguments,
216+
metadata,
217+
name: activityName,
218+
start_offset: startOffset,
219+
tags,
220+
type,
221+
}) => {
222+
const activityDirectiveInsertInput: ActivityDirectiveInsertInput = {
223+
anchor_id: null,
187224
anchored_to_start: anchoredToStart,
188225
arguments: activityArguments,
189-
id,
190226
metadata,
191227
name: activityName,
228+
plan_id: (createdPlan as PlanSchema).id,
192229
start_offset: startOffset,
193-
tags,
230+
tags: {
231+
data:
232+
tags?.map(({ tag: { name } }) => ({
233+
tag_id: tagsMap[name].id,
234+
})) ?? [],
235+
},
194236
type,
195-
}) => {
196-
const activityDirectiveInsertInput: ActivityDirectiveInsertInput = {
197-
anchor_id: null,
198-
anchored_to_start: anchoredToStart,
199-
arguments: activityArguments,
200-
metadata,
201-
name: activityName,
202-
plan_id: (createdPlan as PlanSchema).id,
203-
start_offset: startOffset,
204-
tags: {
205-
data:
206-
tags?.map(({ tag: { name } }) => ({
207-
tag_id: tagsMap[name].id,
208-
})) ?? [],
209-
},
210-
type,
211-
};
212-
213-
const createdActivityDirectiveResponse = await fetch(GQL_API_URL, {
214-
body: JSON.stringify({
215-
query: gql.CREATE_ACTIVITY_DIRECTIVE,
216-
variables: { activityDirectiveInsertInput },
217-
}),
218-
headers,
219-
method: 'POST',
220-
});
221-
222-
const createdActivityDirectiveData = (await createdActivityDirectiveResponse.json()) as {
223-
data: {
224-
insert_activity_directive_one: ActivityDirective;
225-
};
226-
} | null;
227-
228-
if (createdActivityDirectiveData) {
229-
const {
230-
data: { insert_activity_directive_one: createdActivityDirective },
231-
} = createdActivityDirectiveData;
232-
activityRemap[id] = createdActivityDirective.id;
233-
}
234-
},
235-
),
237+
};
238+
239+
return activityDirectiveInsertInput;
240+
},
236241
);
237242

243+
const createdActivitiesResponse = await fetch(GQL_API_URL, {
244+
body: JSON.stringify({
245+
query: gql.CREATE_ACTIVITY_DIRECTIVES,
246+
variables: {
247+
activityDirectivesInsertInput,
248+
},
249+
}),
250+
headers,
251+
method: 'POST',
252+
});
253+
254+
const createdActivityDirectivesData = (await createdActivitiesResponse.json()) as {
255+
data: {
256+
insert_activity_directive: {
257+
returning: ActivityDirective[];
258+
};
259+
};
260+
} | null;
261+
262+
if (createdActivityDirectivesData) {
263+
const {
264+
data: {
265+
insert_activity_directive: { returning: createdActivityDirectives },
266+
},
267+
} = createdActivityDirectivesData;
268+
269+
if (createdActivityDirectives.length === activities.length) {
270+
createdActivityDirectives.forEach((createdActivityDirective, index) => {
271+
const { id } = activities[index];
272+
273+
activityRemap[id] = createdActivityDirective.id;
274+
});
275+
} else {
276+
throw new Error('Activity insertion failed.');
277+
}
278+
}
279+
238280
// remap all the anchor ids to the newly created activity directives
239281
logger.info(`POST /importPlan: Re-assigning anchors: ${name}`);
240-
await Promise.all(
241-
activities.map(async ({ anchor_id: anchorId, id }) => {
242-
if (anchorId !== null && activityRemap[anchorId] != null && activityRemap[id] != null) {
243-
logger.info(
244-
`POST /importPlan: Re-assigning anchor ${anchorId} to ${activityRemap[anchorId]} for activity ${activityRemap[id]}: ${name}`,
245-
);
246-
const activityDirectiveSetInput: ActivityDirectiveSetInput = {
247-
anchor_id: activityRemap[anchorId],
248-
};
249-
250-
return fetch(GQL_API_URL, {
251-
body: JSON.stringify({
252-
query: gql.UPDATE_ACTIVITY_DIRECTIVE,
253-
variables: {
254-
activityDirectiveSetInput,
255-
id: activityRemap[id],
256-
plan_id: (createdPlan as PlanSchema).id,
257-
},
258-
}),
259-
headers,
260-
method: 'POST',
261-
});
262-
}
282+
283+
const activityDirectivesSetInput = activities
284+
.filter(({ anchor_id: anchorId }) => anchorId !== null)
285+
.map(({ anchor_id: anchorId, id }) => ({
286+
_set: { anchor_id: activityRemap[anchorId as number] },
287+
where: { id: { _eq: activityRemap[id] }, plan_id: { _eq: (createdPlan as PlanSchema).id } },
288+
}));
289+
290+
await fetch(GQL_API_URL, {
291+
body: JSON.stringify({
292+
query: gql.UPDATE_ACTIVITY_DIRECTIVES,
293+
variables: {
294+
updates: activityDirectivesSetInput,
295+
},
263296
}),
264-
);
297+
headers,
298+
method: 'POST',
299+
});
265300

266301
// associate the tags with the newly created plan
267302
logger.info(`POST /importPlan: Importing plan tags: ${name}`);
@@ -288,14 +323,23 @@ export async function importPlan(req: Request, res: Response) {
288323
logger.error(`POST /importPlan: Error occurred during plan ${name} import`);
289324
logger.error(error);
290325

326+
// cleanup the imported plan if it failed along the way
291327
if (createdPlan) {
328+
// delete the plan - activities associated to the plan will be automatically cleaned up
292329
await fetch(GQL_API_URL, {
293330
body: JSON.stringify({ query: gql.DELETE_PLAN, variables: { id: createdPlan.id } }),
294331
headers,
295332
method: 'POST',
296333
});
334+
335+
// if any activity tags were created as a result of this import, remove them
336+
await fetch(GQL_API_URL, {
337+
body: JSON.stringify({ query: gql.DELETE_TAGS, variables: { tagIds: createdTags.map(({ id }) => id) } }),
338+
headers,
339+
method: 'POST',
340+
});
297341
}
298-
res.send(500);
342+
res.sendStatus(500);
299343
}
300344
}
301345

0 commit comments

Comments
 (0)