Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Kysely] Migrate location banks API out of sequelize (#261)

* [Kysely] Migrate location banks API out of sequelize

* add guard for org id.

* lint fix

authored by

Juan Mrad and committed by
GitHub
ecec6475 071f0926

+227 -78
+224 -75
server/graphql/datasources/LocationBankApi.ts
··· 1 1 import { type Exception } from '@opentelemetry/api'; 2 + import { type Kysely } from 'kysely'; 2 3 import { uid } from 'uid'; 3 4 import { v1 as uuidV1 } from 'uuid'; 4 5 5 6 import { inject, type Dependencies } from '../../iocContainer/index.js'; 6 - import { type LocationBank as TLocationBank } from '../../models/banks/LocationBankModel.js'; 7 - import { isUniqueConstraintError } from '../../models/errors.js'; 8 7 import { type LocationArea } from '../../models/types/locationArea.js'; 9 - import { type User } from '../../models/UserModel.js'; 8 + import { type CombinedPg } from '../../services/combinedDbTypes.js'; 10 9 import { makeLocationBankNameExistsError } from '../../services/moderationConfigService/index.js'; 11 10 import { type PlacesApiService } from '../../services/placesApiService/index.js'; 12 - import { patchInPlace, safePick } from '../../utils/misc.js'; 11 + import { isUniqueViolationError } from '../../utils/kysely.js'; 12 + import { makeKyselyTransactionWithRetry } from '../../utils/kyselyTransactionWithRetry.js'; 13 + import { makeNotFoundError } from '../../utils/errors.js'; 14 + import { safePick } from '../../utils/misc.js'; 13 15 import { 14 16 type GQLCreateLocationBankInput, 15 17 type GQLLocationAreaInput, 16 18 type GQLUpdateLocationBankInput, 17 19 } from '../generated.js'; 18 20 19 - // NB: this is the type that our GQL resolvers for a location bank rely on 20 - // getting as the parent object. (I.e., we don't promise the location bank field 21 - // resolvers that they'll be able to see the fullPlacesApiResponse). 22 - export type LocationBankWithoutFullPlacesAPIResponse = Omit< 23 - TLocationBank, 24 - 'fullPlacesApiResponse' 25 - >; 21 + type LocationBankRow = { 22 + id: string; 23 + name: string; 24 + description: string | null; 25 + org_id: string; 26 + owner_id: string; 27 + }; 28 + 29 + /** 30 + * GraphQL parent for `LocationBank` (no Sequelize, no `fullPlacesApiResponse`). 31 + * Resolvers use {@link LocationBankWithoutFullPlacesAPIResponse.getLocations}. 32 + */ 33 + export type LocationBankWithoutFullPlacesAPIResponse = { 34 + id: string; 35 + name: string; 36 + description: string | null; 37 + orgId: string; 38 + ownerId: string; 39 + getLocations: () => Promise<LocationArea[]>; 40 + }; 26 41 27 42 class LocationBankAPI { 28 43 private lookupPlaceId: PlacesApiService['lookupPlaceId']; 44 + private readonly db: Kysely<CombinedPg>; 45 + private readonly transactionWithRetry: ReturnType< 46 + typeof makeKyselyTransactionWithRetry<CombinedPg> 47 + >; 48 + 29 49 constructor( 30 50 placesApiService: PlacesApiService, 31 - private readonly sequelize: Dependencies['Sequelize'], 51 + db: Dependencies['KyselyPg'], 32 52 private readonly tracer: Dependencies['Tracer'], 33 53 ) { 34 54 this.lookupPlaceId = placesApiService.lookupPlaceId.bind(placesApiService); 55 + this.db = db as Kysely<CombinedPg>; 56 + this.transactionWithRetry = makeKyselyTransactionWithRetry(this.db); 57 + } 58 + 59 + #rowToParent(row: LocationBankRow): LocationBankWithoutFullPlacesAPIResponse { 60 + return { 61 + id: row.id, 62 + name: row.name, 63 + description: row.description, 64 + orgId: row.org_id, 65 + ownerId: row.owner_id, 66 + getLocations: async () => this.#loadLocationsForBank(row.id), 67 + }; 68 + } 69 + 70 + async #loadLocationsForBank(bankId: string): Promise<LocationArea[]> { 71 + const rows = await this.db 72 + .selectFrom('public.location_bank_locations') 73 + .selectAll() 74 + .where('bank_id', '=', bankId) 75 + .execute(); 76 + return rows.map(locationRowToLocationArea); 35 77 } 36 78 37 79 async getGraphQLLocationBankFromId(opts: { id: string; orgId: string }) { 38 80 const { id, orgId } = opts; 39 - return this.sequelize.LocationBank.findOne({ 40 - where: { id, orgId }, 41 - rejectOnEmpty: true, 42 - attributes: { exclude: ['fullPlacesApiResponses'] }, 43 - }) as Promise<LocationBankWithoutFullPlacesAPIResponse>; 81 + const row = (await this.db 82 + .selectFrom('public.location_banks') 83 + .select(['id', 'name', 'description', 'org_id', 'owner_id']) 84 + .where('id', '=', id) 85 + .where('org_id', '=', orgId) 86 + .executeTakeFirst()) as LocationBankRow | undefined; 87 + 88 + if (row == null) { 89 + throw makeNotFoundError('Location bank not found', { 90 + shouldErrorSpan: true, 91 + }); 92 + } 93 + 94 + return this.#rowToParent(row); 44 95 } 45 96 46 97 async getGraphQLLocationBanksForOrg(orgId: string) { 47 - return this.sequelize.LocationBank.findAll({ 48 - where: { orgId }, 49 - attributes: { exclude: ['fullPlacesApiResponses'] }, 50 - }) as Promise<LocationBankWithoutFullPlacesAPIResponse[]>; 98 + const rows = (await this.db 99 + .selectFrom('public.location_banks') 100 + .select(['id', 'name', 'description', 'org_id', 'owner_id']) 101 + .where('org_id', '=', orgId) 102 + .execute()) as LocationBankRow[]; 103 + 104 + return rows.map((r) => this.#rowToParent(r)); 51 105 } 52 106 53 - async createLocationBank(input: GQLCreateLocationBankInput, user: User) { 107 + async createLocationBank( 108 + input: GQLCreateLocationBankInput, 109 + user: { id: string; orgId: string }, 110 + ) { 54 111 const { name, description, locations: locationInputs } = input; 55 112 const { orgId, id: ownerId } = user; 56 113 57 114 const newBankId = uid(); 58 - const locations = this.sequelize.LocationBankLocation.bulkBuild( 59 - await this.expandLocationAreaInputs(newBankId, locationInputs), 115 + const expandedLocations = await this.expandLocationAreaInputs( 116 + newBankId, 117 + locationInputs, 60 118 ); 61 119 62 120 try { 63 - return await this.sequelize.transactionWithRetry(async () => { 64 - const bank = this.sequelize.LocationBank.build( 65 - { 121 + return await this.transactionWithRetry(async (trx) => { 122 + await trx 123 + .insertInto('public.location_banks') 124 + .values({ 66 125 id: newBankId, 67 126 name, 68 - description, 69 - ownerId, 70 - orgId, 71 - locations, 72 - }, 73 - { 74 - include: [ 75 - { model: this.sequelize.LocationBankLocation, as: 'locations' }, 76 - ], 77 - }, 78 - ); 127 + description: description ?? null, 128 + org_id: orgId, 129 + owner_id: ownerId, 130 + updated_at: new Date(), 131 + full_places_api_responses: [], 132 + }) 133 + .execute(); 79 134 80 - await bank.save(); 135 + if (expandedLocations.length > 0) { 136 + await trx 137 + .insertInto('public.location_bank_locations') 138 + .values( 139 + expandedLocations.map((loc) => 140 + locationAreaToLocationInsertRow(loc.bankId, loc), 141 + ), 142 + ) 143 + .execute(); 144 + } 81 145 82 - return bank; 146 + return this.#rowToParent({ 147 + id: newBankId, 148 + name, 149 + description: description ?? null, 150 + org_id: orgId, 151 + owner_id: ownerId, 152 + }); 83 153 }); 84 154 } catch (e: unknown) { 85 - throw isUniqueConstraintError(e) 155 + throw isUniqueViolationError(e) 86 156 ? makeLocationBankNameExistsError({ shouldErrorSpan: true }) 87 157 : e; 88 158 } ··· 95 165 ? await this.expandLocationAreaInputs(id, locationsToAdd) 96 166 : undefined; 97 167 98 - const bank = await this.sequelize.LocationBank.findOne({ 99 - where: { id, orgId }, 100 - rejectOnEmpty: true, 101 - }); 168 + const row = (await this.db 169 + .selectFrom('public.location_banks') 170 + .select(['id', 'name', 'description', 'org_id', 'owner_id']) 171 + .where('id', '=', id) 172 + .where('org_id', '=', orgId) 173 + .executeTakeFirst()) as LocationBankRow | undefined; 102 174 103 - // Name can be missing in the input object (in which case it'll be 104 - // undefined), but it can't be present + null (which would normally have the 105 - // semantic of trying to unset the name, which is invalid b/c name is 106 - // required). 175 + if (row == null) { 176 + throw makeNotFoundError('Location bank not found', { 177 + shouldErrorSpan: true, 178 + }); 179 + } 180 + 107 181 if (name === null) { 108 182 throw new Error('Cannot clear bank name.'); 109 183 } 110 184 111 - patchInPlace(bank, { 112 - name, 113 - description: description ?? undefined, 114 - }); 185 + const nextName = name ?? row.name; 186 + const nextDescription = 187 + description !== undefined ? description ?? null : row.description; 115 188 116 189 try { 117 - return await this.sequelize.transactionWithRetry(async () => { 118 - await bank.save(); 119 - await Promise.all([ 120 - locationsToDelete?.length && 121 - this.sequelize.LocationBankLocation.destroy({ 122 - where: { 123 - id: locationsToDelete, 124 - bankId: bank.id, 125 - }, 126 - }), 127 - expandedLocationsToAdd 128 - ? this.sequelize.LocationBankLocation.bulkCreate( 129 - expandedLocationsToAdd, 130 - ) 131 - : null, 132 - ]); 133 - return bank; 190 + return await this.transactionWithRetry(async (trx) => { 191 + await trx 192 + .updateTable('public.location_banks') 193 + .set({ 194 + name: nextName, 195 + description: nextDescription, 196 + updated_at: new Date(), 197 + }) 198 + .where('id', '=', id) 199 + .where('org_id', '=', orgId) 200 + .execute(); 201 + 202 + if (locationsToDelete?.length) { 203 + await trx 204 + .deleteFrom('public.location_bank_locations') 205 + .where('bank_id', '=', id) 206 + .where('id', 'in', [...locationsToDelete]) 207 + .execute(); 208 + } 209 + 210 + if (expandedLocationsToAdd?.length) { 211 + await trx 212 + .insertInto('public.location_bank_locations') 213 + .values( 214 + expandedLocationsToAdd.map((loc) => 215 + locationAreaToLocationInsertRow(loc.bankId, loc), 216 + ), 217 + ) 218 + .execute(); 219 + } 220 + 221 + return this.#rowToParent({ 222 + id: row.id, 223 + name: nextName, 224 + description: nextDescription, 225 + org_id: row.org_id, 226 + owner_id: row.owner_id, 227 + }); 134 228 }); 135 229 } catch (e: unknown) { 136 - throw isUniqueConstraintError(e) 230 + throw isUniqueViolationError(e) 137 231 ? makeLocationBankNameExistsError({ shouldErrorSpan: true }) 138 232 : e; 139 233 } ··· 143 237 const { id, orgId } = opts; 144 238 145 239 try { 146 - const bank = await this.sequelize.LocationBank.findOne({ 147 - where: { id, orgId }, 240 + const result = await this.db.transaction().execute(async (trx) => { 241 + const bank = await trx 242 + .selectFrom('public.location_banks') 243 + .select('id') 244 + .where('id', '=', id) 245 + .where('org_id', '=', orgId) 246 + .executeTakeFirst(); 247 + 248 + if (!bank) { 249 + return { numDeletedRows: BigInt(0) }; 250 + } 251 + 252 + await trx 253 + .deleteFrom('public.location_bank_locations') 254 + .where('bank_id', '=', id) 255 + .execute(); 256 + return trx 257 + .deleteFrom('public.location_banks') 258 + .where('id', '=', id) 259 + .where('org_id', '=', orgId) 260 + .executeTakeFirst(); 148 261 }); 149 - await bank?.destroy(); 262 + 263 + if (!result.numDeletedRows) { 264 + return false; 265 + } 150 266 } catch (exception) { 151 267 const activeSpan = this.tracer.getActiveSpan(); 152 268 if (activeSpan?.isRecording()) { ··· 184 300 } 185 301 } 186 302 303 + function locationRowToLocationArea( 304 + r: Record<string, unknown> & { 305 + id: string; 306 + bank_id: string; 307 + geometry: unknown; 308 + bounds: unknown | null; 309 + name: string | null; 310 + google_place_info: unknown | null; 311 + }, 312 + ): LocationArea { 313 + return { 314 + id: r.id, 315 + name: r.name ?? undefined, 316 + geometry: r.geometry as LocationArea['geometry'], 317 + bounds: (r.bounds as LocationArea['bounds']) ?? undefined, 318 + googlePlaceInfo: r.google_place_info as LocationArea['googlePlaceInfo'], 319 + }; 320 + } 321 + 322 + function locationAreaToLocationInsertRow( 323 + bankId: string, 324 + area: LocationArea & { bankId?: string }, 325 + ) { 326 + return { 327 + id: area.id, 328 + bank_id: bankId, 329 + geometry: area.geometry, 330 + bounds: area.bounds ?? null, 331 + name: area.name ?? null, 332 + google_place_info: area.googlePlaceInfo ?? null, 333 + }; 334 + } 335 + 187 336 export default inject( 188 - ['PlacesApiService', 'Sequelize', 'Tracer'], 337 + ['PlacesApiService', 'KyselyPg', 'Tracer'], 189 338 LocationBankAPI, 190 339 ); 191 340 export type { LocationBankAPI };
+3 -3
server/graphql/modules/locationBank.ts
··· 1 - import { type LocationBank as TLocationBank } from '../../models/banks/LocationBankModel.js'; 2 1 import { isCoopErrorOfType } from '../../utils/errors.js'; 2 + import { type LocationBankWithoutFullPlacesAPIResponse } from '../datasources/LocationBankApi.js'; 3 3 import { 4 4 type GQLMutationResolvers, 5 5 type GQLQueryResolvers, ··· 121 121 } 122 122 `; 123 123 124 - const LocationBank: ResolverMap<TLocationBank> = { 124 + const LocationBank: ResolverMap<LocationBankWithoutFullPlacesAPIResponse> = { 125 125 async locations(locationBank, _, __) { 126 126 return locationBank.getLocations(); 127 127 }, ··· 172 172 throw unauthenticatedError('User required.'); 173 173 } 174 174 175 - const bank = context.dataSources.locationBankAPI.updateLocationBank( 175 + const bank = await context.dataSources.locationBankAPI.updateLocationBank( 176 176 params.input, 177 177 user.orgId, 178 178 );